master
John Lauer v2.0.733 (insiders): pup tells an AI about importing the user's own logins before anyone is stuck at a sign-in page (Jo… 50d4feb 21d ago
//! Credential and payment-card verbs: pup_credentials / credential_list, credential_set,
//! credential_delete, pup_login (fill, reason-gated submit, 2FA/captcha walls), the
//! X-Adom-Verified-Origin-gated pup_credential_reveal behind a consent toast, pup_card_set /
//! pup_cards / pup_card_delete / pup_fill_card (per-fill consent, digits typed in-page only), the
//! login-capture binding, and autofill-on-load. Ports server.js + credential_vault.js + card_vault.js.

use crate::input::resolve_tab;
use crate::{consent::Ask, detect_wall, touch, Ctx};
use pup_core::{blocked_on_user, err, ok, Caller};
use pup_session::Session;
use serde_json::{json, Value};
use std::time::Duration;

const T: Duration = Duration::from_secs(10);
pub const CAPTURE_LOGIN_JS: &str = include_str!("../assets/capture-login.js");

pub fn capture_login_script() -> String { CAPTURE_LOGIN_JS.replace("${BRIDGE_VERSION}", pup_core::version()) }

/// POST /extcred from the extension lane's capture script (2.0.602). Same record shape as the CDP
/// binding, minus a CDP session id: the session hint is resolved by host instead.
pub fn on_capture_ext(ctx: &Ctx, raw: &str) {
    if ctx.settings.get("captureLogins", "on") == "off" { return; }
    let rec: Value = serde_json::from_str(raw).unwrap_or(json!({}));
    let (host, user, pass) = (rec["host"].as_str().unwrap_or(""), rec["u"].as_str().unwrap_or(""), rec["p"].as_str().unwrap_or(""));
    if host.is_empty() || pass.is_empty() { return; }
    if let Some(c) = ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), if user.is_empty() { None } else { Some(user) }) {
        if c.password == pass && (user.is_empty() || c.username == user) { ctx.vault.touch_used_user(&c.host, &c.username); return; }
    }
    match ctx.vault.set(&*ctx.host, host, if user.is_empty() { "(unknown)" } else { user }, pass) {
        Ok(_) => pup_core::log(format!("[cred] captured login for {host}{} (extension lane) → OS keychain", if user.is_empty() { String::new() } else { format!(" ({user})") })),
        Err(e) => pup_core::log(format!("[cred] capture store failed: {e}")),
    }
}

/// 2.0.644 (pup-bridge#119): HTTP auth challenges seen by the extension lane: (tabId, host, answered, when).
static AUTH_CHALLENGES: std::sync::Mutex<Vec<(i64, String, bool, u64)>> = std::sync::Mutex::new(Vec::new());

/// POST /extauth from the extension's onAuthRequired: answer a Basic/Digest challenge from the vault, matched
/// on the registrable domain (st./ms./nxp.componentsearchengine.com all match a componentsearchengine.com
/// entry). The password goes to the extension only, never to the log. A second attempt for the same tab+host
/// means the stored credential was rejected: do not resend it, let the challenge cancel, and the next verb
/// says so. Never returns a credential when captureLogins/autoLogin are off.
pub fn on_auth_required(ctx: &Ctx, raw: &str) -> Value {
    let rec: Value = serde_json::from_str(raw).unwrap_or(json!({}));
    let host = rec["host"].as_str().unwrap_or("").to_string();
    let tab = rec["tabId"].as_i64().unwrap_or(-1);
    let attempt = rec["attempt"].as_i64().unwrap_or(1);
    if host.is_empty() { return json!({}); }
    let found = if attempt <= 1 && ctx.settings.get("autoLogin", "on") != "off" { ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), None) } else { None };
    let now = pup_core::now_ms();
    { let mut m = AUTH_CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()); m.retain(|c| now.saturating_sub(c.3) < 120_000); m.push((tab, host.clone(), found.is_some(), now)); }
    match found {
        Some(c) => { pup_core::log(format!("[cred] HTTP auth challenge from {host}: answered with the vault entry for {} ({}) on the extension lane", c.host, c.username)); ctx.vault.touch_used_user(&c.host, &c.username); json!({ "username": c.username, "password": c.password }) }
        None => { pup_core::log(format!("[cred] HTTP auth challenge from {host}: {} (attempt {attempt}); cancelled so no native dialog blocks the window", if attempt > 1 { "the stored credential was REJECTED" } else { "no vault entry for that domain" })); json!({}) }
    }
}

/// The host of an HTTP auth challenge on `tab` in the last 60 s that pup could not answer, if any.
pub fn unanswered_challenge(tab: i64) -> Option<(String, bool)> {
    let now = pup_core::now_ms();
    let m = AUTH_CHALLENGES.lock().unwrap_or_else(|e| e.into_inner());
    m.iter().rev().find(|c| c.0 == tab && now.saturating_sub(c.3) < 60_000).map(|c| (c.1.clone(), c.2))
}

/// The typed answer to an unanswered challenge: which host asked, and the three ways to supply it.
pub fn no_credential_error(host: &str, rejected: bool) -> Value {
    let what = if rejected { format!("{host} asked for HTTP authentication and REJECTED the stored vault credential (stale password).") } else { format!("{host} asked for HTTP authentication (Basic/Digest) and the vault has no entry for that domain.") };
    crate::err("no_credential", format!("{what} pup cancelled the challenge so no native dialog blocks the window; the page is an error page until a credential exists."),
        Some(&format!("Three routes, in order: pup_import_browser_logins {{}} (the user's own Chrome/Edge saved logins, one consent + one UAC), pup_vault_sync {{peer, hostFilter:\"{d}\"}} from a box that has it, or credential_set {{host:\"{d}\", username, password}} (a plain domain entry matches every subdomain). Then navigate again. {IMPORT_HINT}", d = pup_vault::registrable_domain(host))))
}

