← Commit history

v2.0.735 (insiders): x.com and other sites that refuse a fresh sign-in from pup's browser (John, 2026-10-27, pup-bridge#136): pup_import_browser_logins {sessionFor:'x.com'} brings the user's existing signed-in session for one site over from their own Chrome or Edge (the same Adom Bridge consent and Windows prompt as the password import), sets its cookies in pup's browser, and never prints them. A popup a pup click opens (an OAuth or Sign in with Google window) is adopted in the background within about 3 s instead of 30 to 40 s (measured on ConfRoomROG), and the click's answer says where it will appear, so an AI drives it with pup verbs and never reaches for the real mouse. New login recipe login-recipes/x.com.md; the pup-import-logins skill covers the session import. Gates on arav-rog and ConfRoomROG: smoke all pass, foreground pass, jump list at baseline, isolated-close replay kept every window, parity 48/56 (pup-bridge#131), login tip and auto-fill. Includes 2.0.734.

John Lauer ·55f65e3509 ·14d ago ·parent 0bf2531
6 files changed +90−18
rust/Cargo.lock+12−12
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-pup-bridge"-version = "2.0.734"+version = "2.0.735" dependencies = [  "pup-browser",  "pup-cdp",@@ -781,7 +781,7 @@ dependencies = [  [[package]] name = "pup-browser"-version = "2.0.734"+version = "2.0.735" dependencies = [  "pup-core",  "pup-host",@@ -793,7 +793,7 @@ dependencies = [  [[package]] name = "pup-cdp"-version = "2.0.734"+version = "2.0.735" dependencies = [  "serde_json",  "thiserror",@@ -803,7 +803,7 @@ dependencies = [  [[package]] name = "pup-core"-version = "2.0.734"+version = "2.0.735" dependencies = [  "serde",  "serde_json",@@ -813,7 +813,7 @@ dependencies = [  [[package]] name = "pup-dashboard"-version = "2.0.734"+version = "2.0.735" dependencies = [  "pup-cdp",  "pup-core",@@ -826,7 +826,7 @@ dependencies = [  [[package]] name = "pup-host"-version = "2.0.734"+version = "2.0.735" dependencies = [  "serde",  "serde_json",@@ -835,7 +835,7 @@ dependencies = [  [[package]] name = "pup-host-mac"-version = "2.0.734"+version = "2.0.735" dependencies = [  "core-foundation",  "core-graphics",@@ -851,7 +851,7 @@ dependencies = [  [[package]] name = "pup-host-win"-version = "2.0.734"+version = "2.0.735" dependencies = [  "pup-core",  "pup-host",@@ -861,7 +861,7 @@ dependencies = [  [[package]] name = "pup-identity"-version = "2.0.734"+version = "2.0.735" dependencies = [  "fontdue",  "image",@@ -871,7 +871,7 @@ dependencies = [  [[package]] name = "pup-session"-version = "2.0.734"+version = "2.0.735" dependencies = [  "pup-core",  "serde",@@ -881,7 +881,7 @@ dependencies = [  [[package]] name = "pup-vault"-version = "2.0.734"+version = "2.0.735" dependencies = [  "pup-core",  "pup-host",@@ -892,7 +892,7 @@ dependencies = [  [[package]] name = "pup-verbs"-version = "2.0.734"+version = "2.0.735" 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.734"+version = "2.0.735" edition = "2021" license = "MIT" 
rust/verbs/src/extdrive.rs+18−1
@@ -691,6 +691,20 @@ pub fn rescue_birth_tabs(ctx: &Ctx) -> usize {     n } +pub static FAST_ADOPT_UNTIL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);++/// 2.0.735: after a click on an extension-lane window, give a popup it opened up to ~3 s to appear and adopt it now.+/// Returns the new pup window ids (for the click's answer).+pub fn adopt_popups_after_click(ctx: &Ctx, sid: &str) -> Vec<String> {+    let before: std::collections::HashSet<String> = ctx.sessions.all().iter().map(|s| s.id.clone()).collect();+    for _ in 0..6 {+        std::thread::sleep(Duration::from_millis(500));+        FAST_ADOPT_UNTIL.store(pup_core::now_ms() + 1500, std::sync::atomic::Ordering::Relaxed);+        if adopt_stray_windows(ctx) > 0 { break; }+    }+    ctx.sessions.all().iter().map(|s| s.id.clone()).filter(|id| !before.contains(id) && id.starts_with(sid)).collect()+}+ pub fn adopt_stray_windows(ctx: &Ctx) -> usize {     static SEEN: Mutex<Option<std::collections::HashMap<i64, u64>>> = Mutex::new(None);     if !alive(ctx) { return 0; }@@ -713,7 +727,10 @@ pub fn adopt_stray_windows(ctx: &Ctx) -> usize {         // taskbar titled PUPBIRTH-... (2026-10-13).         if wtabs.iter().all(|t| is_blank_or_birth(t["url"].as_str().unwrap_or(""))) { continue; }         let first = *seen.entry(wid).or_insert(now);-        if now.saturating_sub(first) < 5000 { waiting += 1; continue; }+        // 2.0.735: right after a pup_click (FAST_ADOPT_UNTIL), a window a pup page opened is adopted at once, so the+        // click's answer can name the popup (pup-bridge#136: the AI waited 0 s, saw nothing, and reached for the mouse).+        let fast = now < FAST_ADOPT_UNTIL.load(std::sync::atomic::Ordering::Relaxed) && wtabs.iter().any(|t| t["openerTabId"].as_i64().is_some());+        if !fast && now.saturating_sub(first) < 5000 { waiting += 1; continue; }         let ids: Vec<String> = wtabs.iter().filter_map(|t| t["tabId"].as_i64()).map(|i| i.to_string()).collect();         let openers: Vec<String> = wtabs.iter().filter_map(|t| t["openerTabId"].as_i64()).map(|i| i.to_string()).collect();         let from_drag = sessions.iter().find(|s| s.tabs.iter().any(|t| ids.contains(&t.target_id)));
rust/verbs/src/import.rs+54−2
@@ -32,7 +32,7 @@ static IMPORT_JOB: std::sync::Mutex<Option<ImportJob>> = std::sync::Mutex::new(N  pub fn import_browser_logins(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     if !cfg!(windows) { return err("unsupported_platform", "Browser-login import is Windows-only (Chromium ABE/DPAPI).", None); }-    let key = json!([args["browsers"], args["hostFilter"]]).to_string();+    let key = json!([args["browsers"], args["hostFilter"], args["sessionFor"], args["sourceProfile"]]).to_string();     let now = pup_core::now_ms();     {         let mut g = IMPORT_JOB.lock().unwrap();@@ -80,7 +80,8 @@ fn import_now(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     // ab-core's signed helper: try the two known verb names (older builds).     let mut resp = Value::Null;     for name in ["desktop_decrypt_browser_credentials", "desktop_decrypt_pup_credentials"] {-        let r = ctx.ab.call(name, json!({ "browsers": want, "include": ["passwords"], "reason": args["reason"].as_str().unwrap_or("Import your saved browser logins into the pup adom-you vault so pup can sign you in.") }), caller, Duration::from_secs(180));+        let include = if session_for(args).is_some() { json!(["cookies"]) } else { json!(["passwords"]) };+        let r = ctx.ab.call(name, json!({ "browsers": want, "include": include, "reason": args["reason"].as_str().unwrap_or("Import your saved browser logins into the pup adom-you vault so pup can sign you in.") }), caller, Duration::from_secs(180));         match r { Ok(v) => { if v.get("errorCode").and_then(|e| e.as_str()) == Some("unknown_verb") { continue; } resp = v; break; } Err(e) => { resp = json!({ "errorCode": "decrypt_call_failed", "error": e.to_string() }); break; } }     }     let payload = if resp.get("output").and_then(|v| v.as_str()).map(|s| s.starts_with('{')).unwrap_or(false) { serde_json::from_str(resp["output"].as_str().unwrap()).unwrap_or(resp.clone()) } else { resp.clone() };@@ -95,6 +96,7 @@ fn import_now(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         };         return err(ec, format!("{msg}{}", payload.get("error").and_then(|e| e.as_str()).map(|e| format!(" ({e})")).unwrap_or_default()), None);     }+    if let Some(site) = session_for(args) { return import_session(ctx, caller, args, &payload, &site); }     let rows: Vec<Value> = payload["passwords"].as_array().cloned().unwrap_or_default();     if payload.get("ok") != Some(&json!(true)) && rows.is_empty() { return err("no_data", format!("decrypt helper returned no data{}", payload.get("error").and_then(|e| e.as_str()).map(|e| format!(": {e}")).unwrap_or_default()), None); }     let filters: Vec<String> = match &args["hostFilter"] { Value::String(s) => vec![s.to_lowercase()], Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(|s| s.to_lowercase())).collect(), _ => vec![] };@@ -109,3 +111,53 @@ fn import_now(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         "stats": { "profilesRead": st["profilesRead"], "passwordsOk": st["passwordsOk"], "passwordsFailed": st["passwordsFailed"] },         "_hint": format!("Imported {} login(s) into the adom-you vault ({added} new, {updated} updated{}). They auto-fill on the matching sites; pup NEVER auto-submits.", added + updated, if skipped > 0 { format!(", {skipped} skipped") } else { String::new() }) }).to_string() })) }+++/// `sessionFor:"x.com"`: bring one site's SIGNED-IN SESSION (its cookies) from the user's own browser into pup.+fn session_for(args: &Value) -> Option<String> {+    args["sessionFor"].as_str().map(|s| s.trim().trim_start_matches("https://").trim_start_matches("http://").trim_start_matches("www.").trim_end_matches('/').to_lowercase()).filter(|s| s.contains('.') && !s.contains('/'))+}++/// 2.0.735 (John, 2026-10-27, pup-bridge#136): X refuses a fresh sign-in from pup's browser ("An unexpected error+/// occurred") even through a clean background Google sign-in, while the user's own Chrome is already signed in. So+/// carry that existing session over: the cookies ab decrypted (one consent + one Windows prompt, the same gates as the+/// password import) are filtered to the site and its subdomains and set into the adom-you browser through a pup+/// window's DevTools channel. Values are never logged or returned.+fn import_session(ctx: &Ctx, caller: &Caller, args: &Value, payload: &Value, site: &str) -> Value {+    let all: Vec<Value> = payload["cookies"].as_array().cloned().unwrap_or_default();+    let host_of = |c: &Value| c["host"].as_str().or(c["host_key"].as_str()).or(c["domain"].as_str()).unwrap_or("").to_lowercase();+    let matches = |h: &str| { let h = h.trim_start_matches('.'); h == site || h.ends_with(&format!(".{site}")) };+    let mut rows: Vec<Value> = all.into_iter().filter(|c| matches(&host_of(c))).collect();+    if rows.is_empty() { return err("no_session", format!("None of the user's browsers has cookies for {site}, so there is no session to bring over."), Some("Ask the user to sign in to that site once in their own browser (or in this pup window), then try again.")); }+    let src = |c: &Value| format!("{} / {}", c["sourceBrowser"].as_str().unwrap_or("?"), c["sourceProfile"].as_str().unwrap_or("?"));+    let mut sources: Vec<String> = rows.iter().map(|c| src(c)).collect(); sources.sort(); sources.dedup();+    if let Some(want) = args["sourceProfile"].as_str() { rows.retain(|c| src(c).to_lowercase().contains(&want.to_lowercase())); }+    else if sources.len() > 1 {+        return pup_core::merge(err("several_sessions", format!("{} browser profiles have a session for {site}: {}", sources.len(), sources.join("; ")), Some("Ask the user which account they mean, then call again with sourceProfile:\"<profile name from the list>\".")), json!({ "sources": sources }));+    }+    if rows.is_empty() { return err("no_session", format!("No cookies for {site} in the profile you named."), Some("Use one of the names from the sources list.")); }+    // a window of the caller on adom-you, else any adom-you extension-lane window, carries the DevTools calls+    let sessions = ctx.sessions.for_profile("adom-you");+    let Some(s) = sessions.iter().find(|s| crate::transport::is_ext(s) && s.owner.as_deref() == Some(caller.thread.as_str())).or_else(|| sessions.iter().find(|s| crate::transport::is_ext(s))) else {+        return err("no_window", "Open a pup window first (any page); the session is loaded through it.", Some("pup_open_window {url:\"https://example.com\"}, then call again."));+    };+    let Some(t) = s.active().cloned() else { return err("no_tab", "that pup window has no tab", None) };+    let lane = match crate::transport::lane_for(ctx, s) { Ok(l) => l, Err(e) => return e };+    let now_s = (pup_core::now_ms() / 1000) as f64;+    let (mut set, mut failed) = (0u32, 0u32);+    for c in &rows {+        let host = host_of(c); let name = c["name"].as_str().unwrap_or(""); let value = c["value"].as_str().unwrap_or("");+        if name.is_empty() { continue; }+        let secure = c["secure"].as_bool().or(c["isSecure"].as_bool()).or(c["is_secure"].as_bool()).unwrap_or(true);+        let http_only = c["httpOnly"].as_bool().or(c["isHttpOnly"].as_bool()).or(c["is_httponly"].as_bool()).unwrap_or(false);+        let expires = c["expires"].as_f64().or(c["expiresUtc"].as_f64()).filter(|e| *e > now_s).unwrap_or(now_s + 30.0 * 86400.0);+        let mut p = json!({ "name": name, "value": value, "path": c["path"].as_str().unwrap_or("/"), "secure": secure, "httpOnly": http_only, "expires": expires });+        if name.starts_with("__Host-") { p["url"] = json!(format!("https://{}/", host.trim_start_matches('.'))); }+        else { p["domain"] = json!(host); }+        match crate::transport::cdp_raw(&lane, &t, "Network.setCookie", p, 10) { Ok(v) if v["success"] != json!(false) => set += 1, _ => failed += 1 }+    }+    pup_core::log(format!("[import] session for {site}: {set} cookie(s) set in adom-you, {failed} refused (from {}; values never logged)", sources.join(", ")));+    ok(json!({ "success": true, "site": site, "cookiesSet": set, "cookiesRefused": failed, "source": if args["sourceProfile"].is_string() { json!(args["sourceProfile"]) } else { json!(sources.first()) },+        "output": json!({ "ok": true, "site": site, "cookiesSet": set, "cookiesRefused": failed }).to_string(),+        "_hint": format!("Brought the user's signed-in {site} session from their own browser into pup ({set} cookies). Reload or open {site} in a pup window: it should be signed in. Nothing was printed; pup never shows cookie values.") }))+}
rust/verbs/src/input.rs+4−1
@@ -156,7 +156,10 @@ pub fn click(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, bits) { return e; }     if let Err(e) = click_once(ctx, &b.cdp, &t.cdp_session, x, y, button, clicks, bits) { return e; }     touch(ctx, &s.id, None);-    out(&s, &t, json!({ "clicked": sel.or(txt).map(|v| v.to_string()).unwrap_or_else(|| format!("{x},{y}")), "method": how, "x": x, "y": y, "_hint": "Click dispatched (isTrusted) at the element's center. Screenshot to confirm the effect; SPA routing may need pup_wait." }))+    // 2.0.735 (pup-bridge#136): a popup this click opened (an OAuth or "Sign in with Google" window) is adopted in the+    // background within ~2 s as "<window>-2", so the AI drives it with pup verbs and never reaches for the real mouse.+    if crate::transport::is_ext(&s) { if let Some(a) = ctx.arc() { let sid = s.id.clone(); std::thread::spawn(move || { let ctx2: &Ctx = &a; let _ = crate::extdrive::adopt_popups_after_click(ctx2, &sid); }); } }+    out(&s, &t, json!({ "clicked": sel.or(txt).map(|v| v.to_string()).unwrap_or_else(|| format!("{x},{y}")), "method": how, "x": x, "y": y, "_popupHint": format!("If this click opened a popup (a sign-in or OAuth window), it becomes the pup window \"{}-2\" within about 2 s: check pup_my_windows {{}} and drive it with pup verbs, never the real mouse.", s.id), "_hint": "Click dispatched (isTrusted) at the element's center. Screenshot to confirm the effect; SPA routing may need pup_wait." })) }  pub fn hover(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
rust/verbs/src/lib.rs+1−1
@@ -817,7 +817,7 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {             if let Ok(mut inner) = serde_json::from_str::<Value>(&inner_s) { if let Some(i) = inner.as_object_mut() {                 let sid = i.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()).or_else(|| o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()));                 if let Some(sid) = sid { if !i.contains_key("window") { i.insert("window".into(), json!(sid)); if !i.contains_key("aiThread") { if let Some(owner) = ctx.sessions.get(&sid).and_then(|x| x.owner) { i.insert("aiThread".into(), json!(owner)); } } } }-                for k in ["_reportIssues", "_cleanupReport", "_autoLogin", "_loginTip", "_importTip", "loginPage", "savedLogins", "_credentialCaptured", "_hint", "_next", "related", "pitfalls", "renderCheck", "_verifyRender"] { if let Some(v) = o.get(k) { if !i.contains_key(k) { i.insert(k.into(), v.clone()); } } }+                for k in ["_reportIssues", "_cleanupReport", "_autoLogin", "_loginTip", "_importTip", "_popupHint", "loginPage", "savedLogins", "_credentialCaptured", "_hint", "_next", "related", "pitfalls", "renderCheck", "_verifyRender"] { if let Some(v) = o.get(k) { if !i.contains_key(k) { i.insert(k.into(), v.clone()); } } }                 o.insert("output".into(), json!(inner.to_string()));             } }         }