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
John Lauer
v2.0.720 (insiders): Groundwork for the macOS build of the Rust pup (Kyle's pull request #3, pup-bridge#128): the Mac h…
87f5051
27d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
//! pup_google_accounts / pup_google_signin (2.0.596).
//!
//! John, 2026-09-16: "my goal is that you can auto log a user into google anytime their ai asks ... i have
//! 3 or 4 google accounts ... you need to be smart about what account you login as. you should pass hints
//! back to the ai asking which profile ... the first time you're asked to login to google you should do a
//! whole profile search using anything at your disposal".
//!
//! What the first attempt taught (AdomLapper, 2026-09-16):
//! - pup_login cannot do Google: it needs a visible password box, and Google asks for the email first.
//! - Google does NOT block this browser: the email step and the password step both went through.
//! - A Gmail link for an account that is not signed in (`/mail/u/?authuser=x`) silently opens the
//! signed-in account's inbox. Landing on Gmail proves nothing; the account must be confirmed by name.
//! - `AddSession?Email=<email>` pre-fills the email. Google only honours trusted clicks on its buttons.
//! - An account found only in pup's vault ([email protected]) was years old and drew a 2FA wall.
//! pup never picks between accounts itself; it hands the list to the calling AI.
use crate::Ctx;
use pup_core::{err, ok, Caller};
use serde_json::{json, Value};
use std::time::{Duration, Instant};
const CACHE_TTL_MS: u64 = 24 * 3600 * 1000;
const PERSONAL_DOMAINS: &[&str] = &["gmail.com", "googlemail.com"];
fn enc(s: &str) -> String { let mut o = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => o.push(b as char), _ => o.push_str(&format!("%{b:02X}")) } } o }
fn is_email(s: &str) -> bool { let t = s.trim(); let at = t.find('@'); t.len() < 120 && !t.contains(' ') && at.map(|i| i > 0 && t[i + 1..].contains('.')).unwrap_or(false) }
fn kind_of(email: &str) -> &'static str { let d = email.rsplit('@').next().unwrap_or("").to_lowercase(); if PERSONAL_DOMAINS.contains(&d.as_str()) { "personal" } else { "work" } }
fn cache_path(ctx: &Ctx) -> std::path::PathBuf { ctx.host.state_dir().join("pup-google-accounts.json") }
fn push_unique(v: &mut Value, key: &str, item: &str) {
if item.trim().is_empty() { return; }
let a = v[key].as_array_mut().expect("array field");
if !a.iter().any(|x| x.as_str() == Some(item)) { a.push(json!(item)); }
}
fn generic_label(l: &str) -> bool { let l = l.trim(); l.is_empty() || l == "Your Chrome" || l.starts_with("Profile ") || l == "Person 1" || l == "Default" }
/// Profiles, wherever the response nests them (ab wraps verb output in `output` or `data`).
fn dig(v: &Value, key: &str) -> Vec<Value> {
if let Some(a) = v[key].as_array() { return a.clone(); }
if let Some(a) = v["data"][key].as_array() { return a.clone(); }
if let Some(s) = v["output"].as_str() { if let Ok(o) = serde_json::from_str::<Value>(s) { return dig(&o, key); } }
if v["output"].is_object() { return dig(&v["output"], key); }
Vec::new()
}
/// Everything this desktop knows about the user's Google accounts WITHOUT a page: nb's browser profiles,
/// the Chrome/Edge/Brave profile scan, and pup's vault. The pup profile's own signed-in accounts need a page
/// on accounts.google.com and are merged in by the caller (`apply_signed_in`).
fn search(ctx: &Ctx, caller: &Caller) -> Vec<Value> {
let mut by: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
fn entry<'a>(by: &'a mut std::collections::BTreeMap<String, Value>, email: &str) -> &'a mut Value {
let k = email.trim().to_lowercase();
by.entry(k.clone()).or_insert_with(|| json!({ "email": k, "kind": kind_of(&k), "labels": [], "browsers": [], "sources": [], "pupSignedIn": false, "hasSavedPassword": false, "savedPasswordUpdatedAt": null }))
}
let mut rows: Vec<(String, &str, String, String)> = Vec::new(); // (email, source, label, browser)
// 1. nb: every browser profile the Adom extension sees, with the label the user gave it ("John Personal")
if let Ok(r) = ctx.ab.call("nbrowser_profiles", json!({}), caller, Duration::from_secs(20)) {
for p in dig(&r, "profiles") {
let e = p["email"].as_str().unwrap_or("");
if is_email(e) { rows.push((e.into(), "nb", p["displayName"].as_str().unwrap_or("").into(), format!("{} {}", p["browser"].as_str().unwrap_or("?"), p["profileDir"].as_str().unwrap_or("")).trim().into())); }
}
}
// 2. the Chromium profile scan: the accounts signed into each Chrome / Edge / Brave profile on disk
let scan = crate::import::import_scan(ctx);
for p in scan["profiles"].as_array().cloned().unwrap_or_default() {
for a in p["accounts"].as_array().cloned().unwrap_or_default() {
if let Some(e) = a.as_str().filter(|e| is_email(e)) {
rows.push((e.into(), "browser-profile", p["profileName"].as_str().unwrap_or("").into(), format!("{} {}", p["browser"].as_str().unwrap_or("?"), p["profileDir"].as_str().unwrap_or("")).trim().into()));
}
}
}
for (e, src, label, browser) in rows {
let v = entry(&mut by, &e);
push_unique(v, "sources", src); if !generic_label(&label) { push_unique(v, "labels", &label); } push_unique(v, "browsers", &browser);
}
// 3. pup's vault: Google logins whose username is an email (the capture rail also stores junk usernames)
for c in ctx.vault.list() {
let host = c["host"].as_str().unwrap_or("");
let user = c["username"].as_str().unwrap_or("");
if pup_vault::registrable_domain(host) != "google.com" || !is_email(user) { continue; }
let v = entry(&mut by, user);
push_unique(v, "sources", "pup-vault");
v["hasSavedPassword"] = json!(true);
let ts = c["updatedAt"].as_str().unwrap_or("");
if v["savedPasswordUpdatedAt"].as_str().map(|t| ts > t).unwrap_or(true) { v["savedPasswordUpdatedAt"] = json!(ts); }
}
let mut out: Vec<Value> = by.into_values().collect();
finalize(&mut out);
out
}
fn finalize(accts: &mut Vec<Value>) {
for a in accts.iter_mut() {
let only_vault = a["sources"].as_array().map(|s| s.len() == 1 && s[0] == json!("pup-vault")).unwrap_or(false);
a["likelyStale"] = json!(only_vault && a["pupSignedIn"] != json!(true));
}
accts.sort_by(|a, b| {
let key = |x: &Value| (x["pupSignedIn"] != json!(true), x["likelyStale"] == json!(true), x["email"].as_str().unwrap_or("").to_string());
key(a).cmp(&key(b))
});
}
fn apply_signed_in(accts: &mut Vec<Value>, signed: &[String]) {
for a in accts.iter_mut() { a["pupSignedIn"] = json!(signed.iter().any(|e| Some(e.as_str()) == a["email"].as_str())); }
for e in signed {
if !accts.iter().any(|a| a["email"].as_str() == Some(e.as_str())) {
accts.push(json!({ "email": e, "kind": kind_of(e), "labels": [], "browsers": [], "sources": ["pup-profile"], "pupSignedIn": true, "hasSavedPassword": false, "savedPasswordUpdatedAt": null }));
} else if let Some(a) = accts.iter_mut().find(|a| a["email"].as_str() == Some(e.as_str())) { push_unique(a, "sources", "pup-profile"); }
}
finalize(accts);
}
fn load_cache(ctx: &Ctx) -> Option<(u64, Vec<Value>)> {
let v: Value = serde_json::from_str(&std::fs::read_to_string(cache_path(ctx)).ok()?).ok()?;
Some((v["searchedAtMs"].as_u64()?, v["accounts"].as_array()?.clone()))
}
fn save_cache(ctx: &Ctx, accts: &[Value]) {
let _ = std::fs::write(cache_path(ctx), serde_json::to_string_pretty(&json!({ "searchedAtMs": pup_core::now_ms(), "accounts": accts })).unwrap_or_default());
}
/// The cached account list, or a fresh whole search when there is none, it is a day old, or `refresh`.
fn accounts(ctx: &Ctx, caller: &Caller, refresh: bool) -> (Vec<Value>, bool) {
if !refresh { if let Some((at, a)) = load_cache(ctx) { if pup_core::now_ms().saturating_sub(at) < CACHE_TTL_MS { return (a, false); } } }
pup_core::log(format!("[google] whole account search for \"{}\" (nb profiles, browser profile scan, pup vault)", caller.thread));
(search(ctx, caller), true)
}
// ── driving the sign-in page ─────────────────────────────────────────────────────────────────────────────
const STATE_JS: &str = r#"(() => {
const vis = (e) => !!e && e.offsetParent !== null && e.getClientRects().length > 0;
const q = (s) => [...document.querySelectorAll(s)].find(vis);
const t = ((document.body && document.body.innerText) || '').slice(0, 1200);
document.querySelectorAll('[data-pup-code]').forEach((e) => e.removeAttribute('data-pup-code'));
const code = q('#idvPin, #totpPin, input[name=Pin], input[name=totpPin], input[autocomplete=one-time-code], input[type=tel]');
if (code) code.setAttribute('data-pup-code', '1');
const opts = [...document.querySelectorAll('[role=link][data-challengetype]')].filter(vis).map((e) => (e.innerText || '').trim().split('\n')[0]).filter(Boolean);
const onChallenge = /\/challenge\//.test(location.pathname) && !/\/challenge\/pwd/.test(location.pathname);
return { host: location.host, path: location.pathname, url: location.href, title: document.title,
idVisible: vis(document.querySelector('#identifierId')), idValue: ((document.querySelector('#identifierId') || {}).value || ''),
pwVisible: !!q('input[name=Passwd], input[type=password]'), challenge: onChallenge, hasCode: onChallenge && !!code, options: onChallenge ? opts : [],
wrongPassword: /Wrong password|Your password was changed/i.test(t), noAccount: /Couldn.t find your Google Account/i.test(t),
insecureBrowser: /browser or app may not be secure/i.test(t), text: t.slice(0, 500) };
})()"#;
const LIST_ACCOUNTS_JS: &str = r#"(async () => {
if (location.host !== 'accounts.google.com') return null;
try {
const r = await fetch('/ListAccounts?gpsia=1&source=ChromiumBrowser&json=standard', { method: 'POST', credentials: 'include' });
const j = JSON.parse(await r.text());
return (j[1] || []).map((a) => String(a[3] || '').toLowerCase()).filter(Boolean);
} catch (e) { return null; }
})()"#;
/// Mark the element to click (a trusted pup_click follows): the first visible id in `ids`, else a visible
/// button labelled Next / Verify / Continue / Done.
fn next_js(ids: &[&str]) -> String {
format!(r#"((ids) => {{
document.querySelectorAll('[data-pup-click]').forEach((e) => e.removeAttribute('data-pup-click'));
const vis = (e) => !!e && e.offsetParent !== null;
let el = null;
for (const id of ids) {{ const c = document.querySelector(id); if (vis(c)) {{ el = c.querySelector('button') || c; break; }} }}
if (!el) el = [...document.querySelectorAll('button, [role=button]')].filter(vis).find((b) => /^(Next|Verify|Continue|Done)$/i.test((b.innerText || '').trim()));
if (!el) return false; el.setAttribute('data-pup-click', '1'); return true;
}})({})"#, serde_json::to_string(ids).unwrap_or_else(|_| "[]".into()))
}
fn option_js(want: &str) -> String {
format!(r#"((want) => {{
document.querySelectorAll('[data-pup-click]').forEach((e) => e.removeAttribute('data-pup-click'));
const el = [...document.querySelectorAll('[role=link][data-challengetype]')].filter((e) => e.offsetParent !== null).find((e) => (e.innerText || '').toLowerCase().includes(want.toLowerCase()));
if (!el) return false; el.setAttribute('data-pup-click', '1'); return true;
}})({})"#, serde_json::to_string(want).unwrap_or_else(|_| "\"\"".into()))
}
fn ev(ctx: &Ctx, caller: &Caller, sid: &str, expr: &str) -> Value {
crate::dispatch(ctx, caller, "pup_eval", &json!({ "window": sid, "expr": expr })).get("result").cloned().unwrap_or(Value::Null)
}
fn state(ctx: &Ctx, caller: &Caller, sid: &str) -> Value { ev(ctx, caller, sid, STATE_JS) }
fn failed(r: &Value) -> bool { r.get("errorCode").and_then(|e| e.as_str()).map(|e| !e.is_empty()).unwrap_or(false) || r["success"] == json!(false) }
fn click_marked(ctx: &Ctx, caller: &Caller, sid: &str, mark_js: &str) -> bool {
if ev(ctx, caller, sid, mark_js) != json!(true) { return false; }
!failed(&crate::dispatch(ctx, caller, "pup_click", &json!({ "window": sid, "selector": "[data-pup-click]" })))
}
fn type_into(ctx: &Ctx, caller: &Caller, sid: &str, selector: &str, text: &str) -> bool {
!failed(&crate::dispatch(ctx, caller, "pup_type", &json!({ "window": sid, "selector": selector, "text": text, "clear": true })))
}
fn phase(st: &Value) -> &'static str {
if !st.is_object() { return "loading"; }
if st["host"].as_str() != Some("accounts.google.com") { return "left"; }
if st["insecureBrowser"] == json!(true) { return "blocked_browser"; }
if st["noAccount"] == json!(true) { return "no_account"; }
if st["wrongPassword"] == json!(true) { return "wrong_password"; }
if st["pwVisible"] == json!(true) { return "password"; }
if st["hasCode"] == json!(true) { return "code"; }
if st["options"].as_array().map(|o| !o.is_empty()).unwrap_or(false) { return "choose"; }
if st["challenge"] == json!(true) { return "challenge"; }
if st["idVisible"] == json!(true) { return "identifier"; }
if st["path"].as_str().map(|p| p.to_lowercase().contains("accountchooser")).unwrap_or(false) { return "chooser"; }
"loading"
}
/// Poll the page until `done(phase)` or the timeout; returns the last state read.
fn wait_for(ctx: &Ctx, caller: &Caller, sid: &str, timeout: Duration, done: impl Fn(&str) -> bool) -> Value {
let t0 = Instant::now();
loop {
let st = state(ctx, caller, sid);
if done(phase(&st)) || t0.elapsed() >= timeout { return st; }
std::thread::sleep(Duration::from_millis(700));
}
}
struct Opened { sid: String, tab: Option<String>, new_window: bool }
/// A tab for the sign-in: the caller's window gets a new tab (its current page is left alone); a thread
/// with no window gets one, and a thread with several windows and none named gets a dedicated one.
fn open_on(ctx: &Ctx, caller: &Caller, args: &Value, url: &str) -> Result<Opened, Value> {
let named = args["window"].as_str().or(args["sessionId"].as_str()).map(|s| s.to_string());
let probe = match &named { Some(w) => json!({ "window": w }), None => json!({}) };
let (r, new_window) = match crate::resolve(ctx, caller, &probe) {
Ok(s) => (crate::dispatch(ctx, caller, "pup_open_tab", &json!({ "window": s.id, "url": url })), false),
Err(_) => {
let mut a = json!({ "url": url });
if let Some(w) = &named { a["window"] = json!(w); }
else if ctx.sessions.all().iter().any(|s| s.owner.as_deref() == Some(caller.thread.as_str())) { a["window"] = json!("google-signin"); }
(crate::dispatch(ctx, caller, "pup_open_window", &a), true)
}
};
if failed(&r) { return Err(r); }
let sid = r["window"].as_str().or(r["sessionId"].as_str()).map(|s| s.to_string()).ok_or_else(|| r.clone())?;
let tab = r["tabId"].as_str().map(|s| s.to_string()).or_else(|| r["tabId"].as_i64().map(|n| n.to_string()));
Ok(Opened { sid, tab, new_window })
}
/// 2.0.603 (John, 2026-09-17: "whenever you need the users attention for a password, you should do it
/// that way" ... "i didn't even realize you were waiting on me. why did you not ab toast me?"). Every
/// answer that parks the flow on the human toasts, and the toast CARRIES THE WINDOW: ab's `focus` makes a
/// click bring that pup window to the front, so the user lands on the exact page that is waiting.
pub(crate) fn toast_waiting(ctx: &Ctx, caller: &Caller, sid: &str, title: &str, body: &str) {
let hwnd = ctx.sessions.get(sid).and_then(|s| s.hwnd).filter(|h| *h > 0);
let mut args = json!({ "title": title, "body": body, "level": "warning" });
match hwnd {
Some(h) => args["focus"] = json!({ "hwnd": h }),
None => args["focus"] = json!({ "app": if cfg!(windows) { "chrome.exe" } else { "Google Chrome for Testing" } }),
}
let _ = ctx.ab.call("notify_user", args, caller, Duration::from_secs(8));
pup_core::log(format!("[cred] toasted the user: {title} (click opens window \"{sid}\")"));
}
fn account_line(a: &Value) -> String {
let labels: Vec<String> = a["labels"].as_array().map(|l| l.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();
let mut parts = vec![a["kind"].as_str().unwrap_or("?").to_string()];
if !labels.is_empty() { parts.push(format!("browser profile \"{}\"", labels.join("\", \""))); }
if a["pupSignedIn"] == json!(true) { parts.push("already signed into pup".into()); }
if a["likelyStale"] == json!(true) { parts.push("only in pup's vault, signed into no browser here: probably old".into()); }
format!("{} ({})", a["email"].as_str().unwrap_or("?"), parts.join(", "))
}
fn choice_required(accts: &[Value], why: &str) -> Value {
let list: Vec<String> = accts.iter().map(account_line).collect();
pup_core::merge(err("google_account_choice_required", format!("Which Google account? {why}"),
Some(&format!("This user has these Google accounts: {}. pup does not guess which one a task needs. Decide in this order: (1) the task itself: company mail, calendars, docs and vendor portals mean the work account; family, banking, school and personal purchases mean personal; (2) your own skills: the user's work and personal skills usually name their accounts, so search them; (3) if still unsure, ask the user. Then call pup_google_signin {{account:\"<email>\", submitReason:\"<why this account>\"}}. account:\"work\" or account:\"personal\" also works when exactly one account of that kind exists. Refresh this list with pup_google_accounts {{refresh:true}}.", list.join("; ")))),
json!({ "accounts": accts }))
}
fn gmail_title_matches(st: &Value, account: &str) -> bool { st["title"].as_str().map(|t| t.to_lowercase().contains(account)).unwrap_or(false) }
/// The page left accounts.google.com, or reached a step only the user or their AI can finish.
fn finish(ctx: &Ctx, caller: &Caller, sid: &str, account: Option<&str>, cont: &str, st: Value) -> Value {
let mut st = st;
// a prompt on the user's phone: give them a moment to tap it before handing back
if phase(&st) == "challenge" {
let _ = ctx.ab.call("notify_user", json!({ "title": "Google wants you to confirm a sign-in", "body": format!("Approve the sign-in{} on your phone. pup is waiting for it.", account.map(|a| format!(" for {a}")).unwrap_or_default()), "level": "info", "focus": false }), caller, Duration::from_secs(8));
st = wait_for(ctx, caller, sid, Duration::from_secs(45), |p| p != "challenge" && p != "loading");
}
let p = phase(&st);
let acct = account.unwrap_or("the account");
if matches!(p, "choose" | "code" | "challenge" | "wrong_password") {
let (t, b) = match p {
"code" => ("pup needs the code Google just sent", format!("Google sent a verification code for {acct}. Click here to open the pup window, or tell your AI thread the code.")),
"choose" => ("Google wants a second step", format!("Pick how to verify {acct}. Click here to open the pup window.")),
"wrong_password" => ("pup's saved password is out of date", format!("Google rejected the stored password for {acct}. Click here to open the pup window and sign in once; pup will save the new one.")),
_ => ("Google is waiting for you", format!("Approve the sign-in for {acct} on your phone, or click here to open the pup window.")),
};
toast_waiting(ctx, caller, sid, t, &b);
}
let base = json!({ "account": account, "window": sid, "url": st["url"], "step": p });
match p {
"left" => {
let mut verified = Value::Null;
if let Some(a) = account {
if st["host"].as_str() == Some("mail.google.com") {
let t0 = Instant::now(); let mut last = st.clone();
while t0.elapsed() < Duration::from_secs(12) { last = state(ctx, caller, sid); if gmail_title_matches(&last, a) { break; } std::thread::sleep(Duration::from_millis(700)); }
verified = json!(gmail_title_matches(&last, a));
if verified == json!(false) {
return pup_core::merge(err("google_signin_unconfirmed", format!("Gmail opened, but not as {a}"), Some("Gmail shows a different signed-in account, which is what Google does when the requested account is not signed in. Call pup_google_signin {account} again; do not assume this window is that account.")), pup_core::merge(base, json!({ "title": last["title"] })));
}
}
}
pup_core::log(format!("[google] signed in {acct} for \"{}\"", caller.thread));
ok(pup_core::merge(base, json!({ "signedIn": true, "verifiedByTitle": verified, "continue": cont, "_hint": format!("Signed in as {acct} in window \"{sid}\". Drive it with the usual verbs; this session lives in the shared pup profile, so later windows are signed in too.") })))
}
"wrong_password" => pup_core::merge(err("google_password_rejected", format!("Google rejected the saved password for {acct}"), Some("The password in pup's vault is out of date. Import the current one with pup_import_browser_logins {hostFilter:\"google.com\"} (the user approves one consent and one Windows prompt), or let the user sign in once in this window: pup captures the new password. Do not retry the same password.")), base),
"no_account" => pup_core::merge(err("google_account_not_found", format!("Google says {acct} is not a Google account"), Some("Check the address, or pick another account from pup_google_accounts {}.")), base),
"blocked_browser" => pup_core::merge(err("google_browser_blocked", "Google refused this browser (\"This browser or app may not be secure\")", Some("This happens on a window with a debug port. Close it and let pup open the sign-in on its default lane (no lane:\"cdp\").")), base),
"choose" => pup_core::merge(err("google_verification_required", format!("Google wants a second step to confirm it is really {acct}"),
Some(&format!("Google offers: {}. Pick one with pup_google_signin {{window:\"{sid}\", account:\"{acct}\", challenge:\"<words from the option>\"}}. A text-message code finishes with no one's help when your thread can read the user's texts (look for a personal texting skill with standing permission). A prompt on the phone needs the user to tap it. Do not start the sign-in over: that sends the password again.", st["options"].as_array().map(|o| o.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" | ")).unwrap_or_default()))),
pup_core::merge(base, json!({ "options": st["options"], "pageText": st["text"] }))),
"code" => pup_core::merge(err("google_verification_required", format!("Google sent a verification code for {acct}"),
Some(&format!("Read the code, then call pup_google_signin {{window:\"{sid}\", account:\"{acct}\", code:\"123456\"}}. If your thread can read the user's texts or authenticator (check your personal skills for standing permission), read it yourself; otherwise ask the user for it. Starting over sends a new code and invalidates this one."))),
pup_core::merge(base, json!({ "pageText": st["text"] }))),
"challenge" => pup_core::merge(err("google_verification_required", format!("Google is waiting for the user to approve the sign-in for {acct} on another device"),
Some(&format!("Tell the user to approve it (usually a Yes on their phone). pup waited 45 s. Once they have, call pup_google_signin {{account:\"{acct}\"}} again: it sees the account is signed in and finishes without sending the password."))),
pup_core::merge(base, json!({ "pageText": st["text"] }))),
_ => pup_core::merge(err("google_signin_incomplete", format!("The Google sign-in for {acct} stopped on an unexpected page"), Some(&format!("pup_screenshot {{window:\"{sid}\"}} to see it. Page text: {}", st["text"].as_str().unwrap_or("").chars().take(200).collect::<String>()))), pup_core::merge(base, json!({ "pageText": st["text"] }))),
}
}
// ── verbs ────────────────────────────────────────────────────────────────────────────────────────────────
pub fn google_accounts(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let refresh = args["refresh"] == json!(true);
let (mut accts, fresh) = accounts(ctx, caller, refresh);
if fresh {
// which accounts pup's own profile is signed into: a short-lived background tab on accounts.google.com
match open_on(ctx, caller, args, "https://accounts.google.com/AccountChooser") {
Ok(o) => {
let _ = wait_for(ctx, caller, &o.sid, Duration::from_secs(10), |p| p != "loading");
let signed: Vec<String> = serde_json::from_value(ev(ctx, caller, &o.sid, LIST_ACCOUNTS_JS)).unwrap_or_default();
apply_signed_in(&mut accts, &signed);
if o.new_window { let _ = crate::dispatch(ctx, caller, "pup_close_window", &json!({ "window": o.sid })); }
else { let _ = crate::dispatch(ctx, caller, "pup_close_tab", &json!({ "window": o.sid, "tabId": o.tab })); }
}
Err(_) => finalize(&mut accts),
}
save_cache(ctx, &accts);
}
let lines: Vec<String> = accts.iter().map(account_line).collect();
ok(json!({ "success": true, "accounts": accts, "count": accts.len(), "fresh": fresh,
"output": json!({ "ok": true, "accounts": accts }).to_string(),
"_hint": format!("Google accounts for this user: {}. Sign one in with pup_google_signin {{account:\"<email>\", submitReason}}. pup never picks between them for you: decide from the task, the user's work and personal skills, or ask.", if lines.is_empty() { "none found".to_string() } else { lines.join("; ") }) }))
}
/// The whole sign-in is a slow multi-step drive (open, email, password, waits): it routinely runs past
/// ab's 60 s CLI ceiling, and the caller then saw "Request timed out" while pup was mid-password (measured,
/// [email protected], 2026-09-16). So it runs on its own thread: the call waits up to 45 s, then answers
/// signin_working; calling again with the SAME args re-attaches to that run and returns its result. Follow-up
/// calls ({window, code} / {window, challenge}) get the same treatment.
struct SigninJob { key: String, started_ms: u64, result: Option<Value>, finished_ms: u64 }
static SIGNIN_JOB: std::sync::Mutex<Option<SigninJob>> = std::sync::Mutex::new(None);
pub fn google_signin(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let key = format!("{}|{}", caller.thread, json!([args["account"], args["window"], args["sessionId"], args["code"], args["challenge"]]));
let now = pup_core::now_ms();
{
let mut g = SIGNIN_JOB.lock().unwrap();
let start = match g.as_ref() {
Some(j) if j.result.is_some() && now.saturating_sub(j.finished_ms) < 600_000 && j.key == key => { 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 && j.key == key => false, // this run is in flight: attach
Some(j) if j.result.is_none() && now.saturating_sub(j.started_ms) < 300_000 => return pup_core::merge(err("signin_busy", "another Google sign-in is still running", Some("Wait for it, or if it is stuck, retry in a couple of minutes.")), json!({ "otherKey": true })),
_ => true,
};
if start {
let Some(a) = ctx.arc() else { return signin_impl(ctx, caller, args); };
*g = Some(SigninJob { key: key.clone(), started_ms: now, result: None, finished_ms: 0 });
let (c, ar, k) = (caller.clone(), args.clone(), key.clone());
std::thread::spawn(move || {
let r = signin_impl(&a, &c, &ar);
if let Some(j) = SIGNIN_JOB.lock().unwrap().as_mut() { if j.key == k { j.result = Some(r); j.finished_ms = pup_core::now_ms(); } }
});
}
}
let t0 = std::time::Instant::now();
while t0.elapsed() < Duration::from_secs(45) {
{ let mut g = SIGNIN_JOB.lock().unwrap(); if let Some(r) = g.as_ref().filter(|j| j.key == key).and_then(|j| j.result.clone()) { *g = None; return r; } }
std::thread::sleep(Duration::from_millis(500));
}
pup_core::merge(err("signin_working", "The Google sign-in is still running on the desktop", Some("It routinely takes longer than the 60 s command window (page loads and Google's own waits). Call pup_google_signin again with the SAME arguments to collect the result; it re-attaches to this run and does not start a second sign-in.")), json!({ "working": true, "output": json!({ "ok": false, "working": true }).to_string() }))
}
fn signin_impl(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
// 2.0.621: no screen handback (pup-user-foreground: deleted in August, it foregrounded the wrong window).
signin_inner(ctx, caller, args)
}
fn signin_inner(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let want = args["account"].as_str().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty());
// finishing a verification step in the window a previous call left on it
if args.get("code").and_then(|c| c.as_str()).is_some() || args.get("challenge").and_then(|c| c.as_str()).is_some() {
let sid = match crate::resolve(ctx, caller, args) { Ok(s) => s.id, Err(e) => return pup_core::merge(e, json!({ "_hint": "Pass the window the earlier pup_google_signin answer named: {window, code} or {window, challenge}." })) };
if let Some(opt) = args["challenge"].as_str() {
if !click_marked(ctx, caller, &sid, &option_js(opt)) {
let st = state(ctx, caller, &sid);
return pup_core::merge(err("challenge_option_not_found", format!("no verification option matches \"{opt}\""), Some("Use words from one of the listed options.")), json!({ "options": st["options"], "window": sid }));
}
let _ = wait_for(ctx, caller, &sid, Duration::from_secs(15), |p| p != "choose" && p != "loading");
}
if let Some(code) = args["code"].as_str() {
let st = wait_for(ctx, caller, &sid, Duration::from_secs(10), |p| p == "code");
if phase(&st) != "code" { return pup_core::merge(err("no_code_field", "the page is not asking for a code", None), json!({ "window": sid, "step": phase(&st), "pageText": st["text"] })); }
let digits: String = code.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let digits = digits.trim_start_matches(['G', 'g']).to_string();
if !type_into(ctx, caller, &sid, "[data-pup-code]", &digits) || !click_marked(ctx, caller, &sid, &next_js(&["#idvPreregisteredPhoneNext", "#totpNext", "#idvanyphoneverifyNext"])) {
return pup_core::merge(err("code_entry_failed", "could not enter the code", Some("pup_screenshot the window to see the page.")), json!({ "window": sid }));
}
}
let st = wait_for(ctx, caller, &sid, Duration::from_secs(20), |p| !matches!(p, "code" | "choose" | "loading"));
let cont = args["continue"].as_str().map(|s| s.to_string()).unwrap_or_default();
return finish(ctx, caller, &sid, want.as_deref(), &cont, st);
}
let (mut accts, fresh) = accounts(ctx, caller, args["refresh"] == json!(true));
let live: Vec<Value> = accts.iter().filter(|a| a["likelyStale"] != json!(true)).cloned().collect();
let account = match want.as_deref() {
Some(w) if is_email(w) => w.to_string(),
Some(k @ ("work" | "personal")) => {
let m: Vec<&Value> = live.iter().filter(|a| a["kind"].as_str() == Some(k)).collect();
if m.len() == 1 { m[0]["email"].as_str().unwrap_or("").to_string() } else { return choice_required(&accts, &format!("\"{k}\" matches {} of this user's accounts.", m.len())); }
}
Some(other) => return choice_required(&accts, &format!("\"{other}\" is not an email address, \"work\" or \"personal\".")),
None if live.len() == 1 => live[0]["email"].as_str().unwrap_or("").to_string(),
None if live.is_empty() => return pup_core::merge(err("no_google_accounts", "pup found no Google account for this user on this desktop", Some("Pass the address: pup_google_signin {account:\"[email protected]\", submitReason}. pup then needs its password: pup_import_browser_logins {hostFilter:\"google.com\"}, or the user signs in once in a pup window.")), json!({ "accounts": accts })),
None => return choice_required(&accts, "No account was named and this user has more than one."),
};
let info = accts.iter().find(|a| a["email"].as_str() == Some(account.as_str())).cloned().unwrap_or(json!({ "email": account, "kind": kind_of(&account) }));
let cont = args["continue"].as_str().map(|s| s.to_string()).unwrap_or_else(|| format!("https://mail.google.com/mail/u/?authuser={}", enc(&account)));
let sid = match open_on(ctx, caller, args, &format!("https://accounts.google.com/AccountChooser?continue={}", enc(&cont))) { Ok(o) => o.sid, Err(e) => return e };
let _ = wait_for(ctx, caller, &sid, Duration::from_secs(10), |p| p != "loading");
let signed: Vec<String> = serde_json::from_value(ev(ctx, caller, &sid, LIST_ACCOUNTS_JS)).unwrap_or_default();
apply_signed_in(&mut accts, &signed);
save_cache(ctx, &accts);
let _ = fresh;
if signed.iter().any(|e| *e == account) {
let _ = crate::dispatch(ctx, caller, "pup_navigate", &json!({ "window": sid, "url": cont }));
let st = wait_for(ctx, caller, &sid, Duration::from_secs(15), |p| p == "left");
let r = finish(ctx, caller, &sid, Some(&account), &cont, st);
return if failed(&r) { r } else { pup_core::merge(r, json!({ "alreadySignedIn": true, "_hint": format!("{account} was already signed into pup; no password was sent. Window \"{sid}\" is on {cont}.") })) };
}
let Some(cred) = ctx.vault.get_user(&*ctx.host, "https://accounts.google.com/", &account) else {
let found_in: Vec<String> = info["browsers"].as_array().map(|b| b.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();
toast_waiting(ctx, caller, &sid, "pup needs you to sign in once",
&format!("Sign in as {account} in the pup window (click here to open it). pup saves the password and will not ask again."));
return pup_core::merge(err("google_password_missing", format!("{account} is not signed into pup and pup has no saved password for it"),
Some(&format!("{} Get the password into pup's vault once, then call pup_google_signin again: pup_import_browser_logins {{hostFilter:\"google.com\"}} with NO hostFilter reads the user's own saved Chrome/Edge/Brave logins from EVERY profile into pup's vault: tell the user to approve the Adom Bridge consent (inside Hydrogen it arrives as a notification and a card, not a window) and then a Windows admin prompt; no one sees the password, and if the call answers consent_pending, call it again to collect the result. Know the limit before you promise it: a browser does NOT save the password of the account it is itself signed in as, so a user's everyday Google account is usually absent however wide the import (measured on John's laptop: 828 logins imported, 240 of them under [email protected], and not one for google.com). For those accounts the only route is the user signing in once in this pup window, which pup captures on submit, or the user signs in once in window \"{sid}\" and pup captures it. Never ask the user to type the password to you.", if found_in.is_empty() { String::new() } else { format!("The account is signed into {} on this desktop, so its browser most likely has the password saved.", found_in.join(", ")) }))),
json!({ "account": account, "window": sid, "foundIn": found_in }));
};
if args["submitReason"].as_str().map(|r| r.trim().len() >= 10) != Some(true) {
return pup_core::merge(err("submit_reason_required", format!("CONFIRM BEFORE SENDING {account}'s PASSWORD"), Some(&format!("pup is about to send the saved password for {account} to accounts.google.com. Re-call with submitReason:\"<why this account, for what task>\" (10+ characters)."))), json!({ "account": account, "liveHost": "accounts.google.com", "window": sid }));
}
pup_core::log(format!("[google] signing in {account} for \"{}\": {}", caller.thread, args["submitReason"].as_str().unwrap_or("").chars().take(120).collect::<String>()));
let _ = crate::dispatch(ctx, caller, "pup_navigate", &json!({ "window": sid, "url": format!("https://accounts.google.com/AddSession?Email={}&continue={}", enc(&account), enc(&cont)) }));
let mut st = wait_for(ctx, caller, &sid, Duration::from_secs(15), |p| p != "loading");
if phase(&st) == "identifier" {
if st["idValue"].as_str().map(|v| v.trim().to_lowercase()) != Some(account.clone()) && !type_into(ctx, caller, &sid, "#identifierId", &account) {
return pup_core::merge(err("google_signin_incomplete", "could not enter the email address", None), json!({ "window": sid }));
}
if !click_marked(ctx, caller, &sid, &next_js(&["#identifierNext"])) { return pup_core::merge(err("google_signin_incomplete", "no Next button on the email step", None), json!({ "window": sid, "pageText": st["text"] })); }
st = wait_for(ctx, caller, &sid, Duration::from_secs(15), |p| p != "identifier" && p != "loading");
}
if phase(&st) == "password" {
let sel = "input[name=Passwd]";
let ok_typed = type_into(ctx, caller, &sid, sel, &cred.password) || type_into(ctx, caller, &sid, "input[type=password]", &cred.password);
if !ok_typed || !click_marked(ctx, caller, &sid, &next_js(&["#passwordNext"])) {
return pup_core::merge(err("google_signin_incomplete", "could not enter the password", Some("pup_screenshot the window to see the page.")), json!({ "window": sid, "account": account }));
}
ctx.vault.touch_used_user(&cred.host, &cred.username);
st = wait_for(ctx, caller, &sid, Duration::from_secs(20), |p| p != "password" && p != "loading");
if phase(&st) == "password" { st = wait_for(ctx, caller, &sid, Duration::from_secs(1), |_| true); }
}
finish(ctx, caller, &sid, Some(&account), &cont, st)
}