/// Runtime.bindingCalled "__pupCredCapture" from the capture script: store the login.
pub fn on_capture(ctx: &Ctx, sid_cdp: &str, payload: &str) {
    if ctx.settings.get("captureLogins", "on") == "off" { return; }
    let rec: Value = serde_json::from_str(payload).unwrap_or(json!({}));
    let (host, user, pass) = (rec["host"].as_str().unwrap_or(""), rec["u"].as_str().unwrap_or(""), rec["p"].as_str().unwrap_or(""));
    if host.is_empty() || pass.is_empty() { return; }
    if let Some(c) = ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), if user.is_empty() { None } else { Some(user) }) {
        if c.password == pass && (user.is_empty() || c.username == user) { ctx.vault.touch_used_user(&c.host, &c.username); return; }   // already stored: blur + submit + autofill all re-fire the capture
    }
    match ctx.vault.set(&*ctx.host, host, if user.is_empty() { "(unknown)" } else { user }, pass) {
        Ok(_) => {
            pup_core::log(format!("[cred] captured login for {host}{} → OS keychain", if user.is_empty() { String::new() } else { format!(" ({user})") }));
            for s in ctx.sessions.all() { if s.tabs.iter().any(|t| t.cdp_session == sid_cdp) { ctx.sessions.with_mut(&s.id, |x| x.pending_cred_hint = Some(format!("pup CAPTURED this login for \"{host}\"{} into the OS keychain (encrypted at rest; never logged, never echoed). Next visit pup auto-fills it.", if user.is_empty() { String::new() } else { format!(" (username {user})") }))); } }
        }
        Err(e) => pup_core::log(format!("[cred] capture store failed: {e}")),
    }
}

/// 2.0.599 (John, 2026-09-16: "you should be telling me you can do that cuz it should be in your ai hints
/// ... no other adom users would even know that ab has that feature"). Every "pup has no password" answer
/// names ab's credential reader, because an AI that has not read the pup-credentials skill has no other way
/// to discover that the user's OWN saved browser logins can be pulled in with one consent.
pub const IMPORT_HINT: &str = "pup can READ THE USER'S OWN SAVED BROWSER LOGINS and use them: pup_import_browser_logins {} pulls every password saved in their Chrome/Edge/Brave profiles into pup's keychain vault (Adom Bridge asks them for one consent plus one Windows admin prompt; no AI ever sees a password). Run it with NO hostFilter the first time: a filter hides logins saved under a different domain, and the import is one prompt either way. Then call this verb again. Other routes: pup_vault_sync {peer} merges another of the user's desktops' logins, or they sign in once in the pup window and pup captures it on submit. Passing {username, password} yourself is the LAST resort, and never ask the user to type a password into the chat.";

pub fn credentials(ctx: &Ctx) -> Value {
    let list = ctx.vault.list();
    ok(json!({ "success": true, "count": list.len(), "credentials": list, "output": json!({ "ok": true, "credentials": list }).to_string(), "_hint": "Host + username only; passwords stay in the OS keychain. pup_login {submit:true, submitReason} signs a window in; pup_google_signin {account} does Google. EMPTY or missing the site you need? pup_import_browser_logins {} pulls the user's OWN saved Chrome/Edge/Brave logins into this vault behind one Adom Bridge consent plus one Windows prompt (no hostFilter the first time: a filter hides logins saved under another domain)." }))
}

pub fn credential_set(ctx: &Ctx, args: &Value) -> Value {
    let (h, u, p) = (args["host"].as_str().unwrap_or("").trim(), args["username"].as_str().unwrap_or("").trim(), args["password"].as_str().unwrap_or(""));
    match ctx.vault.set(&*ctx.host, h, u, p) { Ok(_) => ok(json!({ "success": true, "host": h, "username": u, "output": json!({ "ok": true, "host": h, "username": u }).to_string(), "_hint": "Stored in the OS keychain. The password was not echoed and will not be." })), Err(e) => err("credential_set_failed", e, Some("host, username and password are all required; host may be a pattern like *.example.com.")) }
}

pub fn credential_delete(ctx: &Ctx, args: &Value) -> Value {
    let h = args["host"].as_str().unwrap_or("").trim();
    if h.is_empty() { return err("host_required", "credential_delete needs {host}", None); }
    let removed = match args["username"].as_str().map(|u| u.trim()).filter(|u| !u.is_empty()) { Some(u) => ctx.vault.delete_user(&*ctx.host, h, u), None => ctx.vault.delete(&*ctx.host, h) };
    ok(json!({ "success": true, "host": h, "removed": removed, "removedFromIndex": removed, "removedFromKeychain": removed, "output": json!({ "ok": true, "host": h, "removed": removed, "removedFromIndex": removed, "removedFromKeychain": removed }).to_string() }))
}

fn no_login_form(live_host: &str, url: &str) -> Value {
    let google = pup_vault::registrable_domain(live_host) == "google.com";
    pup_core::merge(err("no_login_form", "No password field on this page", Some(if google {
        "Google asks for the email on one page and the password on the next, so pup_login cannot do it. Use pup_google_signin {account:\"<email>\", submitReason}: it picks the account, fills both steps from the vault and handles the verification step."
    } else {
        "It may not be the login page yet, the form may be inside an iframe or shadow DOM, or the site asks for the username first (fill it with pup_type, click Next with pup_click, then call pup_login again on the password page). Screenshot to see which."
    })), json!({ "output": json!({ "ok": false, "url": url }).to_string(), "liveHost": live_host }))
}

