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.738 (insiders): Login import cut over from ab's retired decrypt helper to nb-bridge: passwords via nbrowser_export_saved_logins (Chrome's own consented export, one Windows Hello), a site session via nbrowser_export_site_session (background cookie read), both handed over as a machine-local file that pup imports then drops; new RFC4180 CSV parser with tests. Fixes the Chrome 156 ABE breakage (adom/adom-bridge#254, adom/pup-bridge#136).
3 files changed
+242−110
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.737"+version = "2.0.738" dependencies = [ "pup-browser", "pup-cdp",@@ -781,7 +781,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.737"+version = "2.0.738" dependencies = [ "pup-core", "pup-host",@@ -793,7 +793,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.737"+version = "2.0.738" dependencies = [ "serde_json", "thiserror",@@ -803,7 +803,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.737"+version = "2.0.738" dependencies = [ "serde", "serde_json",@@ -813,7 +813,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.737"+version = "2.0.738" dependencies = [ "pup-cdp", "pup-core",@@ -826,7 +826,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.737"+version = "2.0.738" dependencies = [ "serde", "serde_json",@@ -835,7 +835,7 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.737"+version = "2.0.738" dependencies = [ "core-foundation", "core-graphics",@@ -851,7 +851,7 @@ dependencies = [ [[package]] name = "pup-host-win"-version = "2.0.737"+version = "2.0.738" dependencies = [ "pup-core", "pup-host",@@ -861,7 +861,7 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.737"+version = "2.0.738" dependencies = [ "fontdue", "image",@@ -871,7 +871,7 @@ dependencies = [ [[package]] name = "pup-session"-version = "2.0.737"+version = "2.0.738" dependencies = [ "pup-core", "serde",@@ -881,7 +881,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.737"+version = "2.0.738" dependencies = [ "pup-core", "pup-host",@@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.737"+version = "2.0.738" 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.737"+version = "2.0.738" edition = "2021" license = "MIT"
rust/verbs/src/import.rs+229−97@@ -1,7 +1,15 @@-//! pup_import_scan / pup_import_browser_logins (Node: the Windows-only Chromium password import).-//! Scan enumerates Chrome/Edge/Brave profiles and their saved-password counts via a PowerShell probe;-//! import calls ab-core's signed decrypt helper (consent + UAC on the desktop) and merges the rows-//! into the adom-you vault. Decrypted passwords are used to fill the keychain and never logged.+//! 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. use crate::Ctx; use pup_core::{err, ok, Caller};@@ -19,19 +27,18 @@ pub fn import_scan(ctx: &Ctx) -> Value { .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": "Found the Chromium profiles with saved passwords on this desktop. pup_import_browser_logins {browsers?, hostFilter?} decrypts and merges them into the adom-you vault (the user sees ab's consent dialog + a Windows UAC; passwords are never logged)." }))+ "_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 (ab's consent, then a Windows admin prompt: up to 4 min) but ab's-/// CLI gives up on a verb after 60 s, so every import answered "Request timed out" while the consent was-/// still up, and the outcome (declined, approved, nothing) was never logged or seen (John's laptop,-/// 2026-09-16). Now the decrypt 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.+/// 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 {- if !cfg!(windows) { return err("unsupported_platform", "Browser-login import is Windows-only (Chromium ABE/DPAPI).", None); } let key = json!([args["browsers"], args["hostFilter"], args["sessionFor"], args["sourceProfile"]]).to_string(); let now = pup_core::now_ms(); {@@ -57,96 +64,90 @@ pub fn import_browser_logins(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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)); }- // 2.0.603: say it on the desktop, not only in the answer. The consent arrives as a Bridge notification- // inside Hydrogen, which is easy to miss: John sat unaware twice while pup waited (2026-09-16).- let _ = ctx.ab.call("notify_user", json!({ "title": "pup is waiting for your approval",- "body": "Approve the Adom Bridge request to read your saved browser logins, then the Windows prompt. pup uses them to sign you in without asking for passwords.",- "level": "warning", "focus": { "host": true } }), caller, Duration::from_secs(8));+ // 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": "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.",+ "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);- pup_core::merge(err("consent_pending", "Waiting for the user to approve the import on their desktop", Some(&format!("{}The user approves an Adom Bridge consent (when Bridge runs inside Hydrogen it arrives as a notification and a card in Hydrogen, not a window), then a Windows admin prompt. Tell the user exactly that, 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. After 2 minutes with no answer Bridge counts it as declined.", if other { "An import with different arguments is already waiting; this call attached to it. " } else { "" }))),+ 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."+ } 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."+ };+ 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 !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(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() }))+ if let Some(site) = session_for(args) { return import_session_via_nb(ctx, caller, args, &site); }+ import_passwords_via_nb(ctx, caller, args) } +/// 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('/')) } -/// 2.0.735 (John, 2026-10-27, pup-bridge#136): X refuses a fresh sign-in from pup's browser ("An unexpected error-/// occurred") even through a clean background Google sign-in, while the user's own Chrome is already signed in. So-/// carry that existing session over: the cookies ab decrypted (one consent + one Windows prompt, the same gates as the-/// password import) are filtered to the site and its subdomains and set into the adom-you browser through a pup-/// window's DevTools channel. Values are never logged or returned.-fn import_session(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.")); }+/// 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 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 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 {+ 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 result = set_session_cookies(ctx, caller, site, &cookies);+ 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 {@@ -154,21 +155,152 @@ fn import_session(ctx: &Ctx, caller: &Caller, args: &Value, payload: &Value, sit }; 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 &rows {+ 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() { 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('.'))); }+ 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 (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()) },+ 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 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 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 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 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."));+ }+ 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 },+ "_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() }))+}++/// 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 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"));+ }+}