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
2.0.413: missing verbs + behaviors (assisted_click, import, fg-watch, url lint, notices, chrome-stable, eval frame, move_to_workspace), issue #97 (disable on-device model, reap by age, CONSERVATIVE auto-janitor), goldens 37->56, macOS unsupported in the exe package
30 files changed
+693−37
CHANGELOG.md+29−1rust/Cargo.lock+13−12rust/Cargo.toml+1−1rust/bridge/src/main.rs+1rust/browser/src/chrome_stable.rs+72rust/browser/src/lib.rs+5−1rust/cdp/src/lib.rs+30rust/core/src/lib.rs+11rust/host-win/Cargo.toml+1rust/host-win/src/lib.rs+14rust/host/src/lib.rs+5rust/session/src/lib.rs+6−1rust/vault/src/lib.rs+19rust/verbs/assets/verb-catalog.json+18rust/verbs/src/annotate.rs+1−1rust/verbs/src/assist.rs+68rust/verbs/src/extras.rs+87−8rust/verbs/src/import.rs+64rust/verbs/src/input.rs+1rust/verbs/src/lib.rs+53−6rust/verbs/src/lint.rs+63rust/verbs/src/record.rs+2−1rust/verbs/src/recovery.rs+12rust/verbs/src/settings.rs+5rust/verbs/src/vault.rs+2−2rust/verbs/src/window.rs+2−1tools/capture-goldens.py+69tools/gen-assets.py+10tools/parity.py+24−2tools/rust-build.sh+5CHANGELOG.md+29−1@@ -1,6 +1,34 @@ # Changelog — Puppeteer Bridge -## 2.0.408 — 2026-09-05 — pup as ONE Rust exe (spawn.kind exe): the Node runtime leaves the package+## 2.0.413 — 2026-09-05 — Rust exe parity + issue #97 (conservative janitor)++SAFETY FIX after the first 2.0.409-2.0.412 janitor auto-deleted every idle non-protected profile+(326 on a real box): the automatic daily profile-janitor now reaps ONLY leaked iso-* throwaway jars+and strips the 4 GB on-device model dirs. A NAMED durable profile is deleted ONLY by an EXPLICIT+pup_reap_profiles call (a human/AI choosing it), never behind the user's back. pup_reap_profiles+{isoOnly:true} is the janitor's safe mode; the default explicit call still sweeps idle named profiles.+++Closes the gaps the first Rust ship left (John: "tackle what is not proven or is known missing").+NEW verbs: pup_assisted_click (the six-step native-click protocol), pup_import_scan ++pup_import_browser_logins (Chromium password import via ab's signed decrypt helper), and the+pup_NATIVE_INPUT_WARNING / pup_eval_frame_note doc entries. NEW behaviors: the no-fault fg-watch+(a background window that fronts itself is captioned, not knocked back), the container-URL lint+(file://container-path, localhost, owner-less wiki URL) on open/navigate, the emulated-viewport and+disruption session notices, real Google Chrome install behind pup_use {browser:"chrome",install:true}+(chromeStablePhase installing/awaiting_uac/ready), pup_eval {frame}, and pup_move_to_workspace hands+the page to Hydrogen and closes the window. The exe package now declares macOS UNSUPPORTED (host-mac+is stubbed; macOS runs the Node build). The golden-fixture set grew from 37 to ~63 (captured from+Node 2.0.396 by tools/capture-goldens.py): vault, cards, recording, the recorder HUD, wiki view,+annotate, screenshots, prewarm, deps, highfps, download, login gate, reap.++Also fixes issue #97 (John, disk emergency): every Chrome launch now disables the on-device AI model+download (--disable-features=OptimizationGuideModelDownloading,...) so a profile never pulls the ~4 GB+OptGuideOnDeviceModel weights.bin pup does not use. pup_reap_profiles now sweeps ANY non-protected+profile idle >= 14 days (not just iso-* leaked jars — John found 319 stale named test profiles at+19.6 GB), strips the on-device model dir from the profiles it keeps, and reports bytes freed; a daily+profile-janitor runs it automatically. Protected: adom-you, adom-wiki-authed, pup-dashboard, _recorder,+plus any live or session-file-named profile. The Node build gets the same launch-flag fix. The bridge package is now a single sha256-verified Windows binary (`adom-pup-bridge.exe`, about 4.6 MB) plus its manifest: no Node, no npm, no postinstall, no 90 MB of node_modules, so a first
rust/Cargo.lock+13−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.407"+version = "2.0.413" dependencies = [ "pup-browser", "pup-cdp",@@ -450,7 +450,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.407"+version = "2.0.413" dependencies = [ "pup-core", "pup-host",@@ -462,7 +462,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.407"+version = "2.0.413" dependencies = [ "serde_json", "thiserror",@@ -472,7 +472,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.407"+version = "2.0.413" dependencies = [ "serde", "serde_json",@@ -482,7 +482,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.407"+version = "2.0.413" dependencies = [ "pup-cdp", "pup-core",@@ -495,7 +495,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.407"+version = "2.0.413" dependencies = [ "serde", "serde_json",@@ -504,29 +504,30 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.407"+version = "2.0.413" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.407"+version = "2.0.413" dependencies = [+ "pup-core", "pup-host", "windows-sys 0.59.0", ] [[package]] name = "pup-identity"-version = "2.0.407"+version = "2.0.413" dependencies = [ "serde_json", ] [[package]] name = "pup-session"-version = "2.0.407"+version = "2.0.413" dependencies = [ "pup-core", "serde",@@ -536,7 +537,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.407"+version = "2.0.413" dependencies = [ "pup-core", "pup-host",@@ -547,7 +548,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.407"+version = "2.0.413" dependencies = [ "base64", "png",
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.407"+version = "2.0.413" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+1@@ -88,6 +88,7 @@ fn main() { pup_verbs::recovery::start_health_loop(c.clone()); pup_verbs::highfps::start_loop(c.clone()); pup_verbs::extras::start_identity_audit(c.clone());+ pup_verbs::extras::start_profile_janitor(c.clone()); if let Ok(v) = c.ab.call("bridge_info", json!({}), &Caller::default(), Duration::from_secs(4)) { *c.ad_version.lock().unwrap() = v.get("version").and_then(|x| x.as_str()).map(|s| s.to_string()); } }); }
rust/browser/src/chrome_stable.rsadded+72@@ -0,0 +1,72 @@+//! Real Google Chrome install (Node: chrome.js installChromeStable / chromeStableState). pup drives+//! the machine's INSTALLED Chrome or Edge by default; `pup_use {browser:"chrome", install:true}`+//! fetches and installs actual Google Chrome. Windows: the silent installer, escalating to an+//! elevated (UAC) install; macOS: the .dmg copied to /Applications. The phase drives pup_readiness's+//! chromeStable* fields. NO ab notify re-arm loop here (the Node build's peer-ping UX stays in Node+//! until the exe has the direct-API notify channel); we run one elevated prompt and report the phase.++use crate::{detect_chrome, Host};+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::{Duration, Instant};++#[derive(Clone)]+pub struct StableState { pub phase: String, pub error: Option<String>, pub error_code: Option<String>, pub in_flight: bool }+static STATE: Mutex<Option<StableState>> = Mutex::new(None);++pub fn snapshot() -> Value {+ let g = STATE.lock().unwrap();+ let st = g.clone().unwrap_or(StableState { phase: "idle".into(), error: None, error_code: None, in_flight: false });+ json!({ "installingChrome": st.in_flight, "chromeStablePhase": st.phase, "chromeStableAwaitingApproval": st.phase == "awaiting_uac",+ "chromeStableError": st.error, "chromeStableNotifyChannel": Value::Null, "chromeStableNotifyPeers": Value::Null })+}+fn set(phase: &str, code: Option<&str>, err: Option<&str>, in_flight: bool) {+ *STATE.lock().unwrap() = Some(StableState { phase: phase.into(), error_code: code.map(|s| s.into()), error: err.map(|s| s.into()), in_flight });+}++/// Kick off the install in the background (idempotent). Poll `snapshot()` / pup_readiness for phase.+pub fn install_background(host: std::sync::Arc<dyn Host>, elevate: Option<bool>) {+ { let g = STATE.lock().unwrap(); if g.as_ref().map(|s| s.in_flight).unwrap_or(false) { return; } }+ set("installing", None, None, true);+ std::thread::Builder::new().name("chrome-stable".into()).spawn(move || { let _ = install_blocking(&*host, elevate); }).ok();+}++pub fn install_blocking(host: &dyn Host, elevate: Option<bool>) -> Value {+ set("installing", None, None, true);+ if let Some(c) = detect_chrome(host) { set("ready", None, None, false); return json!({ "ok": true, "executablePath": c.executable_path.to_string_lossy(), "alreadyInstalled": true }); }+ let res = if cfg!(target_os = "macos") { install_mac(host) } else if cfg!(windows) { install_win(host, elevate) } else {+ set("failed", Some("chrome_stable_unsupported_os"), Some("Auto-installing Google Chrome is only wired up for Windows and macOS."), false);+ json!({ "ok": false, "errorCode": "chrome_stable_unsupported_os" })+ };+ res+}++fn install_mac(host: &dyn Host) -> Value {+ let dmg = host.temp_dir().join(format!("chrome_installer_{}.dmg", std::process::id()));+ if let Err(e) = host.download_file("https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg", &dmg) { set("failed", Some("chrome_download_failed"), Some(&e), false); return json!({ "ok": false, "errorCode": "chrome_download_failed", "error": e }); }+ // hdiutil attach, ditto Google Chrome.app to /Applications, detach. Node: the darwin branch.+ let script = format!("set -e; MP=$(hdiutil attach -nobrowse -readonly '{}' | grep Volumes | sed 's/.*\\/Volumes/\\/Volumes/'); ditto \"$MP/Google Chrome.app\" \"/Applications/Google Chrome.app\" || ditto \"$MP/Google Chrome.app\" \"$HOME/Applications/Google Chrome.app\"; hdiutil detach \"$MP\" -quiet; rm -f '{}'", dmg.display(), dmg.display());+ let _ = host.run_shell_script(&script, Duration::from_secs(180));+ match detect_chrome(host) { Some(c) => { set("ready", None, None, false); json!({ "ok": true, "executablePath": c.executable_path.to_string_lossy() }) } None => { set("failed", Some("chrome_stable_not_detected"), Some("Copied Google Chrome.app but it did not detect afterward."), false); json!({ "ok": false, "errorCode": "chrome_stable_not_detected" }) } }+}++fn install_win(host: &dyn Host, elevate: Option<bool>) -> Value {+ let tmp = host.temp_dir().join(format!("chrome_installer_{}.exe", std::process::id()));+ if let Err(e) = host.download_file("https://dl.google.com/chrome/install/standalonesetup64.exe", &tmp) { set("failed", Some("chrome_download_failed"), Some(&e), false); return json!({ "ok": false, "errorCode": "chrome_download_failed", "error": e }); }+ let tmp_s = tmp.to_string_lossy().replace('\'', "''");+ // silent first (no prompt on a normal PC), then poll ~45s+ if elevate != Some(true) {+ let _ = host.run_shell_script(&format!("Start-Process -FilePath '{tmp_s}' -ArgumentList '/silent','/install' -WindowStyle Hidden"), Duration::from_secs(15));+ let t0 = Instant::now(); while t0.elapsed() < Duration::from_secs(45) { if let Some(c) = detect_chrome(host) { let _ = std::fs::remove_file(&tmp); set("ready", None, None, false); return json!({ "ok": true, "executablePath": c.executable_path.to_string_lossy() }); } std::thread::sleep(Duration::from_millis(1500)); }+ }+ // escalate: one elevated install with a UAC prompt (Start-Process -Verb RunAs)+ if elevate != Some(false) {+ set("awaiting_uac", None, None, true);+ let _ = host.run_shell_script(&format!("try {{ Start-Process -FilePath '{tmp_s}' -ArgumentList '/silent','/install' -Verb RunAs -WindowStyle Hidden -ErrorAction Stop }} catch {{ exit 3 }}"), Duration::from_secs(20));+ let t0 = Instant::now(); while t0.elapsed() < Duration::from_secs(150) { if let Some(c) = detect_chrome(host) { let _ = std::fs::remove_file(&tmp); set("ready", None, None, false); return json!({ "ok": true, "executablePath": c.executable_path.to_string_lossy() }); } std::thread::sleep(Duration::from_millis(2500)); }+ }+ let _ = std::fs::remove_file(&tmp);+ let (code, msg) = if elevate == Some(false) { ("chrome_stable_not_detected", "Ran the Chrome installer but chrome.exe did not appear (elevation was declined via elevate:false). pup still works with Edge in the meantime.") } else { ("chrome_stable_needs_approval", "Chrome needs a Windows approval (UAC) that was not accepted in time. Re-run pup_use {browser:\"chrome\", install:true} and click YES on the prompt; pup drives Edge until then.") };+ set("failed", Some(code), Some(msg), false);+ json!({ "ok": false, "errorCode": code, "error": msg })+}
rust/browser/src/lib.rs+5−1@@ -13,6 +13,10 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; pub mod ext_assets;+pub mod chrome_stable;++/// The machine's installed Google Chrome, if any (never Edge, never Chrome for Testing).+pub fn detect_chrome(host: &dyn Host) -> Option<Candidate> { list_system_browsers(host).into_iter().find(|c| c.kind == "chrome") } /// Chrome extension ids (deterministic from each manifest's `key`, so staging path does not matter). pub const EXT_IDS: &[(&str, &str)] = &[("pup-extension", "liomcaladmooagjiebkfgegciadapnpi"), ("pup-annotate-extension", "ghadnmahlldiigemdbekmhcbfcpkmclg"), ("pup-identity-extension", "mngfopllhdfgomjphhejmcdimcohcgnf"), ("pup-identity-anon-extension", "mhoenaodccbppcinfbaifamkgfhbakbe")];@@ -385,7 +389,7 @@ pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool, e "--install-autogenerated-theme=13,17,23".into(), "--start-maximized".into(), "--disable-quic".into(), "--disable-async-dns".into(), "--force-webrtc-ip-handling-policy=disable_non_proxied_udp".into(), "--webrtc-ip-handling-policy=disable_non_proxied_udp".into(),- "--disable-features=CalculateNativeWinOcclusion,WebRtcHideLocalIpsWithMdns,WebRtcAllowInputVolumeAdjustment,AsyncDns,DnsOverHttps,DnsOverHttpsUpgrade,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,EncryptedClientHello,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider".into(),+ "--disable-features=OptimizationGuideModelDownloading,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,TextSafetyClassifier,CalculateNativeWinOcclusion,WebRtcHideLocalIpsWithMdns,WebRtcAllowInputVolumeAdjustment,AsyncDns,DnsOverHttps,DnsOverHttpsUpgrade,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,EncryptedClientHello,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider".into(), ]; if high_fps { v.extend(["--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding"].map(String::from)); } if insecure_dev { v.push("--disable-web-security".into()); }
rust/cdp/src/lib.rs+30@@ -194,6 +194,36 @@ impl Cdp { Ok(r.get("result").and_then(|x| x.get("value")).cloned().unwrap_or(Value::Null)) } + /// Runtime.evaluate inside one execution context (a frame's isolated world), same semantics as `evaluate`.+ pub fn evaluate_in_context(&self, session: &str, context_id: u64, expression: &str, timeout: Duration) -> Result<Value, CdpError> {+ let mut r = self.send(Some(session), "Runtime.evaluate", json!({ "expression": expression, "contextId": context_id, "returnByValue": true, "awaitPromise": true, "userGesture": true }), timeout)?;+ let is_syntax = r.get("exceptionDetails").map(|ex| ex.get("exception").and_then(|e| e.get("description")).and_then(|d| d.as_str()).unwrap_or("").starts_with("SyntaxError")).unwrap_or(false);+ if is_syntax { r = self.send(Some(session), "Runtime.evaluate", json!({ "expression": expression, "contextId": context_id, "returnByValue": true, "awaitPromise": true, "userGesture": true, "replMode": true }), timeout)?; }+ if let Some(ex) = r.get("exceptionDetails") {+ let desc = ex.get("exception").and_then(|e| e.get("description")).and_then(|d| d.as_str()).or_else(|| ex.get("text").and_then(|t| t.as_str())).unwrap_or("evaluation threw");+ return Ok(json!({ "__error": desc }));+ }+ Ok(r.get("result").and_then(|x| x.get("value")).cloned().unwrap_or(Value::Null))+ }+ /// Every frame of the page, depth-first: [{id, name, url, parentId}] (Page.getFrameTree).+ pub fn frames(&self, session: &str) -> Result<Vec<Value>, CdpError> {+ let r = self.send(Some(session), "Page.getFrameTree", json!({}), Duration::from_secs(10))?;+ let mut out = Vec::new();+ fn walk(n: &Value, out: &mut Vec<Value>) { let f = &n["frame"]; out.push(json!({ "id": f["id"], "name": f["name"], "url": f["url"], "parentId": f["parentId"] })); if let Some(c) = n["childFrames"].as_array() { for k in c { walk(k, out); } } }+ walk(&r["frameTree"], &mut out);+ Ok(out)+ }+ /// An execution context for a frame (an isolated world with universal access), for evaluate_in_context.+ pub fn frame_context(&self, session: &str, frame_id: &str) -> Result<u64, CdpError> {+ let r = self.send(Some(session), "Page.createIsolatedWorld", json!({ "frameId": frame_id, "worldName": "pup-eval", "grantUniveralAccess": true }), Duration::from_secs(10))?;+ r.get("executionContextId").and_then(|v| v.as_u64()).ok_or_else(|| CdpError::Json("createIsolatedWorld: no executionContextId".into()))+ }+ /// Target.getTargetInfo with a caller-chosen budget (the list verbs use a short one so a jammed page cannot stall them).+ pub fn target_info_within(&self, session: &str, timeout: Duration) -> Result<Value, CdpError> {+ let r = self.send(Some(session), "Target.getTargetInfo", json!({}), timeout)?;+ Ok(r.get("targetInfo").cloned().unwrap_or(Value::Null))+ }+ /// PNG bytes (base64-decoded) of the viewport or the full page. pub fn screenshot_png(&self, session: &str, full_page: bool, timeout: Duration) -> Result<Vec<u8>, CdpError> { let mut p = json!({ "format": "png", "captureBeyondViewport": full_page });
rust/core/src/lib.rs+11@@ -228,6 +228,17 @@ pub mod loops { } } +/// Download a URL to a file, following redirects, via ureq (used by the real-Chrome installer). The+/// TLS + redirect handling lives here so host-win needs no HTTP dependency of its own.+pub fn http_download(url: &str, dest: &std::path::Path) -> Result<(), String> {+ let agent = ureq::AgentBuilder::new().timeout(std::time::Duration::from_secs(120)).redirects(8).build();+ let resp = agent.get(url).call().map_err(|e| e.to_string())?;+ let mut r = resp.into_reader();+ let mut f = std::fs::File::create(dest).map_err(|e| e.to_string())?;+ std::io::copy(&mut r, &mut f).map_err(|e| e.to_string())?;+ Ok(())+}+ #[cfg(test)] mod tests { use super::*;
rust/host-win/Cargo.toml+1@@ -7,6 +7,7 @@ description = "The Windows implementation of pup-host (windows-sys). The ONLY cr [dependencies] pup-host = { path = "../host" }+pup-core = { path = "../core" } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [
rust/host-win/src/lib.rs+14@@ -106,6 +106,20 @@ mod imp { Ok(child.id()) } // Node: killBrowserProcess (taskkill /F)+ fn run_shell_script(&self, script: &str, timeout: std::time::Duration) -> String {+ use std::io::Write;+ let dir = std::env::temp_dir(); let f = dir.join(format!("pup-ps-{}.ps1", std::process::id()));+ if std::fs::write(&f, script).is_err() { return String::new(); }+ let mut cmd = std::process::Command::new("powershell.exe");+ cmd.args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File"]).arg(&f);+ cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());+ let out = match cmd.spawn() { Ok(c) => { let _ = timeout; let o = c.wait_with_output(); let _ = std::io::stdout().flush(); o } Err(_) => { let _ = std::fs::remove_file(&f); return String::new(); } };+ let _ = std::fs::remove_file(&f);+ out.ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default()+ }+ fn download_file(&self, url: &str, dest: &std::path::Path) -> Result<(), String> {+ match pup_core::http_download(url, dest) { Ok(_) => Ok(()), Err(e) => Err(e) }+ } fn kill(&self, pid: u32) -> bool { if pid == 0 || pid == std::process::id() { return false; } // never the bridge itself, never "no pid" unsafe {
rust/host/src/lib.rs+5@@ -54,6 +54,11 @@ pub trait HostProcess: Send + Sync { /// Spawn a browser DETACHED from this bridge (it must survive a bridge restart). Returns the pid. fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32>; fn kill(&self, pid: u32) -> bool;+ /// 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() }+ /// Download a URL to a file, following redirects. Returns Ok(path) or an error string.+ fn download_file(&self, _url: &str, _dest: &Path) -> Result<(), String> { Err("download unsupported on this platform".into()) } } pub trait HostPaths: Send + Sync {
rust/session/src/lib.rs+6−1@@ -51,13 +51,18 @@ pub struct Session { pub progress_count: u32, pub progress_ms: u64, pub owner_from_caller: bool,+ pub disruption_notice: Option<String>,+ pub fg_watch_until_ms: u64,+ pub last_outer_width: Option<u64>,+ pub last_url: Option<String>,+ pub last_title: Option<String>, } impl Session { /// Node: session._lastAgentUpdate = {tabId, verb, note, ts} | null. pub fn last_agent_update(&self) -> Value { match &self.last_verb { Some(v) => json!({ "tabId": self.last_tab, "verb": v, "note": self.last_note, "ts": self.last_agent_update_ms }), None => Value::Null } } pub fn new(id: &str, profile: &str, owner: Option<&str>, owner_container: Option<&str>, cdp_port: u16, pid: Option<u32>) -> Session {- Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, owner_from_caller: false }+ Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, owner_from_caller: false, disruption_notice: None, fg_watch_until_ms: 0, last_outer_width: None, last_url: None, last_title: None } } pub fn next_tab_id(&mut self) -> String { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; id } pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) }
rust/vault/src/lib.rs+19@@ -75,6 +75,25 @@ impl Vault { Ok(()) } + /// 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) {+ let (mut added, mut updated, mut skipped) = (0u64, 0u64, 0u64);+ for r in rows {+ let raw_host = r["host"].as_str().or(r["url"].as_str()).or(r["origin"].as_str()).unwrap_or("");+ 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; } }+ 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,+ }+ }+ (added, updated, skipped)+ }+ /// Best-matching stored login for a URL (exact host > glob > sibling; then most recently used). pub fn get_for_url(&self, host: &dyn HostSecrets, url: &str, prefer_account: Option<&str>) -> Option<Credential> { let h = host_of(url)?;
rust/verbs/assets/verb-catalog.json+18@@ -1677,6 +1677,24 @@ "statusVerb": null, "summary": "This list \u2014 the bridge's verbs + schemas, for AD's Verbs tab.", "timeoutSeconds": 10+ },+ {+ "name": "pup_import_scan",+ "summary": "List the Chromium browser profiles on this desktop that have saved passwords (Chrome/Edge/Brave). Windows-only. Read-only discovery; the decrypt is a separate, consented step.",+ "input": {},+ "output": {+ "success": "bool"+ },+ "timeoutSeconds": 60,+ "statusVerb": null,+ "longRunning": false,+ "example": {},+ "hint": "Run this first to see what pup_import_browser_logins can pull.",+ "related": [+ "pup_import_browser_logins",+ "pup_credentials"+ ],+ "pitfalls": [] } ] }
rust/verbs/src/annotate.rs+1−1@@ -30,7 +30,7 @@ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pup_core::log(format!("[annotate] \"{}\" overlay started (by {}, tool={tool}, mode={})", s.id, caller.thread, mode.as_deref().unwrap_or("default"))); if let Some(a) = ctx.arc() { watch(a, s.id.clone(), t.cdp_session.clone(), caller.clone()); } touch(ctx, &s.id, None);- ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "tool": tool, "mode": mode, "armed": true }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner,+ ok(json!({ "success": true, "output": json!({ "ok": true, "started": true, "raised": false, "sessionId": s.id, "tool": tool, "mode": mode, "armed": true }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "_hint": "Overlay armed on the page: the user draws (pen) or drags a region (snip). Auto mode captures on mouse-up; Multi mode captures on Done. The capture lands as a PNG in pup's screenshots folder AND on the user's clipboard (image + JSON sidecar); the owning thread's next pup_screenshot also shows the drawing. Nothing else to call." })) }
rust/verbs/src/assist.rsadded+68@@ -0,0 +1,68 @@+//! pup_assisted_click (Node: the six-step native click protocol, John's hard rule of 2026-07-29):+//! caption + toast first, raise the window, WAIT until the user is actually on it (foreground stable+//! for 1.2 s), re-check right before acting, then a real-cursor hover and click through ab aimed by+//! a fresh window screenshot, narrating every step. Never dispatches while the user is elsewhere.++use crate::{resolve, touch, Ctx};+use pup_core::{blocked_on_user, err, ok, Caller};+use serde_json::{json, Value};+use std::time::{Duration, Instant};++const AB_T: Duration = Duration::from_secs(8);++fn caption(ctx: &Ctx, caller: &Caller, text: &str, ms: u64) {+ if ctx.settings.get("fgCaption", "on") == "off" { return; }+ let _ = ctx.ab.call("desktop_caption", json!({ "id": "pup-assist", "text": text, "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium" }), caller, AB_T);+}++/// Poll the OS foreground window until it is `hwnd` and stays there 1.2 s (at most 3 reminder captions).+pub fn wait_for_user_ready(ctx: &Ctx, caller: &Caller, hwnd: i64, max: Duration) -> bool {+ let t0 = Instant::now(); let mut stable: Option<Instant> = None; let mut caps = 0; let mut last_cap = Instant::now() - Duration::from_secs(10);+ let cap_ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse::<u64>().unwrap_or(2000).clamp(500, 12000);+ while t0.elapsed() < max {+ if ctx.host.foreground() == hwnd {+ match stable { Some(s) if s.elapsed() >= Duration::from_millis(1200) => return true, Some(_) => {}, None => stable = Some(Instant::now()) }+ } else {+ stable = None;+ if caps < 3 && last_cap.elapsed() > Duration::from_millis(3500) { caps += 1; last_cap = Instant::now(); caption(ctx, caller, "Adom is waiting: click back into THIS window when you are ready, and I will continue. (Nothing happens until you do.)", cap_ms); }+ }+ std::thread::sleep(Duration::from_millis(400));+ }+ false+}++pub fn assisted_click(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let (Some(x), Some(y)) = (args["x"].as_f64(), args["y"].as_f64()) else { return err("args_required", "pup_assisted_click needs window-image x+y (from a desktop_screenshot_window of this session) plus reason+label.", None) };+ if !cfg!(windows) { return err("unsupported_platform", "assisted click is Windows-only (uses the OS cursor + foreground detection).", None); }+ let reason: String = args["reason"].as_str().unwrap_or("Adom needs to click something on your screen.").chars().take(180).collect();+ let label: String = args["label"].as_str().unwrap_or("the highlighted control").chars().take(80).collect();+ caption(ctx, caller, &format!("Adom: {reason} I will bring this window up and WAIT until you are ready before clicking {label}."), 9000);+ let _ = ctx.ab.call("notify_user", json!({ "title": "Adom needs one click", "body": format!("{label} — {reason} I will wait until you are on the window."), "focus": false }), caller, Duration::from_secs(4));+ ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.last_foreground = Some(json!({ "ts": pup_core::now_ms(), "verb": "pup_assisted_click", "reason": reason })); });+ // Bring it to the user's screen directly (the caption + toast above are the narration; raise() would add its own).+ let Some(hwnd) = crate::window::hwnd_of_pub(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window", Some("pup_rescan {} re-resolves handles.")) };+ if ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": hwnd }), caller, AB_T).is_err() { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": hwnd }), caller, AB_T); }+ let max_wait = Duration::from_millis(args["maxWaitMs"].as_u64().unwrap_or(120_000).min(45_000));+ if !wait_for_user_ready(ctx, caller, hwnd, max_wait) {+ caption(ctx, caller, "Adom is still waiting for you to come back to this window; your AI will ask again when you are ready.", 7000);+ return blocked_on_user(false, "The user must bring the pup window to the foreground (click it or Alt-Tab to it) so the real-cursor click lands in it and nowhere else.", "Nothing was clicked. Tell the user which window to click into and call pup_assisted_click again with the same args; pup waits up to 45 s each call and never dispatches while the user is elsewhere.", json!({ "reason": "user_not_ready", "sessionId": s.id, "window": s.id, "hwnd": hwnd }));+ }+ caption(ctx, caller, &format!("Adom is clicking {label} now."), 5000);+ let shot = ctx.ab.call("desktop_screenshot_window", json!({ "hwnd": hwnd }), caller, Duration::from_secs(8)).unwrap_or(Value::Null);+ let inner = shot.get("output").and_then(|v| v.as_str()).and_then(|o| serde_json::from_str::<Value>(o).ok());+ let pick = |v: &Value| v.get("shotId").and_then(|x| x.as_str()).map(|s| s.to_string()).or_else(|| v["screenshots"][0]["shotId"].as_str().map(|s| s.to_string()));+ let shot_id = pick(&shot).or_else(|| inner.as_ref().and_then(pick));+ let Some(shot_id) = shot_id else { return err("aim_failed", "could not capture the window to aim the click", Some("desktop_screenshot_window answered without a shotId; retry once, then fall back to pup_click if the control is in-page.")) };+ // re-verify RIGHT before each native event: focus can move between two events+ if ctx.host.foreground() != hwnd { return blocked_on_user(false, "The user left the window between the wait and the click.", "Nothing was clicked (the foreground moved). Call pup_assisted_click again.", json!({ "reason": "focus_moved", "sessionId": s.id })); }+ let (xi, yi) = (x.round() as i64, y.round() as i64);+ let _ = ctx.ab.call("desktop_hover", json!({ "space": "image", "shotId": shot_id, "x": xi, "y": yi }), caller, Duration::from_secs(6));+ std::thread::sleep(Duration::from_millis(900));+ if ctx.host.foreground() != hwnd { return blocked_on_user(false, "The user left the window right before the click.", "Nothing was clicked (the foreground moved after the hover). Call pup_assisted_click again.", json!({ "reason": "focus_moved", "sessionId": s.id })); }+ let clicked = ctx.ab.call("desktop_click", json!({ "space": "image", "shotId": shot_id, "x": xi, "y": yi }), caller, Duration::from_secs(6)).unwrap_or(Value::Null);+ caption(ctx, caller, &format!("Adom clicked {label}. Continuing."), 5000);+ touch(ctx, &s.id, Some(&format!("assisted click on {label}")));+ ok(json!({ "success": true, "output": json!({ "ok": true, "clicked": label, "x": xi, "y": yi, "sessionId": s.id, "abClick": clicked.get("success").cloned().unwrap_or(Value::Null) }).to_string(), "clicked": label, "x": xi, "y": yi, "sessionId": s.id, "window": s.id, "aiThread": s.owner,+ "_hint": "Assisted click done after the user was confirmed on the window. Verify the effect with pup_eval or a screenshot; the window stays in the foreground (the user is on it)." }))+}
rust/verbs/src/extras.rs+87−8@@ -35,7 +35,7 @@ pub fn screenshot_element(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let path = ctx.shots_dir().join(format!("pup-el-{}.png", pup_core::now_ms())); if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); } touch(ctx, &s.id, None);- ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "element": rect, "_hint": "PNG of ONE element (scrolled into view first). READ the image before asserting what it shows." }))+ ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "matchedBy": if sel.is_some() { "selector" } else { "text" }, "base64": pup_identity::base64_encode(&buf), "resized": resized, "sizeKB": (buf.len() / 1024) as u64, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "element": rect, "_hint": "PNG of ONE element (scrolled into view first). READ the image before asserting what it shows." })) } pub fn screenshot_full_res(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {@@ -45,7 +45,7 @@ pub fn screenshot_full_res(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let (w, h) = pup_identity::png_size(&png).unwrap_or((0, 0)); let path = ctx.shots_dir().join(format!("pup-full-{}.png", pup_core::now_ms())); if let Err(e) = std::fs::write(&path, &png) { return err("screenshot_write_failed", e.to_string(), None); }- ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "sizeKB": png.len() / 1024, "fullPage": full, "resized": false, "_hint": "FULL-resolution PNG, NOT downscaled: for saving/handing to the user, not for Read (it can exceed the image limit). Use pup_screenshot for an AI-readable copy." }))+ ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": w, "origHeight": h, "resized": false, "savedTo": path.to_string_lossy().replace('\\', "/"), "_warning": if w > 4000 || h > 4000 { json!(format!("This is the FULL-resolution image ({w}x{h}); large images can exceed a vision model's limit. pup_screenshot returns a Claude-optimal resized copy.")) } else { Value::Null }, "sizeKB": png.len() / 1024, "fullPage": full, "resized": false, "_hint": "FULL-resolution PNG, NOT downscaled: for saving/handing to the user, not for Read (it can exceed the image limit). Use pup_screenshot for an AI-readable copy." })) } /// pup_download: click a real download control or an in-page a[download]; Chrome saves it.@@ -78,6 +78,25 @@ pub fn download(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { } /// Every 5 min: read back what the OS says each window is stamped with (ab desktop_get_window_identity).+/// Once a day: strip the on-device model dirs and reap non-protected profiles idle >= 14 days, so a+/// box that opens many windows over months never piles up 300 stale jars + a 4 GB model each (#97).+pub fn start_profile_janitor(ctx: Arc<Ctx>) {+ std::thread::Builder::new().name("profile-janitor".into()).spawn(move || {+ std::thread::sleep(Duration::from_secs(120)); // let boot recovery settle first+ loop {+ let _t0 = std::time::Instant::now();+ let r = reap_profiles(&ctx, &json!({ "isoOnly": true }));+ if let Some(o) = r.get("output").and_then(|v| v.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()) {+ if o["removed"].as_u64().unwrap_or(0) > 0 || o["onDeviceModelBytesFreed"].as_u64().unwrap_or(0) > 0 {+ pup_core::log(format!("[janitor] reaped {} stale profile(s), freed {:.1} GB of on-device model", o["removed"], o["onDeviceModelBytesFreed"].as_u64().unwrap_or(0) as f64 / 1e9));+ }+ }+ pup_core::loops::tick("profile-janitor", 86_400_000, _t0.elapsed().as_millis() as u64);+ std::thread::sleep(Duration::from_secs(86400));+ }+ }).ok();+}+ pub fn start_identity_audit(ctx: Arc<Ctx>) { std::thread::Builder::new().name("identity-audit".into()).spawn(move || loop { std::thread::sleep(Duration::from_secs(300));@@ -99,19 +118,58 @@ pub fn start_identity_audit(ctx: Arc<Ctx>) { pub fn launch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let mut a = args.clone(); if a.get("url").is_none() { a["url"] = json!("about:blank"); } crate::open_window(ctx, caller, &a) } -pub fn deps(_ctx: &Ctx) -> Value { ok(json!({ "success": true, "output": json!({ "ok": true, "shell": "rust", "deps": "none: this build is a single exe (no Node, no npm, no native modules)" }).to_string(), "_hint": "There is nothing to install: the Rust pup exe carries everything but Chrome for Testing, which pup_readiness / pup_prewarm manage." })) }+pub fn deps(_ctx: &Ctx) -> Value { ok(json!({ "success": true, "output": json!({ "ok": true, "depsPresent": true, "selfInstalledThisBoot": false, "lastError": Value::Null, "dir": Value::Null, "shell": "rust", "deps": "none: this build is a single exe (no Node, no npm, no native modules)" }).to_string(), "_hint": "There is nothing to install: the Rust pup exe carries everything but Chrome for Testing, which pup_readiness / pup_prewarm manage." })) } pub fn install_toolbar(ctx: &Ctx) -> Value { let dirs = pup_browser::stage_extensions(&*ctx.host, ctx.port, "adom-you", true);- ok(json!({ "success": true, "staged": dirs.iter().map(|d| d.to_string_lossy().replace('\\', "/")).collect::<Vec<_>>(), "_hint": "The Adom toolbar extensions are staged and ride every Chrome for Testing launch (--load-extension, pinned). A warm Chrome never reloads them: pup_quit_idle_browsers then reopen the window to pick up new extension code." }))+ let driving = pup_browser::read_default(&*ctx.host).and_then(|d| d.get("kind").and_then(|k| k.as_str()).map(|s| s.to_string())).unwrap_or_else(|| "chrome-for-testing".into());+ ok(json!({ "success": true, "staged": dirs.iter().map(|d| d.to_string_lossy().replace('\\', "/")).collect::<Vec<_>>(), "output": json!({ "ok": true, "mode": "launch-flag", "stagedAt": dirs.first().map(|d| d.to_string_lossy().replace('\\', "/")), "driving": driving, "active": false }).to_string(), "_hint": "The Adom toolbar extensions are staged and ride every Chrome for Testing launch (--load-extension, pinned). A warm Chrome never reloads them: pup_quit_idle_browsers then reopen the window to pick up new extension code." }))+}++pub const PROTECTED_PROFILES: [&str; 4] = ["adom-you", "adom-wiki-authed", "pup-dashboard", "_recorder"];++/// The Chrome on-device AI model (OptGuideOnDeviceModel/.../weights.bin) is ~4 GB and pup never uses+/// it. New launches carry --disable-features=OptimizationGuideModelDownloading so it never re-downloads;+/// this strips any copy an older launch already wrote, inside every profile. Returns bytes freed.+pub fn strip_ondevice_model(root: &std::path::Path) -> u64 {+ let mut freed = 0u64;+ if let Ok(rd) = std::fs::read_dir(root) { for e in rd.flatten() {+ let d = e.path().join("OptGuideOnDeviceModel");+ if d.is_dir() { freed += dir_size(&d); let _ = std::fs::remove_dir_all(&d); }+ } }+ freed+}+fn dir_size(p: &std::path::Path) -> u64 {+ let mut n = 0; if let Ok(rd) = std::fs::read_dir(p) { for e in rd.flatten() { match e.metadata() { Ok(m) if m.is_dir() => n += dir_size(&e.path()), Ok(m) => n += m.len(), _ => {} } } } n } pub fn reap_profiles(ctx: &Ctx, args: &Value) -> Value { let root = pup_browser::profiles_dir(&*ctx.host); let live: Vec<String> = ctx.browsers.lock().unwrap().keys().cloned().collect();- let mut removed = Vec::new();- if let Ok(rd) = std::fs::read_dir(&root) { for e in rd.flatten() { let name = e.file_name().to_string_lossy().to_string(); let keep = name == "adom-you" || name == "pup-dashboard" || name == "_recorder" || live.contains(&name) || pup_browser::existing_port(&e.path()).is_some(); if !keep && (name.starts_with("iso-") || args["all"].as_bool() == Some(true)) { if std::fs::remove_dir_all(e.path()).is_ok() { removed.push(name); } } } }- ok(json!({ "success": true, "removed": removed, "kept": live, "_hint": "Throwaway (isolated) profiles with no live browser were deleted; durable profiles are never touched." }))+ let dry = args["dryRun"].as_bool() == Some(true);+ let all = args["all"].as_bool() == Some(true);+ let iso_only = args["isoOnly"].as_bool() == Some(true);+ let stale_days = args["staleDays"].as_u64().unwrap_or(14);+ let stale = std::time::Duration::from_secs(stale_days * 86400);+ let (mut removed, mut spared, mut fresh, mut held) = (Vec::new(), 0u64, 0u64, 0u64);+ // profiles a session file still names must never be reaped (they can be recovered)+ let named: std::collections::HashSet<String> = ctx.sessions.read_files().iter().filter_map(|f| f["profile"].as_str().map(|s| s.to_string())).chain(ctx.sessions.all().iter().map(|s| s.profile.clone())).collect();+ if let Ok(rd) = std::fs::read_dir(&root) { for e in rd.flatten() {+ if !e.path().is_dir() { continue; }+ let name = e.file_name().to_string_lossy().to_string();+ if PROTECTED_PROFILES.contains(&name.as_str()) || live.contains(&name) || named.contains(&name) { spared += 1; continue; }+ if pup_browser::existing_port(&e.path()).is_some() { held += 1; continue; }+ let idle_ok = e.metadata().ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).map(|el| el >= stale).unwrap_or(false);+ // iso-* leaked jars go once idle (always safe). A NAMED durable profile is removed ONLY on an+ // explicit call (isoOnly=false) — never by the auto janitor, so a profile the user deliberately+ // named and its logins are never deleted behind their back (John #97).+ let should = if iso_only { name.starts_with("iso-") && idle_ok } else { all || idle_ok };+ if !should { fresh += 1; continue; }+ if dry { removed.push(name); } else if std::fs::remove_dir_all(e.path()).is_ok() { removed.push(name); }+ } }+ let freed = if dry { 0 } else { strip_ondevice_model(&root) };+ ok(json!({ "success": true, "output": json!({ "ok": true, "dryRun": dry, "removed": if dry { 0 } else { removed.len() }, "wouldRemove": if dry { removed.len() } else { 0 }, "sampleNames": removed.iter().take(12).collect::<Vec<_>>(), "spared": spared, "tooFresh": fresh, "inUse": held, "onDeviceModelBytesFreed": freed, "staleDays": stale_days,+ "_hint": format!("{} profile(s) {} ({}). Protected: {}. On-device AI model dirs (~4 GB each) stripped from remaining profiles, freeing {:.1} GB; new launches disable its download (issue #97).", removed.len(), if dry { "would be removed" } else { "removed" }, if iso_only { "leaked iso-* jars only — the auto janitor NEVER deletes a named durable profile".to_string() } else { format!("idle >= {stale_days}d, not protected/live/recovered{}", if all { ", all:true forced non-idle too" } else { "" }) }, PROTECTED_PROFILES.join(", "), freed as f64 / 1e9) }).to_string() })) } pub fn uninstall(ctx: &Ctx, args: &Value) -> Value {@@ -123,4 +181,25 @@ pub fn uninstall(ctx: &Ctx, args: &Value) -> Value { ok(json!({ "success": true, "removed": removed, "_hint": "pup's on-disk footprint is gone (the vault in the OS keychain is kept unless credential_delete is called per host). ab's bridge_uninstall removes the bridge itself." })) } -pub fn move_to_workspace(_ctx: &Ctx) -> Value { err("not_in_rust_yet", "pup_move_to_workspace (send a window back to a Hydrogen webview) lands after the Hydrogen endpoint contract settles (docs/PLAN-interactive-surface.md phase 3)", None) }+pub fn move_to_workspace(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let url = s.active().map(|t| t.url.clone()).unwrap_or_default();+ if url.is_empty() || url.starts_with("about:") || url.starts_with("chrome:") || url.starts_with("edge:") || url.starts_with("devtools:") {+ return err("no_movable_url", format!("This window's active tab has no movable URL ({}).", if url.is_empty() { "none" } else { &url }), None);+ }+ // Hand the URL to Hydrogen; its Browser Picker routes it (webview by default, honoring the user's per-domain choice). Node: hd_api POST /open-url.+ let hd = ctx.ab.call("hd_api", json!({ "method": "POST", "path": "/open-url", "body": { "url": url } }), caller, std::time::Duration::from_secs(12));+ let ok_hd = hd.as_ref().map(|v| v.get("success") != Some(&json!(false)) && v["body"]["error"].is_null()).unwrap_or(false);+ if !ok_hd {+ let why = hd.as_ref().ok().and_then(|v| v["body"]["error"].as_str().or(v["error"].as_str())).map(|s| s.to_string()).or_else(|| hd.as_ref().err().map(|e| e.to_string())).unwrap_or_else(|| "unknown error".into());+ return err("hydrogen_unreachable", format!("Hydrogen did not accept the URL: {why}"), Some("Moving a pup page to a workspace needs Hydrogen (ah) running on this desktop. The pup window was left open."));+ }+ let mut closed = false;+ if args.get("keepWindow").and_then(|b| b.as_bool()) != Some(true) {+ let r = crate::close_window(ctx, caller, &json!({ "window": s.id, "takeover": true }));+ closed = r.get("ok") != Some(&json!(false));+ }+ pup_core::log(format!("[move-to-workspace] \"{}\" -> Hydrogen ({}) — windowClosed={closed}", s.id, url.chars().take(80).collect::<String>()));+ ok(json!({ "success": true, "output": json!({ "ok": true, "movedUrl": url, "windowClosed": closed, "sessionId": s.id,+ "_hint": if closed { "The page was handed to Hydrogen (its Browser Picker routes it — webview by default) and this pup window closed. The user picks the surface if the picker prompts." } else { "The page was handed to Hydrogen; the pup window was KEPT (keepWindow:true)." } }).to_string() }))+}
rust/verbs/src/import.rsadded+64@@ -0,0 +1,64 @@+//! pup_import_scan / pup_import_browser_logins (Node: the Windows-only Chromium password import).+//! Scan enumerates Chrome/Edge/Brave profiles and their saved-password counts via a PowerShell probe;+//! import calls ab-core's signed decrypt helper (consent + UAC on the desktop) and merges the rows+//! into the adom-you vault. Decrypted passwords are used to fill the keychain and never logged.++use crate::Ctx;+use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::time::Duration;++const SCAN_PS: &str = include_str!("../assets/import-scan.ps1");++pub fn import_scan(ctx: &Ctx) -> Value {+ if !cfg!(windows) {+ return ok(json!({ "success": true, "output": json!({ "profiles": [] }).to_string(), "profiles": [], "_hint": "Credential import discovery is Windows-only (Chromium ABE / DPAPI). No profiles enumerated on this OS." }));+ }+ let out = ctx.host.run_shell_script(SCAN_PS, Duration::from_secs(60));+ let profiles: Value = out.trim().lines().last().and_then(|l| serde_json::from_str(l).ok())+ .or_else(|| serde_json::from_str(out.trim()).ok()).unwrap_or(json!([]));+ let profiles = if profiles.is_array() { profiles } else { profiles.get("profiles").cloned().unwrap_or(json!([])) };+ ok(json!({ "success": true, "output": json!({ "profiles": profiles }).to_string(), "profiles": profiles,+ "_hint": "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)." }))+}++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 scan = import_scan(ctx);+ let all: Vec<Value> = scan["profiles"].as_array().cloned().unwrap_or_default();+ let want: Vec<String> = match args["browsers"].as_array() {+ Some(a) if !a.is_empty() => a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),+ _ => { let mut v: Vec<String> = all.iter().filter_map(|p| p["browser"].as_str().map(|s| s.to_string())).collect(); v.sort(); v.dedup(); v }+ };+ if want.is_empty() { return err("no_profiles", "No Chromium browser profiles with saved passwords were found to import from.", None); }+ pup_core::log(format!("[import] decrypt requested for browsers=[{}] — ab helper, consent + UAC prompt on the desktop", want.join(", ")));+ // ab-core's signed helper: try the two known verb names (older builds).+ let mut resp = Value::Null;+ for name in ["desktop_decrypt_browser_credentials", "desktop_decrypt_pup_credentials"] {+ let r = ctx.ab.call(name, json!({ "browsers": want, "include": ["passwords"], "reason": args["reason"].as_str().unwrap_or("Import your saved browser logins into the pup adom-you vault so pup can sign you in.") }), caller, Duration::from_secs(180));+ match r { Ok(v) => { if v.get("errorCode").and_then(|e| e.as_str()) == Some("unknown_verb") { continue; } resp = v; break; } Err(e) => { resp = json!({ "errorCode": "decrypt_call_failed", "error": e.to_string() }); break; } }+ }+ let payload = if resp.get("output").and_then(|v| v.as_str()).map(|s| s.starts_with('{')).unwrap_or(false) { serde_json::from_str(resp["output"].as_str().unwrap()).unwrap_or(resp.clone()) } else { resp.clone() };+ if let Some(ec) = payload.get("errorCode").and_then(|e| e.as_str()).or(resp.get("errorCode").and_then(|e| e.as_str())) {+ let msg = match ec {+ "relay_forbidden" => "ab refused the decrypt as a relayed call — this should not happen from pup (a local bridge). Please report it.",+ "consent_declined" => "You declined the Adom Bridge consent dialog on the desktop. Nothing was read.",+ "elevation_declined" => "You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.",+ "helper_missing" => "The signed decrypt helper is not installed — update/repair Adom Bridge.",+ "decrypt_call_failed" => "The decrypt helper call failed.",+ _ => "Decrypt failed.",+ };+ return err(ec, format!("{msg}{}", payload.get("error").and_then(|e| e.as_str()).map(|e| format!(" ({e})")).unwrap_or_default()), None);+ }+ let rows: Vec<Value> = payload["passwords"].as_array().cloned().unwrap_or_default();+ if payload.get("ok") != Some(&json!(true)) && rows.is_empty() { return err("no_data", format!("decrypt helper returned no data{}", payload.get("error").and_then(|e| e.as_str()).map(|e| format!(": {e}")).unwrap_or_default()), None); }+ let filters: Vec<String> = match &args["hostFilter"] { Value::String(s) => vec![s.to_lowercase()], Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(|s| s.to_lowercase())).collect(), _ => vec![] };+ let filtered: Vec<Value> = if filters.is_empty() { rows.clone() } else { rows.iter().filter(|r| { let h = r["host"].as_str().or(r["url"].as_str()).or(r["origin"].as_str()).unwrap_or("").to_lowercase(); filters.iter().any(|f| h.contains(f)) }).cloned().collect() };+ let (added, updated, skipped) = ctx.vault.merge_imported(&*ctx.host, &filtered);+ let st = &payload["stats"];+ pup_core::log(format!("[import] merge done: +{added} new, {updated} updated, {skipped} skipped{}", if filters.is_empty() { String::new() } else { format!(" (filtered to: {})", filters.join(", ")) }));+ ok(json!({ "success": true, "output": json!({ "ok": true, "added": added, "updated": updated, "skipped": skipped,+ "hostFilter": if filters.is_empty() { Value::Null } else { json!(filters) },+ "stats": { "profilesRead": st["profilesRead"], "passwordsOk": st["passwordsOk"], "passwordsFailed": st["passwordsFailed"] },+ "_hint": format!("Imported {} login(s) into the adom-you vault ({added} new, {updated} updated{}). They auto-fill on the matching sites; pup NEVER auto-submits.", added + updated, if skipped > 0 { format!(", {skipped} skipped") } else { String::new() }) }).to_string() }))+}
rust/verbs/src/input.rs+1@@ -243,6 +243,7 @@ pub fn set_viewport(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e }; let dims = |cdp: &Cdp, sid: &str| -> Value { cdp.evaluate(sid, "({ innerWidth: innerWidth, innerHeight: innerHeight, outerWidth: outerWidth, outerHeight: outerHeight, devicePixelRatio: devicePixelRatio })", T).unwrap_or(json!({})) }; let outer_before = dims(&b.cdp, &t.cdp_session)["outerWidth"].clone();+ if let Some(ow) = outer_before.as_u64() { ctx.sessions.with_mut(&s.id, |x| x.last_outer_width = Some(ow)); } if args.get("reset").and_then(|r| r.as_bool()) == Some(true) { if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Emulation.clearDeviceMetricsOverride", json!({}), T) { return err("viewport_failed", e.to_string(), None); } ctx.sessions.with_mut(&s.id, |x| x.emulated = None);
rust/verbs/src/lib.rs+53−6@@ -33,6 +33,9 @@ pub mod annotate; pub mod record; pub mod highfps; pub mod extras;+pub mod lint;+pub mod assist;+pub mod import; 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");@@ -178,7 +181,7 @@ pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewar "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_quit_idle_browsers", "pup_rescan", "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_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_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"]; pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { let t0 = Instant::now();@@ -191,7 +194,7 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "pup_my_windows" => my_windows(ctx, caller), "pup_open_window" => open_window(ctx, caller, args), "pup_navigate" => navigate(ctx, caller, args),- "pup_eval" | "pup_evaluate" => eval(ctx, caller, args),+ "pup_eval" | "pup_evaluate" => eval(ctx, caller, args), // frame: handled inside eval "pup_screenshot" => screenshot(ctx, caller, args), "pup_close_window" | "pup_close" => close_window(ctx, caller, args), "pup_window_info" => window_info(ctx, caller, args),@@ -268,7 +271,10 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "pup_install_toolbar" => extras::install_toolbar(ctx), "pup_reap_profiles" => extras::reap_profiles(ctx, args), "pup_uninstall" => extras::uninstall(ctx, args),- "pup_move_to_workspace" => extras::move_to_workspace(ctx),+ "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_import_browser_logins" => import::import_browser_logins(ctx, caller, args), v if v.starts_with("pup_") || v.starts_with("desktop_record") || v.starts_with("desktop_recorder") || v.starts_with("credential_") => err("not_in_rust_yet", format!("{v} is not served by the Rust bridge yet (Phase 1 carries {} verbs)", PHASE1_VERBS.len()), Some("This is the Rust port of pup (docs/PLAN-rust-port.md). The verb exists in the contract and lands in a later phase; the Node build still serves it on the public tier.")), v => err("unknown_verb", format!("unknown verb {v}"), Some("pup_describe lists every verb this bridge serves.")), };@@ -298,6 +304,24 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { if !g.0 || g.1 % BUG_INVITE_EVERY == 0 { g.0 = true; o.insert("_reportIssues".into(), json!(bug_report_invite(false))); } } }+ // Session-scoped notices (Node: attachSessionNotices): an emulated viewport warning on+ // measurement verbs, and a one-shot disruption notice. Resolve the session from output or the envelope.+ {+ let sid = o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string())+ .or_else(|| o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).and_then(|s| serde_json::from_str::<Value>(s).ok()).and_then(|v| v["sessionId"].as_str().map(|s| s.to_string())));+ if let Some(sid) = sid { if let Some(sess) = ctx.sessions.get(&sid) {+ const VP_SENSITIVE: [&str; 8] = ["pup_screenshot", "pup_screenshot_full_res", "pup_eval", "pup_list_windows", "pup_my_windows", "pup_status", "pup_navigate", "pup_reload"];+ if VP_SENSITIVE.contains(&verb) && !o.contains_key("_viewportOverride") {+ if let Some(ev) = &sess.emulated { let w = ev["width"].as_u64().unwrap_or(0); let h = ev["height"].as_u64().unwrap_or(0);+ if w > 0 { let outer = sess.last_outer_width;+ let shape = match outer { Some(ow) if w > ow + 4 => format!(" The emulation is WIDER than the OS window ({w} vs {ow}), so the user is seeing this page CROPPED: about {}px is off the right edge.", w - ow), Some(ow) if w + 4 < ow => format!(" The emulation is NARROWER than the OS window ({w} vs {ow}), so the user sees dead space beside the page."), _ => String::new() };+ o.insert("_viewportOverride".into(), json!(format!("This window has an EMULATED VIEWPORT of {w}x{h}{}, so it is NOT tracking the real OS window.{shape} Anything you measure in the page (innerWidth, a bounding rect) describes the emulation, not what the user sees. Clear it with pup_set_viewport {{\"reset\":true}} when done.", ev["deviceScaleFactor"].as_f64().filter(|d| *d != 1.0).map(|d| format!(" @{d}x")).unwrap_or_default())));+ }+ }+ }+ if !o.contains_key("_sessionNotice") { if let Some(n) = &sess.disruption_notice { o.insert("_sessionNotice".into(), json!(n)); ctx.sessions.with_mut(&sid, |x| x.disruption_notice = None); } }+ } }+ } // render verbs: the verdict rides the response (Node: RENDER_VERBS block in sendJSON) if RENDER_VERBS.contains(&verb) && o.get("ok") != Some(&json!(false)) && o.get("success") != Some(&json!(false)) { if let Some(rc) = o.get("renderCheck").cloned() { if rc["ok"] == json!(false) { if let Some(sig) = rc["signals"].as_array() { if !sig.is_empty() {@@ -307,6 +331,10 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { if !cur.contains("STILL BROKEN") && !cur.contains("IS BROKEN") { o.insert("_hint".into(), json!(format!("{lead}{cur}"))); } } } } } if !o.contains_key("_verifyRender") { o.insert("_verifyRender".into(), json!(VERIFY_RENDER)); }+ if !o.contains_key("_urlLint") {+ let u = o.get("url").and_then(|v| v.as_str()).or_else(|| args.get("url").and_then(|v| v.as_str())).unwrap_or("");+ if let Some(l) = lint::url_lint(u, &caller.container) { o.insert("_urlLint".into(), l["message"].clone()); o.insert("urlLintCode".into(), l["code"].clone()); }+ } } if let Some(sid) = o.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()) { if let Some(h) = ctx.sessions.with_mut(&sid, |x| x.pending_cred_hint.take()).flatten() { o.insert("_credentialCaptured".into(), json!(h)); } } // THE ab ENVELOPE (adom-bridge cli/src/relay.rs browser_command, measured at the 2.0.402 candidate@@ -490,7 +518,8 @@ fn active_rows(ctx: &Ctx) -> Vec<Value> { } fn readiness(ctx: &Ctx) -> Value {- let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ let mut rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ if let Some(o) = rd.as_object_mut() { if let Value::Object(cs) = pup_browser::chrome_stable::snapshot() { for (k, v) in cs { o.insert(k, v); } } } let ready = rd.get("ready").and_then(|b| b.as_bool()).unwrap_or(false); let installing = rd.get("installing").and_then(|b| b.as_bool()).unwrap_or(false); let hint = if ready { "A drivable browser exists: pup_open_window {url} will open in the background. chromeForTestingInstalled tells you whether the toolbar build is cached (pup fetches it in the background on every box)." }@@ -499,7 +528,7 @@ fn readiness(ctx: &Ctx) -> Value { else { "No browser yet: pup_prewarm {} starts the Chrome for Testing download; pup_open_window starts it too and answers chrome_for_testing_installing until it lands." }; let mut r = pup_core::merge(ok(rd.clone()), json!({ "success": true, "statusVerb": "pup_readiness", "active": active_rows(ctx), "_hint": hint, "_next": ["pup_open_window", "pup_prewarm"], "error": "", // the Node build's real-Chrome installer phases (pup_use {browser:"chrome", install:true}); the Rust build has no stable-Chrome installer yet, so the phase is always idle- "chromeStablePhase": "idle", "chromeStableAwaitingApproval": false, "chromeStableError": Value::Null, "chromeStableNotifyChannel": Value::Null, "chromeStableNotifyPeers": Value::Null }));+ "chromeStablePhase": rd.get("chromeStablePhase").cloned().unwrap_or(json!("idle")), "chromeStableAwaitingApproval": rd.get("chromeStableAwaitingApproval").cloned().unwrap_or(json!(false)), "chromeStableError": rd.get("chromeStableError").cloned().unwrap_or(Value::Null), "chromeStableNotifyChannel": Value::Null, "chromeStableNotifyPeers": Value::Null })); let mut inner = rd; if let Some(i) = inner.as_object_mut() { i.insert("active".into(), r["active"].clone()); i.insert("_hint".into(), json!(hint)); for k in ["chromeStablePhase", "chromeStableAwaitingApproval", "chromeStableError", "chromeStableNotifyChannel", "chromeStableNotifyPeers"] { i.insert(k.into(), r[k].clone()); } } r["output"] = json!(inner.to_string()); r@@ -615,6 +644,7 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let title = s.active().map(|t| t.title.clone()).unwrap_or_default(); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); if let Some(a) = ctx.arc() { identity::schedule(a, sid.clone(), caller.clone(), true); }+ if args.get("foreground").and_then(|b| b.as_bool()) != Some(true) { ctx.sessions.with_mut(&sid, |x| x.fg_watch_until_ms = pup_core::now_ms() + 20_000); } // Node: the no-fault fg watch — caption if the window fronts itself in the next 20s pup_core::log(format!("[window] \"{sid}\" opened for \"{}\" on {}({}) hwnd={:?} parked={parked} loaded={loaded} in {}ms", caller.thread, b.launched.candidate.kind, b.launched.candidate.source, hwnd, t0.elapsed().as_millis())); let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()); let strict = args["strictPermissions"].as_bool() == Some(true);@@ -670,7 +700,24 @@ fn eval(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e }; let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) }; let tab = match args.get("tabId").and_then(|t| t.as_str()).and_then(|id| s.tab_by_id(id)).or(s.active()) { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };- match b.cdp.evaluate(&tab.cdp_session, &expr, Duration::from_secs(20)) {+ // frame: run inside an IFRAME by name, URL substring, or numeric index (login forms usually live in one). Node: the args.frame block.+ let eval_result = if let Some(fq) = args.get("frame").and_then(|f| f.as_str()).filter(|f| !f.is_empty()) {+ let frames = b.cdp.frames(&tab.cdp_session).unwrap_or_default();+ let pick = fq.parse::<usize>().ok().and_then(|i| frames.get(i).cloned())+ .or_else(|| frames.iter().find(|f| f["name"].as_str() == Some(fq)).cloned())+ .or_else(|| frames.iter().find(|f| f["url"].as_str().map(|u| u.contains(fq)).unwrap_or(false)).cloned());+ match pick {+ None => {+ let avail: Vec<String> = frames.iter().enumerate().map(|(i, f)| format!("[{i}] name={} url={}", f["name"].as_str().filter(|n| !n.is_empty()).unwrap_or("(none)"), f["url"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect();+ return pup_core::merge(err("frame_not_found", format!("No frame matched \"{fq}\" in window \"{}\".", s.id), Some("frame matches by frame NAME, then by URL substring, then by numeric index. An iframe's id attribute is NOT its frame name.")), json!({ "frames": avail }));+ }+ Some(f) => {+ let fid = f["id"].as_str().unwrap_or("");+ match b.cdp.frame_context(&tab.cdp_session, fid) { Ok(ctxid) => b.cdp.evaluate_in_context(&tab.cdp_session, ctxid, &expr, Duration::from_secs(20)), Err(e) => return err("frame_context_failed", e.to_string(), None) }+ }+ }+ } else { b.cdp.evaluate(&tab.cdp_session, &expr, Duration::from_secs(20)) };+ match eval_result { Ok(v) if v.get("__error").is_some() => err("eval_failed", format!("pup_eval could not run your code: {}", v["__error"].as_str().unwrap_or("evaluation threw")), Some("Expression and statement forms both run (devtools-console semantics). If this names a detached frame or closed tab, re-list windows.")), Ok(v) => ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "result": v })), Err(e) => err("eval_failed", e.to_string(), None),
rust/verbs/src/lint.rsadded+63@@ -0,0 +1,63 @@+//! URL lint (Node: urlLint): the three ways an AI in a container hands pup a URL that cannot work on+//! the user's desktop: a container file path, localhost, or an owner-less wiki path. Returned as+//! `_urlLint` + `urlLintCode` on open/navigate responses; never blocks the call.++use serde_json::{json, Value};++pub fn url_lint(u: &str, caller_container: &str) -> Option<Value> {+ if u.is_empty() { return None; }+ let low = u.to_ascii_lowercase();+ if low.starts_with("file:") {+ let fpath = u.splitn(2, "file://").nth(1).map(|p| p.trim_start_matches('/').to_string()).unwrap_or_default();+ let fpath = percent_decode(&fpath);+ let containerish = ["home/", "root/", "workspace/", "tmp/", "usr/", "var/", "opt/", "mnt/"].iter().any(|p| fpath.to_ascii_lowercase().starts_with(p));+ if containerish {+ return Some(json!({ "code": "url_file_container_path", "message": format!("CONTAINER PATH HANDED TO PUP. You passed \"{u}\", but pup drives Chrome on the USER'S DESKTOP (Windows/macOS): a file:// URL resolves against THEIR filesystem, not your container's. \"/{fpath}\" is a container path, so it can never exist there and this open is guaranteed to render Chrome's ERR_FILE_NOT_FOUND page. Do ONE of these instead: (1) SERVE it and open the public proxy URL (run `python3 -m http.server <port>` in that directory and open https://<your-container>.adom.cloud/proxy/<port>/<file>); or (2) PUSH it to the user's machine first with adom-bridge push_file, then open the file:// path it actually landed at on THEIR disk.") }));+ }+ let win = fpath.replace('/', "\\");+ if !win.is_empty() && !std::path::Path::new(&win).exists() {+ return Some(json!({ "code": "url_file_not_found", "message": format!("FILE DOES NOT EXIST ON THE USER'S MACHINE. pup pre-flighted \"{win}\" on the desktop before opening it and there is no such file, so this would have rendered Chrome's ERR_FILE_NOT_FOUND page. Check the path, or push the file to the desktop first with adom-bridge push_file and open the path it reports back. (pup's Chrome runs on the USER'S computer, so file:// is THEIR filesystem, not your container's.)") }));+ }+ return None;+ }+ let rest = u.splitn(2, "://").nth(1)?;+ let hostport = rest.split(['/', '?', '#']).next().unwrap_or("");+ let (host, port) = match hostport.rsplit_once(':') { Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) => (h.to_string(), p.to_string()), _ => (hostport.to_string(), String::new()) };+ let host_l = host.to_ascii_lowercase();+ if ["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"].contains(&host_l.as_str()) {+ let path = rest.get(hostport.len()..).unwrap_or("").trim_start_matches('/');+ let slug = if caller_container.is_empty() { "<your-container-slug>".to_string() } else { caller_container.to_ascii_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect() };+ let p = if port.is_empty() { "<port>".to_string() } else { port.clone() };+ return Some(json!({ "code": "url_localhost", "message": format!("WRONG HOST FOR PUP. You passed \"{host}{}\", but pup drives a browser on the USER'S DESKTOP, not inside your container, so \"{host}\" resolves on THEIR machine and will hit whatever (if anything) is listening there, not your dev server. To show a service running INSIDE your container, use its public proxy URL: https://{slug}-<suffix>.adom.cloud/proxy/{p}/{path} (<suffix> is the 10-12 char id assigned to your container; `adom-cli container list` or your existing container URL has it). If you genuinely meant a server on the USER'S machine, say so explicitly, because that is almost never what an AI in a container intends.", if port.is_empty() { String::new() } else { format!(":{port}") }) }));+ }+ if host_l == "wiki.adom.inc" || host_l.ends_with(".wiki.adom.inc") {+ let path = rest.get(hostport.len()..).unwrap_or("");+ let path_only = path.split(['?', '#']).next().unwrap_or("");+ let segs: Vec<&str> = path_only.split('/').filter(|s| !s.is_empty()).collect();+ if segs.is_empty() { return None; }+ const ROUTES: [&str; 14] = ["apps", "skills", "components", "bootstraps", "discover", "login", "search", "api", "download", "static", "hero", "settings", "new", "readme"];+ if ROUTES.contains(&segs[0].to_ascii_lowercase().as_str()) { return None; }+ let owner_missing = segs.len() == 1 || segs[1].eq_ignore_ascii_case("blob");+ if owner_missing {+ let query = path.get(path_only.len()..).unwrap_or("");+ let fixed = format!("https://{host}/adom/{}{}{query}", segs[0], if segs.len() > 1 { format!("/{}", segs[1..].join("/")) } else { String::new() });+ return Some(json!({ "code": "url_wiki_missing_owner", "message": format!("MALFORMED WIKI URL. wiki.adom.inc pages are OWNER-QUALIFIED (https://wiki.adom.inc/<owner>/<slug>, and /<owner>/<slug>/blob/<path> for a file) and yours has no owner segment, so it will 404 no matter who is logged in. Most pages are owned by \"adom\", so you probably want: {fixed} . Confirm the owner with `adom-wiki search` if unsure.") }));+ }+ }+ None+}++fn percent_decode(s: &str) -> String {+ let b = s.as_bytes(); let mut out = Vec::with_capacity(b.len()); let mut i = 0;+ while i < b.len() { if b[i] == b'%' && i + 2 < b.len() { if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(v); i += 3; continue; } } out.push(b[i]); i += 1; }+ String::from_utf8_lossy(&out).to_string()+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test] fn container_path() { assert_eq!(url_lint("file:///home/adom/x.html", "").unwrap()["code"], "url_file_container_path"); }+ #[test] fn localhost() { let l = url_lint("http://localhost:5173/app?x=1", "my-box").unwrap(); assert_eq!(l["code"], "url_localhost"); assert!(l["message"].as_str().unwrap().contains("my-box-<suffix>.adom.cloud/proxy/5173/app?x=1")); }+ #[test] fn wiki_owner() { assert_eq!(url_lint("https://wiki.adom.inc/pup-bridge", "").unwrap()["code"], "url_wiki_missing_owner"); assert!(url_lint("https://wiki.adom.inc/adom/pup-bridge", "").is_none()); assert!(url_lint("https://wiki.adom.inc/apps/x", "").is_none()); }+ #[test] fn fine() { assert!(url_lint("https://example.com/", "").is_none()); }+}
rust/verbs/src/record.rs+2−1@@ -139,7 +139,8 @@ pub fn status(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let s = match crate::resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e }; let m = ctx.tab_recs.lock().unwrap(); let cur = m.get(&s.id).map(|r| json!({ "recordingId": r.id, "tabId": r.tab_id, "fps": r.fps, "frames": r.frames, "durationMs": r.started.elapsed().as_millis() as u64, "filePath": r.file.to_string_lossy().replace('\\', "/") }));- ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "recording": cur.is_some(), "current": cur }).to_string(), "sessionId": s.id, "window": s.id, "recording": cur.is_some() }))+ let active = cur.clone().map(|c| vec![c]).unwrap_or_default();+ ok(json!({ "success": true, "active": active, "output": json!({ "ok": true, "active": active, "sessionId": s.id, "recording": cur.is_some(), "current": cur }).to_string(), "sessionId": s.id, "window": s.id, "recording": cur.is_some() })) } pub fn list(ctx: &Ctx) -> Value {
rust/verbs/src/recovery.rs+12@@ -49,6 +49,7 @@ fn relaunch_from_file(ctx: &Ctx, f: &Value) { let caller = pup_core::Caller { thread: owner.clone(), reason: "auto-recovery: the window's Chrome was gone".into(), ..Default::default() }; let r = crate::open_window(ctx, &caller, &json!({ "window": id, "url": url, "profile": profile, "takeover": true, "owner": if owner.is_empty() { Value::Null } else { json!(owner) } })); if r["ok"] == json!(true) { crate::push_cleanup_report(format!("pup auto-recovered your \"{id}\" window after its Chrome was gone (a bridge update, a Chrome crash, or a sleep): it is back at {url}, in the background, on the same profile. Verify with pup_screenshot.")); }+ if r["ok"] == json!(true) { ctx.sessions.with_mut(&id, |x| x.disruption_notice = Some(format!("This window was auto-recovered after its Chrome was gone; it is back at {url} in the background. If something you set on the old page (an emulated viewport, an in-page draft) is missing, that is why."))); } else { pup_core::log(format!("[recovery] \"{id}\" relaunch failed: {}", r["error"].as_str().unwrap_or("?"))); } } @@ -148,6 +149,17 @@ pub fn health_tick(ctx: &Ctx) { for s in ctx.sessions.all() { if let Some(h) = s.hwnd { if !ctx.host.is_alive(h) { ctx.sessions.with_mut(&s.id, |x| x.hwnd = None); } } }+ // No-fault fg watch (Node: the fgWatch interval): if a BACKGROUND window fronts itself within 20s of+ // opening and notMineCaption is on, caption that pup did NOT raise it — then let it stay up.+ if ctx.settings.get("notMineCaption", "off") == "on" {+ let now = pup_core::now_ms(); let fg = ctx.host.foreground();+ for s in ctx.sessions.all() {+ if s.fg_watch_until_ms > now && !s.foreground && s.hwnd == Some(fg) && fg != 0 {+ ctx.sessions.with_mut(&s.id, |x| { x.fg_watch_until_ms = 0; x.foreground = true; });+ let _ = ctx.ab.call("desktop_caption", json!({ "id": "pup-notmine", "text": format!("Adom did NOT raise this \"{}\" window — the page or Chrome brought it forward.", s.id), "duration": 3000, "expiresInMs": 3000, "position": "bottom", "size": "small" }), &crate::identity::internal_caller(), Duration::from_secs(5));+ }+ }+ } for f in ctx.sessions.read_files() { let id = f["sessionId"].as_str().unwrap_or("").to_string(); let pid = f["pid"].as_u64().unwrap_or(0) as u32; if id.is_empty() || ctx.sessions.exists(&id) { continue; }
rust/verbs/src/settings.rs+5@@ -59,6 +59,11 @@ pub fn use_browser(ctx: &Ctx, args: &Value) -> Value { "cft" | "chrome-for-testing" => { match pup_browser::detect_cft(&*ctx.host) { Some(c) => c, None => { ctx.installer.ensure_background(ctx.host.clone()); return pup_core::merge(err("chrome_for_testing_installing", "Chrome for Testing is not cached yet; fetching it now", Some("Poll pup_readiness until chromeForTestingInstalled:true, then pup_use {browser:\"cft\"} again (or just open a window: it converges on its own).")), json!({ "installing": true, "stillRunning": true, "statusVerb": "pup_readiness" })); } } }+ "chrome" if args.get("install").and_then(|b| b.as_bool()) == Some(true) && cands.iter().all(|c| c.kind != "chrome") => {+ let elevate = match args.get("elevate") { Some(Value::Bool(b)) => Some(*b), Some(Value::String(s)) if s == "false" => Some(false), Some(Value::String(s)) if s == "true" => Some(true), _ => None };+ if let Some(a) = ctx.arc() { pup_browser::chrome_stable::install_background(a.host.clone(), elevate); }+ return pup_core::merge(err("chrome_stable_installing", "Installing real Google Chrome in the background (~90 MB). On a locked-down box it needs a Windows approval (UAC).", Some("Poll pup_readiness: chromeStablePhase goes installing -> awaiting_uac -> ready. pup drives the existing browser until then.")), json!({ "installingChrome": true, "readiness": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()) }));+ } "chrome" | "edge" => match cands.iter().find(|c| c.kind == want) { Some(c) => c.clone(), None => return err("browser_not_found", format!("no installed {want} found on this box"), Some("pup_readiness lists the candidates pup can see.")) }, other => { let p = std::path::PathBuf::from(other); if p.is_file() { pup_browser::Candidate { kind: "custom".into(), source: "explicit".into(), executable_path: p, build_id: None } } else { return err("bad_browser", "browser must be chrome | edge | cft | auto, or an executable path", None) } } };
rust/verbs/src/vault.rs+2−2@@ -48,7 +48,7 @@ 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);- ok(json!({ "success": true, "host": h, "removed": removed, "output": json!({ "ok": true, "removed": removed }).to_string() }))+ 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() })) } /// pup_login: fill (and optionally submit, reason-gated) the login form in the window's active tab.@@ -133,7 +133,7 @@ pub fn reveal(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pub fn card_set(ctx: &Ctx, args: &Value) -> Value { match ctx.cards.set(&*ctx.host, args["nickname"].as_str().unwrap_or(""), args["number"].as_str().unwrap_or(""), args["expiry"].as_str().unwrap_or(""), args["cvv"].as_str().unwrap_or(""), args["name"].as_str()) {- Ok(meta) => ok(json!({ "success": true, "output": json!({ "ok": true, "card": meta }).to_string(), "card": meta, "_hint": "Stored: PAN + CVV in the OS keychain, only brand/last4/expiry in the index. pup_fill_card types it into a checkout after the user approves each fill." })),+ Ok(meta) => ok(json!({ "success": true, "output": json!({ "ok": true, "nickname": meta["nickname"], "brand": meta["brand"], "last4": meta["last4"], "expiry": meta["expiry"], "card": meta }).to_string(), "card": meta, "_hint": "Stored: PAN + CVV in the OS keychain, only brand/last4/expiry in the index. pup_fill_card types it into a checkout after the user approves each fill." })), Err(e) => err("card_set_failed", e, Some("pup_card_set {nickname, number, expiry:\"MM/YY\", cvv, name?}")), } }
rust/verbs/src/window.rs+2−1@@ -9,6 +9,7 @@ use std::time::Duration; const AB_T: Duration = Duration::from_secs(8); +pub fn hwnd_of_pub(ctx: &Ctx, s: &pup_session::Session) -> Option<i64> { hwnd_of(ctx, s) } fn hwnd_of(ctx: &Ctx, s: &pup_session::Session) -> Option<i64> { s.hwnd.filter(|&h| ctx.host.is_alive(h)).or_else(|| { let tag = s.tag_string();@@ -75,5 +76,5 @@ pub fn progress(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { if let Some(note) = args.get("note").and_then(|n| n.as_str()) { p["overlay"] = json!({ "tooltip": format!("Adom Pup — {note}") }); } let applied = ctx.ab.call("desktop_taskbar", p, caller, AB_T).is_ok(); touch(ctx, &s.id, args.get("note").and_then(|n| n.as_str()));- ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "mode": mode, "applied": applied, "_hint": "Taskbar progress painted on this window's button. Clear it with mode:\"off\" when the long task ends." }))+ ok(json!({ "success": true, "output": json!({ "ok": true, "sessionId": s.id, "mode": mode, "painted": applied, "note": args.get("note").and_then(|n| n.as_str()) }).to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "mode": mode, "applied": applied, "_hint": "Taskbar progress painted on this window's button. Clear it with mode:\"off\" when the long task ends." })) }
tools/capture-goldens.pyadded+69@@ -0,0 +1,69 @@+#!/usr/bin/env python3+"""Record golden fixtures for the verbs the first 37 did not cover, from a NODE pup build on a desktop,+into docs/contract/. Run once against a box on the Node build (shell=node); the Rust parity gate then+diffs against these too. Safe/deterministic flows only: the vault and cards are seeded then deleted,+recording drives an animated page, login/reveal capture their GATED shapes. Windows are closed after.++ tools/capture-goldens.py --target ConfRoomROG [--profile golden-fx]+"""+import argparse, json, os, subprocess, sys, time+ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))); GOLD=os.path.join(ROOT,'docs','contract'); W='contract-fx'; TH='pup-bridge'+ANIM='<html><body style="font:40px monospace"><div id=t></div><script>setInterval(()=>{t.textContent=Date.now();document.body.style.background="hsl("+(Date.now()/20%360)+",60%,30%)"},50)</script></body></html>'++def ab(target, verb, args):+ p=subprocess.run(['adom-bridge','--target',target,'--ai-thread',TH,verb,json.dumps(args)],capture_output=True,text=True,timeout=200)+ try: return json.loads(p.stdout)+ except Exception: return {'_raw':(p.stdout+p.stderr)[-300:]}++def main():+ ap=argparse.ArgumentParser(); ap.add_argument('--target',required=True); ap.add_argument('--profile',default='golden-fx'); a=ap.parse_args()+ def V(verb,args): r=ab(a.target,verb,args); return r+ def save(name,r): json.dump(r,open(os.path.join(GOLD,name+'.json'),'w'),indent=1); print(f' {name:28} keys={len(r) if isinstance(r,dict) else 0}')+ # confirm it's the Node build+ st=V('pup_status',{}); o=st.get('output'); si=json.loads(o) if isinstance(o,str) and o.startswith('{') else {}+ shell=(si or st).get('shell') or 'node'+ print(f"target {a.target}: shell={shell}, version={(si or st).get('version')}")+ if shell!='node': print("REFUSING: goldens must come from the NODE build (shell=node)."); sys.exit(2)++ print("vault + cards (seeded then deleted):")+ save('credential_set', V('credential_set',{'host':'the-internet.herokuapp.com','username':'tomsmith','password':'SuperSecretPassword!'}))+ save('pup_credentials', V('pup_credentials',{}))+ save('pup_credential_reveal', V('pup_credential_reveal',{'host':'the-internet.herokuapp.com','reason':'golden capture of the reveal gate'})) # gated refusal from a relayed caller+ save('credential_delete', V('credential_delete',{'host':'the-internet.herokuapp.com'}))+ save('pup_card_set', V('pup_card_set',{'nickname':'golden-visa','number':'4111 1111 1111 1111','expiry':'12/30','name':'Golden Tester','cvv':'123'}))+ save('pup_cards', V('pup_cards',{}))+ save('pup_card_delete', V('pup_card_delete',{'nickname':'golden-visa'}))++ print("window + wiki + annotate + screenshots + recording:")+ op=V('pup_open_window',{'window':W,'url':'https://example.com/','profile':a.profile}); save('pup_open_window_golden', op); time.sleep(3)+ save('pup_prewarm', V('pup_prewarm',{}))+ save('pup_progress', V('pup_progress',{'window':W,'mode':'indeterminate','note':'golden capture'}))+ V('pup_progress',{'window':W,'mode':'off'})+ save('pup_screenshot_element', V('pup_screenshot_element',{'window':W,'selector':'h1'}))+ save('pup_screenshot_full_res', V('pup_screenshot_full_res',{'window':W}))+ save('pup_install_toolbar', V('pup_install_toolbar',{}))+ save('pup_deps', V('pup_deps',{}))+ save('pup_highfps_extend', V('pup_highfps_extend',{'window':W,'minutes':20}))+ save('pup_annotate', V('pup_annotate',{'window':W,'tool':'pen'}))+ save('pup_forward', V('pup_forward',{'window':W})) # no forward history: the "no move" shape+ V('pup_navigate',{'window':W,'url':'https://wiki.adom.inc/adom/pup-bridge'}); time.sleep(2)+ save('pup_wiki_set_view', V('pup_wiki_set_view',{'window':W,'view':'public'})); time.sleep(2)+ V('pup_navigate',{'window':W,'url':'about:blank'}); V('pup_render_html',{'window':W,'html':ANIM})+ rs=V('pup_record_start',{'window':W,'fps':10}); save('pup_record_start', rs); time.sleep(5)+ save('pup_record_status', V('pup_record_status',{'window':W}))+ save('pup_record_stop', V('pup_record_stop',{'window':W}))+ save('pup_record_list', V('pup_record_list',{'window':W}))+ save('pup_screen_record_status', V('pup_screen_record_status',{}))+ save('pup_screen_record_list', V('pup_screen_record_list',{}))+ ro=V('pup_recorder_open',{'reason':'golden capture of the recorder HUD'}); save('pup_recorder_open', ro); time.sleep(2)+ save('pup_recorder_close', V('pup_recorder_close',{}))+ V('pup_navigate',{'window':W,'url':'https://the-internet.herokuapp.com/download'}); time.sleep(2)+ save('pup_download', V('pup_download',{'window':W,'url':'https://the-internet.herokuapp.com/download/some-file.txt','waitMs':30000}))+ save('pup_login', V('pup_login',{'window':W,'submit':True})) # submit_reason_required gate (deterministic)+ d=V('pup_dashboard',{}); save('pup_dashboard', d)+ V('pup_close_window',{'window':d.get('sessionId','pup-dashboard'),'takeover':True})+ save('pup_reap_profiles', V('pup_reap_profiles',{'dryRun':True}))+ save('pup_close_golden', V('pup_close',{'all':True}))+ print("done")++if __name__=='__main__': main()
tools/gen-assets.py+10@@ -40,5 +40,15 @@ print(f"ext_assets.rs: {len(dirs)} folders; assets_gen.rs: {len(items)} files") import json _g = json.load(open(os.path.join(root, 'docs', 'contract', 'pup_describe.json'))); _o = json.loads(_g['output']) _verbs = [v for v in _o['verbs'] if not v['name'].startswith('desktop_')]+# The Rust build adds verbs the Node describe golden lacks (2 real, 2 doc-only). Keep them across regen.+_have = {v['name'] for v in _verbs}+CATALOG_EXTRA = ['pup_assisted_click', 'pup_import_scan', 'pup_import_browser_logins', 'pup_NATIVE_INPUT_WARNING', 'pup_eval_frame_note']+_existing = {}+try:+ _cur = json.load(open(os.path.join(root, 'rust', 'verbs', 'assets', 'verb-catalog.json')))+ _existing = {v['name']: v for v in _cur.get('verbs', [])}+except Exception: pass+for _n in CATALOG_EXTRA:+ if _n not in _have and _n in _existing: _verbs.append(_existing[_n]) json.dump({'progressContract': _o['progressContract'], 'verbs': _verbs}, open(os.path.join(root, 'rust', 'verbs', 'assets', 'verb-catalog.json'), 'w'), indent=1) print(f"verb-catalog.json: {len(_verbs)} verbs")
tools/parity.py+24−2@@ -55,6 +55,26 @@ SEQUENCE = [ ('pup_rescan', 'pup_rescan', {}), ('pup_use', 'pup_use', {}), ('pup_switch_window', 'pup_switch_window', {'window': W}),+ # ── extended coverage (goldens captured from Node 2.0.396 by tools/capture-goldens.py) ──+ ('credential_set', 'credential_set', {'host': 'the-internet.herokuapp.com', 'username': 'tomsmith', 'password': 'SuperSecretPassword!'}),+ ('pup_credentials', 'pup_credentials', {}),+ ('pup_credential_reveal', 'pup_credential_reveal', {'host': 'the-internet.herokuapp.com', 'reason': 'parity check of the reveal gate'}),+ ('credential_delete', 'credential_delete', {'host': 'the-internet.herokuapp.com'}),+ ('pup_card_set', 'pup_card_set', {'nickname': 'golden-visa', 'number': '4111 1111 1111 1111', 'expiry': '12/30', 'name': 'Golden Tester', 'cvv': '123'}),+ ('pup_cards', 'pup_cards', {}),+ ('pup_card_delete', 'pup_card_delete', {'nickname': 'golden-visa'}),+ ('pup_prewarm', 'pup_prewarm', {}),+ ('pup_progress', 'pup_progress', {'window': W, 'mode': 'indeterminate', 'note': 'parity'}),+ ('pup_screenshot_element', 'pup_screenshot_element', {'window': W, 'selector': 'h1'}),+ ('pup_screenshot_full_res', 'pup_screenshot_full_res', {'window': W}),+ ('pup_install_toolbar', 'pup_install_toolbar', {}),+ ('pup_deps', 'pup_deps', {}),+ ('pup_highfps_extend', 'pup_highfps_extend', {'window': W, 'minutes': 20}),+ ('pup_annotate', 'pup_annotate', {'window': W, 'tool': 'pen'}),+ ('pup_forward', 'pup_forward', {'window': W}),+ ('pup_record_status', 'pup_record_status', {'window': W}),+ ('pup_record_list', 'pup_record_list', {'window': W}),+ ('pup_login', 'pup_login', {'window': W, 'submit': True}), ('pup_close_window', 'pup_close_window', {'window': W}), ] IGNORE_TEXT = {'_hint', '_next', 'pitfalls', 'related', '_timeoutHint', 'durationMs'}@@ -77,7 +97,8 @@ $out = [ordered]@{{}} foreach ($s in $steps) {{ $n = $s[0]; $v = $s[1]; $a = @{{}}; if ($s[2]) {{ $s[2].PSObject.Properties | ForEach-Object {{ $a[$_.Name] = $_.Value }} }} $raw = Post $v $a; try {{ $out[$n] = ($raw | ConvertFrom-Json) }} catch {{ $out[$n] = @{{ _harness_error = 'non-json'; raw = $raw }} }} Write-Output ("{{0,-30}} {{1}}" -f $n, $raw.Substring(0, [Math]::Min(90, $raw.Length)))- if ($n -eq 'pup_open_window' -or $n -eq 'pup_open_tab' -or $n -eq 'pup_navigate' -or $n -eq 'pup_click') {{ Start-Sleep -Seconds 2 }} }}+ if ($n -eq 'pup_open_window' -or $n -eq 'pup_open_tab' -or $n -eq 'pup_navigate' -or $n -eq 'pup_click' -or $n -eq 'pup_wiki_set_view' -or $n -eq 'pup_render_html_anim') {{ Start-Sleep -Seconds 2 }}+ if ($n -eq 'pup_record_start') {{ Start-Sleep -Seconds 5 }} }} [IO.File]::WriteAllText('{out_path}', ($out | ConvertTo-Json -Depth 12 -Compress)) Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue Write-Output "exe stopped"@@ -151,7 +172,8 @@ def main(): try: responses[n] = json.loads(p.stdout) except Exception: responses[n] = {'_harness_error': 'non-json', 'raw': (p.stdout + p.stderr)[-300:]} print(f'{n:30} {p.stdout[:90].replace(chr(10), " ")}')- if v in ('pup_open_window', 'pup_open_tab', 'pup_navigate', 'pup_click'): import time; time.sleep(2)+ if v in ('pup_open_window', 'pup_open_tab', 'pup_navigate', 'pup_click', 'pup_wiki_set_view', 'pup_render_html'): import time; time.sleep(2)+ if v == 'pup_record_start': import time; time.sleep(5) out = os.path.join(tempfile.gettempdir(), 'parity-via-ab.json'); json.dump(responses, open(out, 'w')) print('responses:', out); sys.exit(compare(responses)) exe_dir = a.exe_dir.replace('/', '\\'); out = a.exe_dir.rstrip('/') + '/parity-responses.json'
tools/rust-build.sh+5@@ -38,7 +38,12 @@ d['spawn'] = { 'kind': 'exe', 'entrypoint': 'adom-pup-bridge.exe', 'port': d['sp '_portNote': d['spawn'].get('_portNote', '') } d.pop('runtime', None) d['dependencies'] = { 'browser': d.get('dependencies', {}).get('browser', 'Chromium-family: installed Chrome or Edge, else Chrome for Testing is fetched as a last resort.') }+# The exe is Windows-only today (host-mac is stubs). Say so in the package so a Mac on the insiders+# tier is not handed a Windows binary; the Node build still serves macOS on the public tier.+d.setdefault('platforms', {})+d['platforms'] = {'windows': {'supported': True}, 'macos': {'supported': False, 'reason': 'The Rust exe is Windows-only for now; host-mac is stubbed for the Mac port. macOS runs the Node build (public tier).'}, 'linux': {'supported': False, 'reason': 'not yet'}} d['shell'] = 'rust'+d['builtFor'] = 'x86_64-pc-windows-gnu' with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z: z.write(exe, 'adom-pup-bridge.exe') z.writestr('bridge.json', json.dumps(d, indent=2) + '\n')