/// pup_login: fill (and optionally submit, reason-gated) the login form in the window's active tab.
pub fn login(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    if let Ok(s0) = crate::resolve(ctx, caller, args) { if crate::transport::is_ext(&s0) { return login_ext(ctx, caller, args, s0); } }
    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
    let cur_url = t.url.clone();
    let live_host = pup_vault::host_of(&cur_url).unwrap_or_else(|| "<unknown>".into());
    let (username, password, source): (String, String, String) = match (args["username"].as_str(), args["password"].as_str()) {
        (Some(u), Some(p)) if !p.is_empty() => {
            if args["save"].as_bool() == Some(true) { let _ = ctx.vault.set(&*ctx.host, &live_host, u, p); }
            (u.into(), p.into(), "args".into())
        }
        _ => match ctx.vault.get_for_url(&*ctx.host, &cur_url, args["preferAccount"].as_str()) {
            Some(c) => (c.username, c.password, format!("vault:{}", c.host)),
            None => return pup_core::merge(err("no_credential", format!("no saved login matches {live_host}"), Some(IMPORT_HINT)), json!({ "output": json!({ "ok": false, "url": cur_url }).to_string() })),
        },
    };
    let submit = args["submit"].as_bool() == Some(true);
    if submit && args["submitReason"].as_str().map(|r| r.trim().len() >= 10) != Some(true) {
        return pup_core::merge(err("submit_reason_required", "CONFIRM THE DOMAIN BEFORE SENDING CREDENTIALS", Some(&format!("You asked to submit a stored login: the password goes to the LIVE host \"{live_host}\". Re-call pup_login with submit:true and submitReason:\"<why {live_host} is the right site>\" (10+ chars) so a lookalike, typo or redirected domain is caught first."))), json!({ "output": json!({ "ok": false, "url": cur_url, "host": live_host }).to_string(), "liveHost": live_host }));
    }
    if submit { pup_core::log(format!("[cred] pup_login submit to {live_host} by \"{}\" — reason: {}", caller.thread, args["submitReason"].as_str().unwrap_or("").chars().take(120).collect::<String>())); }
    let keystrokes = args["realKeystrokes"].as_bool() == Some(true) || args["keystrokes"].as_bool() == Some(true);
    let located = match b.cdp.evaluate(&t.cdp_session, pup_vault::locate_login_js(), T) { Ok(v) => v, Err(e) => return err("login_fill_failed", e.to_string(), None) };
    if located["ok"] != json!(true) { return no_login_form(&live_host, &cur_url); }
    let (filled_user, filled_pass) = if keystrokes {
        let type_into = |mark: &str, text: &str| -> bool {
            let focus = format!("(() => {{ const el = document.querySelector('[data-pup-{mark}]'); if (!el) return false; el.scrollIntoView({{ block: 'center' }}); el.focus(); return true; }})()");
            if b.cdp.evaluate(&t.cdp_session, &focus, T).ok() != Some(json!(true)) { return false; }
            let _ = crate::input::press_pub(&b.cdp, &t.cdp_session, "a", 2); let _ = crate::input::press_pub(&b.cdp, &t.cdp_session, "Backspace", 0);
            for ch in text.chars() { let cs = ch.to_string(); if crate::input::press_pub(&b.cdp, &t.cdp_session, &cs, if ch.is_uppercase() { 8 } else { 0 }).is_err() { let _ = b.cdp.send(Some(&t.cdp_session), "Input.insertText", json!({ "text": cs }), T); } }
            b.cdp.evaluate(&t.cdp_session, &format!("(document.querySelector('[data-pup-{mark}]') || {{ value: '' }}).value.length"), T).ok().and_then(|v| v.as_u64()).unwrap_or(0) > 0
        };
        let fu = located["hasUser"] == json!(true) && !username.is_empty() && type_into("uf", &username);
        let mut fp = type_into("pf", &password);
        if !fp { fp = type_into("pf", &password); }
        (fu, fp)
    } else {
        match b.cdp.evaluate(&t.cdp_session, &pup_vault::fill_login_js(&username, &password), T) { Ok(v) => (located["hasUser"] == json!(true) && v["fu"] == json!(true), v["plen"].as_u64().unwrap_or(0) > 0), Err(e) => return err("login_fill_failed", e.to_string(), None) }
    };
    let submitted = b.cdp.evaluate(&t.cdp_session, &pup_vault::submit_login_js(submit), T).ok() == Some(json!(true));
    if source.starts_with("vault:") { ctx.vault.touch_used_user(source.trim_start_matches("vault:"), &username); }
    touch(ctx, &s.id, None);
    if submitted {
        std::thread::sleep(Duration::from_millis(1800));
        if let Some(w) = detect_wall(&b.cdp, &t.cdp_session) {
            let url_now = b.cdp.target_info(&t.cdp_session).ok().and_then(|i| i["url"].as_str().map(|s| s.to_string())).unwrap_or(cur_url.clone());
            pup_core::log(format!("[login] {live_host}: submitted, now on a {} wall — blocked_on_user", w["kind"].as_str().unwrap_or("?")));
            return blocked_on_user(false, if w["kind"] == json!("captcha") { "The user must solve the captcha in the pup window" } else { "The user must enter the one-time / two-factor code in the pup window" }, &format!("The login was filled and submitted; the site now needs the HUMAN ({}). Raise the window with pup_raise_os_window {{foregroundReason:\"finish the sign-in for {live_host}\"}} and tell them what to do, then continue (screenshot to confirm). Do not re-submit the password.", w["kind"].as_str().unwrap_or("?")), json!({ "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "reason": w["kind"], "evidence": w["evidence"], "output": json!({ "ok": false, "url": url_now, "submitted": true, "filledUsername": filled_user, "filledPassword": filled_pass, "wall": w["kind"] }).to_string() }));
        }
    }
    let react_miss = !keystrokes && !filled_pass;
    ok(json!({ "success": true, "output": json!({ "ok": true, "url": cur_url, "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "submitted": submitted, "reactMaskedFieldSuspected": react_miss }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner,
        "_hint": if react_miss { "The password did NOT land: this field looks React-controlled/masked (SSO/Keycloak logins). Re-call pup_login with realKeystrokes:true (raises the window briefly so trusted keystrokes register)." } else if submitted { "Login filled and SUBMITTED. Screenshot to confirm the landing page; pup_errors for console noise." } else { "Login form filled in the BACKGROUND (not submitted). Pass submit:true with submitReason to submit, or drive the submit button yourself." } }))
}

/// pup_credential_reveal: local callers only, one host, the user clicks Reveal on the desktop.
pub fn reveal(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let host = args["host"].as_str().unwrap_or("").trim().to_lowercase();
    let reason = args["reason"].as_str().unwrap_or("").trim().to_string();
    if host.is_empty() || reason.len() < 8 { return err("args_required", "pup_credential_reveal needs {host, reason} (reason 8+ chars; it is shown to the user)", None); }
    if caller.verified_origin != "local" || !caller.container.is_empty() {
        let relay = caller.verified_origin == "relay" || !caller.container.is_empty();
        pup_core::log(format!("[credential] reveal REFUSED for {host}: verified-origin={} caller \"{}\"", if caller.verified_origin.is_empty() { "MISSING" } else { &caller.verified_origin }, caller.thread));
        return err(if relay { "relay_forbidden" } else { "origin_unverified" }, if relay { "pup_credential_reveal is for LOCAL callers on the desktop only; a relayed (cloud container) call is refused".to_string() } else { format!("pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin \"{}\"", if caller.verified_origin.is_empty() { "MISSING" } else { &caller.verified_origin }) }, Some(if relay { "From a cloud thread do not ask for the secret: pup_login {submit:true, submitReason} signs the site in inside a pup window without the credential ever leaving the vault." } else { "Call through the adom-bridge CLI on this desktop (ab 2.0.96+)." }));
    }
    let Some(cred) = ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), None) else { return err("no_credential", format!("no saved login for {host}"), Some("pup_credentials {} lists the hosts the vault knows.")) };
    let who = if caller.thread.is_empty() { "an unnamed local caller".to_string() } else { caller.thread.clone() };
    let ask = ctx.consent.ask(ctx.ab.clone(), caller, &format!("pup_credential_reveal|{host}|{}", caller.thread), "pup_credential_reveal", &format!("Waiting for the user to allow revealing the {host} login to {who}"), "credreveal",
        json!({ "level": "warning", "scenario": "reminder", "title": format!("Reveal your {host} login to {who}?"), "body": format!("{who} is asking pup for the saved login for {host} (username {}). Reason given: \"{reason}\". Reveal sends the password to that caller ONCE. Deny sends nothing. This request came from a process on THIS desktop.", cred.username), "buttons": [{ "label": "Reveal" }, { "label": "Deny" }], "reason": reason, "aiThread": caller.thread, "delegate": "puppeteer" }));
    match ask {
        Ask::Pending { consent_id, remaining_sec, .. } => blocked_on_user(true, &format!("The user must click Reveal or Deny on the desktop toast \"Reveal your {host} login to {who}?\""), &format!("Not an error. The toast stays up for {remaining_sec}s more. Call pup_credential_reveal again with the SAME args to re-attach and collect the decision (no second toast), or poll pup_readiness {{}} and watch active[] for consentId {consent_id}."), json!({ "consentId": consent_id, "remainingSec": remaining_sec })),
        Ask::Decided { decision: Some(d), .. } if d == "Reveal" => {
            pup_core::log(format!("[credential] reveal GRANTED for {host} (user {}) to \"{who}\" — user clicked Reveal", cred.username));
            ok(json!({ "success": true, "output": json!({ "ok": true, "host": cred.host, "username": cred.username, "password": cred.password, "sourceAccount": cred.source_account, "revealedTo": who, "_hint": "Released ONCE because the user clicked Reveal. Use it, then drop it: never log it, never echo it into another verb's args or a response." }).to_string() }))
        }
        Ask::Decided { decision, .. } => { let code = if decision.is_some() { "consent_declined" } else { "consent_timeout" }; pup_core::log(format!("[credential] reveal {} for {host} by \"{who}\"", code.to_uppercase())); err(code, if decision.is_some() { "the user declined on the desktop; nothing was revealed" } else { "the user did not answer the consent toast; nothing was revealed" }, Some("Do not retry in a loop: the user saw the request. Ask them in chat, then call once more if they say yes.")) }
    }
}

pub fn card_set(ctx: &Ctx, args: &Value) -> Value {
    match ctx.cards.set(&*ctx.host, args["nickname"].as_str().unwrap_or(""), args["number"].as_str().unwrap_or(""), args["expiry"].as_str().unwrap_or(""), args["cvv"].as_str().unwrap_or(""), args["name"].as_str()) {
        Ok(meta) => ok(json!({ "success": true, "output": json!({ "ok": true, "nickname": meta["nickname"], "brand": meta["brand"], "last4": meta["last4"], "expiry": meta["expiry"], "card": meta }).to_string(), "card": meta, "_hint": "Stored: PAN + CVV in the OS keychain, only brand/last4/expiry in the index. pup_fill_card types it into a checkout after the user approves each fill." })),
        Err(e) => err("card_set_failed", e, Some("pup_card_set {nickname, number, expiry:\"MM/YY\", cvv, name?}")),
    }
}
pub fn cards(ctx: &Ctx) -> Value { let l = ctx.cards.list(); ok(json!({ "success": true, "cards": l, "output": json!({ "ok": true, "cards": l }).to_string() })) }
pub fn card_delete(ctx: &Ctx, args: &Value) -> Value { let n = args["nickname"].as_str().unwrap_or(""); let r = ctx.cards.delete(&*ctx.host, n); ok(json!({ "success": true, "removed": r, "output": json!({ "ok": true, "removed": r }).to_string() })) }

/// pup_fill_card: per-fill human approval, then trusted keystrokes into the checkout (frames included).
pub fn fill_card(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
    let nickname = args["nickname"].as_str().unwrap_or("").trim().to_string();
    let Some(meta) = ctx.cards.meta(&nickname) else { return err("no_card", format!("no saved card \"{nickname}\""), Some("pup_cards {} lists the nicknames; pup_card_set stores one.")) };
    let merchant = args["merchant"].as_str().map(|m| m.to_string()).filter(|m| !m.trim().is_empty()).or_else(|| pup_vault::host_of(&t.url)).unwrap_or_else(|| "this page".into());
    let amount = args["amount"].as_str().unwrap_or("").to_string();
    let who = if caller.thread.is_empty() { "an unnamed caller".to_string() } else { caller.thread.clone() };
    let (brand, last4) = (meta["brand"].as_str().unwrap_or("Card").to_string(), meta["last4"].as_str().unwrap_or("????").to_string());
    let ask = ctx.consent.ask(ctx.ab.clone(), caller, &format!("pup_fill_card|{nickname}|{merchant}|{}", caller.thread), "pup_fill_card", &format!("Waiting for the user to approve the {brand} …{last4} fill on {merchant}"), "cardfill",
        json!({ "level": "warning", "scenario": "reminder", "title": format!("Pay with {brand} …{last4} on {merchant}?"), "body": format!("{who} wants to fill your card \"{nickname}\" ({brand} ending {last4}) into the checkout on {merchant}{}. Approve fills the card fields in the pup window (the digits never leave this machine). Deny fills nothing.", if amount.is_empty() { String::new() } else { format!(" for {amount}") }), "buttons": [{ "label": "Approve" }, { "label": "Deny" }], "reason": args["reason"].as_str().unwrap_or(""), "aiThread": caller.thread, "delegate": "puppeteer" }));
    match ask {
        Ask::Pending { consent_id, remaining_sec, .. } => return blocked_on_user(true, &format!("The user must click Approve or Deny on the desktop toast \"Pay with {brand} …{last4} on {merchant}?\""), &format!("Not an error. The toast stays up for {remaining_sec}s more. Call pup_fill_card again with the SAME args to re-attach and collect the decision, or poll pup_readiness active[] for consentId {consent_id}."), json!({ "consentId": consent_id, "remainingSec": remaining_sec })),
        Ask::Decided { decision: Some(d), .. } if d == "Approve" => {}
        Ask::Decided { decision, .. } => return err(if decision.is_some() { "consent_declined" } else { "consent_timeout" }, if decision.is_some() { "the user declined the card fill on the desktop; nothing was filled" } else { "the user did not answer the approval toast; nothing was filled" }, Some("Do not retry in a loop: ask the user in chat first.")),
    }
    let Some((pan, cvv)) = ctx.cards.secret(&*ctx.host, &nickname) else { return err("no_card", "the card vanished from the keychain between listing and filling", None) };
    let expiry = meta["expiry"].as_str().unwrap_or("").to_string(); let name = meta["name"].as_str().unwrap_or("").to_string();
    // frames: the page session plus every child frame session (Stripe/Adyen iframes)
    let mut sessions_to_try = vec![t.cdp_session.clone()];
    if let Ok(r) = b.cdp.send(Some(&t.cdp_session), "Target.setAutoAttach", json!({ "autoAttach": true, "waitForDebuggerOnStart": false, "flatten": true }), T) { let _ = r; }
    std::thread::sleep(Duration::from_millis(300));
    for tgt in b.cdp.targets().unwrap_or_default() { if tgt["type"] == json!("iframe") && tgt["openerId"].is_null() { if let Ok(sid) = b.cdp.attach(tgt["targetId"].as_str().unwrap_or("")) { let _ = b.cdp.send(Some(&sid), "Runtime.enable", json!({}), T); sessions_to_try.push(sid); } } }
    let mut filled = json!({ "number": false, "expiry": false, "cvv": false, "name": false });
    for (field, value) in [("number", pan.as_str()), ("expiry", expiry.as_str()), ("cvv", cvv.as_str()), ("name", name.as_str())] {
        if value.is_empty() { continue; }
        for sid in &sessions_to_try {
            if filled[field] == json!(true) { break; }
            if let Ok(v) = b.cdp.evaluate(sid, &pup_vault::card_field_js(field), T) { if !v.is_null() {
                let mut okk = true;
                for ch in value.chars() { let cs = ch.to_string(); if crate::input::press_pub(&b.cdp, sid, &cs, 0).is_err() { if b.cdp.send(Some(sid), "Input.insertText", json!({ "text": cs }), T).is_err() { okk = false; break; } } std::thread::sleep(Duration::from_millis(20)); }
                if okk { filled[field] = json!(true); }
            } }
        }
    }
    pup_core::log(format!("[card] fill GRANTED+DONE {nickname} (…{last4}) on {merchant} by \"{who}\": {filled} ({} frame session(s))", sessions_to_try.len()));
    touch(ctx, &s.id, None);
    let missing: Vec<&str> = ["number", "expiry", "cvv", "name"].iter().filter(|f| filled[**f] != json!(true) && (**f != "name" || !name.is_empty())).cloned().collect();
    ok(json!({ "success": true, "output": json!({ "ok": true, "filled": filled, "framesScanned": sessions_to_try.len(), "last4": last4, "brand": brand, "_hint": if missing.is_empty() { "All card fields typed with trusted keystrokes; the digits never left this machine. Screenshot to confirm, then submit the checkout yourself." } else { "Some fields were not found (see filled); the checkout may use unusual markup. Screenshot, then pup_fill or pup_type the remaining fields with values from the user, never the PAN." } }).to_string(), "missing": missing }))
}

/// Autofill on load (Node: autoLoginOnLoad): fill, never submit, and note it on the response.
/// 2.0.731 (John, 2026-10-20: "drew, who uses pup all the time, and runs into login problems all the time, you never
/// suggest to him that you can import all of his credentials"). The extension lane, every window's default, had NO
/// login handling on page load: no auto-fill, and nothing told the AI about pup_import_browser_logins until a
/// pup_login had already failed. After every extension-lane open and navigate: a page with a password field is
/// filled in the background from the vault when pup has the login (never submitted), and when it does not, the
/// answer tells the AI to OFFER the import of the user's own saved browser logins.
pub fn login_check_ext(ctx: &Ctx, lane: &crate::transport::Lane, t: &pup_session::Tab, url: &str) -> Value {
    if ctx.settings.get("autoLogin", "on") == "off" || !url.starts_with("http") { return json!({}); }
    let Ok(located) = crate::transport::evaluate(lane, t, pup_vault::locate_login_js()) else { return json!({}) };
    if located["ok"] != json!(true) { return json!({}); }
    let host = pup_vault::host_of(url).unwrap_or_default();
    match ctx.vault.get_for_url(&*ctx.host, url, None) {
        Some(cred) => {
            let v = crate::transport::evaluate(lane, t, &pup_vault::fill_login_js(&cred.username, &cred.password)).unwrap_or(Value::Null);
            if v["plen"].as_u64().unwrap_or(0) == 0 { return json!({}); }
            json!({ "_autoLogin": format!("pup AUTO-FILLED this login for saved host \"{}\" (username {}) in the BACKGROUND, NOT submitted. CONFIRM the live domain is the intended site, then pup_login {{submit:true, submitReason}} to sign in.", cred.host, cred.username) })
        }
        None => {
            let saved = ctx.vault.list().len();
            // The import of the browser's own saved passwords is Windows-only today (Chromium ABE/DPAPI through ab).
            // On a Mac, say what does work instead of offering something that will answer unsupported_platform.
            if !cfg!(windows) {
                return json!({ "loginPage": true, "savedLogins": saved, "_loginTip": format!(
                    "This is a sign-in page for {host}, and pup has no saved login for it ({saved} saved in all). On this Mac pup cannot import the browser's saved passwords yet. What works: ask the user to sign in once in this pup window themselves; pup saves the login as they submit it and fills it here from then on. If they also use a Windows desktop on the same Adom account, pup_vault_sync can bring that desktop's logins over. Never ask the user to read out a password.") });
            }
            json!({ "loginPage": true, "savedLogins": saved, "_loginTip": format!(
                "This is a sign-in page for {host}, and pup has no saved login for it ({saved} saved in all). The user's own browser very likely has it: OFFER to import their saved logins, in these words or close to them: \"I can bring your saved passwords from Chrome/Edge into pup, so I can sign you in here and on other sites automatically. It takes one approval in Adom Bridge plus one Windows prompt. Want me to?\" On yes: pup_import_browser_logins {{}} (no hostFilter the first time: a filter hides logins saved under another domain). pup then fills this page by itself. Never type a password you were not given, and never ask the user to read one out.") })
        }
    }
}

/// 2.0.733 (John: "i'd rather him auto discover it"): the first pup window each AI thread opens on Windows carries a
/// one-time `_importTip` while the vault is nearly empty and no import has ever run, so an AI learns about the import
/// BEFORE the user is stuck at a sign-in page. Once per thread per pup run; never again after the first import.
pub fn import_tip_once(ctx: &Ctx, thread: &str) -> Value {
    static TOLD: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
    if !cfg!(windows) || thread.is_empty() { return json!({}); }
    if ctx.host.state_dir().join("pup-logins-imported.json").exists() { return json!({}); }
    let saved = ctx.vault.list().len();
    if saved >= ctx.settings.get("importTipBelow", "5").parse::<usize>().unwrap_or(5) { return json!({}); }
    { let mut g = TOLD.lock().unwrap(); if !g.get_or_insert_with(Default::default).insert(thread.to_string()) { return json!({}); } }
    json!({ "_importTip": format!("Good to know before any sign-in: pup has only {saved} saved login(s), and the user's own Chrome or Edge almost certainly has many more. pup can bring them all over in one approval (an Adom Bridge consent plus one Windows prompt): pup_import_browser_logins {{}}. After that pup fills sign-in pages by itself. When this task needs a login, or at a natural pause, OFFER it in plain words: \"I can bring your saved passwords from Chrome or Edge into pup, so I can sign you into sites automatically. Want me to?\" Offer once; if they say no, do not ask again. See the pup-import-logins skill.") })
}

pub fn autofill_on_load(ctx: &Ctx, s: &Session, cdp_session: &str, url: &str) -> Option<String> {
    if ctx.settings.get("autoLogin", "on") == "off" { return None; }
    let b = crate::browser_for(ctx, &s.profile)?;
    let cred = ctx.vault.get_for_url(&*ctx.host, url, None)?;
    let located = b.cdp.evaluate(cdp_session, pup_vault::locate_login_js(), T).ok()?;
    if located["ok"] != json!(true) { return None; }
    let v = b.cdp.evaluate(cdp_session, &pup_vault::fill_login_js(&cred.username, &cred.password), T).ok()?;
    let _ = b.cdp.evaluate(cdp_session, &pup_vault::submit_login_js(false), T);
    if v["plen"].as_u64().unwrap_or(0) == 0 { return None; }
    Some(format!("pup AUTO-FILLED this login for saved host \"{}\" (username {}) in the BACKGROUND, NOT submitted. CONFIRM the live domain is the intended site, then pup_login {{submit:true, submitReason}} to sign in.", cred.host, cred.username))
}

// ── pup-to-pup vault sync (John: a real bidirectional union, not a one-way push) ─────────────
// Same-account desktops each build up different logins; sync merges BOTH directions so both boxes
// end with the union. Passwords move pup->pup over ab's cross-desktop egress and never touch an AI.

/// Refuse a cloud/relay caller (a container). A LOCAL caller or a same-account bridge egress is fine
/// (both boxes are the user's own, authenticated to the same account through ab).
fn vault_sync_origin_ok(caller: &Caller) -> bool { caller.container.is_empty() }  // a cloud container sets `container`; a same-account desktop egress does not (it is trusted even though the transport is the relay)

pub fn vault_export(ctx: &Ctx, caller: &Caller) -> Value {
    if !vault_sync_origin_ok(caller) { return err("relay_forbidden", "pup_vault_export hands out passwords and is refused for cloud/relay callers; it is for a same-account desktop sync only.", None); }
    let entries = ctx.vault.export_all(&*ctx.host);
    pup_core::log(format!("[vault-sync] exported {} credential(s) to a same-account peer (caller \"{}\")", entries.len(), caller.thread));
    ok(json!({ "success": true, "count": entries.len(), "entries": entries, "output": json!({ "ok": true, "count": entries.len(), "entries": entries }).to_string() }))
}

pub fn vault_import(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    if !vault_sync_origin_ok(caller) { return err("relay_forbidden", "pup_vault_import is refused for cloud/relay callers; it is for a same-account desktop sync only.", None); }
    let entries: Vec<Value> = args["entries"].as_array().cloned().unwrap_or_default();
    let (added, updated, skipped) = ctx.vault.import_merge(&*ctx.host, &entries);
    pup_core::log(format!("[vault-sync] merged a peer's vault: +{added} new, {updated} updated, {skipped} skipped"));
    ok(json!({ "success": true, "added": added, "updated": updated, "skipped": skipped, "output": json!({ "ok": true, "added": added, "updated": updated, "skipped": skipped }).to_string() }))
}

/// pup_vault_sync {peer, reason}: one consent toast on THIS box, then a bidirectional merge with the
/// named peer desktop. Both boxes end with the union of their adom-you logins (newest-wins on conflict).
pub fn vault_sync(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let peer = args["peer"].as_str().unwrap_or("").trim().to_string();
    let reason = args["reason"].as_str().unwrap_or("").trim().to_string();
    if peer.is_empty() || reason.len() < 8 { return err("args_required", "pup_vault_sync needs {peer, reason} (peer = the other desktop's name from `targets`; reason 8+ chars, shown to the user)", None); }
    let mine = ctx.vault.list().len();
    let ask = ctx.consent.ask(ctx.ab.clone(), caller, &format!("pup_vault_sync|{peer}"), "pup_vault_sync",
        &format!("Waiting for you to allow syncing your adom-you logins with {peer}"), "pup-vault-sync",
        json!({ "level": "warning", "scenario": "reminder", "title": format!("Sync your saved logins with {peer}?"), "body": format!("pup will merge this desktop's {mine} saved logins with {peer}'s, both directions, so both boxes have all of them. Passwords move pup-to-pup and are never shown. Reason: {reason}"), "buttons": [{ "label": "Allow" }, { "label": "Deny" }], "reason": reason, "aiThread": caller.thread, "delegate": "puppeteer" }));
    match ask {
        Ask::Pending { consent_id, remaining_sec, .. } => return blocked_on_user(true, &format!("The user must click Allow on the \"Sync your saved logins with {peer}?\" toast"), &format!("No sync yet. Re-call pup_vault_sync with the same args to re-attach (no second toast); it decides within {remaining_sec}s."), json!({ "consentId": consent_id })),
        Ask::Decided { decision: Some(d), .. } if d == "Allow" || d == "Sync" || d == "Reveal" => {}
        Ask::Decided { decision, .. } => return err(if decision.is_some() { "consent_declined" } else { "consent_timeout" }, format!("Vault sync with {peer} was not authorized."), None),
    }
    // 1) push MINE to the peer (peer merges)
    let my_entries = ctx.vault.export_all(&*ctx.host);
    let internal = crate::identity::internal_caller();
    let push = match ctx.ab.call_target(&peer, "pup_vault_import", json!({ "entries": my_entries }), &internal, std::time::Duration::from_secs(30)) {
        Ok(v) => v, Err(e) => return err("peer_unreachable", format!("could not reach {peer}'s pup to push the vault: {e}"), Some("Is the peer desktop online and on a pup build with vault sync? `targets` lists connected desktops.")),
    };
    let push_in = push.get("output").and_then(|v| v.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()).unwrap_or(push.clone());
    // 2) pull the PEER's and merge locally
    let pulled = match ctx.ab.call_target(&peer, "pup_vault_export", json!({}), &internal, std::time::Duration::from_secs(30)) {
        Ok(v) => v, Err(e) => return err("peer_unreachable", format!("pushed to {peer} but could not pull its vault back: {e}"), None),
    };
    let peer_entries: Vec<Value> = pulled.get("entries").and_then(|v| v.as_array()).cloned()
        .or_else(|| pulled.get("output").and_then(|v| v.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()).and_then(|v| v["entries"].as_array().cloned())).unwrap_or_default();
    let (added, updated, skipped) = ctx.vault.import_merge(&*ctx.host, &peer_entries);
    let total = ctx.vault.list().len();
    pup_core::log(format!("[vault-sync] with {peer}: pushed {} mine, pulled {} theirs (+{added} new, {updated} updated here); union now {total}", my_entries.len(), peer_entries.len()));
    ok(json!({ "success": true, "output": json!({ "ok": true, "peer": peer,
        "pushedToPeer": { "sent": my_entries.len(), "added": push_in["added"], "updated": push_in["updated"], "skipped": push_in["skipped"] },
        "pulledFromPeer": { "received": peer_entries.len(), "added": added, "updated": updated, "skipped": skipped },
        "unionHere": total,
        "_hint": format!("Both desktops now hold the union of their adom-you logins ({total} here). Newest-wins settled any conflicts. Run pup_vault_sync again anytime a box has learned new logins.") }).to_string() }))
}


/// pup_login on the extension lane: the same locate / fill / submit scripts, evaluated through the transport.
/// realKeystrokes needs CDP trusted input and is refused here with the lane:"cdp" hint.
fn login_ext(ctx: &Ctx, caller: &Caller, args: &Value, s: Session) -> Value {
    let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };
    let t = match args.get("tabId").and_then(|t| t.as_str()).and_then(|id| s.tab_by_id(id).cloned()).or_else(|| s.active().cloned()) { Some(t) => t, None => return err("no_tab", "the window has no tabs", None) };
    let cur_url = crate::transport::evaluate(&lane, &t, "location.href").ok().and_then(|v| v.as_str().map(|x| x.to_string())).unwrap_or_else(|| t.url.clone());
    let live_host = pup_vault::host_of(&cur_url).unwrap_or_else(|| "<unknown>".into());
    let (username, password, source): (String, String, String) = match (args["username"].as_str(), args["password"].as_str()) {
        (Some(u), Some(p)) if !p.is_empty() => { if args["save"].as_bool() == Some(true) { let _ = ctx.vault.set(&*ctx.host, &live_host, u, p); } (u.into(), p.into(), "args".into()) }
        _ => match ctx.vault.get_for_url(&*ctx.host, &cur_url, args["preferAccount"].as_str()) {
            Some(c) => (c.username, c.password, format!("vault:{}", c.host)),
            None => {
                crate::google::toast_waiting(ctx, caller, &s.id, "pup needs a login it does not have",
                    &format!("Sign in to {live_host} once in the pup window (click here to open it) and pup will remember it."));
                return err("no_credential", format!("no saved login matches {live_host}"), Some(IMPORT_HINT));
            }
        },
    };
    let submit = args["submit"].as_bool() == Some(true);
    if submit && args["submitReason"].as_str().map(|r| r.trim().len() >= 10) != Some(true) {
        return pup_core::merge(err("submit_reason_required", "CONFIRM THE DOMAIN BEFORE SENDING CREDENTIALS", Some(&format!("You asked to submit a stored login: the page is on {live_host}. Re-call with submitReason:\"<why {live_host} is the intended site>\"."))), json!({ "liveHost": live_host, "url": cur_url }));
    }
    if args["realKeystrokes"].as_bool() == Some(true) || args["keystrokes"].as_bool() == Some(true) {
        return err("realkeystrokes_unwired", "pup_login's realKeystrokes is not wired on the extension lane yet", Some("For a form that ignores filled values, click the field with pup_click and type with pup_type: both are trusted input on this window. pup windows drive the DevTools protocol through pup's own extension: pup_devtools {method, params} sends any command (DOM, Runtime, Input, Page, Network...) to the window with no debug port. Do not stop, and do not move to another machine, because the CDP lane is off."));
    }
    if submit { pup_core::log(format!("[cred] pup_login submit to {live_host} by \"{}\" (ext lane) — reason: {}", caller.thread, args["submitReason"].as_str().unwrap_or("").chars().take(120).collect::<String>())); }
    let located = match crate::transport::evaluate(&lane, &t, pup_vault::locate_login_js()) { Ok(v) => v, Err(e) => return e };
    // 2.0.596: this lane answered ok:true with nothing filled when the page had no password box (Google's email
    // step, 2026-09-16), and read a field the fill script never returns, so filledPassword was always false.
    if located["ok"] != json!(true) { return no_login_form(&live_host, &cur_url); }
    let filled = crate::transport::evaluate(&lane, &t, &pup_vault::fill_login_js(&username, &password)).unwrap_or(Value::Null);
    let (filled_user, filled_pass) = (located["hasUser"] == json!(true) && filled["fu"] == json!(true), filled["plen"].as_u64().unwrap_or(0) > 0);
    let submitted = crate::transport::evaluate(&lane, &t, &pup_vault::submit_login_js(submit)).ok() == Some(json!(true));
    if source.starts_with("vault:") { ctx.vault.touch_used_user(source.trim_start_matches("vault:"), &username); }
    touch(ctx, &s.id, None);
    if submitted {
        std::thread::sleep(Duration::from_millis(1800));
        if let Some(w) = crate::transport::evaluate(&lane, &t, crate::WALL_JS).ok().filter(|v| v.is_object()) {
            pup_core::log(format!("[login] {live_host}: submitted, now on a {} wall — blocked_on_user", w["kind"].as_str().unwrap_or("?")));
            let url_now = crate::transport::evaluate(&lane, &t, "location.href").ok().and_then(|v| v.as_str().map(|x| x.to_string())).unwrap_or_else(|| cur_url.clone());
            return blocked_on_user(false, if w["kind"] == json!("captcha") { "The user must solve the captcha in the pup window" } else { "The user must enter the one-time / two-factor code in the pup window" },
                &format!("The login was filled and submitted; the site now needs the HUMAN ({}). Raise the window with pup_raise_os_window {{foregroundReason:\"finish the sign-in for {live_host}\"}} and tell them what to do, then continue (screenshot to confirm). Do not re-submit the password.", w["kind"].as_str().unwrap_or("?")),
                json!({ "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "reason": w["kind"], "evidence": w["evidence"], "output": json!({ "ok": false, "url": url_now, "submitted": true, "filledUsername": filled_user, "filledPassword": filled_pass, "wall": w["kind"], "transport": "ext" }).to_string() }));
        }
    }
    // top-level fields AND a string output: the CLI drops an object `output`, which hid every field here
    let o = json!({ "ok": true, "url": cur_url, "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "submitted": submitted, "transport": "ext" });
    ok(pup_core::merge(o.clone(), json!({ "success": true, "sessionId": s.id, "window": s.id, "output": o.to_string() })))
}