app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
master
John Lauer
v2.0.730 (insiders): pup_eval and pup_login work on strict pages again (pup-bridge#130): American Express's login pageā¦
705edb1
21d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
//! ONE seam between a pup window and the thing that drives it. A window is a Session either way; only
//! the driving differs: CDP over a DevTools port, or the extension channel with no port at all (the lane
//! vendor sites cannot fingerprint). Every verb reaches the page through here, so identity, overlay,
//! progress, park, recovery and listing never need to know which transport a window uses.
//!
//! 2.0.525 (John, 2026-09-13): "i honestly still don't get why you made the non-cdp lane be like a whole new
//! set of code". It was outside the session model; this module is what puts it back inside.
use crate::{browser_for, BrowserHandle, Ctx};
use pup_core::err;
use pup_session::{Session, Tab};
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;
pub enum Lane { Cdp(Arc<BrowserHandle>), Ext }
pub fn is_ext(s: &Session) -> bool { s.transport == "ext" }
pub fn lane_for(ctx: &Ctx, s: &Session) -> Result<Lane, Value> {
if is_ext(s) {
crate::extdrive::enter_profile(&s.profile); // 2.0.659: this thread now talks to the session's own lane
if crate::extdrive::alive(ctx) { Ok(Lane::Ext) } else { Err(err("browser_gone", "the extension-driven browser behind this window is not running", Some("pup_close_window then pup_open_window."))) }
} else {
browser_for(ctx, &s.profile).map(Lane::Cdp).ok_or_else(|| err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; Phase 2 reconnects automatically.")))
}
}
/// An ext tab's `target_id` is Chrome's numeric tab id; its `cdp_session` is a synthetic unique key so
/// per-tab state maps (tabstate, focus emulation) keep working without a CDP session.
pub fn ext_tab(s: &mut Session, chrome_tab_id: i64, url: &str, title: &str) -> Tab {
Tab { tab_id: s.next_tab_id(), target_id: chrome_tab_id.to_string(), cdp_session: format!("ext:{chrome_tab_id}"), url: url.into(), title: title.into(), error_count: 0, fav: None }
}
pub(crate) fn ext_id(t: &Tab) -> Value { t.target_id.parse::<i64>().map(Value::from).unwrap_or(Value::Null) }
fn run(cmd: Value, secs: u64) -> Result<Value, Value> { crate::extdrive::run(cmd, Duration::from_secs(secs)) }
pub fn navigate(lane: &Lane, t: &Tab, url: &str) -> Result<(String, String, bool), Value> {
match lane {
Lane::Cdp(b) => match b.cdp.navigate(&t.cdp_session, url, Duration::from_secs(25)) {
Ok((u, loaded)) => { let mut tt = t.clone(); crate::refresh_tab(&b.cdp, &mut tt); Ok((if tt.url.is_empty() { u } else { tt.url }, tt.title, loaded)) }
Err(e) => Err(err("navigate_failed", e.to_string(), Some("Check the URL; a container-local localhost is not reachable from the desktop (use the proxy URL)."))),
},
Lane::Ext => {
let r = run(json!({ "action": "navigate", "url": url, "tabId": ext_id(t) }), 60)?;
// 2.0.619: say WHICH path loaded the page. chrome.tabs.update surfaces the window (measured), so
// Page.navigate over DevTools is preferred and a fallback to the tab API is worth seeing in the log.
if r["navVia"].as_str() == Some("tabs") {
pup_core::log(format!("[nav] fell back to the tab API (the window may surface): {}", r["navErr"].as_str().unwrap_or("no reason given")));
}
Ok((r["url"].as_str().unwrap_or(url).to_string(), r["title"].as_str().unwrap_or("").to_string(), true))
}
}
}
/// 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()),
}
}
/// 2.0.655: wait until the tab's document is the one we navigated to and is past `loading` (up to `max`). A
/// script evaluated straight after a navigate ran in the OLD document and was wiped by the load (the owner
/// chip on the extension lane never appeared; the smoke caught it). Returns true when ready.
pub fn wait_ready(lane: &Lane, t: &Tab, want_url: &str, max: std::time::Duration) -> bool {
// a blank or extension page has nothing to wait for and forbids evals ("Cannot access contents of url
// chrome-extension://..."): 2.0.657 measured 10 s burned twice per about:blank navigate
if want_url.is_empty() || want_url.starts_with("about:") || want_url.starts_with("chrome") { return true; }
let t0 = std::time::Instant::now();
let want_host = want_url.split("//").nth(1).and_then(|r| r.split('/').next()).unwrap_or("").to_string();
while t0.elapsed() < max {
let t1 = std::time::Instant::now();
let r = evaluate(lane, t, "document.readyState + '|' + location.href");
if r.is_err() || t0.elapsed() > std::time::Duration::from_secs(3) { pup_core::log(format!("[ready] tab {} want {} -> {} in {} ms", t.tab_id, want_url.chars().take(60).collect::<String>(), match &r { Ok(v) => v.as_str().unwrap_or("?").chars().take(90).collect::<String>(), Err(e) => format!("ERR {}", e["error"].as_str().unwrap_or("?").chars().take(90).collect::<String>()) }, t1.elapsed().as_millis())); }
if let Ok(v) = r {
let sv = v.as_str().unwrap_or("").to_string();
let (state, href) = sv.split_once('|').unwrap_or(("", ""));
let blank_wanted = want_url.starts_with("about:") || want_url.is_empty();
let host_ok = blank_wanted || want_host.is_empty() || href.contains(&want_host);
if state != "loading" && host_ok && (blank_wanted || href != "about:blank") { return true; }
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
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)),
Lane::Ext => {
let r = run(json!({ "action": "eval", "expr": expr, "tabId": ext_id(t) }), 25)?; // 25 s, not 45: identity's badge eval runs on every open and was the step that blew ab's 60 s when a worker restarted
let res = &r["result"];
if let Some(m) = res.as_str().and_then(|x| x.strip_prefix("ERR:")) {
// 2.0.578: a strict Content-Security-Policy (wiki.adom.inc, claude.ai, Google Docs) refuses the
// injected eval; Runtime.evaluate through the DevTools driver ignores page CSP, as the CDP lane does.
// 2.0.591 (pup-bridge#116): Trusted Types refuse with "violates this document's Trusted Type assignment
// requirements", which the CSP wording never matched, so Google Calendar and Admin got no fallback.
// 2.0.730 (pup-bridge#130): American Express's login page refuses it as "eval is disabled", which none of the
// phrases above matched, so pup_eval and pup_login failed there on the extension lane.
if m.contains("Content Security Policy") || m.contains("unsafe-eval") || m.contains("Trusted Type") || m.contains("Evaluating a string as JavaScript")
|| m.contains("eval is disabled") || m.contains("EvalError") || m.contains("Refused to evaluate") {
return crate::extdrive::ext_handle().cdp.evaluate(&t.cdp_session, expr, Duration::from_secs(20)).map_err(|e| err("eval_failed", e.to_string(), None));
}
return Err(err("eval_failed", format!("pup_eval could not run your code: {m}"), None));
}
// Same shape as the CDP lane's Runtime.evaluate: strings stay strings, objects come back as values.
if let Some(sv) = res["s"].as_str() { return Ok(Value::String(sv.to_string())); }
if res["u"] == json!(true) { return Ok(Value::Null); }
if let Some(jv) = res["j"].as_str() { return Ok(serde_json::from_str::<Value>(jv).unwrap_or_else(|_| Value::String(jv.to_string()))); }
// an older worker (untyped): keep the previous behaviour
let raw = res.as_str().unwrap_or("");
Ok(serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string())))
}
}
}
/// Run a capture with pup's parked browsers producing frames (Host::capture_frames_begin/end; a no-op on
/// Windows, an unhide-behind-the-user on macOS). Every Page.captureScreenshot / captureVisibleTab goes through
/// here so a window parked for a while still answers.
pub fn with_frames<T>(f: impl FnOnce() -> T) -> T {
let host = crate::CAPTURE_HOST.get().cloned();
let unhid = host.as_ref().map(|h| h.capture_frames_begin()).unwrap_or(false);
let r = f();
if unhid { if let Some(h) = host { h.capture_frames_end(); } }
r
}
pub fn screenshot_png(lane: &Lane, t: &Tab, full: bool) -> Result<Vec<u8>, Value> {
with_frames(|| match lane {
Lane::Cdp(b) => b.cdp.screenshot_png(&t.cdp_session, full, Duration::from_secs(20)).map_err(|e| err("screenshot_timeout", format!("page.screenshot: {e}"), Some("The page did not answer the capture. Reload it, or pup_eval document.readyState."))),
Lane::Ext => {
let r = run(json!({ "action": "screenshot", "tabId": ext_id(t) }), 60)?;
if let Some(v) = r["via"].as_str() { if v.starts_with("debugger") { pup_core::log(format!("[screenshot] tab {} captured via {v}", t.tab_id)); } }
let b64 = r["dataUrl"].as_str().and_then(|d| d.split(',').nth(1)).unwrap_or("");
pup_cdp::base64_decode(b64).map_err(|e| err("screenshot_decode_failed", e.to_string(), None))
}
})
}
/// Open a tab in the window. Ext: in the SAME Chrome window (windowId) the session was born with.
pub fn open_tab(lane: &Lane, s: &mut Session, url: &str) -> Result<Tab, Value> {
match lane {
Lane::Cdp(b) => {
let target = b.cdp.create_target(url, false, None, None).map_err(|e| err("cdp_create_target_failed", e.to_string(), None))?;
let tab_id = crate::attach_tab(s, &b.cdp, &target)?;
Ok(s.tab_by_id(&tab_id).cloned().expect("attached tab"))
}
Lane::Ext => {
// 20 s: a slow site (nordicsemi behind Cloudflare) must return BEFORE ab's ~60 s relay gives up, or the
// tab opens in Chrome while the caller sees "Request timed out" and pup never records it. The title
// fills in on the next listing; refresh_all also adopts any tab pup missed.
// 2.0.675: name the window twice over (a live tab in it, and its Chrome window id); the worker refuses
// rather than guess, so a tab can never land in another thread's window again.
let anchor = s.active().map(ext_id).filter(|v| !v.is_null()).or_else(|| s.tabs.iter().map(ext_id).find(|v| !v.is_null()));
if anchor.is_none() && s.window_id.is_none() { return Err(err("window_not_found", "pup does not know which Chrome window this session is (no tab, no window id); refusing to open a tab in some other window", Some("pup_my_windows shows the window's state; pup_open_window with this window name opens it fresh."))); }
let r = run(json!({ "action": "newtab", "url": url, "tabId": anchor, "windowId": s.window_id, "waitMs": 20000 }), 40)?;
let id = r["tabId"].as_i64().ok_or_else(|| err("ext_no_tab", "the extension opened no tab", None))?;
Ok(ext_tab(s, id, r["url"].as_str().unwrap_or(url), r["title"].as_str().unwrap_or("")))
}
}
}
pub fn activate(lane: &Lane, t: &Tab) -> Result<(), Value> {
match lane {
Lane::Cdp(b) => { let _ = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": t.target_id }), Duration::from_secs(5)); Ok(()) }
Lane::Ext => run(json!({ "action": "activate", "tabId": ext_id(t) }), 20).map(|_| ()),
}
}
/// Bring a tab's url/title up to date. Ext: one `tabs` listing serves every tab of the window.
pub fn refresh_all(lane: &Lane, s: &mut Session) {
match lane {
Lane::Cdp(b) => { for t in s.tabs.iter_mut() { crate::refresh_tab(&b.cdp, t); } }
Lane::Ext => {
if let Ok(r) = run(json!({ "action": "tabs" }), 25) {
let live = r["tabs"].as_array().cloned().unwrap_or_default();
for t in s.tabs.iter_mut() {
if let Some(l) = live.iter().find(|l| l["tabId"].as_i64().map(|i| i.to_string()) == Some(t.target_id.clone())) {
if let Some(u) = l["url"].as_str() { crate::extdrive::keep_url(&mut t.url, u); }
if let Some(ti) = l["title"].as_str() { t.title = ti.into(); }
}
}
// adopt tabs Chrome has in THIS session's window that pup does not track (a tab whose open
// outlived the CLI relay, or one the user opened by hand), in Chrome's order
if let Some(wid) = s.window_id {
let known: Vec<String> = s.tabs.iter().map(|t| t.target_id.clone()).collect();
let mut adopted = 0;
for l in live.iter().filter(|l| l["windowId"].as_i64() == Some(wid)) {
if let Some(id) = l["tabId"].as_i64() {
if !known.contains(&id.to_string()) {
let t = ext_tab(s, id, l["url"].as_str().unwrap_or(""), l["title"].as_str().unwrap_or(""));
s.tabs.push(t); adopted += 1;
}
}
}
if adopted > 0 { pup_core::log(format!("[lane] \"{}\": adopted {adopted} tab(s) Chrome had in its window that pup did not track", s.id)); }
}
if let Some(i) = live.iter().find(|l| l["active"] == json!(true) && l["windowId"].as_i64() == s.window_id).and_then(|l| l["tabId"].as_i64()) {
if let Some(pos) = s.tabs.iter().position(|t| t.target_id == i.to_string()) { s.active_tab = pos; }
}
}
}
}
}
pub fn close_tab(lane: &Lane, t: &Tab) {
match lane {
Lane::Cdp(b) => { let _ = b.cdp.close_target(&t.target_id); }
Lane::Ext => { let _ = run(json!({ "action": "close", "tabId": ext_id(t) }), 20); }
}
}
/// Close the OS window behind a session. Ext: only that Chrome window; the lane browser stays up for
/// other sessions and is reaped when it has none (the same rule the CDP browsers follow).
/// true when the lane confirmed the close; false when it could not even be asked (no window id) or did not answer.
pub fn close_window(lane: &Lane, s: &Session) -> bool {
match lane {
Lane::Cdp(b) => { let mut any = false; for t in &s.tabs { if b.cdp.close_target(&t.target_id).is_ok() { any = true; } } any || s.tabs.is_empty() }
Lane::Ext => match s.window_id { Some(w) => run(json!({ "action": "close", "windowId": w }), 20).is_ok(), None => false },
}
}
/// Does the lane still hold this session's window? Ext: any tab whose windowId is the session's. CDP: any of
/// the session's targets still attached. The lane's word, for hosts whose native handle outlives a close.
pub fn lane_has_window(lane: &Lane, s: &Session) -> bool {
match lane {
Lane::Cdp(b) => { let live = b.cdp.targets().unwrap_or_default(); s.tabs.iter().any(|t| live.iter().any(|v| v["targetId"].as_str() == Some(t.target_id.as_str()))) }
Lane::Ext => match s.window_id {
Some(w) => match run(json!({ "action": "tabs" }), 10) { Ok(r) => r["tabs"].as_array().map(|ts| ts.iter().any(|t| t["windowId"].as_i64() == Some(w))).unwrap_or(false), Err(_) => true },
None => false,
},
}
}
/// Download a URL through the browser. Ext: chrome.downloads in the control extension. CDP callers keep the
/// existing Browser.setDownloadBehavior path in extras.rs.
pub fn download(lane: &Lane, url: &str, save_as: Option<&str>, wait_ms: u64) -> Result<Value, Value> {
match lane {
Lane::Cdp(_) => Err(err("internal", "transport::download is the extension arm; CDP downloads run in extras::download", None)),
Lane::Ext => run(json!({ "action": "download", "url": url, "filename": save_as, "waitMs": wait_ms }), wait_ms / 1000 + 15),
}
}
/// Delete cookies for a domain (and its subdomains), HttpOnly included, on either lane. `names` narrows it.
pub fn clear_cookies(lane: &Lane, domain: &str, names: &[String]) -> Result<u64, Value> {
let dom = domain.trim_start_matches('.').to_lowercase();
match lane {
Lane::Ext => run(json!({ "action": "clearCookies", "domain": dom, "names": names }), 20).map(|r| r["removed"].as_u64().unwrap_or(0)),
Lane::Cdp(b) => {
let all = b.cdp.send(None, "Network.getAllCookies", json!({}), Duration::from_secs(10)).map_err(|e| err("cookies_failed", e.to_string(), None))?;
let mut n = 0u64;
for c in all["cookies"].as_array().cloned().unwrap_or_default() {
let cd = c["domain"].as_str().unwrap_or("").trim_start_matches('.').to_lowercase();
let name = c["name"].as_str().unwrap_or("").to_string();
if (dom.is_empty() || cd == dom || cd.ends_with(&format!(".{dom}"))) && (names.is_empty() || names.contains(&name)) {
if b.cdp.send(None, "Network.deleteCookies", json!({ "name": name, "domain": c["domain"], "path": c["path"] }), Duration::from_secs(5)).is_ok() { n += 1; }
}
}
Ok(n)
}
}
}
/// Clear everything the jar keeps for these origins (cache, cookies, storage, service workers). Ext only; the CDP
/// lane keeps Network/Storage domain verbs.
pub fn clear_site_data(lane: &Lane, origins: &[String]) -> Result<(), Value> {
match lane {
Lane::Ext => run(json!({ "action": "clearSiteData", "origins": origins }), 20).map(|_| ()),
Lane::Cdp(b) => { for o in origins { let _ = b.cdp.send(None, "Storage.clearDataForOrigin", json!({ "origin": o, "storageTypes": "all" }), Duration::from_secs(10)); } Ok(()) }
}
}
/// Inject an extension-shipped script FILE into the page's MAIN world. Ext: chrome.scripting files
/// injection, which a page CSP cannot refuse. CDP: Runtime.evaluate of the same file's text (it ignores
/// CSP). `text` is that file's contents: the ONE copy lives in src/extension.
pub fn inject_script(lane: &Lane, t: &Tab, file: &str, text: &str, guard: &str) -> Result<(), Value> {
match lane {
Lane::Cdp(b) => b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ if (!{guard}) {{ {text} }} return !!{guard}; }})()"), Duration::from_secs(10)).map(|_| ()).map_err(|e| err("inject_failed", e.to_string(), None)),
Lane::Ext => run(json!({ "action": "inject", "file": file, "tabId": ext_id(t), "world": "MAIN" }), 20).map(|_| ()),
}
}
/// window[name](...args) in the page.
pub fn call_window_fn(lane: &Lane, t: &Tab, name: &str, args: &[Value]) -> Result<Value, Value> {
match lane {
Lane::Cdp(b) => {
let a = args.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", ");
b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const f = window[{}]; return (typeof f === 'function') ? (f({a}) ?? null) : null; }})()", serde_json::to_string(name).unwrap()), Duration::from_secs(10)).map_err(|e| err("call_failed", e.to_string(), None))
}
Lane::Ext => {
let r = run(json!({ "action": "call", "fn": name, "args": args, "tabId": ext_id(t) }), 20)?;
if let Some(e) = r["error"].as_str() { return Err(err("call_failed", e.to_string(), None)); }
if r["missing"] == json!(true) { return Err(err("call_missing", format!("window.{name} is not defined in the page"), None)); }
Ok(r["value"].clone())
}
}
}
/// window[name] (cleared when asked) and the page's device pixel ratio.
pub fn read_window(lane: &Lane, t: &Tab, name: &str, clear: bool) -> Result<(Value, f64), Value> {
match lane {
Lane::Cdp(b) => {
let n = serde_json::to_string(name).unwrap();
let v = b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const v = window[{n}]; {} return JSON.stringify({{ value: v === undefined ? null : v, dpr: window.devicePixelRatio || 1 }}); }})()", if clear { format!("window[{n}] = null;") } else { String::new() }), Duration::from_secs(5)).map_err(|e| err("read_failed", e.to_string(), None))?;
let o: Value = v.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(v);
Ok((o["value"].clone(), o["dpr"].as_f64().unwrap_or(1.0)))
}
Lane::Ext => { let r = run(json!({ "action": "read", "name": name, "clear": clear, "tabId": ext_id(t) }), 8)?; Ok((r["value"].clone(), r["dpr"].as_f64().unwrap_or(1.0))) }
}
}
/// A PNG of the viewport, optionally clipped to a CSS-pixel rect. CDP clips in the capture; the
/// extension lane captures the visible tab (device pixels) and the clip is applied in the binary.
pub fn capture_viewport_png(lane: &Lane, t: &Tab, clip_css: Option<(f64, f64, f64, f64)>, dpr: f64) -> Result<Vec<u8>, Value> {
with_frames(|| match lane {
Lane::Cdp(b) => {
let mut p = json!({ "format": "png", "captureBeyondViewport": false });
if let Some((x, y, w, h)) = clip_css { p["clip"] = json!({ "x": x.max(0.0), "y": y.max(0.0), "width": w.max(1.0), "height": h.max(1.0), "scale": 1 }); }
let r = b.cdp.send(Some(&t.cdp_session), "Page.captureScreenshot", p, Duration::from_secs(15)).map_err(|e| err("capture_failed", e.to_string(), None))?;
pup_cdp::base64_decode(r["data"].as_str().unwrap_or("")).map_err(|e| err("capture_failed", e.to_string(), None))
}
Lane::Ext => {
let r = run(json!({ "action": "captureVisible", "tabId": ext_id(t) }), 20)?;
let b64 = r["dataUrl"].as_str().and_then(|d| d.split("base64,").nth(1)).unwrap_or("");
let bytes = pup_cdp::base64_decode(b64).map_err(|e| err("capture_failed", e.to_string(), None))?;
match clip_css {
None => Ok(bytes),
Some((x, y, w, h)) => pup_identity::badge::crop_png(&bytes, (x.max(0.0) * dpr).round() as u32, (y.max(0.0) * dpr).round() as u32, ((w.max(1.0) * dpr).round() as u32).max(1), ((h.max(1.0) * dpr).round() as u32).max(1)).ok_or_else(|| err("capture_failed", "could not decode the captured viewport".to_string(), None)),
}
}
})
}