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.600 (insiders): pup's own kill is no longer read as the user closing a window; lane adoption no longer claims another profile's browser.
14 files changed
+734−47
dev-skills/pup-user-foreground/SKILL.md+10rust/Cargo.lock+12−12rust/Cargo.toml+1−1rust/host-win/src/lib.rs+1−1rust/host/src/lib.rs+3−1rust/vault/src/lib.rs+76−12rust/verbs/assets/verb-catalog.json+56rust/verbs/src/extdrive.rs+21−5rust/verbs/src/google.rs+442rust/verbs/src/import.rs+40rust/verbs/src/lib.rs+4−1rust/verbs/src/recovery.rs+10−2rust/verbs/src/vault.rs+30−12tools/gen-assets.py+28dev-skills/pup-user-foreground/SKILL.md+10@@ -141,6 +141,16 @@ The rules now: code 0 (the last window closed), a hung process silent 10 min with no window on glass, or an unwatched lane after a pup restart: all forget, none restore. The CDP path in `health_tick` has the same gate. Silence is never proof of a crash.+2b. **PUP'S OWN KILL IS NOT A USER CLOSE (2.0.600).** The first cut read any clean exit as "the user+ closed the last window", but pup ends browsers for its own reasons all the time: a profile handover to+ the lane, a lane relaunch, a restart, `kill_extdrive`. Those windows must COME BACK, and reading them+ as a user close deleted them: the ab-smoke window died that way three runs running ("Chrome for profile+ rs-smoke is gone with exit code Some(0): not a crash. Forgetting its 1 window(s)"). `Host::ended_by_pup`+ answers it from the kill list, and `Host::exit_code` now reports the true code instead of flattening a+ killed process to 0. Rule: forget ONLY a browser that ended on its own with code 0. Anything pup did,+ and any nonzero code, restores. A second bug fed it: `adopt_tokenless` learned its pids from EVERY+ Chrome-for-Testing window on the box, including another profile's CDP browser, so the next lane kill+ took that browser down too. It now excludes pids pup already owns in `ctx.browsers`. 3. **The one restore a user expects is the pup restart** (`recover_at_boot`): what was live when pup died comes back. Since closed windows have no file any more, that restores only live windows. 4. **Never close a lane's last window on inference.** `close_orphan_blank_windows` skips while a birth
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.595"+version = "2.0.600" dependencies = [ "pup-browser", "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.595"+version = "2.0.600" dependencies = [ "pup-core", "pup-host",@@ -667,7 +667,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.595"+version = "2.0.600" dependencies = [ "serde_json", "thiserror",@@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.595"+version = "2.0.600" dependencies = [ "serde", "serde_json",@@ -687,7 +687,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.595"+version = "2.0.600" dependencies = [ "pup-cdp", "pup-core",@@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.595"+version = "2.0.600" dependencies = [ "serde", "serde_json",@@ -709,14 +709,14 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.595"+version = "2.0.600" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.595"+version = "2.0.600" dependencies = [ "pup-core", "pup-host",@@ -725,7 +725,7 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.595"+version = "2.0.600" dependencies = [ "fontdue", "image",@@ -735,7 +735,7 @@ dependencies = [ [[package]] name = "pup-session"-version = "2.0.595"+version = "2.0.600" dependencies = [ "pup-core", "serde",@@ -745,7 +745,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.595"+version = "2.0.600" dependencies = [ "pup-core", "pup-host",@@ -756,7 +756,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.595"+version = "2.0.600" 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.595"+version = "2.0.600" edition = "2021" license = "MIT"
rust/host-win/src/lib.rs+1−1@@ -168,8 +168,8 @@ mod imp { KILLED.lock().unwrap().retain(|p| *p != pid); } }+ fn ended_by_pup(&self, pid: u32) -> bool { KILLED.lock().unwrap().contains(&pid) } fn exit_code(&self, pid: u32) -> Option<u32> {- if KILLED.lock().unwrap().contains(&pid) { return Some(0); } let h = WATCHED.lock().unwrap().iter().find(|(p, _)| *p == pid).map(|(_, h)| *h)?; unsafe { let mut code: u32 = 0;
rust/host/src/lib.rs+3−1@@ -79,8 +79,10 @@ pub trait HostProcess: Send + Sync { /// browser whose last window the user closed exits 0; a crash does not. Recovery restores only a crash. fn watch_process(&self, pid: u32) { let _ = pid; } /// The exit code of a watched process that has exited: None while it runs, or if it was never watched.- /// A process pup itself killed reads as 0 (a deliberate end, not a crash). fn exit_code(&self, pid: u32) -> Option<u32> { let _ = pid; None }+ /// Did PUP end this process itself (Host::kill)? 2.0.600: pup's own kill is NOT the user closing a+ /// window, and conflating them lost windows (see recovery::browser_gone).+ fn ended_by_pup(&self, pid: u32) -> bool { let _ = pid; false } /// Run a script through the platform shell and return stdout (Windows: powershell -Command; empty /// on platforms without one). For the credential-import scan and the real-Chrome installer. fn run_shell_script(&self, _script: &str, _timeout: std::time::Duration) -> String { String::new() }
rust/vault/src/lib.rs+76−12@@ -51,6 +51,10 @@ pub fn host_of(url: &str) -> Option<String> { pub struct Vault { pub state_dir: PathBuf } +fn same_user(a: &str, b: &str) -> bool { a.trim().eq_ignore_ascii_case(b.trim()) }+/// The keychain account name an index entry's password lives under (entries before 2.0.596 carry none: the host).+fn key_of(e: &Value) -> String { e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or("").to_string() }+ #[derive(Debug, Clone)] pub struct Credential { pub host: String, pub username: String, pub password: String, pub source_account: Option<String> } @@ -62,19 +66,41 @@ impl Vault { } fn write_index(&self, entries: &[Value]) { let _ = std::fs::write(self.index_path(), serde_json::to_string_pretty(&json!({ "version": 1, "entries": entries })).unwrap_or_default()); } + /// 2.0.596: one login per host AND username. It was one per host: a second account on the same site+ /// (John's work and personal Google accounts both sign in at accounts.google.com) overwrote the first,+ /// password and all. The first login for a host keeps the host as its keychain key, so every vault+ /// written before this reads unchanged; a further account on that host gets "host|username". pub fn set(&self, host: &dyn HostSecrets, hostname: &str, username: &str, password: &str) -> Result<(), String> { if hostname.is_empty() || username.is_empty() || password.is_empty() { return Err("host, username, and password are all required".into()); }- host.secret_set(CRED_SERVICE, hostname, password)?; let mut entries = self.read_index(); let now = pup_core::iso_now();- match entries.iter_mut().find(|e| e["host"] == json!(hostname)) {- Some(e) => { e["username"] = json!(username); e["updatedAt"] = json!(now); }- None => entries.push(json!({ "host": hostname, "username": username, "addedAt": now, "updatedAt": now })),+ match entries.iter_mut().find(|e| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username)) {+ Some(e) => {+ host.secret_set(CRED_SERVICE, &key_of(e), password)?;+ e["username"] = json!(username); e["updatedAt"] = json!(now);+ }+ None => {+ let taken = entries.iter().any(|e| e["host"] == json!(hostname) || key_of(e) == hostname);+ let key = if taken { format!("{hostname}|{}", username.trim().to_lowercase()) } else { hostname.to_string() };+ host.secret_set(CRED_SERVICE, &key, password)?;+ entries.push(json!({ "host": hostname, "username": username, "keytarAccount": key, "addedAt": now, "updatedAt": now }));+ } } self.write_index(&entries); Ok(()) } + /// The stored login for this URL's site family whose username is exactly `username` (case-insensitive).+ pub fn get_user(&self, host: &dyn HostSecrets, url: &str, username: &str) -> Option<Credential> {+ let h = host_of(url)?;+ let mut matches: Vec<(i64, Value)> = self.read_index().into_iter().filter(|e| same_user(e["username"].as_str().unwrap_or(""), username))+ .filter_map(|e| { let s = score_pattern(e["host"].as_str().unwrap_or(""), &h); if s >= 0 { Some((s, e)) } else { None } }).collect();+ matches.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| { let at = a.1["lastUsed"].as_str().or(a.1["updatedAt"].as_str()).unwrap_or(""); let bt = b.1["lastUsed"].as_str().or(b.1["updatedAt"].as_str()).unwrap_or(""); bt.cmp(at) }));+ let best = &matches.first()?.1;+ let password = host.secret_get(CRED_SERVICE, &key_of(best))?;+ Some(Credential { host: best["host"].as_str().unwrap_or("").into(), username: best["username"].as_str().unwrap_or("").into(), password, source_account: best["sourceAccount"].as_str().map(|s| s.into()) })+ }+ /// Merge decrypted rows [{host|url|origin, username, password}] from the browser-login import, /// skipping ones already stored with the same password. Node: credentialVault.mergeImportedCredentials. pub fn merge_imported(&self, host: &dyn HostSecrets, rows: &[Value]) -> (u64, u64, u64) {@@ -84,8 +110,8 @@ impl Vault { let hostname = host_of(raw_host).unwrap_or_else(|| raw_host.trim().to_string()); let (user, pass) = (r["username"].as_str().unwrap_or(""), r["password"].as_str().unwrap_or("")); if hostname.is_empty() || pass.is_empty() { skipped += 1; continue; }- let existed = self.get_for_url(host, &format!("https://{hostname}/"), if user.is_empty() { None } else { Some(user) });- if let Some(ref c) = existed { if c.password == pass && (user.is_empty() || c.username == user) { skipped += 1; continue; } }+ let existed = if user.is_empty() { self.get_for_url(host, &format!("https://{hostname}/"), None) } else { self.get_user(host, &format!("https://{hostname}/"), user) }.filter(|c| c.host == hostname);+ if let Some(ref c) = existed { if c.password == pass { skipped += 1; continue; } } match self.set(host, &hostname, if user.is_empty() { "(unknown)" } else { user }, pass) { Ok(_) => if existed.is_some() { updated += 1 } else { added += 1 }, Err(_) => skipped += 1,@@ -102,12 +128,11 @@ impl Vault { let prefer = prefer_account.map(|p| p.to_lowercase()); matches.sort_by(|a, b| { b.0.cmp(&a.0)- .then_with(|| { let ap = prefer.as_ref().map(|p| a.1["sourceAccount"].as_str().unwrap_or("").to_lowercase() == *p).unwrap_or(false); let bp = prefer.as_ref().map(|p| b.1["sourceAccount"].as_str().unwrap_or("").to_lowercase() == *p).unwrap_or(false); bp.cmp(&ap) })+ .then_with(|| { let hit = |e: &Value| prefer.as_ref().map(|p| e["sourceAccount"].as_str().unwrap_or("").to_lowercase() == *p || e["username"].as_str().unwrap_or("").to_lowercase() == *p).unwrap_or(false); hit(&b.1).cmp(&hit(&a.1)) }) .then_with(|| { let at = a.1["lastUsed"].as_str().or(a.1["updatedAt"].as_str()).unwrap_or(""); let bt = b.1["lastUsed"].as_str().or(b.1["updatedAt"].as_str()).unwrap_or(""); bt.cmp(at) }) }); let best = &matches[0].1;- let account = best["keytarAccount"].as_str().or(best["host"].as_str()).unwrap_or("");- let password = host.secret_get(CRED_SERVICE, account)?;+ let password = host.secret_get(CRED_SERVICE, &key_of(best))?; Some(Credential { host: best["host"].as_str().unwrap_or("").into(), username: best["username"].as_str().unwrap_or("").into(), password, source_account: best["sourceAccount"].as_str().map(|s| s.into()) }) } @@ -115,6 +140,17 @@ impl Vault { self.read_index().into_iter().map(|e| json!({ "host": e["host"], "username": e["username"], "sourceAccount": e["sourceAccount"], "sourceBrowser": e["sourceBrowser"], "sourceProfile": e["sourceProfile"], "imported": e["imported"].as_bool().unwrap_or(false), "addedAt": e["addedAt"], "updatedAt": e["updatedAt"] })).collect() } + /// Remove ONE account's login for a host (the others on that host stay).+ pub fn delete_user(&self, host: &dyn HostSecrets, hostname: &str, username: &str) -> bool {+ let entries = self.read_index();+ let hit = |e: &Value| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username);+ let mut removed = false;+ for e in entries.iter().filter(|e| hit(e)) { if host.secret_delete(CRED_SERVICE, &key_of(e)) { removed = true; } }+ let kept: Vec<Value> = entries.into_iter().filter(|e| !hit(e)).collect();+ self.write_index(&kept);+ removed+ }+ pub fn delete(&self, host: &dyn HostSecrets, hostname: &str) -> bool { let entries = self.read_index(); let mut removed = false;@@ -148,12 +184,11 @@ impl Vault { let hostname = e["host"].as_str().unwrap_or("").trim(); let (user, pass, incoming_ts) = (e["username"].as_str().unwrap_or(""), e["password"].as_str().unwrap_or(""), e["updatedAt"].as_str().unwrap_or("")); if hostname.is_empty() || pass.is_empty() { skipped += 1; continue; }- let local = self.read_index().into_iter().find(|x| x["host"].as_str() == Some(hostname));+ let local = self.read_index().into_iter().find(|x| x["host"].as_str() == Some(hostname) && (user.is_empty() || same_user(x["username"].as_str().unwrap_or(""), user))); match &local { None => { if self.set(host, hostname, if user.is_empty() { "(unknown)" } else { user }, pass).is_ok() { added += 1; } else { skipped += 1; } } Some(le) => {- let account = le["keytarAccount"].as_str().or(Some(hostname)).unwrap_or(hostname);- let local_pw = host.secret_get(CRED_SERVICE, account).unwrap_or_default();+ let local_pw = host.secret_get(CRED_SERVICE, &key_of(le)).unwrap_or_default(); let same = local_pw == pass && (user.is_empty() || le["username"].as_str() == Some(user)); if same { skipped += 1; continue; } let local_ts = le["updatedAt"].as_str().or(le["addedAt"].as_str()).unwrap_or("");@@ -166,6 +201,11 @@ impl Vault { (added, updated, skipped) } + pub fn touch_used_user(&self, hostname: &str, username: &str) {+ let mut entries = self.read_index();+ if let Some(e) = entries.iter_mut().find(|e| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username)) { e["lastUsed"] = json!(pup_core::iso_now()); self.write_index(&entries); }+ }+ pub fn touch_used(&self, hostname: &str) { let mut entries = self.read_index(); if let Some(e) = entries.iter_mut().find(|e| e["host"] == json!(hostname)) { e["lastUsed"] = json!(pup_core::iso_now()); self.write_index(&entries); }@@ -288,6 +328,30 @@ mod tests { assert!(score_pattern("www.x.com", "www.x.com") > score_pattern("*.x.com", "www.x.com")); assert_eq!(host_of("https://[email protected]:8443/p?q"), Some("a.b.com".into())); }+ #[test] fn two_accounts_on_one_host_both_survive() {+ struct Mem(std::sync::Mutex<std::collections::HashMap<String, String>>);+ impl HostSecrets for Mem {+ fn secret_get(&self, s: &str, a: &str) -> Option<String> { self.0.lock().unwrap().get(&format!("{s}/{a}")).cloned() }+ fn secret_set(&self, s: &str, a: &str, v: &str) -> Result<(), String> { self.0.lock().unwrap().insert(format!("{s}/{a}"), v.into()); Ok(()) }+ fn secret_delete(&self, s: &str, a: &str) -> bool { self.0.lock().unwrap().remove(&format!("{s}/{a}")).is_some() }+ }+ let dir = std::env::temp_dir().join(format!("pup-vault-test-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ let v = Vault::new(&dir); let m = Mem(Default::default());+ v.set(&m, "accounts.google.com", "[email protected]", "pw-work").unwrap();+ v.set(&m, "accounts.google.com", "[email protected]", "pw-personal").unwrap();+ v.set(&m, "accounts.google.com", "[email protected]", "pw-work-2").unwrap(); // same account, new password: an update+ assert_eq!(v.read_index().len(), 2);+ let u = "https://accounts.google.com/v3/signin/challenge/pwd";+ assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-work-2");+ assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-personal");+ assert!(v.get_user(&m, u, "[email protected]").is_none());+ assert_eq!(v.get_for_url(&m, u, Some("[email protected]")).unwrap().username, "[email protected]");+ assert!(v.delete_user(&m, "accounts.google.com", "[email protected]"));+ assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-work-2");+ let _ = std::fs::remove_dir_all(&dir);+ }+ #[test] fn cards() { assert_eq!(normalize_pan("4242 4242 4242 4242").as_deref(), Some("4242424242424242")); assert!(normalize_pan("4242424242424241").is_none());
rust/verbs/assets/verb-catalog.json+56@@ -1705,6 +1705,62 @@ "summary": "Choose a <select> option by value or visible label, in the background (fires input+change).", "timeoutSeconds": 20 },+ {+ "example": {},+ "hint": "Each account comes back with kind (work or personal, from its domain), the browser profile labels the user gave it (\"John Personal\"), whether pup is already signed in, whether pup holds its password, and likelyStale for an account found only in pup's vault. pup never chooses between accounts: pick from the task, the user's work and personal skills, or ask the user.",+ "input": {+ "refresh": "optional bool (redo the whole search now)"+ },+ "longRunning": false,+ "name": "pup_google_accounts",+ "output": {+ "success": "bool"+ },+ "pitfalls": [+ "An account only in pup's vault and signed into no browser here is usually years old; Google will demand a second step for it."+ ],+ "related": [+ "pup_google_signin",+ "pup_credentials",+ "pup_import_browser_logins"+ ],+ "statusVerb": null,+ "summary": "List the user's Google accounts: every Chrome/Edge profile, nb's browser profiles, pup's vault, and which ones pup's own profile is already signed into. Cached a day.",+ "timeoutSeconds": 10+ },+ {+ "example": {+ "account": "[email protected]",+ "submitReason": "the task is the work calendar, so the work account"+ },+ "hint": "Already signed into pup: no password is sent, the window just opens the account. Otherwise pup fills the email and the password saved for exactly that account and submits. If Google asks for a second step the answer is google_verification_required with the options or the code field, and the exact call to finish it; a text code can be read by a thread with the user's standing permission to read their texts. google_password_missing names where the account is signed in and how to import its password (pup_import_browser_logins {hostFilter:\"google.com\"}). The account is confirmed by name at the end: Gmail silently opens another signed-in account when the requested one is not signed in.",+ "input": {+ "account": "optional str: an email, or \"work\" / \"personal\" when exactly one account of that kind exists. Omit and pup lists the accounts and asks (google_account_choice_required).",+ "submitReason": "str, 10+ chars: required before pup sends a saved password (why this account, for what task)",+ "continue": "optional str: where to land after sign-in (default: that account's Gmail)",+ "window": "optional str: the pup window to use (a new tab in it); default the calling thread's window",+ "code": "optional str: finish a verification step with the code Google sent (with window)",+ "challenge": "optional str: pick a verification option by its words, e.g. \"text message\" (with window)",+ "refresh": "optional bool: redo the account search first"+ },+ "longRunning": false,+ "name": "pup_google_signin",+ "output": {+ "success": "bool"+ },+ "pitfalls": [+ "Do not restart a sign-in that stopped on a verification step: that sends the password again and a new code. Finish it with {window, code} or {window, challenge}.",+ "Never ask the user to type their password to you: import it or let them sign in once in the pup window."+ ],+ "related": [+ "pup_google_accounts",+ "pup_login",+ "pup_import_browser_logins"+ ],+ "statusVerb": null,+ "summary": "Sign the user into Google as a named account, hands-free: account search, email step, saved password, and the verification step handed back with exact next calls.",+ "timeoutSeconds": 20+ }, { "example": {}, "hint": "START HERE for window management: everything YOUR thread owns \u2014 windows, tabs, urls, titles, state \u2014 with ready-to-run next steps. Zero args. You NEVER need an hwnd, desktop_find_window, or desktop_screenshot_window for a pup window: pup keys every window to its owning --ai-thread name and resolves ids for you \u2014 omit sessionId in any pup verb to target your own window, or pass \"ai-thread\" to address a thread by name (the CLI's --ai-thread flag, same spelling). pup_screenshot {} screenshots it; pup_reload {} refreshes it.",
rust/verbs/src/extdrive.rs+21−5@@ -142,9 +142,13 @@ pub fn hello(v: Value) { pup_core::log(format!("[extdrive] adopted the running extension browser (lane {lane}); pup had lost track of it")); return; }- if !matched && lane.is_empty() && want.is_empty() && *LAST_POLL.lock().unwrap() == 0 {+ // 2.0.599: record the token-less hello whether or not pup holds a token. It used to require pup to hold+ // NONE, so a pup whose token belonged to a killed browser refused this live one on every hello and never+ // recovered (ConfRoomROG, 2026-09-16: "carrying lane \"\" (pup wants ext-1789600049390) NOT served", for+ // 20 minutes, until a pup restart). adopt_tokenless still refuses while pup's own browser is alive.+ if !matched && lane.is_empty() && *LAST_POLL.lock().unwrap() == 0 { *TOKENLESS_HELLO_AT.lock().unwrap() = pup_core::now_ms();- pup_core::log("[extdrive] a pup extension is polling with no lane token and pup holds none: the next health tick adopts it".to_string());+ pup_core::log(format!("[extdrive] a pup extension is polling with no lane token{}: the next health tick adopts it if pup's own browser is gone", if want.is_empty() { String::new() } else { format!(" while pup wants \"{want}\"") })); return; } if matched {@@ -192,16 +196,28 @@ static TOKENLESS_HELLO_AT: Mutex<u64> = Mutex::new(0); pub fn adopt_tokenless(ctx: &Ctx) { let at = *TOKENLESS_HELLO_AT.lock().unwrap(); if at == 0 || pup_core::now_ms().saturating_sub(at) > 90_000 { return; }- if !crate::EXTDRIVE_LANE.lock().unwrap().is_empty() || last_poll_ms() != 0 { return; }+ // 2.0.599: pup holding a token is NOT a reason to refuse forever. On the conference room PC pup kept+ // wanting "ext-1789600049390" while the live extension said lane "" on every hello, so every poll was+ // refused and only a pup restart fixed it (measured 2026-09-16). The token belongs to a browser that is+ // GONE: adopt the live one. Still never while the browser pup minted that token for is alive (a cold+ // launch sets the token, then waits for its first poll: an old browser must not be able to hijack that).+ if last_poll_ms() != 0 { return; }+ let mine = crate::extdrive_pids();+ if !mine.is_empty() && mine.iter().any(|p| ctx.host.alive(*p)) { return; }+ let had = crate::EXTDRIVE_LANE.lock().unwrap().clone(); let lane = format!("ext-{}", pup_core::now_ms()); let staged = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, "adom-you", true, Some(&lane), crate::lane_shim(ctx)); if staged.is_empty() { return; }- let pids: Vec<u32> = { let mut v: Vec<u32> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.title.ends_with("Google Chrome for Testing")).map(|w| w.pid).collect(); v.sort(); v.dedup(); v };+ // 2.0.600: never the pids of a CDP browser pup already owns on another profile. Learning them here made+ // the next kill_extdrive take the ab-smoke window's browser down with the lane (ConfRoomROG, 2026-09-16).+ let cdp: Vec<u32> = ctx.browsers.lock().unwrap().values().map(|b| b.launched.pid).filter(|p| *p > 0).collect();+ let pids: Vec<u32> = { let mut v: Vec<u32> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.title.ends_with("Google Chrome for Testing") && !cdp.contains(&w.pid)).map(|w| w.pid).collect(); v.sort(); v.dedup(); v };+ if pids.is_empty() { return; } *crate::EXTDRIVE_LANE.lock().unwrap() = lane.clone(); crate::set_extdrive_pids(pids.clone()); crate::persist_lane(ctx); *TOKENLESS_HELLO_AT.lock().unwrap() = 0;- pup_core::log(format!("[extdrive] adopted a token-less extension browser: minted lane {lane}, stamped it into port.json, {} Chrome for Testing process(es) learned from its windows", pids.len()));+ pup_core::log(format!("[extdrive] adopted a token-less extension browser: minted lane {lane}, stamped it into port.json, {} Chrome for Testing process(es) learned from its windows{}", pids.len(), if had.is_empty() { String::new() } else { format!(" (dropping the stale token {had}, whose browser is gone)") })); } /// Queue one command and block until the extension answers it (or the deadline passes).
rust/verbs/src/google.rsadded+442@@ -0,0 +1,442 @@+//! 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 })+}++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");+ 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 {+ 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();+ 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)+}
rust/verbs/src/import.rs+40@@ -22,7 +22,47 @@ pub fn import_scan(ctx: &Ctx) -> Value { "_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)." })) } +/// 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.+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"]]).to_string();+ let now = pup_core::now_ms();+ {+ let mut g = IMPORT_JOB.lock().unwrap();+ let start = match g.as_ref() {+ Some(j) if j.result.is_some() && now.saturating_sub(j.finished_ms) < 600_000 => { let r = j.result.clone().unwrap(); *g = None; return r; }+ Some(j) if j.result.is_none() && now.saturating_sub(j.started_ms) < 300_000 => false, // in flight: attach+ _ => true,+ };+ if start {+ let Some(a) = ctx.arc() else { return import_now(ctx, caller, args); };+ *g = Some(ImportJob { key: key.clone(), started_ms: now, result: None, finished_ms: 0 });+ let (c, ar) = (caller.clone(), args.clone());+ std::thread::spawn(move || {+ let r = import_now(&a, &c, &ar);+ pup_core::log(format!("[import] finished: {}", r.get("errorCode").and_then(|e| e.as_str()).map(|e| format!("{e}: {}", r["error"].as_str().unwrap_or(""))).unwrap_or_else(|| "ok".into())));+ if let Some(j) = IMPORT_JOB.lock().unwrap().as_mut() { j.result = Some(r); j.finished_ms = pup_core::now_ms(); }+ });+ }+ }+ let t0 = std::time::Instant::now();+ while t0.elapsed() < Duration::from_secs(40) {+ { let mut g = IMPORT_JOB.lock().unwrap(); if let Some(r) = g.as_ref().and_then(|j| j.result.clone()) { *g = None; return r; } }+ std::thread::sleep(Duration::from_millis(500));+ }+ 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 { "" }))),+ 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();
rust/verbs/src/lib.rs+4−1@@ -38,6 +38,7 @@ pub mod extras; pub mod lint; pub mod assist; pub mod import;+pub mod google; pub fn assets_recorder_html() -> Option<&'static [u8]> { Some(include_bytes!("../../../src/recorder.html")) } pub const OWNER_CHIP_JS: &str = include_str!("../assets/owner-chip.js");@@ -475,7 +476,7 @@ pub fn install_event_hook(ctx: Arc<Ctx>) { pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewarm", "pup_status", "pup_list_windows", "pup_my_windows", "pup_list_sessions", "pup_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_close_all_windows", "pup_window_info", "pup_open_tab", "pup_list_tabs", "pup_switch_tab", "pup_close_tab", "pup_switch_window", "pup_click", "pup_select", "pup_hover", "pup_type", "pup_press_key", "pup_scroll", "pup_fill", "pup_wait", "pup_reload", "pup_back", "pup_forward", "pup_set_viewport", "pup_errors", "pup_dialog", "pup_input_dispatch", "pup_render_html", "pup_fetch_url", "pup_maximize", "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_clear_cookies", "pup_clear_site_data", "pup_quit_idle_browsers", "pup_rescan", "pup_restart_browser", "pup_set_identity", "pup_audit_icons", "pup_dashboard",- "pup_credentials", "credential_list", "credential_set", "credential_delete", "pup_login", "pup_credential_reveal", "pup_card_set", "pup_cards", "pup_card_delete", "pup_fill_card", "pup_wiki_set_view", "pup_annotate", "pup_floaty_click",+ "pup_credentials", "credential_list", "credential_set", "credential_delete", "pup_login", "pup_google_accounts", "pup_google_signin", "pup_credential_reveal", "pup_card_set", "pup_cards", "pup_card_delete", "pup_fill_card", "pup_wiki_set_view", "pup_annotate", "pup_floaty_click", "pup_record_start", "pup_record_stop", "pup_record_status", "pup_record_list", "pup_recorder_open", "pup_recorder_close", "pup_screen_record_start", "pup_screen_record_stop", "pup_screen_record_status", "pup_screen_record_list", "pup_highfps_extend", "pup_screenshot_element", "pup_screenshot_full_res", "pup_download", "pup_launch", "pup_deps", "pup_install_toolbar", "pup_reap_profiles", "pup_uninstall", "pup_move_to_workspace", "pup_assisted_click", "pup_import_scan", "pup_import_browser_logins", "pup_vault_sync", "pup_vault_export", "pup_vault_import"]; @@ -580,6 +581,8 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "pup_move_to_workspace" => extras::move_to_workspace(ctx, caller, args), "pup_assisted_click" => assist::assisted_click(ctx, caller, args), "pup_import_scan" => import::import_scan(ctx),+ "pup_google_accounts" => google::google_accounts(ctx, caller, args),+ "pup_google_signin" => google::google_signin(ctx, caller, args), "pup_import_browser_logins" => import::import_browser_logins(ctx, caller, args), "pup_vault_sync" => vault::vault_sync(ctx, caller, args), "pup_vault_export" => vault::vault_export(ctx, caller),
rust/verbs/src/recovery.rs+10−2@@ -418,6 +418,8 @@ fn forget_user_closed(ctx: &Ctx) { /// Some(code) with code != 0 is a crash; 0 is the user closing the last window (or pup's own kill); None when /// still running or never watched (after a pup restart), which is NOT proof of a crash. fn lane_crash_code(ctx: &Ctx) -> Option<u32> {+ // pup's own kill counts as "not the user": restore, do not forget (2.0.600).+ if crate::extdrive_pids().iter().any(|p| ctx.host.ended_by_pup(*p)) { return Some(1); } crate::extdrive_pids().iter().filter_map(|p| ctx.host.exit_code(*p)).find(|c| *c != 0) } @@ -504,7 +506,13 @@ pub fn health_tick(ctx: &Ctx) { // 2.0.595: Chrome exits with its last window (code 0) when the USER closes it; only a nonzero // exit code is a crash worth restoring. Never watched (pid 0, or a pup restart) is not proof. let code = if pid == 0 { None } else { ctx.host.exit_code(pid) };- let crashed = matches!(code, Some(c) if c != 0) && ctx.settings.get("sleepRecovery", "on") == "on";+ // 2.0.600: PUP's OWN kill is not a user close. pup ends browsers for its own reasons (a+ // profile handover, a lane relaunch, a restart), and reading that as "the user closed the+ // last window" deleted live windows: the ab-smoke window died this way three runs running+ // (ConfRoomROG, 2026-09-16, "is gone with exit code Some(0): not a crash. Forgetting").+ // Only a browser that ended on ITS OWN with code 0 means the user closed its last window.+ let ours = pid != 0 && ctx.host.ended_by_pup(pid);+ let crashed = (ours || matches!(code, Some(c) if c != 0)) && ctx.settings.get("sleepRecovery", "on") == "on"; if !crashed { let gone: Vec<Session> = ctx.sessions.for_profile(&p); pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) is gone with exit code {code:?}: not a crash. Forgetting its {} window(s), nothing restored (pup windows are fleeting)", gone.len()));@@ -512,7 +520,7 @@ pub fn health_tick(ctx: &Ctx) { continue; } let files: Vec<Value> = ctx.sessions.for_profile(&p).iter().map(|s| s.file_json()).collect();- pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) CRASHED (exit code 0x{:x}) — relaunching its {} window(s) from their files", code.unwrap_or(0), files.len()));+ pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) {} — relaunching its {} window(s) from their files", if ours { "was ended by pup itself (a handover or relaunch), not by the user".to_string() } else { format!("CRASHED (exit code 0x{:x})", code.unwrap_or(0)) }, files.len())); // 2.0.469: drop and reserve EVERY session first, then relaunch. Dropping each only at its turn left // the second window listed with its three dead tabs for the ~30 s the first window's relaunch took // (CRR crash drill: "restored 2x3" but 3 of 6 live); a listed window must be a live one.
rust/verbs/src/vault.rs+30−12@@ -23,7 +23,7 @@ pub fn on_capture(ctx: &Ctx, sid_cdp: &str, payload: &str) { 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(&c.host); return; } // already stored: blur + submit + autofill all re-fire the capture+ 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(_) => {@@ -34,9 +34,15 @@ pub fn on_capture(ctx: &Ctx, sid_cdp: &str, payload: &str) { } } +/// 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_credential_reveal (local callers only) hands one out after the user clicks Reveal." }))+ 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 {@@ -47,10 +53,19 @@ pub fn credential_set(ctx: &Ctx, args: &Value) -> Value { 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 = ctx.vault.delete(&*ctx.host, h);+ 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); } }@@ -64,7 +79,7 @@ pub fn login(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { } _ => 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("Pass {username, password} (add save:true to remember it for this host), or store one first with credential_set {host, username, password}.")), json!({ "output": json!({ "ok": false, "url": cur_url }).to_string() })),+ 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);@@ -74,9 +89,7 @@ pub fn login(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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 pup_core::merge(err("no_login_form", "No password field found on this page", Some("It may not be the login page yet, or the form is inside an iframe / shadow DOM pup did not reach. Navigate to the actual login page (screenshot to see), then call pup_login again.")), json!({ "output": json!({ "ok": false, "reason": located["reason"], "url": cur_url }).to_string() }));- }+ 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; }})()");@@ -93,7 +106,7 @@ pub fn login(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { 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(source.trim_start_matches("vault:")); }+ 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));@@ -268,7 +281,7 @@ fn login_ext(ctx: &Ctx, caller: &Caller, args: &Value, s: Session) -> Value { (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 err("no_credential", format!("no saved login matches {live_host}"), Some("Pass {username, password} (add save:true to remember it for this host).")),+ None => return err("no_credential", format!("no saved login matches {live_host}"), Some(IMPORT_HINT)), }, }; let submit = args["submit"].as_bool() == Some(true);@@ -280,10 +293,13 @@ fn login_ext(ctx: &Ctx, caller: &Caller, args: &Value, s: Session) -> Value { } 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["fp"] == json!(true));+ 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(source.trim_start_matches("vault:")); }+ 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));@@ -295,5 +311,7 @@ fn login_ext(ctx: &Ctx, caller: &Caller, args: &Value, s: Session) -> Value { 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() })); } }- ok(json!({ "success": true, "output": json!({ "ok": true, "url": cur_url, "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "submitted": submitted, "transport": "ext" }) }))+ // 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() }))) }
tools/gen-assets.py+28@@ -64,6 +64,34 @@ if 'pup_select' not in _have: _sv['hint'] = 'Sets the option through the native setter and fires input+change so framework-bound selects update; works on both lanes (DevTools evaluate, so page CSP and Trusted Types cannot refuse it). For custom dropdowns (Material, React Select) use pup_click on the control, then pup_click the option by text. option_not_found lists the first 30 options.' _sv['related'] = ['pup_fill', 'pup_click', 'pup_type'] _verbs.append(_sv)+# 2.0.596 (John, 2026-09-16): Google sign-in as the right account, with the account search behind it.+_to = lambda n: next((v for v in _verbs if v['name'] == n), None)+if 'pup_google_accounts' not in _have and _to('pup_credentials'):+ _ga = json.loads(json.dumps(_to('pup_credentials'))); _ga['name'] = 'pup_google_accounts'+ _ga['summary'] = "List the user's Google accounts: every Chrome/Edge profile, nb's browser profiles, pup's vault, and which ones pup's own profile is already signed into. Cached a day."+ _ga['input'] = { 'refresh': 'optional bool (redo the whole search now)' }+ _ga['example'] = {}+ _ga['hint'] = "Each account comes back with kind (work or personal, from its domain), the browser profile labels the user gave it (\"John Personal\"), whether pup is already signed in, whether pup holds its password, and likelyStale for an account found only in pup's vault. pup never chooses between accounts: pick from the task, the user's work and personal skills, or ask the user."+ _ga['related'] = ['pup_google_signin', 'pup_credentials', 'pup_import_browser_logins']+ _ga['pitfalls'] = ["An account only in pup's vault and signed into no browser here is usually years old; Google will demand a second step for it."]+ _verbs.append(_ga)+if 'pup_google_signin' not in _have and _to('pup_login'):+ _gs = json.loads(json.dumps(_to('pup_login'))); _gs['name'] = 'pup_google_signin'+ _gs['summary'] = "Sign the user into Google as a named account, hands-free: account search, email step, saved password, and the verification step handed back with exact next calls."+ _gs['input'] = {+ 'account': 'optional str: an email, or "work" / "personal" when exactly one account of that kind exists. Omit and pup lists the accounts and asks (google_account_choice_required).',+ 'submitReason': 'str, 10+ chars: required before pup sends a saved password (why this account, for what task)',+ 'continue': 'optional str: where to land after sign-in (default: that account\'s Gmail)',+ 'window': 'optional str: the pup window to use (a new tab in it); default the calling thread\'s window',+ 'code': 'optional str: finish a verification step with the code Google sent (with window)',+ 'challenge': 'optional str: pick a verification option by its words, e.g. "text message" (with window)',+ 'refresh': 'optional bool: redo the account search first',+ }+ _gs['example'] = { 'account': '[email protected]', 'submitReason': 'the task is the work calendar, so the work account' }+ _gs['hint'] = "Already signed into pup: no password is sent, the window just opens the account. Otherwise pup fills the email and the password saved for exactly that account and submits. If Google asks for a second step the answer is google_verification_required with the options or the code field, and the exact call to finish it; a text code can be read by a thread with the user's standing permission to read their texts. google_password_missing names where the account is signed in and how to import its password (pup_import_browser_logins {hostFilter:\"google.com\"}). The account is confirmed by name at the end: Gmail silently opens another signed-in account when the requested one is not signed in."+ _gs['related'] = ['pup_google_accounts', 'pup_login', 'pup_import_browser_logins']+ _gs['pitfalls'] = ["Do not restart a sign-in that stopped on a verification step: that sends the password again and a new code. Finish it with {window, code} or {window, challenge}.", "Never ask the user to type their password to you: import it or let them sign in once in the pup window."]+ _verbs.append(_gs) if 'pup_list_sessions' not in _have: _mw = next((v for v in _verbs if v['name'] == 'pup_my_windows'), None) if _mw: