← Commit history
CHANGELOG.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": []   }  ] }\ No newline at end of file
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')