← 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)

John Lauer ·06c2fef42b ·1mo ago ·parent 8b99317
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