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.
← Commit history
v2.0.645 (insiders): pup-bridge#119: HTTP Basic/Digest auth answered from the vault on the extension lane; no_credential typed error; pup_fetch_url on the extension lane; landed hint (2.0.642/643)
8 files changed
+106−16
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.643"+version = "2.0.645" dependencies = [ "pup-browser", "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.643"+version = "2.0.645" dependencies = [ "pup-core", "pup-host",@@ -667,7 +667,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.643"+version = "2.0.645" dependencies = [ "serde_json", "thiserror",@@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.643"+version = "2.0.645" dependencies = [ "serde", "serde_json",@@ -687,7 +687,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.643"+version = "2.0.645" dependencies = [ "pup-cdp", "pup-core",@@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.643"+version = "2.0.645" dependencies = [ "serde", "serde_json",@@ -709,14 +709,14 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.643"+version = "2.0.645" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.643"+version = "2.0.645" dependencies = [ "pup-core", "pup-host",@@ -725,7 +725,7 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.643"+version = "2.0.645" dependencies = [ "fontdue", "image",@@ -735,7 +735,7 @@ dependencies = [ [[package]] name = "pup-session"-version = "2.0.643"+version = "2.0.645" dependencies = [ "pup-core", "serde",@@ -745,7 +745,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.643"+version = "2.0.645" dependencies = [ "pup-core", "pup-host",@@ -756,7 +756,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.643"+version = "2.0.645" 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.643"+version = "2.0.645" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+8@@ -148,6 +148,14 @@ fn main() { pup_verbs::vault::on_capture_ext(&c, &raw); let _ = req.respond(json_response(200, &json!({ "ok": true }))); }+ // 2.0.644 (pup-bridge#119): the extension asks for an HTTP auth credential (Basic/Digest) from the vault.+ (Method::Post, "/extauth") => {+ let mut raw = String::new();+ let mut req = req;+ let _ = req.as_reader().read_to_string(&mut raw);+ let out = pup_verbs::vault::on_auth_required(&c, &raw);+ let _ = req.respond(json_response(200, &out));+ } (Method::Post, "/exthello") => { let mut raw = String::new(); let mut req = req;
rust/verbs/src/input.rs+11−1@@ -389,7 +389,17 @@ pub fn fetch_url(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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!({})));- match b.cdp.evaluate(&t.cdp_session, &expr, Duration::from_secs(40)) {+ // 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.+ 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()) };+ 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),
rust/verbs/src/lib.rs+4@@ -1411,6 +1411,10 @@ fn navigate_ext(ctx: &Ctx, caller: &Caller, s: &Session, url: &str) -> Value { // 2.0.629: force, so a navigation inside the birth grace that Chrome ACTIVATED is still bottomed under the // idle-hands rule; outside the grace, or with any input since birth, the primitive stands down as before. let _ = park::bottom_unless_foreground(ctx, &s.id, true);+ // 2.0.644 (pup-bridge#119): a cancelled HTTP auth challenge is reported as a typed error, not a blank page.+ if let Some((host, answered)) = transport::ext_id(&tab).as_i64().and_then(vault::unanswered_challenge) {+ if !answered { return pup_core::merge(vault::no_credential_error(&host, false), json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "url": u, "title": title, "transport": "ext" })); }+ } ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "url": u, "title": title, "loaded": loaded, "transport": "ext", "landed": landed_ext(ctx, s, &tab, &u, &title) })) }
rust/verbs/src/vault.rs+37@@ -32,6 +32,43 @@ pub fn on_capture_ext(ctx: &Ctx, raw: &str) { } } +/// 2.0.644 (pup-bridge#119): HTTP auth challenges seen by the extension lane: (tabId, host, answered, when).+static AUTH_CHALLENGES: std::sync::Mutex<Vec<(i64, String, bool, u64)>> = std::sync::Mutex::new(Vec::new());++/// POST /extauth from the extension's onAuthRequired: answer a Basic/Digest challenge from the vault, matched+/// on the registrable domain (st./ms./nxp.componentsearchengine.com all match a componentsearchengine.com+/// entry). The password goes to the extension only, never to the log. A second attempt for the same tab+host+/// means the stored credential was rejected: do not resend it, let the challenge cancel, and the next verb+/// says so. Never returns a credential when captureLogins/autoLogin are off.+pub fn on_auth_required(ctx: &Ctx, raw: &str) -> Value {+ let rec: Value = serde_json::from_str(raw).unwrap_or(json!({}));+ let host = rec["host"].as_str().unwrap_or("").to_string();+ let tab = rec["tabId"].as_i64().unwrap_or(-1);+ let attempt = rec["attempt"].as_i64().unwrap_or(1);+ if host.is_empty() { return json!({}); }+ let found = if attempt <= 1 && ctx.settings.get("autoLogin", "on") != "off" { ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), None) } else { None };+ let now = pup_core::now_ms();+ { let mut m = AUTH_CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()); m.retain(|c| now.saturating_sub(c.3) < 120_000); m.push((tab, host.clone(), found.is_some(), now)); }+ match found {+ Some(c) => { pup_core::log(format!("[cred] HTTP auth challenge from {host}: answered with the vault entry for {} ({}) on the extension lane", c.host, c.username)); ctx.vault.touch_used_user(&c.host, &c.username); json!({ "username": c.username, "password": c.password }) }+ None => { pup_core::log(format!("[cred] HTTP auth challenge from {host}: {} (attempt {attempt}); cancelled so no native dialog blocks the window", if attempt > 1 { "the stored credential was REJECTED" } else { "no vault entry for that domain" })); json!({}) }+ }+}++/// The host of an HTTP auth challenge on `tab` in the last 60 s that pup could not answer, if any.+pub fn unanswered_challenge(tab: i64) -> Option<(String, bool)> {+ let now = pup_core::now_ms();+ let m = AUTH_CHALLENGES.lock().unwrap_or_else(|e| e.into_inner());+ m.iter().rev().find(|c| c.0 == tab && now.saturating_sub(c.3) < 60_000).map(|c| (c.1.clone(), c.2))+}++/// The typed answer to an unanswered challenge: which host asked, and the three ways to supply it.+pub fn no_credential_error(host: &str, rejected: bool) -> Value {+ let what = if rejected { format!("{host} asked for HTTP authentication and REJECTED the stored vault credential (stale password).") } else { format!("{host} asked for HTTP authentication (Basic/Digest) and the vault has no entry for that domain.") };+ crate::err("no_credential", format!("{what} pup cancelled the challenge so no native dialog blocks the window; the page is an error page until a credential exists."),+ Some(&format!("Three routes, in order: pup_import_browser_logins {{}} (the user's own Chrome/Edge saved logins, one consent + one UAC), pup_vault_sync {{peer, hostFilter:\"{d}\"}} from a box that has it, or credential_set {{host:\"{d}\", username, password}} (a plain domain entry matches every subdomain). Then navigate again. {IMPORT_HINT}", d = pup_vault::registrable_domain(host))))+}+ /// Runtime.bindingCalled "__pupCredCapture" from the capture script: store the login. pub fn on_capture(ctx: &Ctx, sid_cdp: &str, payload: &str) { if ctx.settings.get("captureLogins", "on") == "off" { return; }
src/extension/manifest.json+4−2@@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Adom Pup",- "version": "0.4.15",+ "version": "0.4.17", "description": "Toolbar for Adom Pup browser windows: annotate, snip, view toggle, dashboard, owner info. Thin trigger surface - the pup bridge is the engine.", "action": { "default_popup": "popup.html",@@ -21,7 +21,9 @@ "downloads", "cookies", "declarativeNetRequest",- "browsingData"+ "browsingData",+ "webRequest",+ "webRequestAuthProvider" ], "host_permissions": [ "<all_urls>",
src/extension/worker.js+29@@ -72,6 +72,35 @@ chrome.alarms.create('pup-badges', { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener(a => { if (a.name === 'pup-badges') sweepBadges(); }); chrome.tabs.onActivated.addListener(() => sweepBadges()); chrome.runtime.onStartup.addListener(() => sweepBadges());++// 0.4.16 (pup-bridge#119): HTTP Basic/Digest auth on this lane. The CDP lane answers a 401 with the vault+// through page.authenticate(); this lane had nothing, so Chrome's native "Sign in" dialog sat on the pup+// window and every verb hung (st.componentsearchengine.com, 2026-09-19). Ask pup for the credential+// (matched on the registrable domain); with none, CANCEL the challenge so no dialog ever blocks the window,+// and pup reports no_credential with the host on the next navigate / fetch. Two tries per tab+host, then+// cancel: a rejected credential must not loop.+const _authTries = new Map();+try {+ chrome.webRequest.onAuthRequired.addListener((details, cb) => {+ (async () => {+ try {+ const host = (details.challenger && details.challenger.host) || '';+ const key = String(details.requestId || ((details.tabId || 0) + '|' + host)); // per REQUEST: the same id fires again only when the credential was rejected+ const n = (_authTries.get(key) || 0) + 1; _authTries.set(key, n);+ setTimeout(() => _authTries.delete(key), 60000);+ const found = await findPort();+ if (!found || n > 2 || details.isProxy) { cb({ cancel: true }); return; }+ const r = await fetch(`http://127.0.0.1:${found.port}/extauth?lane=${encodeURIComponent(_pkgLane || '')}`, {+ method: 'POST', headers: { 'content-type': 'application/json' },+ body: JSON.stringify({ host, port: details.challenger && details.challenger.port, realm: details.realm || '', scheme: details.scheme || '', url: details.url, tabId: details.tabId, attempt: n }),+ signal: AbortSignal.timeout(4000),+ }).then(x => x.json()).catch(() => null);+ if (r && r.username) cb({ authCredentials: { username: r.username, password: r.password || '' } });+ else cb({ cancel: true });+ } catch (e) { cb({ cancel: true }); }+ })();+ }, { urls: ['<all_urls>'] }, ['asyncBlocking']);+} catch (e) {} sweepBadges(); // content-script bridge: the floaty pencil's config + click routing (page cannot reach the