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
Colby Knox
import: unit test for an older extension's refusal shape after nb-bridge#57 (no behaviour change)
5534c33
5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
//! pup_import_scan / pup_import_browser_logins (the Windows-only Chromium login/session import).
//! Scan enumerates Chrome/Edge/Brave profiles and their saved-password counts via a PowerShell probe.
//!
//! Import NO LONGER cracks Chrome's sealed store from outside Chrome (ab's
//! `desktop_decrypt_browser_credentials`, retired per adom/adom-bridge#254 after it broke on Chrome 156's
//! app-bound encryption). Instead it coordinates with nb-bridge, which lives INSIDE the user's real
//! signed-in browser, and lets Chrome do its own decryption with the user present:
//! - passwords -> nbrowser_export_saved_logins (Chrome's own export, one Windows Hello) [nb#55]
//! - a session -> nbrowser_export_site_session (a single domain's cookies, read via chrome.cookies) [nb#54]
//! 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};
use serde_json::{json, Value};
use std::time::Duration;
const SCAN_PS: &str = include_str!("../assets/import-scan.ps1");
pub fn import_scan(ctx: &Ctx) -> Value {
if !cfg!(windows) {
return ok(json!({ "success": true, "output": json!({ "profiles": [] }).to_string(), "profiles": [], "_hint": "Credential import discovery is Windows-only (Chromium ABE / DPAPI). No profiles enumerated on this OS." }));
}
let out = ctx.host.run_shell_script(SCAN_PS, Duration::from_secs(60));
let profiles: Value = out.trim().lines().last().and_then(|l| serde_json::from_str(l).ok())
.or_else(|| serde_json::from_str(out.trim()).ok()).unwrap_or(json!([]));
let profiles = if profiles.is_array() { profiles } else { profiles.get("profiles").cloned().unwrap_or(json!([])) };
ok(json!({ "success": true, "output": json!({ "profiles": profiles }).to_string(), "profiles": profiles,
"_hint": "The Chromium profiles with saved passwords on this desktop. pup_import_browser_logins {hostFilter?} imports them into the adom-you vault via nb's Chrome-own export (the user answers one Windows Hello); passwords are never logged." }))
}
/// 2.0.596: the import waits on a HUMAN (now nb driving Chrome's own export, and the user answering Windows
/// Hello) but ab's CLI gives up on a verb after 60 s, so a blocking call answered "Request timed out" while
/// the prompt was still up and the outcome was never seen (John's laptop, 2026-09-16). So the work runs on
/// its own thread: a call waits up to 40 s, then answers consent_pending; calling again re-attaches to the
/// SAME request (no second prompt) and collects the result.
struct ImportJob { key: String, started_ms: u64, result: Option<Value>, finished_ms: u64 }
static IMPORT_JOB: std::sync::Mutex<Option<ImportJob>> = std::sync::Mutex::new(None);
pub fn import_browser_logins(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
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();
let start = match g.as_ref() {
Some(j) if j.result.is_some() && now.saturating_sub(j.finished_ms) < 600_000 => { let r = j.result.clone().unwrap(); *g = None; return r; }
Some(j) if j.result.is_none() && now.saturating_sub(j.started_ms) < 300_000 => false, // in flight: attach
_ => true,
};
if start {
let Some(a) = ctx.arc() else { return import_now(ctx, caller, args); };
*g = Some(ImportJob { key: key.clone(), started_ms: now, result: None, finished_ms: 0 });
let (c, ar) = (caller.clone(), args.clone());
std::thread::spawn(move || {
let r = import_now(&a, &c, &ar);
pup_core::log(format!("[import] finished: {}", r.get("errorCode").and_then(|e| e.as_str()).map(|e| format!("{e}: {}", r["error"].as_str().unwrap_or(""))).unwrap_or_else(|| "ok".into())));
if let Some(j) = IMPORT_JOB.lock().unwrap().as_mut() { j.result = Some(r); j.finished_ms = pup_core::now_ms(); }
});
}
}
let t0 = std::time::Instant::now();
while t0.elapsed() < Duration::from_secs(40) {
{ let mut g = IMPORT_JOB.lock().unwrap(); if let Some(r) = g.as_ref().and_then(|j| j.result.clone()) { *g = None; return r; } }
std::thread::sleep(Duration::from_millis(500));
}
// A password import drives Chrome's own export and waits for the user to answer Windows Hello. nb notifies
// the user on the machine first; this reinforces it in case the Hydrogen-side notification is missed.
let passwords = session_for(args).is_none();
if passwords {
let _ = ctx.ab.call("notify_user", json!({ "title": "pup is waiting for your approval",
"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 {
"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 {
"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 {
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).
fn unwrap_payload(resp: &Value) -> Value {
if resp.get("output").and_then(|v| v.as_str()).map(|s| s.trim_start().starts_with('{')).unwrap_or(false) {
serde_json::from_str(resp["output"].as_str().unwrap()).unwrap_or_else(|_| resp.clone())
} else { resp.clone() }
}
fn error_code(resp: &Value, payload: &Value) -> Option<String> {
payload.get("errorCode").and_then(|e| e.as_str()).or_else(|| resp.get("errorCode").and_then(|e| e.as_str())).map(|s| s.to_string())
}
/// Turn an nb error code into an err() the caller can act on. Returns None when the code is not a failure.
fn relay_nb_error(ec: &str, payload: &Value, what: &str) -> Option<Value> {
let extra = payload.get("error").and_then(|e| e.as_str()).or_else(|| payload.get("message").and_then(|e| e.as_str()));
let suffix = extra.map(|e| format!(" ({e})")).unwrap_or_default();
let (msg, hint): (String, Option<&str>) = match ec {
"unknown_verb" => (format!("The native-browser bridge (nb) does not expose the verb needed to {what}.{suffix}"), Some("nb must be updated: nbrowser_export_site_session needs nb >=0.16.43 and nbrowser_export_saved_logins needs nb >=0.16.45. Update Adom Bridge / the native-browser bridge.")),
"confirm_required" => (format!("nb refused without confirmation.{suffix}"), Some("This should not happen: pup passes confirm:true. Please report it.")),
"reason_required" => (format!("nb refused without a reason.{suffix}"), None),
"no_session" => (format!("The user's browser has no signed-in session for this site.{suffix}"), Some("The user may be signed in under a different browser profile. Check nbrowser_profiles / nbrowser_login_state, or ask the user to sign in to the site once in their own browser, then try again.")),
"awaiting_user" => (format!("The Windows Hello prompt is waiting to be answered on the desktop.{suffix}"), Some("Tell the user to approve the Hello prompt, then call pup_import_browser_logins again with the same arguments.")),
"export_control_not_found" => (format!("Chrome moved its password-manager UI, so nb could not find the Export control.{suffix}"), Some("nb returned a screenshot in its own response. File it on adom/nb-bridge; do not retry blindly.")),
"unsupported_platform" | "mac_unsupported" => (format!("This import is Windows-only today.{suffix}"), Some("On a Mac, sign in once in the pup window (pup saves it), or sync logins from a Windows desktop with pup_vault_sync.")),
_ => return None,
};
Some(err(ec, msg, hint))
}
/// `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('/'))
}
/// nb#54: nb reads the one registrable domain's cookies from inside the user's real browser (chrome.cookies, no
/// store-cracking, no OS reauth) and writes them to an owner-only handoff file on this machine. pup reads that
/// file locally, sets the cookies into the adom-you browser through a pup window's DevTools channel, and tells
/// nb to drop the handoff. Values are never logged or returned.
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 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 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 {
return err("no_handoff", format!("nb did not return a handoff path for the {site} session export."), None);
};
let data = match std::fs::read_to_string(&path) { Ok(d) => d, Err(e) => return err("handoff_unreadable", format!("Could not read the session handoff file nb wrote at {path}: {e}"), Some("pup and nb must be on the same desktop (pup is a local bridge). If this is a relayed call, that is the bug.")) };
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 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
}
/// Set a site's cookies (from nb's handoff file) into the adom-you browser via CDP Network.setCookie.
fn set_session_cookies(ctx: &Ctx, caller: &Caller, site: &str, cookies: &[Value]) -> Value {
if cookies.is_empty() { return err("no_session", format!("nb's export for {site} contained no cookies."), Some("Ask the user to sign in to the site once in their own browser, then try again.")); }
// 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 host_of = |c: &Value| c["domain"].as_str().or(c["host"].as_str()).or(c["host_key"].as_str()).unwrap_or("").to_lowercase();
let now_s = (pup_core::now_ms() / 1000) as f64;
let (mut set, mut failed) = (0u32, 0u32);
for c in cookies {
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() || host.is_empty() { continue; }
let secure = c["secure"].as_bool().or(c["isSecure"].as_bool()).unwrap_or(true);
let http_only = c["httpOnly"].as_bool().or(c["http_only"].as_bool()).unwrap_or(false);
let is_session = c["session"].as_bool().unwrap_or(false);
let host_only = c["hostOnly"].as_bool().unwrap_or(false);
let path = c["path"].as_str().unwrap_or("/");
let mut p = json!({ "name": name, "value": value, "path": path, "secure": secure, "httpOnly": http_only });
if let Some(v) = c["sameSite"].as_str().and_then(|ss| match ss.to_lowercase().as_str() { "strict" => Some("Strict"), "lax" => Some("Lax"), "no_restriction" | "none" => Some("None"), _ => None }) { p["sameSite"] = json!(v); }
if !is_session { if let Some(e) = c["expirationDate"].as_f64().or(c["expires"].as_f64()).or(c["expiresUtc"].as_f64()).filter(|e| *e > now_s) { p["expires"] = json!(e); } }
let clean = host.trim_start_matches('.');
if name.starts_with("__Host-") || host_only { p["url"] = json!(format!("https://{clean}/")); }
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 (values never logged)"));
ok(json!({ "success": true, "site": site, "cookiesSet": set, "cookiesRefused": failed,
"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.") }))
}
/// nb#55: nb drives Chrome's own consented password export (the user answers one Windows Hello), writes the CSV
/// to an owner-only handoff file on this machine (the `note` column stripped), and hands pup the path plus counts.
/// pup reads the CSV locally, merges the logins into the adom-you vault, and tells nb to drop the handoff. One
/// Hello, the whole profile. Passwords are never logged or returned over the relay.
fn import_passwords_via_nb(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let reason = args["reason"].as_str().map(|s| s.to_string()).unwrap_or_else(|| "Import your saved browser logins into pup's vault so pup can sign you in without asking for passwords.".to_string());
// 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 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 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 {
return err("no_handoff", "nb did not return a handoff path for the password export.", None);
};
let csv = match std::fs::read_to_string(&path) { Ok(d) => d, Err(e) => return err("handoff_unreadable", format!("Could not read the password handoff file nb wrote at {path}: {e}"), Some("pup and nb must be on the same desktop (pup is a local bridge).")) };
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 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() };
let (added, updated, skipped) = ctx.vault.merge_imported(&*ctx.host, &filtered);
if let Some(id) = &handoff_id { let _ = ctx.ab.call("nbrowser_drop_handoff", json!({ "handoffId": id }), caller, Duration::from_secs(10)); }
// 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());
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 }, "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> {
let table = parse_csv(csv);
let Some(header) = table.first() else { return vec![] };
let idx = |name: &str| header.iter().position(|h| h.trim().eq_ignore_ascii_case(name));
let (iu, ius, ip) = (idx("url").or_else(|| idx("origin")), idx("username"), idx("password"));
let (Some(iu), Some(ip)) = (iu, ip) else { return vec![] };
table.iter().skip(1).filter_map(|row| {
let get = |i: Option<usize>| i.and_then(|i| row.get(i)).map(|s| s.as_str()).unwrap_or("");
let (url, user, pass) = (get(Some(iu)), get(ius), get(Some(ip)));
if url.is_empty() || pass.is_empty() { return None; }
Some(json!({ "url": url, "username": user, "password": pass }))
}).collect()
}
fn parse_csv(s: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut row: Vec<String> = Vec::new();
let mut field = String::new();
let mut in_quotes = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if in_quotes {
if c == '"' { if chars.peek() == Some(&'"') { field.push('"'); chars.next(); } else { in_quotes = false; } }
else { field.push(c); }
} else {
match c {
'"' => in_quotes = true,
',' => row.push(std::mem::take(&mut field)),
'\r' => {}
'\n' => { row.push(std::mem::take(&mut field)); rows.push(std::mem::take(&mut row)); }
_ => field.push(c),
}
}
}
if !field.is_empty() || !row.is_empty() { row.push(field); rows.push(row); }
rows
}
#[cfg(test)]
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());
// nb-bridge#57: the same answer now arrives as a refusal with no errorCode (ab reads it as code "error")
assert!(super::nb_cannot_run("error", "verb not implemented yet: nbrowser_export_site_session").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).
let csv = "name,url,username,password,note\n\
Example,https://example.com/,alice,p@ss,\n\
Tricky,https://x.com/,\"bob,jr\",\"a,\"\"b\"\"c\",\n";
let rows = csv_to_login_rows(csv);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["url"], json!("https://example.com/"));
assert_eq!(rows[0]["username"], json!("alice"));
assert_eq!(rows[0]["password"], json!("p@ss"));
// quoted comma in username, and "" -> " with an embedded comma in the password
assert_eq!(rows[1]["username"], json!("bob,jr"));
assert_eq!(rows[1]["password"], json!("a,\"b\"c"));
}
#[test]
fn csv_tolerates_reordered_columns_and_missing_username() {
let csv = "url,password,username\nhttps://s.io/,secret,\n";
let rows = csv_to_login_rows(csv);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["password"], json!("secret"));
assert_eq!(rows[0]["username"], json!(""));
}
#[test]
fn csv_skips_rows_with_no_password_or_url() {
let csv = "url,username,password\nhttps://a.com/,u,\n,u,p\nhttps://b.com/,u,p\n";
let rows = csv_to_login_rows(csv);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["url"], json!("https://b.com/"));
}
#[test]
fn csv_embedded_newline_in_quoted_field() {
let csv = "url,username,password\n\"https://a.com/\",u,\"line1\nline2\"\n";
let rows = csv_to_login_rows(csv);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["password"], json!("line1\nline2"));
}
}