← Commit history

v2.0.741 (insiders): Login import keeps working where nb cannot run it (pup-bridge#145): pup asks nb first, and only when nb is missing, older than 0.16.45 or has no extension in the user's browser it uses Adom Bridge's older decrypt helper, as 2.0.737 did; nb's own answers (Hello waiting, no session, export failed) now reach the caller instead of nb_call_failed, and profile:"chrome:<email>" picks which browser profile nb reads (pup-bridge#140).

Colby Knox ·87889f6896 ·5d ago ·parent a128f10
5 files changed +220−35
rust/Cargo.lock+12−12
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-pup-bridge"-version = "2.0.740"+version = "2.0.741" dependencies = [  "pup-browser",  "pup-cdp",@@ -781,7 +781,7 @@ dependencies = [  [[package]] name = "pup-browser"-version = "2.0.740"+version = "2.0.741" dependencies = [  "pup-core",  "pup-host",@@ -793,7 +793,7 @@ dependencies = [  [[package]] name = "pup-cdp"-version = "2.0.740"+version = "2.0.741" dependencies = [  "serde_json",  "thiserror",@@ -803,7 +803,7 @@ dependencies = [  [[package]] name = "pup-core"-version = "2.0.740"+version = "2.0.741" dependencies = [  "serde",  "serde_json",@@ -813,7 +813,7 @@ dependencies = [  [[package]] name = "pup-dashboard"-version = "2.0.740"+version = "2.0.741" dependencies = [  "pup-cdp",  "pup-core",@@ -826,7 +826,7 @@ dependencies = [  [[package]] name = "pup-host"-version = "2.0.740"+version = "2.0.741" dependencies = [  "serde",  "serde_json",@@ -835,7 +835,7 @@ dependencies = [  [[package]] name = "pup-host-mac"-version = "2.0.740"+version = "2.0.741" dependencies = [  "core-foundation",  "core-graphics",@@ -851,7 +851,7 @@ dependencies = [  [[package]] name = "pup-host-win"-version = "2.0.740"+version = "2.0.741" dependencies = [  "pup-core",  "pup-host",@@ -861,7 +861,7 @@ dependencies = [  [[package]] name = "pup-identity"-version = "2.0.740"+version = "2.0.741" dependencies = [  "fontdue",  "image",@@ -871,7 +871,7 @@ dependencies = [  [[package]] name = "pup-session"-version = "2.0.740"+version = "2.0.741" dependencies = [  "pup-core",  "serde",@@ -881,7 +881,7 @@ dependencies = [  [[package]] name = "pup-vault"-version = "2.0.740"+version = "2.0.741" dependencies = [  "pup-core",  "pup-host",@@ -892,7 +892,7 @@ dependencies = [  [[package]] name = "pup-verbs"-version = "2.0.740"+version = "2.0.741" 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.740"+version = "2.0.741" edition = "2021" license = "MIT" 
rust/verbs/assets/verb-catalog.json+8−4
@@ -2094,10 +2094,14 @@      "digikey"     ]    },-   "hint": "Seed pup's vault from the user's OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY \u2014 the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. [\"snapeda\",\"digikey\"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).",+   "hint": "Answers consent_pending while the user approves; call again with the SAME arguments to collect the result (no second prompt). The answer says via:\"nb\" or via:\"ab-helper\", and which profile was read. no_data means that profile has no saved passwords: pass profile:\"<key from nbrowser_profiles>\" for another.",    "input": {-    "browsers": "optional str[] subset of [\"chrome\",\"edge\",\"brave\"] (default: all found)",-    "hostFilter": "optional str or str[] \u2014 only import logins whose host contains one of these (surgical import)"+    "hostFilter": "optional str or str[]: only import logins whose host contains one of these",+    "sessionFor": "optional str: one site (e.g. \"x.com\") whose signed-in session to bring over instead of passwords",+    "profile": "optional str: the browser profile nb reads, as nbrowser_profiles lists it (e.g. \"chrome:[email protected]\"); default the active one",+    "browsers": "optional str[] (helper fallback only): subset of [\"chrome\",\"edge\",\"brave\"]",+    "sourceProfile": "optional str (helper fallback, sessionFor): which \"<browser> / <profile>\" to take when several have a session",+    "reason": "optional str: shown to the user with the prompt"    },    "longRunning": false,    "name": "pup_import_browser_logins",@@ -2107,7 +2111,7 @@    "pitfalls": [],    "related": [],    "statusVerb": null,-   "summary": "Seed pup's vault from the user's OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY \u2014 the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. [\"snapeda\",\"digikey\"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).",+   "summary": "Seed pup's vault from the user's OWN saved browser passwords (or, with sessionFor, one site's signed-in session), so pup can sign in as them on sites they already use. Through nb, the browser exports its own store and the user answers one Windows Hello; where nb cannot run (not installed, too old, no extension connected) pup falls back to Adom Bridge's decrypt helper (a consent and a Windows admin prompt; Edge yes, Chrome 156+ no). Returns COUNTS ONLY; values are never logged or returned.",    "timeoutSeconds": 190   },   {
rust/verbs/src/import.rs+194−18
@@ -10,6 +10,12 @@ //! Both hand pup a machine-local `handoffPath` plus counts, never a value over the relay. pup is a local //! bridge on the same desktop, so it reads the handoff file directly, imports it, then drops the handoff. //! Credentials are used to fill the keychain / set cookies and are never logged.+//!+//! pup-bridge#145 (Colby, 2026-11-05): nb is the path, and pup falls back to ab's decrypt helper ONLY when nb+//! cannot run the export at all: nb missing, nb older than these verbs (public nb was 0.16.33), or no nb+//! extension connected. Without the fallback, promoting pup broke login import on every public desktop, where+//! the helper still serves Edge (Chrome 156+ is beyond it, adom/adom-bridge#254). A user's own answer from nb+//! (Hello declined, no session, export failed) is never retried through the helper.  use crate::Ctx; use pup_core::{err, ok, Caller};@@ -69,22 +75,71 @@ pub fn import_browser_logins(ctx: &Ctx, caller: &Caller, args: &Value) -> Value     let passwords = session_for(args).is_none();     if passwords {         let _ = ctx.ab.call("notify_user", json!({ "title": "pup is waiting for your approval",-            "body": "A Windows Hello prompt is opening so Chrome can export your saved logins into pup. Approve it; pup uses the logins to sign you in without asking for passwords.",+            "body": "Approve the prompt on your screen (Windows Hello, or on older setups an Adom Bridge consent and then a Windows admin prompt) so pup can import your saved logins. pup uses them to sign you in without asking for passwords.",             "level": "warning", "focus": { "host": true } }), caller, Duration::from_secs(8));     }     let other = IMPORT_JOB.lock().unwrap().as_ref().map(|j| j.key != key).unwrap_or(false);     let detail = if passwords {-        "nb is driving Chrome's own password export on the desktop; the user answers a Windows Hello prompt (Chrome decrypts its own store, pup never touches the sealed store). Tell the user to approve Hello, then call pup_import_browser_logins again with the same arguments: it re-attaches to this request and returns the result, it does not raise a second prompt."+        "The import is waiting on the user. With nb it is Chrome's own password export and one Windows Hello prompt; where nb cannot run, Adom Bridge's consent and then a Windows admin prompt (inside Hydrogen the consent arrives as a notification and a card). Tell the user to approve what is on screen, then call pup_import_browser_logins again with the same arguments: it re-attaches to this request and returns the result, it does not raise a second prompt."     } else {-        "nb is reading the site's session from the user's own browser. Call pup_import_browser_logins again with the same arguments to collect the result; it re-attaches to the same request."+        "The site's session is being read from the user's own browser (where nb cannot run, after an Adom Bridge consent and a Windows admin prompt). Call pup_import_browser_logins again with the same arguments to collect the result; it re-attaches to the same request."     };     pup_core::merge(err("consent_pending", "Waiting for the import to finish on the user's desktop", Some(&format!("{}{}", if other { "An import with different arguments is already waiting; this call attached to it. " } else { "" }, detail))),         json!({ "pending": true, "output": json!({ "ok": false, "pending": true }).to_string() })) }  fn import_now(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {-    if let Some(site) = session_for(args) { return import_session_via_nb(ctx, caller, args, &site); }-    import_passwords_via_nb(ctx, caller, args)+    let site = session_for(args);+    let r = match &site { Some(s) => import_session_via_nb(ctx, caller, args, s), None => import_passwords_via_nb(ctx, caller, args) };+    let Some(why) = r.get("_nbUnavailable").and_then(|w| w.as_str()).map(|w| w.to_string()) else { return pup_core::merge(r, json!({ "via": "nb" })) };+    if !cfg!(windows) { return err("nb_unavailable", format!("nb could not run the import: {why}"), Some("Install or update the native-browser bridge (nb) with its extension in the user's browser.")); }+    pup_core::log(format!("[import] nb cannot run the export ({why}); falling back to ab's decrypt helper"));+    let h = import_via_helper(ctx, caller, args);+    let next = "The supported path is nb: install or update the native-browser bridge with its extension in the user's browser (nbrowser_readiness). Chrome 156 and later can only be imported that way.";+    if h.get("success") == Some(&json!(false)) {+        let msg = format!("{} (nb could not run it first: {why})", h["error"].as_str().unwrap_or("the import failed"));+        let hint = format!("{} {next}", h["_hint"].as_str().unwrap_or(""));+        return pup_core::merge(h, json!({ "error": msg, "_hint": hint.trim(), "nbUnavailable": why, "via": "ab-helper" }));+    }+    let hint = format!("{} Imported through Adom Bridge's older decrypt helper because nb could not run it ({why}). {next}", h["_hint"].as_str().unwrap_or(""));+    pup_core::merge(h, json!({ "via": "ab-helper", "nbUnavailable": why, "_hint": hint.trim() }))+}++/// Ask nb for an export. Err(Ok(why)) = nb cannot run it here (the helper may); Err(Err(resp)) = a real answer to+/// return as it is (the user declined, no session, ...). Before 2.0.741 every nb refusal surfaced as nb_call_failed+/// and nb's own codes (awaiting_user, no_session) never reached the caller.+fn nb_export(ctx: &Ctx, caller: &Caller, verb: &str, args: Value, what: &str) -> Result<(Value, Value), Result<String, Value>> {+    let resp = match ctx.ab.call(verb, args, caller, Duration::from_secs(180)) {+        Ok(v) => v,+        Err(pup_core::AbError::Refused { code, message, .. }) => {+            if let Some(why) = nb_cannot_run(&code, &message) { return Err(Ok(why)); }+            let p = json!({ "error": message });+            return Err(Err(relay_nb_error(&code, &p, what).unwrap_or_else(|| err(if code == "error" { "nb_refused" } else { code.as_str() }, format!("nb refused to {what}: {message}"), None))));+        }+        Err(e) => return Err(Err(err("nb_call_failed", format!("Could not reach the native-browser bridge (nb) to {what}: {e}"), Some("Adom Bridge did not answer pup's call. Retry once; if it keeps failing, report it on adom/pup-bridge with the time.")))),+    };+    let payload = unwrap_payload(&resp);+    if payload["ok"] == json!(false) && payload["handoffPath"].is_null() {+        let msg = payload["error"].as_str().unwrap_or("").to_string();+        if let Some(why) = nb_cannot_run(payload["errorCode"].as_str().unwrap_or(""), &msg) { return Err(Ok(why)); }+    }+    if let Some(ec) = error_code(&resp, &payload) { if let Some(e) = relay_nb_error(&ec, &payload, what) { return Err(Err(e)); } }+    Ok((resp, payload))+}++/// nb cannot run the export on this desktop at all, as opposed to answering it. Matched on what ab and nb+/// actually send: ab's routing codes when no bridge serves the verb, an older nb's "verb not implemented yet"+/// (nb 0.16.33, measured 2026-11-05), and "native browser extension not connected".+fn nb_cannot_run(code: &str, message: &str) -> Option<String> {+    let m = message.to_lowercase();+    let codes = ["unknown_verb", "unknown_command", "no_bridge", "bridge_not_installed", "bridge_not_found", "bridge_unavailable", "bridge_spawn_failed", "unsupported_on_this_os"];+    if codes.contains(&code) || m.contains("not implemented") || m.contains("unknown verb") || m.contains("unknown command") || m.contains("no bridge") {+        return Some("nb is not installed here, or is older than the export verbs (it needs nb 0.16.45 or newer)".into());+    }+    if m.contains("extension not connected") || m.contains("not connected") {+        return Some("no nb extension is connected in the user's browser".into());+    }+    None }  /// Unwrap a bridge response that may carry its real JSON in an `output` string (ab's passthrough shape).@@ -125,12 +180,13 @@ fn session_for(args: &Value) -> Option<String> { fn import_session_via_nb(ctx: &Ctx, caller: &Caller, args: &Value, site: &str) -> Value {     let reason = args["reason"].as_str().map(|s| s.to_string()).unwrap_or_else(|| format!("Bring your {site} session from your own browser into pup so it can act as you there."));     pup_core::log(format!("[import] session for {site}: asking nb (nbrowser_export_site_session)"));-    let resp = match ctx.ab.call("nbrowser_export_site_session", json!({ "domain": site, "confirm": true, "reason": reason }), caller, Duration::from_secs(180)) {-        Ok(v) => v,-        Err(e) => return err("nb_call_failed", format!("Could not reach the native-browser bridge (nb) to export the {site} session: {e}"), Some("Is Adom Bridge's native-browser bridge installed with its extension loaded? nbrowser_profiles should list the user's real browser.")),+    let mut a = json!({ "domain": site, "confirm": true, "reason": reason });+    if let Some(p) = nb_profile(args) { a["profile"] = json!(p); }+    let (resp, payload) = match nb_export(ctx, caller, "nbrowser_export_site_session", a, "export a site session") {+        Ok(x) => x,+        Err(Ok(why)) => return json!({ "success": false, "_nbUnavailable": why }),+        Err(Err(e)) => return e,     };-    let payload = unwrap_payload(&resp);-    if let Some(ec) = error_code(&resp, &payload) { if let Some(e) = relay_nb_error(&ec, &payload, "export a site session") { return e; } }      let handoff_id = payload["handoffId"].as_str().or_else(|| resp["handoffId"].as_str()).map(|s| s.to_string());     let Some(path) = payload["handoffPath"].as_str().or_else(|| resp["handoffPath"].as_str()).map(|s| s.to_string()) else {@@ -140,7 +196,8 @@ fn import_session_via_nb(ctx: &Ctx, caller: &Caller, args: &Value, site: &str) -     let file: Value = serde_json::from_str(&data).unwrap_or(Value::Null);     let cookies: Vec<Value> = file["cookies"].as_array().cloned().unwrap_or_default(); -    let result = set_session_cookies(ctx, caller, site, &cookies);+    let mut result = set_session_cookies(ctx, caller, site, &cookies);+    if let Some(p) = payload["profile"].as_str() { result["profile"] = json!(p); }     if let Some(id) = &handoff_id { let _ = ctx.ab.call("nbrowser_drop_handoff", json!({ "handoffId": id }), caller, Duration::from_secs(10)); }     result }@@ -189,12 +246,14 @@ fn import_passwords_via_nb(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     // nb exports the whole profile on one Windows Hello (nb#55): never pass `sites`, which would re-prompt per     // site. pup narrows to hostFilter client-side after reading the CSV.     pup_core::log("[import] passwords: asking nb (nbrowser_export_saved_logins) — Chrome's own export, one Windows Hello".to_string());-    let resp = match ctx.ab.call("nbrowser_export_saved_logins", json!({ "confirm": true, "reason": reason }), caller, Duration::from_secs(180)) {-        Ok(v) => v,-        Err(e) => return err("nb_call_failed", format!("Could not reach the native-browser bridge (nb) to export saved logins: {e}"), Some("Is Adom Bridge's native-browser bridge installed with its extension loaded? It must be nb >=0.16.45 for nbrowser_export_saved_logins.")),+    let mut a = json!({ "confirm": true, "reason": reason });+    if let Some(p) = nb_profile(args) { a["profile"] = json!(p); }+    let (resp, payload) = match nb_export(ctx, caller, "nbrowser_export_saved_logins", a, "export saved logins") {+        Ok(x) => x,+        Err(Ok(why)) => return json!({ "success": false, "_nbUnavailable": why }),+        Err(Err(e)) => return e,     };-    let payload = unwrap_payload(&resp);-    if let Some(ec) = error_code(&resp, &payload) { if let Some(e) = relay_nb_error(&ec, &payload, "export saved logins") { return e; } }+    let read_from = payload["profile"].as_str().map(|s| s.to_string());      let handoff_id = payload["handoffId"].as_str().or_else(|| resp["handoffId"].as_str()).map(|s| s.to_string());     let Some(path) = payload["handoffPath"].as_str().or_else(|| resp["handoffPath"].as_str()).map(|s| s.to_string()) else {@@ -204,7 +263,7 @@ fn import_passwords_via_nb(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let rows = csv_to_login_rows(&csv);     if rows.is_empty() {         if let Some(id) = &handoff_id { let _ = ctx.ab.call("nbrowser_drop_handoff", json!({ "handoffId": id }), caller, Duration::from_secs(10)); }-        return err("no_data", "nb's export contained no logins.", Some("The exported profile may have no saved passwords. Check nbrowser_profiles for the right profile."));+        return pup_core::merge(err("no_data", format!("nb's export{} contained no logins.", read_from.as_deref().map(|p| format!(" of {p}")).unwrap_or_default()), Some("That browser profile may have no saved passwords. nbrowser_profiles lists the user's profiles: call again with profile:\"<its key, e.g. chrome:[email protected]>\" to read another one.")), json!({ "profile": read_from }));     }     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![] };     let filtered: Vec<Value> = if filters.is_empty() { rows.clone() } else { rows.iter().filter(|r| { let h = r["url"].as_str().or(r["host"].as_str()).unwrap_or("").to_lowercase(); filters.iter().any(|f| h.contains(f)) }).cloned().collect() };@@ -215,10 +274,115 @@ fn import_passwords_via_nb(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     pup_core::log(format!("[import] merge done: +{added} new, {updated} updated, {skipped} skipped{}", if filters.is_empty() { String::new() } else { format!(" (filtered to: {})", filters.join(", ")) }));     ok(json!({ "success": true, "output": json!({ "ok": true, "added": added, "updated": updated, "skipped": skipped,         "hostFilter": if filters.is_empty() { Value::Null } else { json!(filters) },-        "stats": { "exported": rows.len(), "imported": added + updated },+        "stats": { "exported": rows.len(), "imported": added + updated }, "profile": read_from,         "_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() })) } +/// nb's browser profile key for the export (pup-bridge#140: the wrong Chrome profile was read and there was no way+/// to name another). `profile:"chrome:[email protected]"`, as nbrowser_profiles lists them.+fn nb_profile(args: &Value) -> Option<String> {+    args["profile"].as_str().map(|s| s.trim().to_string()).filter(|s| s.contains(':'))+}++// ── the fallback: ab's decrypt helper, exactly as 2.0.737 called it ─────────────────────────────────────────────+// Used only when nb cannot run the export (see import_now). Consent + a Windows admin prompt on the desktop.++fn import_via_helper(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 scan = import_scan(ctx);+    let all: Vec<Value> = scan["profiles"].as_array().cloned().unwrap_or_default();+    let want: Vec<String> = match args["browsers"].as_array() {+        Some(a) if !a.is_empty() => a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),+        _ => { let mut v: Vec<String> = all.iter().filter_map(|p| p["browser"].as_str().map(|s| s.to_string())).collect(); v.sort(); v.dedup(); v }+    };+    if want.is_empty() { return err("no_profiles", "No Chromium browser profiles with saved passwords were found to import from.", None); }+    pup_core::log(format!("[import] decrypt requested for browsers=[{}] — ab helper, consent + UAC prompt on the desktop", want.join(", ")));+    // 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 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() };+    if let Some(ec) = payload.get("errorCode").and_then(|e| e.as_str()).or(resp.get("errorCode").and_then(|e| e.as_str())) {+        let msg = match ec {+            "relay_forbidden" => "ab refused the decrypt as a relayed call — this should not happen from pup (a local bridge). Please report it.",+            "consent_declined" => "You declined the Adom Bridge consent dialog on the desktop. Nothing was read.",+            "elevation_declined" => "You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.",+            "helper_missing" => "The signed decrypt helper is not installed — update/repair Adom Bridge.",+            "decrypt_call_failed" => "The decrypt helper call failed.",+            _ => "Decrypt failed.",+        };+        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_from_helper(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![] };+    let filtered: Vec<Value> = if filters.is_empty() { rows.clone() } else { rows.iter().filter(|r| { let h = r["host"].as_str().or(r["url"].as_str()).or(r["origin"].as_str()).unwrap_or("").to_lowercase(); filters.iter().any(|f| h.contains(f)) }).cloned().collect() };+    let (added, updated, skipped) = ctx.vault.merge_imported(&*ctx.host, &filtered);+    // 2.0.733: remembered so the first-window import tip (vault::import_tip_once) stops for good after an import.+    let _ = std::fs::write(ctx.host.state_dir().join("pup-logins-imported.json"), json!({ "at": pup_core::now_ms(), "added": added, "updated": updated }).to_string());+    let st = &payload["stats"];+    pup_core::log(format!("[import] merge done: +{added} new, {updated} updated, {skipped} skipped{}", if filters.is_empty() { String::new() } else { format!(" (filtered to: {})", filters.join(", ")) }));+    ok(json!({ "success": true, "output": json!({ "ok": true, "added": added, "updated": updated, "skipped": skipped,+        "hostFilter": if filters.is_empty() { Value::Null } else { json!(filters) },+        "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() }))+}++/// The helper's cookies for one site, set into adom-you (2.0.735, pup-bridge#136).+fn import_session_from_helper(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() {+        // 2.0.736: say what came back, so "no session" is never a mystery (John's laptop, 2026-10-28: X is signed in+        // in his Chrome, yet nothing matched). Counts and field names only; never a cookie value.+        let all2: Vec<Value> = payload["cookies"].as_array().cloned().unwrap_or_default();+        let mut per: std::collections::BTreeMap<String, usize> = Default::default();+        for c in &all2 { *per.entry(format!("{} / {}", c["sourceBrowser"].as_str().or(c["source_browser"].as_str()).unwrap_or("?"), c["sourceProfile"].as_str().or(c["source_profile"].as_str()).unwrap_or("?"))).or_default() += 1; }+        let near: usize = all2.iter().filter(|c| { let h = host_of(c); h.contains("x.com") || h.contains("twitter") }).count();+        let keys: Vec<String> = all2.first().and_then(|c| c.as_object()).map(|o| o.keys().cloned().collect()).unwrap_or_default();+        pup_core::log(format!("[import] session for {site}: no match among {} cookie(s); per profile {:?}; near-matches {near}; stats {}; row keys {:?}", all2.len(), per, payload["stats"], keys));+        return pup_core::merge(err("no_session", format!("None of the cookies the user's browsers returned are for {site} ({} cookies came back in all).", all2.len()), Some("Check cookiesByProfile: if the profile the user is signed into is missing or shows 0, its cookies could not be read. Ask the user to sign in to that site once in this pup window instead.")),+            json!({ "cookiesReturned": all2.len(), "cookiesByProfile": per, "nearMatches": near, "decryptStats": payload["stats"], "cookieFields": keys }));+    }+    let src = |c: &Value| format!("{} / {}", c["sourceBrowser"].as_str().or(c["source_browser"].as_str()).unwrap_or("?"), c["sourceProfile"].as_str().or(c["source_profile"].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["http_only"].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.") }))+}+ /// Parse a Chrome/Edge password-export CSV (header row names the columns; `note` already stripped by nb) into /// vault rows {url, username, password}. RFC4180: quoted fields may contain commas, quotes ("" escape) and newlines. fn csv_to_login_rows(csv: &str) -> Vec<Value> {@@ -263,6 +427,18 @@ fn parse_csv(s: &str) -> Vec<Vec<String>> { mod tests {     use super::*; +    #[test]+    fn nb_cannot_run_only_when_nb_cannot_serve() {+        // what ab and nb really send when nb cannot run the export (nb 0.16.33 measured 2026-11-05)+        assert!(super::nb_cannot_run("error", "native browser extension not connected").is_some());+        assert!(super::nb_cannot_run("", "verb not implemented yet: nbrowser_export_saved_logins").is_some());+        assert!(super::nb_cannot_run("unknown_verb", "").is_some());+        assert!(super::nb_cannot_run("bridge_not_installed", "").is_some());+        // a real answer from nb or the user is never retried through the helper+        for c in ["awaiting_user", "no_session", "export_failed", "consent_declined", "browser_elevated", "export_control_not_found", "profile_mismatch"] {+            assert!(super::nb_cannot_run(c, "the user said no").is_none(), "{c}");+        }+    }     #[test]     fn csv_handles_quotes_commas_and_note_column() {         // Chrome export header order, note column present but blanked (nb strips it).
tools/gen-assets.py+5
@@ -167,6 +167,11 @@ _STUBS = { # contract, so they must describe what the exe does (pup_uninstall took confirm:"uninstall" and offered a dry run in # Node; the Rust verb takes confirm:true and has no dry run). _OVERRIDES = {+    'pup_import_browser_logins': {+        'summary': "Seed pup's vault from the user's OWN saved browser passwords (or, with sessionFor, one site's signed-in session), so pup can sign in as them on sites they already use. Through nb, the browser exports its own store and the user answers one Windows Hello; where nb cannot run (not installed, too old, no extension connected) pup falls back to Adom Bridge's decrypt helper (a consent and a Windows admin prompt; Edge yes, Chrome 156+ no). Returns COUNTS ONLY; values are never logged or returned.",+        'input': {'hostFilter': 'optional str or str[]: only import logins whose host contains one of these', 'sessionFor': 'optional str: one site (e.g. "x.com") whose signed-in session to bring over instead of passwords', 'profile': 'optional str: the browser profile nb reads, as nbrowser_profiles lists it (e.g. "chrome:[email protected]"); default the active one', 'browsers': 'optional str[] (helper fallback only): subset of ["chrome","edge","brave"]', 'sourceProfile': 'optional str (helper fallback, sessionFor): which "<browser> / <profile>" to take when several have a session', 'reason': 'optional str: shown to the user with the prompt'},+        'hint': "Answers consent_pending while the user approves; call again with the SAME arguments to collect the result (no second prompt). The answer says via:\"nb\" or via:\"ab-helper\", and which profile was read. no_data means that profile has no saved passwords: pass profile:\"<key from nbrowser_profiles>\" for another.",+    },     'pup_uninstall': {         'summary': "DESTRUCTIVE: remove pup's on-disk footprint from this machine: every pup profile and saved window, the staged extensions, the taskbar icons and tiles, and every taskbar identity pup registered. Pass confirm:true. The saved-login vault is kept (credential_delete removes logins one by one), and Adom Bridge's bridge_uninstall removes the pup program itself.",         'input': {'confirm': 'required bool true: actually remove'},