app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
2.0.407: the Rust exe ships to insiders; rust-build/ship candidate flow, parity + ab-smoke gates, plan and docs updated
21 files changed
+396−50
CHANGELOG.md+21CLAUDE.md+12PUBLISHING.md+10docs/PLAN-rust-port.md+25−6rust/Cargo.lock+12−12rust/Cargo.toml+1−1rust/bridge/src/main.rs+19−1rust/browser/src/lib.rs+1−1rust/host-mac/src/lib.rs+1rust/host-win/src/lib.rs+9rust/host/src/lib.rs+4rust/identity/src/lib.rs+7−4rust/verbs/src/identity.rs+8−6rust/verbs/src/input.rs+1−1rust/verbs/src/lib.rs+26−2rust/verbs/src/record.rs+3−1rust/verbs/src/recovery.rs+49−10tools/ab-smoke.sh+86tools/parity.py+13−1tools/rust-build.sh+48tools/ship.sh+40−4CHANGELOG.md+21@@ -1,5 +1,26 @@ # Changelog — Puppeteer Bridge +## 2.0.407 — 2026-09-05 — pup as ONE Rust exe (spawn.kind exe): the Node runtime leaves the package++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+run on a fresh box downloads one small zip and (only when the box has no Chrome or Edge) Chrome for+Testing. Same verb contract as the Node build: every verb in bridge.json is native and the+golden-fixture parity gate (tools/parity.py, 37 fixtures recorded from 2.0.399) is green. The+architecture is 12 crates with every Win32 call in host-win behind a host trait (macOS stubs in+host-mac). Plan, measurements and what is still Node-only: docs/PLAN-rust-port.md. The Node build+stays the public tier's shell until a Rust build is promoted on the human's words; ship.sh builds+the Rust package by default (`--shell node` keeps the old path) and gained a `--candidate` mode so+one desktop can install and prove a build before insiders sees it.++## 2.0.400 — 2026-09-05 — desktop_record_* leaves pup (ab-core serves it natively)++pup-bridge#93 / adom-bridge#143: ab 2.1.36 records the desktop natively (WGC + WASAPI loopback to+H.264 MP4, its own consent caption), and ab-core now owns the `desktop_record_` / `desktop_recorder_`+prefixes (a bridge's claim is refused at rescan). pup drops both prefixes and the six alias verbs;+the Chrome screen-share recorder lives only under `pup_screen_record_start/stop/status/list` and+`pup_recorder_open/close`, and its hint points at ab's native recorder for the no-browser MP4 case.+ ## 2.0.399 — 2026-09-05 — Canonical pup_screen_record_* names (pup-bridge#94); ship.sh publishes the pkg before the release - pup-bridge#94 (John): the Chrome screen-share recorder's canonical verbs are now
CLAUDE.md+12@@ -23,6 +23,18 @@ via the `updateManifestUrl` in `bridge.json`. (Extracted from `adom-bridge/plugi capability is missing (a new lifecycle verb, calling `pup_prewarm` on embedded first-run), file that against AD — don't re-absorb the bridge. +## The Rust exe (insiders since 2.0.407, 2026-09-05)++The package on the insiders tier is ONE Rust exe: `rust/` (12 crates; every Win32 call in+`host-win` behind the traits in `host`; `host-mac` carries the stubs for the Mac port). Build and+package with `tools/rust-build.sh <ver>` (ship.sh runs it by default); `tools/check-borders.sh` is+the border gate; `tools/gen-assets.py` embeds the extensions, dashboard assets and the verb catalog;+`tools/parity.py --target <box>` (loopback) / `--via-ab` (through ab) replays the 37 golden+fixtures in `docs/contract/`; `tools/ab-smoke.sh <box>` drives the installed bridge through ab.+The plan, measurements and what is still Node-only: `docs/PLAN-rust-port.md`. `src/` (the Node+build) is the PUBLIC tier's shell until a Rust build is promoted; keep it buildable+(`tools/ship.sh <ver> "<log>" --shell node`).+ ## Map of what you manage (`src/` = the runnable bridge, zipped to the cache) - `src/server.js` — the bridge HTTP server / verb router. Cold-start gate in `pup_open_window`;
PUBLISHING.md+10@@ -19,6 +19,16 @@ printf '1.1.1' > src/BRIDGE_VERSION sed -i 's/"version": "1.1.0"/"version": "1.1.1"/' src/bridge.json ``` +## 1b — the Rust exe package (the default since 2.0.407)++`tools/ship.sh <ver> "<log>"` builds the Rust exe zip (`tools/rust-build.sh`: exe + spawn.kind exe+bridge.json + BRIDGE_VERSION). Prove a build on ONE desktop first: `tools/ship.sh <ver> "<log>"+--candidate` publishes the release and a candidate manifest+(`adom-bridge-puppeteer-manifest-candidate.json` on the product page); install it with+`bridge_install {manifestUrl:<that file>, force:true}`, run `tools/parity.py --via-ab --target <box>`+and `tools/ab-smoke.sh <box>`, then `tools/ship.sh <ver> "<log>" --insiders-from-candidate`.+`--shell node` builds the Node zip below.+ ## 2 — build the zip (the `src/` tree at the zip ROOT, no top-level dir) `zip` may be absent — use Python. node_modules is NOT included (AD `npm install`s it on spawn; the
docs/PLAN-rust-port.md+25−6@@ -1,7 +1,8 @@ # pup in Rust: the port plan -**Status (2026-09-05, night): Phases 0 to 3 BUILT and measured on winvm; Phase 4 (parity gate, exe-packaging, ship) is next.** Written 2026-09-05 against pup 2.0.396; John approved the plan the same day+**Status (2026-09-05, late night): SHIPPED TO INSIDERS as 2.0.407, the first Rust-exe build.** Phases 0+to 4 are done; the public tier stays on the Node build (2.0.396) until a Rust build is promoted on+John's words. Written 2026-09-05 against pup 2.0.396; John approved the plan the same day ("lets get back to the plan"). - Phase 0: fixtures recorded from the Node build into `docs/contract/*.json`; the five ab asks filed@@ -62,10 +63,28 @@ packaging, ship) is next.** Written 2026-09-05 against pup 2.0.396; John approve the hint with the verdict; the session row carries lastAgentUpdate {tabId, verb, note, ts}, ownerSource, overlay, flash/progress counters; pup_status.loops reports real loop cadences; and the 32 permissive default permissions are granted at launch (they were missing from the Rust build- until the gate caught the count). Still to do in Phase 4: `bridge.json` with- `spawn.kind: exe` + sha-verified zip and the ship.sh build step; the wiped-box first-run gate- (section 6b) proven on a fresh VM; the runtime-crate split above; then an insiders ship. Nothing- on the public tier changes until John's words.+ until the gate caught the count). Then the exe packaging and the ship (2026-09-05, late night): `tools/rust-build.sh` builds the+ exe and the zip (exe + a spawn.kind exe bridge.json + BRIDGE_VERSION, 2.5 MB); `tools/ship.sh`+ builds the Rust package by default (`--shell node` keeps the Node path) and gained `--candidate`+ (everything except the insiders push; the manifest lands on the product page as+ adom-bridge-puppeteer-manifest-candidate.json for ONE desktop to install) and+ `--insiders-from-candidate`. Seven candidates (2.0.401 to 2.0.407) went through ab's real+ `bridge_install` path on winvm before insiders saw one; each found something: ab's relay computes+ success from `success` and the CLI returns the parsed `output` alone (so every success now carries+ both); the Node-era Chrome had no DevToolsActivePort file (recovery now reads the session file's+ cdpPort); a window whose Chrome is gone must come back (relaunch from the file at boot and from+ the health loop, twice at most, with `_cleanupReport`); a killed exe could lose the port for a+ second (bind retry); panics now land in ~/.adom/bridge-logs/pup-rust-panic.log; the wiki's CSP+ blocks inline scripts in a pup_render_html document (hint added). Measured on 2.0.407 through ab:+ `tools/parity.py --via-ab` 37 of 37, `tools/ab-smoke.sh` all pass (vault, cards, wiki auto-sign+ and view flip, annotate, recording, recorder HUD, download, dashboard, taskbar identity),+ exe kill then respawn with the window reattached, Chrome kill then every window relaunched, and+ the first-run provisioning path (Chrome for Testing cache hidden: readiness fell back to Edge,+ pup_use cft fetched the build, the next window opened on it). Open on ab's side:+ adom-bridge#152, ab runs the node dependency install against its bundled seed on+ recover_not_listening for a kind:exe bridge (10 to 15 s of bridge_installing_deps after an+ unexpected exe death). Next: the runtime-crate split (wiki/annotate/record out of verbs), macOS+ in host-mac (Kyle), and promotion to public on John's words. Commissioned by John: "make a version of pup next that is a rust binary like we've recently done for fusion and for altium ... make sure that you did not reimplement anything in ab already does for you
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.399"+version = "2.0.407" dependencies = [ "pup-browser", "pup-cdp",@@ -450,7 +450,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.399"+version = "2.0.407" dependencies = [ "pup-core", "pup-host",@@ -462,7 +462,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.399"+version = "2.0.407" dependencies = [ "serde_json", "thiserror",@@ -472,7 +472,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.399"+version = "2.0.407" dependencies = [ "serde", "serde_json",@@ -482,7 +482,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.399"+version = "2.0.407" dependencies = [ "pup-cdp", "pup-core",@@ -495,7 +495,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.399"+version = "2.0.407" dependencies = [ "serde", "serde_json",@@ -504,14 +504,14 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.399"+version = "2.0.407" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.399"+version = "2.0.407" dependencies = [ "pup-host", "windows-sys 0.59.0",@@ -519,14 +519,14 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.399"+version = "2.0.407" dependencies = [ "serde_json", ] [[package]] name = "pup-session"-version = "2.0.399"+version = "2.0.407" dependencies = [ "pup-core", "serde",@@ -536,7 +536,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.399"+version = "2.0.407" dependencies = [ "pup-core", "pup-host",@@ -547,7 +547,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.399"+version = "2.0.407" 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.399"+version = "2.0.407" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+19−1@@ -47,7 +47,25 @@ fn main() { pup_verbs::install_event_hook(ctx.clone()); *ctx.self_ref.lock().unwrap() = Arc::downgrade(&ctx); - let server = match Server::http(format!("{bind}:{port}")) { Ok(s) => s, Err(e) => { eprintln!("[pup] cannot bind {bind}:{port}: {e}"); std::process::exit(2); } };+ // Evidence first: a panic anywhere (a verb thread, the boot thread, the event pump) lands in a+ // file ab never truncates, because ab's per-spawn stdout log is wiped on the next respawn and+ // "the exe was gone" is all that survives otherwise (measured 2026-09-05 on winvm).+ let panic_log = state_dir.join("bridge-logs").join("pup-rust-panic.log");+ std::panic::set_hook(Box::new(move |info| {+ let msg = format!("[{}] PANIC in thread {:?}: {info}\n", pup_core::iso_now(), std::thread::current().name().unwrap_or("?"));+ eprint!("{msg}"); pup_core::log(msg.trim_end());+ if let Some(d) = panic_log.parent() { let _ = std::fs::create_dir_all(d); }+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&panic_log) { use std::io::Write; let _ = f.write_all(msg.as_bytes()); }+ }));+ // The port can still be held for a few seconds by the instance ab just reaped (measured: an+ // instant exit 513 ms after spawn, "cannot bind"), so retry the bind before giving up.+ let server = {+ let mut last = String::new(); let mut got = None;+ for attempt in 0..20 {+ match Server::http(format!("{bind}:{port}")) { Ok(s) => { got = Some(s); break; } Err(e) => { last = e.to_string(); if attempt == 0 { pup_core::log(format!("[pup] port {port} busy ({last}) — retrying for up to 10 s")); } std::thread::sleep(std::time::Duration::from_millis(500)); } }+ }+ match got { Some(s) => s, None => { eprintln!("[pup] cannot bind {bind}:{port} after 10 s: {last}"); pup_core::log(format!("[pup] cannot bind {bind}:{port} after 10 s: {last} — exiting 2")); std::process::exit(2); } }+ }; pup_core::log(format!("Puppeteer Bridge v{} (rust) running on http://{bind}:{port} (bind={bind}; no firewall prompt)", pup_core::version())); pup_core::log("Endpoints: /health, /status, /command");
rust/browser/src/lib.rs+1−1@@ -467,7 +467,7 @@ mod tests { use super::*; use pup_host::HostPaths; struct H(PathBuf);- impl pup_host::HostWindows for H { fn windows_for_pids(&self, _: &[u32], _: Option<&str>) -> Vec<pup_host::HostWindow> { vec![] } fn is_alive(&self, _: i64) -> bool { false } fn tag(&self, _: i64, _: &str) -> bool { false } fn has_tag(&self, _: i64, _: &str) -> bool { false } fn send_to_bottom_no_activate(&self, _: i64) -> bool { false } fn set_bounds_no_activate(&self, _: i64, _: i32, _: i32, _: i32, _: i32) -> bool { false } fn foreground(&self) -> i64 { 0 } fn work_area(&self) -> Option<(i32, i32, i32, i32)> { None } }+ impl pup_host::HostWindows for H { fn script_launch(&self, _: &std::path::Path, _: &str, _: bool) -> (String, String) { (String::new(), String::new()) } fn windows_for_pids(&self, _: &[u32], _: Option<&str>) -> Vec<pup_host::HostWindow> { vec![] } fn is_alive(&self, _: i64) -> bool { false } fn tag(&self, _: i64, _: &str) -> bool { false } fn has_tag(&self, _: i64, _: &str) -> bool { false } fn send_to_bottom_no_activate(&self, _: i64) -> bool { false } fn set_bounds_no_activate(&self, _: i64, _: i32, _: i32, _: i32, _: i32) -> bool { false } fn foreground(&self) -> i64 { 0 } fn work_area(&self) -> Option<(i32, i32, i32, i32)> { None } } impl pup_host::HostProcess for H { fn alive(&self, _: u32) -> bool { false } fn spawn_detached(&self, _: &Path, _: &[String], _: Option<&Path>) -> std::io::Result<u32> { Ok(1) } fn kill(&self, _: u32) -> bool { false } } impl pup_host::HostSecrets for H { fn secret_get(&self, _: &str, _: &str) -> Option<String> { None } fn secret_set(&self, _: &str, _: &str, _: &str) -> Result<(), String> { Err("n/a".into()) } fn secret_delete(&self, _: &str, _: &str) -> bool { false } } impl pup_host::HostPaths for H { fn home(&self) -> PathBuf { self.0.clone() } fn browser_roots(&self) -> Vec<PathBuf> { vec![] } fn cft_platform(&self) -> &'static str { "linux64" } fn cft_exe_relative(&self) -> PathBuf { PathBuf::from("chrome-linux64").join("chrome") } }
rust/host-mac/src/lib.rs+1@@ -18,6 +18,7 @@ impl HostWindows for MacHost { } fn is_alive(&self, _hwnd: i64) -> bool { todo!("window id still present in CGWindowListCopyWindowInfo (Windows: IsWindow)") } fn tag(&self, _hwnd: i64, _tag: &str) -> bool { todo!("no per-window property store on macOS: keep a pid->session map in the state dir instead (Windows: SetProp \"AdomPupSid=<sid>\")") }+ fn script_launch(&self, _script: &std::path::Path, _tail: &str, _quote: bool) -> (String, String) { todo!("launch a helper script hidden (Windows: powershell.exe -WindowStyle Hidden -File <script> <tail>); macOS: /bin/sh or osascript") } fn has_tag(&self, _hwnd: i64, _tag: &str) -> bool { todo!("see tag() (Windows: GetProp)") } fn send_to_bottom_no_activate(&self, _hwnd: i64) -> bool { todo!("order the window below without key/main (NSWindow orderBack: via the app's accessibility API, or hide the browser app: the Cmd+H state) (Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE)") } fn set_bounds_no_activate(&self, _hwnd: i64, _l: i32, _t: i32, _w: i32, _h: i32) -> bool { todo!("AXUIElementSetAttributeValue kAXPosition/kAXSize (Windows: SetWindowPos SWP_NOACTIVATE | SWP_NOZORDER)") }
rust/host-win/src/lib.rs+9@@ -54,6 +54,10 @@ mod imp { fn is_alive(&self, hwnd: i64) -> bool { unsafe { IsWindow(hwnd as HWND) != 0 } } // Node: tagWindowSession (SetProp "AdomPupSid=<sid>" = 1) fn tag(&self, hwnd: i64, tag: &str) -> bool { unsafe { SetPropW(hwnd as HWND, wide(tag).as_ptr(), 1 as *mut c_void) != 0 } }+ fn script_launch(&self, script: &std::path::Path, tail: &str, quote_for_relaunch: bool) -> (String, String) {+ let q = if quote_for_relaunch { "\\\"" } else { "\"" };+ ("powershell.exe".into(), format!("-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File {q}{}{q} {tail}", script.display()))+ } // Node: win32.hasProp fn has_tag(&self, hwnd: i64, tag: &str) -> bool { unsafe { !GetPropW(hwnd as HWND, wide(tag).as_ptr()).is_null() } } // Node: osBottomUnlessForeground's in-process leg (SetWindowPos HWND_BOTTOM, NOACTIVATE)@@ -103,6 +107,7 @@ mod imp { } // Node: killBrowserProcess (taskkill /F) fn kill(&self, pid: u32) -> bool {+ if pid == 0 || pid == std::process::id() { return false; } // never the bridge itself, never "no pid" unsafe { let h = OpenProcess(PROCESS_TERMINATE, 0, pid); if h.is_null() { return false; }@@ -165,6 +170,10 @@ mod imp { fn windows_for_pids(&self, _p: &[u32], _c: Option<&str>) -> Vec<HostWindow> { Vec::new() } fn is_alive(&self, _h: i64) -> bool { false } fn tag(&self, _h: i64, _t: &str) -> bool { false }+ fn script_launch(&self, script: &std::path::Path, tail: &str, quote_for_relaunch: bool) -> (String, String) {+ let q = if quote_for_relaunch { "\\\"" } else { "\"" };+ ("powershell.exe".into(), format!("-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File {q}{}{q} {tail}", script.display()))+ } fn has_tag(&self, _h: i64, _t: &str) -> bool { false } fn send_to_bottom_no_activate(&self, _h: i64) -> bool { false } fn set_bounds_no_activate(&self, _h: i64, _l: i32, _t: i32, _w: i32, _hh: i32) -> bool { false }
rust/host/src/lib.rs+4@@ -32,6 +32,10 @@ pub trait HostWindows: Send + Sync { /// Tag a window with a pup-owned marker (Windows: SetProp "AdomPupSid=<sid>") so it can be /// recognised after a bridge restart. Returns false when tagging is unsupported. fn tag(&self, hwnd: i64, tag: &str) -> bool;+ /// The (target, argument string) that runs a pup helper script with `tail` appended, for+ /// jump-list tasks and taskbar relaunch commands (Windows: a hidden PowerShell). `quote_for_relaunch`+ /// escapes the script path the way the relaunch-command string needs it.+ fn script_launch(&self, script: &std::path::Path, tail: &str, quote_for_relaunch: bool) -> (String, String); fn has_tag(&self, hwnd: i64, tag: &str) -> bool; /// Bottom of the z-order, never activated (Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE). /// ab exposes the same as desktop_set_window_state {state:"bottom"}; this is the in-process
rust/identity/src/lib.rs+7−4@@ -204,14 +204,17 @@ if (-not $ok) {{ pub fn launcher_path(local_app_data: &Path) -> PathBuf { local_app_data.join("Adom Bridge").join("pup-jumplist-call.ps1") } pub fn port_file_path(local_app_data: &Path) -> PathBuf { local_app_data.join("Adom Bridge").join("pup-bridge-port.json") } -pub fn task(launcher: &Path, verb: &str, args: serde_json::Value, title: &str, description: &str, icon: Option<&Path>) -> serde_json::Value {+/// `launch(tail, quote_for_relaunch)` -> (target, args): the host's hidden script runner (HostWindows::script_launch).+pub fn task(launch: &dyn Fn(&str, bool) -> (String, String), verb: &str, args: serde_json::Value, title: &str, description: &str, icon: Option<&Path>) -> serde_json::Value { let b64 = base64_encode(args.to_string().as_bytes());- json!({ "title": title, "description": description, "iconPath": icon.map(|p| p.to_string_lossy().to_string()), "target": "powershell.exe", "args": format!("-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \"{}\" {verb} {b64}", launcher.display()) })+ let (target, argv) = launch(&format!("{verb} {b64}"), false);+ json!({ "title": title, "description": description, "iconPath": icon.map(|p| p.to_string_lossy().to_string()), "target": target, "args": argv }) } -pub fn relaunch_command(launcher: &Path, session_id: &str) -> String {+pub fn relaunch_command(launch: &dyn Fn(&str, bool) -> (String, String), session_id: &str) -> String { let b64 = base64_encode(json!({ "sessionId": session_id, "foregroundReason": "the user clicked this window's taskbar entry" }).to_string().as_bytes());- format!("powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \\\"{}\\\" pup_focus_window {b64}", launcher.display())+ let (target, argv) = launch(&format!("pup_focus_window {b64}"), true);+ format!("{target} {argv}") } // ── tiny hashing + base64 (no dependencies) ────────────────────────────────────────────
rust/verbs/src/identity.rs+8−6@@ -60,15 +60,16 @@ fn ensure_tile(ctx: &Ctx, sid: &str, name: &str) -> Option<PathBuf> { fn jumplist_tasks(ctx: &Ctx, sid: &str, url: &str) -> Vec<Value> { let launcher = id::launcher_path(&local_app_data(ctx));+ let launch = |tail: &str, q: bool| ctx.host.script_launch(&launcher, tail, q); let st = &ctx.settings; let mut tasks = Vec::new(); if st.get("jlViewTasks", "on") != "off" && id::is_adom_url(url) {- tasks.push(id::task(&launcher, "pup_wiki_set_view", json!({ "sessionId": sid, "view": "authed" }), "Switch to logged-in view", "Reload this Adom wiki page SIGNED IN", Some(&icons_dir(ctx).join("pup-cat-wiki-in9.ico"))));- tasks.push(id::task(&launcher, "pup_wiki_set_view", json!({ "sessionId": sid, "view": "public" }), "Switch to public view", "Reload this Adom wiki page logged OUT, exactly what the public sees", Some(&icons_dir(ctx).join("pup-cat-wiki9.ico"))));+ tasks.push(id::task(&launch, "pup_wiki_set_view", json!({ "sessionId": sid, "view": "authed" }), "Switch to logged-in view", "Reload this Adom wiki page SIGNED IN", Some(&icons_dir(ctx).join("pup-cat-wiki-in9.ico"))));+ tasks.push(id::task(&launch, "pup_wiki_set_view", json!({ "sessionId": sid, "view": "public" }), "Switch to public view", "Reload this Adom wiki page logged OUT, exactly what the public sees", Some(&icons_dir(ctx).join("pup-cat-wiki9.ico")))); }- if st.get("jlAnnotate", "on") != "off" { tasks.push(id::task(&launcher, "pup_annotate", json!({ "sessionId": sid, "tool": "pen" }), "Annotate this page", "Draw on the page; the capture goes to the owning AI thread", None)); }- if st.get("jlDashboard", "on") != "off" { tasks.push(id::task(&launcher, "pup_dashboard", json!({}), "Open pup dashboard", "Every window pup is driving, live", Some(&icons_dir(ctx).join("adom-pup.ico")))); }- if st.get("jlCloseAll", "on") != "off" { tasks.push(id::task(&launcher, "pup_close", json!({ "all": true, "everyone": true }), "Close ALL Adom Pup windows", "Closes every pup window on this machine", None)); }+ if st.get("jlAnnotate", "on") != "off" { tasks.push(id::task(&launch, "pup_annotate", json!({ "sessionId": sid, "tool": "pen" }), "Annotate this page", "Draw on the page; the capture goes to the owning AI thread", None)); }+ if st.get("jlDashboard", "on") != "off" { tasks.push(id::task(&launch, "pup_dashboard", json!({}), "Open pup dashboard", "Every window pup is driving, live", Some(&icons_dir(ctx).join("adom-pup.ico")))); }+ if st.get("jlCloseAll", "on") != "off" { tasks.push(id::task(&launch, "pup_close", json!({ "all": true, "everyone": true }), "Close ALL Adom Pup windows", "Closes every pup window on this machine", None)); } tasks } @@ -95,7 +96,8 @@ pub fn stamp(ctx: &Ctx, sid: &str, caller: &Caller, full: bool) { if full || s.app_id.as_deref() != Some(app_id.as_str()) { if let Err(e) = ctx.ab.call("desktop_register_app_identity", json!({ "appId": app_id, "displayName": display, "iconPath": icon.to_string_lossy(), "shortcut": false }), caller, AB_T) { pup_core::log(format!("[identity] \"{sid}\" register {app_id} failed: {e}")); return; } let launcher = id::launcher_path(&local_app_data(ctx));- match ctx.ab.call("desktop_set_window_identity", json!({ "hwnd": hwnd, "appId": app_id, "displayName": display, "iconPath": icon.to_string_lossy(), "relaunchCommand": id::relaunch_command(&launcher, sid), "relaunchDisplayName": display, "relaunchIconResource": icon.to_string_lossy() }), caller, AB_T) {+ let launch = |tail: &str, q: bool| ctx.host.script_launch(&launcher, tail, q);+ match ctx.ab.call("desktop_set_window_identity", json!({ "hwnd": hwnd, "appId": app_id, "displayName": display, "iconPath": icon.to_string_lossy(), "relaunchCommand": id::relaunch_command(&launch, sid), "relaunchDisplayName": display, "relaunchIconResource": icon.to_string_lossy() }), caller, AB_T) { Ok(_) => { pup_core::log(format!("[identity] \"{sid}\" stamped {app_id} ({m})")); ctx.sessions.with_mut(sid, |x| x.app_id = Some(app_id.clone())); if let Some(x) = ctx.sessions.get(sid) { ctx.sessions.save_file(&x); } } Err(e) => { pup_core::log(format!("[identity] \"{sid}\" stamp failed: {e}")); return; } }
rust/verbs/src/input.rs+1−1@@ -302,7 +302,7 @@ pub fn render_html(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.setDocumentContent", json!({ "frameId": frame, "html": html }), T) { return err("render_failed", e.to_string(), None); } let title = b.cdp.evaluate(&t.cdp_session, "document.title", T).ok().and_then(|v| v.as_str().map(|x| x.to_string())).unwrap_or_default(); touch(ctx, &s.id, args.get("updateNote").and_then(|n| n.as_str()));- out(&s, &t, json!({ "title": title, "bytes": html.len(), "_hint": "Rendered your HTML in the tab (UTF-8 correct, Page.setDocumentContent). Screenshot to verify." }))+ out(&s, &t, json!({ "title": title, "bytes": html.len(), "_hint": if t.url.starts_with("http") { "Rendered your HTML in the tab (UTF-8 correct, Page.setDocumentContent). The document KEEPS the tab's origin and that origin's Content-Security-Policy: on a strict site (script-src 'self', e.g. wiki.adom.inc) your inline <script> and <style> are blocked. For a clean origin pup_navigate {url:\"about:blank\"} first. Screenshot to verify." } else { "Rendered your HTML in the tab (UTF-8 correct, Page.setDocumentContent). Screenshot to verify." } })) } pub fn fetch_url(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
rust/verbs/src/lib.rs+26−2@@ -48,6 +48,10 @@ pub fn catalog_meta(verb: &str) -> Option<&'static Value> { catalog()["verbs"].a pub struct LoopTick(pub &'static str, pub u64, pub std::time::Instant); impl Drop for LoopTick { fn drop(&mut self) { pup_core::loops::tick(self.0, self.1, self.2.elapsed().as_millis() as u64); } } +static CLEANUP_REPORTS: Mutex<Vec<String>> = Mutex::new(Vec::new());+pub fn push_cleanup_report(msg: String) { CLEANUP_REPORTS.lock().unwrap().push(msg); }+fn drain_cleanup_reports() -> Option<String> { let mut g = CLEANUP_REPORTS.lock().unwrap(); if g.is_empty() { None } else { Some(g.drain(..).collect::<Vec<_>>().join(" | ")) } }+ const PUP_ISSUES_REF: &str = "adom/pup-bridge"; const BUG_INVITE_EVERY: u64 = 40; static BUG_INVITE: Mutex<(bool, u64)> = Mutex::new((false, 0));@@ -161,7 +165,8 @@ pub fn install_event_hook(ctx: Arc<Ctx>) { _ => {} } }- "Page.screencastFrame" => { if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.cdp_session == sid)) { if let Some(b) = browser_for(&ctx, &s.profile) { record::on_frame(&ctx.tab_recs, &b.cdp, sid, params); } } }+ "Page.screencastFrame" => { if ctx.sessions.all().iter().all(|s| !s.tabs.iter().any(|t| t.cdp_session == sid)) { static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); if N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 3 { pup_core::log(format!("[screencast] frame for a session no window owns: {sid} (known: {:?})", ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.cdp_session.clone())).collect::<Vec<_>>())); } }+ if let Some(s) = ctx.sessions.all().into_iter().find(|s| s.tabs.iter().any(|t| t.cdp_session == sid)) { if let Some(b) = browser_for(&ctx, &s.profile) { record::on_frame(&ctx.tab_recs, &b.cdp, sid, params); } } } _ => {} } }));@@ -283,6 +288,7 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { if !o.contains_key("_next") { if m["related"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("_next".into(), m["related"].clone()); } } if !o.contains_key("pitfalls") { if m["pitfalls"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { o.insert("pitfalls".into(), m["pitfalls"].clone()); } } }+ if !PROBE_VERBS.contains(&verb) && !o.contains_key("_cleanupReport") { if let Some(rep) = drain_cleanup_reports() { o.insert("_cleanupReport".into(), json!(rep)); } } // Node maybeAttachBugInvite: every failure carries the invite; substantive successes carry it once per process, then every 40th if ctx.settings.get("reportIssues", "on") != "off" && !o.contains_key("_reportIssues") { let is_error = o.get("success") == Some(&json!(false)) || o.contains_key("errorCode") || o.get("error").and_then(|e| e.as_str()).map(|e| !e.is_empty()).unwrap_or(false);@@ -303,6 +309,19 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { if !o.contains_key("_verifyRender") { o.insert("_verifyRender".into(), json!(VERIFY_RENDER)); } } 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+ // install): ab computes success = bridge.success (absent = false) and output = bridge.output (absent+ // = ""); the CLI then RETURNS THE PARSED `output` ALONE when success is true, and collapses a+ // non-JSON output to {success, output}. So every success carries success:true and an `output`+ // that is the JSON of the WHOLE payload, exactly as the Node build's sendJSON sites did.+ let is_err = o.get("success") == Some(&json!(false)) || o.get("ok") == Some(&json!(false)) || o.contains_key("errorCode");+ if !is_err {+ o.insert("success".into(), json!(true));+ if !o.get("output").and_then(|v| v.as_str()).map(|s| s.starts_with('{')).unwrap_or(false) {+ let mut inner = o.clone(); inner.remove("output");+ o.insert("output".into(), json!(Value::Object(inner).to_string()));+ }+ } else { o.entry("success").or_insert(json!(false)); o.entry("output").or_insert(json!("")); } // Node v1.9.98: ab/the CLI unwrap `output` and drop the envelope on success, so the advisory // fields have to live INSIDE output too (never overwriting what the handler put there). if let Some(inner_s) = o.get("output").and_then(|v| v.as_str()).filter(|s| s.starts_with('{')).map(|s| s.to_string()) {@@ -690,7 +709,12 @@ fn window_info(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { // pup_close {} with no ids closes the caller's window; pup_close {all:true} closes everything of theirs. let mut targets: Vec<Session> = Vec::new();- if args.get("all").and_then(|b| b.as_bool()) == Some(true) { targets = if caller.thread.is_empty() { ctx.sessions.all() } else { ctx.sessions.owned_by(&caller.thread) }; }+ if args.get("all").and_then(|b| b.as_bool()) == Some(true) {+ // all:true = the caller's windows; everyone:true (the jump list's "Close ALL Adom Pup windows", a+ // user action) = every window on this desktop whoever owns it. Node: pup_close {all, everyone}.+ let everyone = args.get("everyone").and_then(|b| b.as_bool()) == Some(true);+ targets = if everyone || caller.thread.is_empty() { ctx.sessions.all() } else { ctx.sessions.owned_by(&caller.thread) };+ } else { match resolve(ctx, caller, args) { Ok(s) => targets.push(s), Err(e) => return e } } let mut closed = Vec::new(); let mut chrome_killed = 0; for s in targets {
rust/verbs/src/record.rs+3−1@@ -60,7 +60,9 @@ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let every = (60 / fps).max(1); // Node: page.bringToFront() before the screencast so Chrome's compositor stays warm — an occluded, // never-activated tab emits no screencast frames at all. Re-park the OS window right after.+ let _ = b.cdp.send(Some(&t.cdp_session), "Page.enable", json!({}), Duration::from_secs(5)); let _ = b.cdp.send(Some(&t.cdp_session), "Page.bringToFront", json!({}), Duration::from_secs(5));+ pup_core::log(format!("[{id}] screencast on session {} (target {}) url={}", &t.cdp_session[..t.cdp_session.len().min(12)], &t.target_id[..t.target_id.len().min(12)], t.url)); if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); } if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.startScreencast", json!({ "format": "jpeg", "quality": quality, "everyNthFrame": every }), Duration::from_secs(10)) { ctx.tab_recs.lock().unwrap().remove(&s.id); return err("record_start_failed", e.to_string(), None); } crate::highfps::grant(ctx, &s.id, args["minutes"].as_u64().unwrap_or(20));@@ -74,7 +76,7 @@ pub fn stop(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let Some(rec) = ctx.tab_recs.lock().unwrap().remove(&s.id) else { return err("no_recording", format!("window \"{}\" is not recording", s.id), None) }; if let Some(b) = browser_for(ctx, &s.profile) { let _ = b.cdp.send(Some(&rec.cdp_session), "Page.stopScreencast", json!({}), Duration::from_secs(5)); } let duration_ms = rec.started.elapsed().as_millis() as u64;- if rec.frames == 0 { let _ = std::fs::remove_dir_all(&rec.frames_dir); return err("no_frames", "zero frames captured — no video produced", Some("Chrome's screencast only emits a frame when the page PAINTS: a static page yields none however long you record (measured: the same window records an animated page fine). Make the page change while recording (scroll, navigate, drive it), or use pup_screen_record_start / ab's native desktop_record_start for a wall-clock capture.")); }+ if rec.frames == 0 { let _ = std::fs::remove_dir_all(&rec.frames_dir); return err("no_frames", "zero frames captured — no video produced", Some("Chrome's screencast only emits a frame when the page PAINTS: a static page yields none however long you record (a pup_render_html page whose inline script the tab origin's CSP blocked is static too: render on about:blank) (measured: the same window records an animated page fine). Make the page change while recording (scroll, navigate, drive it), or use pup_screen_record_start / ab's native desktop_record_start for a wall-clock capture.")); } let actual_fps = rec.frames as f64 / (duration_ms.max(1) as f64 / 1000.0); let (out_path, encoder) = match ffmpeg_path() { Some(ff) => match mux_ffmpeg(&ff, &rec, actual_fps) { Ok(()) => (rec.file.clone(), "ffmpeg VP9 WebM".to_string()), Err(e) => { pup_core::log(format!("[{}] ffmpeg mux failed: {e} — packing MJPEG AVI instead", rec.id)); let avi = rec.file.with_extension("avi"); match pack_avi(&rec, &avi, actual_fps) { Ok(()) => (avi, "built-in MJPEG AVI (ffmpeg failed)".into()), Err(e2) => return err("mux_failed", format!("ffmpeg: {e}; avi: {e2}"), None) } } },
rust/verbs/src/recovery.rs+49−10@@ -14,10 +14,14 @@ use std::sync::Arc; use std::time::Duration; /// Connect to a profile's live browser (by its DevToolsActivePort) and register the handle.-fn reconnect_profile(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> {+fn reconnect_profile(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { reconnect_profile_hint(ctx, profile, None) }+/// `hint`: the cdpPort a session file recorded. The Node build's Chrome answers on it while its+/// user-data-dir carries no DevToolsActivePort file (measured on winvm at the 2.0.401 install: Chrome+/// alive on 9420, no port file), so the file's port is the second place to look.+fn reconnect_profile_hint(ctx: &Ctx, profile: &str, hint: Option<u16>) -> Option<Arc<BrowserHandle>> { if let Some(b) = ctx.browsers.lock().unwrap().get(profile) { if b.cdp.is_alive() { return Some(b.clone()); } } let profile_dir = pup_browser::profiles_dir(&*ctx.host).join(profile);- let port = pup_browser::existing_port(&profile_dir)?;+ let port = pup_browser::existing_port(&profile_dir).or_else(|| hint.filter(|p| *p > 0 && Cdp::connect_port(*p, Duration::from_secs(3)).map(|c| { let ok = c.version().is_ok(); c.close(); ok }).unwrap_or(false)))?; let cdp = Cdp::connect_port(port, Duration::from_secs(8)).ok()?; let product = cdp.version().ok().and_then(|v| v["product"].as_str().map(|s| s.to_string())).unwrap_or_default(); let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| (product.contains("Edg") && c.kind == "edge") || (!product.contains("Edg") && c.kind != "edge")).unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: Default::default(), build_id: None });@@ -30,6 +34,24 @@ fn reconnect_profile(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { Some(h) } +/// Node: sleepRecovery / the crash auto-recover. A session whose Chrome is gone comes back at its+/// last URL, in the background, signed in (same profile), at most twice per session id; the caller+/// hears about it through `_cleanupReport` on its next verb.+static RELAUNCHES: std::sync::Mutex<Vec<(String, u32)>> = std::sync::Mutex::new(Vec::new());+fn relaunch_from_file(ctx: &Ctx, f: &Value) {+ let id = f["sessionId"].as_str().unwrap_or("").to_string();+ let url = f["url"].as_str().unwrap_or("about:blank").to_string();+ let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();+ let owner = f["owner"].as_str().unwrap_or("").to_string();+ let attempts = { let mut m = RELAUNCHES.lock().unwrap(); match m.iter_mut().find(|(s, _)| *s == id) { Some(e) => { e.1 += 1; e.1 } None => { m.push((id.clone(), 1)); 1 } } };+ if attempts > 2 { pup_core::log(format!("[recovery] \"{id}\" failed to come back twice — giving up on it (the page itself may be crashing Chrome); removing its file")); ctx.sessions.delete_file(&id); crate::push_cleanup_report(format!("pup could NOT auto-recover the \"{id}\" window: it crashed again on relaunch ({url}). Open it again yourself with pup_open_window if you still need it.")); return; }+ pup_core::log(format!("[recovery] \"{id}\": its Chrome is gone (profile {profile}) — relaunching at {url} in the background (attempt {attempts})"));+ 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.")); }+ else { pup_core::log(format!("[recovery] \"{id}\" relaunch failed: {}", r["error"].as_str().unwrap_or("?"))); }+}+ fn page_targets(cdp: &Cdp) -> Vec<Value> { cdp.targets().unwrap_or_default().into_iter().filter(|t| t["type"] == json!("page")).collect() } /// Rebuild one session from its file against a connected browser. Match by the recorded@@ -71,10 +93,11 @@ pub fn recover_at_boot(ctx: &Ctx) { if id.is_empty() { continue; } let pid = f["pid"].as_u64().unwrap_or(0) as u32; let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();- if pid > 0 && !ctx.host.alive(pid) && pup_browser::existing_port(&pup_browser::profiles_dir(&*ctx.host).join(&profile)).is_none() {- pup_core::log(format!("Session \"{id}\" (PID {pid}) is dead — removing stale file")); ctx.sessions.delete_file(&id); continue;- }- let Some(h) = reconnect_profile(ctx, &profile) else { pup_core::log(format!("Session \"{id}\": profile \"{profile}\" has no live DevTools port — leaving the file for the health loop")); continue };+ let hint = f["cdpPort"].as_u64().map(|p| p as u16);+ let Some(h) = reconnect_profile_hint(ctx, &profile, hint) else {+ if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, f); } else { pup_core::log(format!("Session \"{id}\": profile \"{profile}\" has no live DevTools port but PID {pid} is alive (Chrome asleep or starting) — leaving the file for the health loop")); }+ continue+ }; match recover_one(ctx, f, &h, &mut taken) { Some(s) => { pup_core::log(format!("Recovered session \"{id}\" (profile: {profile}, URL: {}, hwnd: {:?})", s.active().map(|t| t.url.clone()).unwrap_or_default(), s.hwnd)); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); } None => { pup_core::log(format!("Session \"{id}\" — no matching page in Chrome, removing")); ctx.sessions.delete_file(&id); }@@ -86,8 +109,8 @@ pub fn recover_at_boot(ctx: &Ctx) { /// One health tick: dead browser connections are reconnected (never killed), sessions whose /// target vanished are dropped, stale files are swept. pub fn health_tick(ctx: &Ctx) {- let profiles: Vec<String> = ctx.browsers.lock().unwrap().iter().filter(|(_, b)| !b.cdp.is_alive()).map(|(p, _)| p.clone()).collect();- for p in profiles {+ let profiles: Vec<(String, u32)> = ctx.browsers.lock().unwrap().iter().filter(|(_, b)| !b.cdp.is_alive()).map(|(p, b)| (p.clone(), b.launched.pid)).collect();+ for (p, pid) in profiles { pup_core::log(format!("[health] browser for profile \"{p}\" disconnected — attempting reconnect")); ctx.browsers.lock().unwrap().remove(&p); match reconnect_profile(ctx, &p) {@@ -98,7 +121,18 @@ pub fn health_tick(ctx: &Ctx) { match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); } None => { pup_core::log(format!("[health] session \"{}\" not found after reconnect — removing", s.id)); ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); } } } }- None => pup_core::log(format!("[health] could not reconnect profile \"{p}\" — its sessions wait for the next tick (Chrome may be asleep or restarting)")),+ None => {+ // Chrome is GONE (its pid is dead): every window on that profile comes back from its file+ // (Node: the crash auto-recover, "pup auto-recovered your window after a Chrome crash").+ // A live-but-silent Chrome (asleep, hung, restarting) is left alone for the next tick.+ if pid == 0 || !ctx.host.alive(pid) {+ let files: Vec<Value> = ctx.sessions.for_profile(&p).iter().map(|s| s.file_json()).collect();+ pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) is gone — relaunching its {} window(s) from their files", files.len()));+ for f in files { if let Some(id) = f["sessionId"].as_str() { ctx.sessions.remove(id); } relaunch_from_file(ctx, &f); }+ } else {+ pup_core::log(format!("[health] could not reconnect profile \"{p}\" but its Chrome (pid {pid}) is alive — its sessions wait for the next tick (Chrome may be asleep or restarting)"));+ }+ } } } for s in ctx.sessions.all() {@@ -106,7 +140,12 @@ pub fn health_tick(ctx: &Ctx) { } 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) && pid > 0 && !ctx.host.alive(pid) { pup_core::log(format!("[health] stale session file \"{id}\" (PID {pid} dead) — deleting")); ctx.sessions.delete_file(&id); }+ if id.is_empty() || ctx.sessions.exists(&id) { continue; }+ let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();+ if let Some(h) = reconnect_profile_hint(ctx, &profile, f["cdpPort"].as_u64().map(|p| p as u16)) {+ let mut taken = Vec::new();+ match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { pup_core::log(format!("[health] re-attached session \"{id}\" to its live Chrome")); ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); } None => relaunch_from_file(ctx, &f) }+ } else if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, &f); } } }
tools/ab-smoke.shadded+86@@ -0,0 +1,86 @@+#!/usr/bin/env bash+# ab-smoke.sh — drive the INSTALLED pup bridge on a desktop through ab's relay + CLI (the real path),+# covering what the fixture parity run does not: the vault, cards, wiki auto-sign + view flip,+# annotate, tab recording, the screen recorder HUD, downloads, the dashboard, taskbar identity.+# tools/ab-smoke.sh <target> [profile]+# Every line prints PASS/FAIL with the evidence; exit 1 if anything failed. Windows are closed at the end.+set -uo pipefail+TARGET="${1:?usage: tools/ab-smoke.sh <target> [profile]}"; PROFILE="${2:-rs-smoke}"; THREAD="pup-ab-smoke"+AB="adom-bridge --target $TARGET --ai-thread $THREAD"; FAILS=0+# The CLI leaves some verbs' payload inside the `output` JSON string (measured identical on the Node build),+# so read fields the way a caller does: top level first, then the parsed output.+j() { python3 -c "import sys,json+try: d=json.load(sys.stdin)+except Exception as e: print('NONJSON'); sys.exit(0)+o=d.get('output')+if isinstance(o,str) and o.startswith('{'):+ try:+ for k,v in json.loads(o).items(): d.setdefault(k,v)+ except Exception: pass+print(eval(sys.argv[1], {'d': d, 'json': json}))" "$1"; }+chk() { local name="$1" got="$2" want="$3"; if [ "$got" = "$want" ]; then echo "PASS $name ($got)"; else echo "FAIL $name got=[$got] want=[$want]"; FAILS=$((FAILS+1)); fi; }+say() { echo "--- $*"; }++say vault+R=$($AB credential_set '{"host":"the-internet.herokuapp.com","username":"tomsmith","password":"SuperSecretPassword!"}'); chk credential_set "$(echo "$R" | j "d.get('ok')")" True+R=$($AB pup_credentials '{}'); chk "credentials lists host, no password" "$(echo "$R" | j "(d.get('count',0)>=1) and ('SuperSecret' not in json.dumps(d))")" True+R=$($AB pup_open_window "{\"url\":\"https://the-internet.herokuapp.com/login\",\"profile\":\"$PROFILE\"}"); SID=$(echo "$R" | j "d.get('sessionId')"); chk "open login page" "$(echo "$R" | j "d.get('ok')")" True+sleep 3+R=$($AB pup_login '{"submit":true}'); chk "submit without reason refused" "$(echo "$R" | j "d.get('errorCode')")" submit_reason_required+R=$($AB pup_login '{"submit":true,"submitReason":"the-internet.herokuapp.com is the official test site for this smoke run"}'); chk "login submitted" "$(echo "$R" | j "d.get('submitted')")" True+sleep 3+R=$($AB pup_eval '{"expr":"location.pathname"}'); chk "landed on /secure" "$(echo "$R" | j "d.get('result')")" /secure+R=$($AB pup_credential_reveal '{"host":"the-internet.herokuapp.com","reason":"smoke test of the reveal gate"}'); chk "reveal refused for a relayed caller" "$(echo "$R" | j "d.get('errorCode') in ('origin_unverified','relay_forbidden')")" True+R=$($AB credential_delete '{"host":"the-internet.herokuapp.com"}'); chk credential_delete "$(echo "$R" | j "d.get('removed')")" True++say cards+R=$($AB pup_card_set '{"nickname":"smoke-visa","number":"4111 1111 1111 1111","expiry":"12/30","name":"Smoke Tester","cvv":"123"}'); chk card_set "$(echo "$R" | j "d.get('ok') and ('1111 1111' not in json.dumps(d))")" True+R=$($AB pup_cards '{}'); chk "cards lists brand+last4" "$(echo "$R" | j "[c.get('brand'), c.get('last4')] if (c:=next((c for c in d.get('cards',[]) if c.get('nickname')=='smoke-visa'), None)) else None")" "['Visa', '1111']"+R=$($AB pup_card_delete '{"nickname":"smoke-visa"}'); chk card_delete "$(echo "$R" | j "d.get('removed')")" True++say wiki+R=$($AB pup_navigate '{"url":"https://wiki.adom.inc/adom/pup-bridge"}'); chk "wiki navigate reports view" "$(echo "$R" | j "d.get('wikiView')")" authed+R=$($AB pup_wiki_set_view '{"view":"public"}'); chk "public view flip" "$(echo "$R" | j "d.get('wikiView')")" public+sleep 2+R=$($AB pup_eval '{"expr":"location.search"}'); chk "url carries view=public" "$(echo "$R" | j "'view=public' in str(d.get('result'))")" True++say annotate + chip+R=$($AB pup_annotate '{"tool":"pen"}'); chk "annotate armed" "$(echo "$R" | j "d.get('ok')")" True+sleep 2+R=$($AB pup_eval '{"expr":"JSON.stringify({ov: typeof window.__pupStartAnnotate, chip: !!document.querySelector(\"div[data-pup-owner]\")})"}'); chk "overlay + owner chip in DOM" "$(echo "$R" | j "d.get('result')")" '{"ov":"function","chip":true}'++say screenshots+R=$($AB pup_screenshot_element '{"selector":"h1"}'); chk "screenshot_element" "$(echo "$R" | j "bool(d.get('filePath')) and d.get('width',0)>0")" True+R=$($AB pup_screenshot_full_res '{}'); chk "screenshot_full_res" "$(echo "$R" | j "d.get('width',0)>1500")" True++say recording+R=$($AB pup_navigate '{"url":"about:blank"}') # a clean origin: the wiki's CSP (script-src self) would block the inline animation script below+R=$($AB pup_render_html '{"html":"<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>"}'); chk "render animated page" "$(echo "$R" | j "d.get('ok')")" True+R=$($AB pup_record_start '{"fps":10}'); chk "record start" "$(echo "$R" | j "bool(d.get('recordingId'))")" True+sleep 6+R=$($AB pup_record_stop '{}'); chk "record stop has frames" "$(echo "$R" | j "d.get('frames',0)>5")" True+R=$($AB pup_recorder_open '{"reason":"smoke test of the recorder HUD"}'); chk "recorder HUD opens" "$(echo "$R" | j "d.get('opened')")" True+sleep 3+R=$($AB pup_screen_record_status '{}'); chk "HUD reported open" "$(echo "$R" | j "d.get('hudOpen')")" True+R=$($AB pup_recorder_close '{}'); chk "recorder close" "$(echo "$R" | j "d.get('ok')")" True++say download+R=$($AB pup_navigate '{"url":"https://the-internet.herokuapp.com/download"}'); sleep 2+R=$($AB pup_download '{"url":"https://the-internet.herokuapp.com/download/some-file.txt","waitMs":30000}'); chk "download landed" "$(echo "$R" | j "d.get('bytes',0)>0 and bool(d.get('savedTo'))")" True++say dashboard+R=$($AB pup_dashboard '{}'); DSID=$(echo "$R" | j "d.get('sessionId')"); chk "dashboard window" "$(echo "$R" | j "d.get('ok')")" True+sleep 4+VER=$(cat src/BRIDGE_VERSION)+R=$($AB pup_eval "{\"window\":\"$DSID\",\"takeover\":true,\"expr\":\"JSON.stringify({t: document.title, hasVersion: document.documentElement.innerHTML.includes('$VER'), windows: (document.body.innerText.match(/(\\\\d+) windows?/)||[null,null])[1]})\"}"); echo " dashboard: $(echo "$R" | j "d.get('result')")"; chk "dashboard page carries the bridge version $VER" "$(echo "$R" | j "'\"hasVersion\":true' in str(d.get('result'))")" True+R=$($AB pup_close_window "{\"window\":\"$DSID\",\"takeover\":true}")++say identity+R=$($AB pup_window_info '{}'); HW=$(echo "$R" | j "d.get('hwnd')"); chk "window_info has hwnd" "$(echo "$R" | j "bool(d.get('hwnd'))")" True+R=$($AB desktop_get_window_identity "{\"hwnds\":[$HW]}"); echo " identity: $(echo "$R" | j "json.dumps(d.get('windows') or d.get('rows') or d)[:200]")"; chk "taskbar AUMID stamped" "$(echo "$R" | j "'Adom.Pup' in json.dumps(d)")" True+R=$($AB pup_audit_icons '{}'); chk "icon audit answers" "$(echo "$R" | j "'audit' in d or d.get('ok') is True")" True++say close+R=$($AB pup_close '{"all":true}'); chk "close all of mine" "$(echo "$R" | j "len(d.get('closed',[]))>=1")" True+R=$($AB pup_my_windows '{}'); chk "nothing of mine left" "$(echo "$R" | j "d.get('count')")" 0+echo; [ $FAILS -eq 0 ] && echo "AB SMOKE: ALL PASS" || echo "AB SMOKE: $FAILS FAILED"; exit $FAILS
tools/parity.py+13−1@@ -8,6 +8,7 @@ and the same again inside the `output` string when it is JSON. `_hint`/`_next` t not shape drift and are ignored; a MISSING `_hint` is reported. tools/parity.py --target winvm [--port 8867] [--exe-dir C:/Users/azureuser/pup-rs] [--profile rs-smoke]+ tools/parity.py --via-ab --target winvm # same sequence through ab's relay + CLI (the installed bridge, not a spare port) tools/parity.py --compare <responses.json> # re-diff a pulled file without re-running Exit 0 = every fixture matches (no missing keys, no type drift); 1 = drift; 2 = harness failure.@@ -138,10 +139,21 @@ def ab(target, verb, args): except Exception: return {'error': p.stdout[-400:] + p.stderr[-400:]} def main():- ap = argparse.ArgumentParser(); ap.add_argument('--target'); ap.add_argument('--port', default='8867'); ap.add_argument('--exe-dir', default='C:/Users/azureuser/pup-rs'); ap.add_argument('--profile', default='rs-smoke'); ap.add_argument('--compare')+ ap = argparse.ArgumentParser(); ap.add_argument('--target'); ap.add_argument('--port', default='8867'); ap.add_argument('--exe-dir', default='C:/Users/azureuser/pup-rs'); ap.add_argument('--profile', default='rs-smoke'); ap.add_argument('--compare'); ap.add_argument('--via-ab', action='store_true') a = ap.parse_args() if a.compare: sys.exit(compare(json.load(open(a.compare)))) if not a.target: ap.error('--target or --compare')+ if a.via_ab:+ responses = {}+ for n, v, args in SEQUENCE:+ args = json.loads(json.dumps(args).replace('__PROFILE__', a.profile))+ p = subprocess.run(['adom-bridge', '--target', a.target, '--ai-thread', 'pup-bridge', v, json.dumps(args)], capture_output=True, text=True, timeout=200)+ 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)+ 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' script = ps_script(a.port, exe_dir, a.profile, out.replace('/', '\\')) r = ab(a.target, 'desktop_write_file', {'path': a.exe_dir.rstrip('/') + '/parity.ps1', 'content': script, 'reason': 'golden-fixture parity run for the Rust pup exe'})
tools/rust-build.shadded+48@@ -0,0 +1,48 @@+#!/usr/bin/env bash+# rust-build.sh — build the Rust pup exe and its release zip (Phase 4 of docs/PLAN-rust-port.md).+#+# tools/rust-build.sh <version> # -> dist/<version>/adom-bridge-puppeteer-v<version>.zip+#+# The zip is ab's bridge package: adom-pup-bridge.exe + bridge.json (spawn.kind exe) + BRIDGE_VERSION+# at the zip root. Nothing else: every browser asset, extension, icon and font is embedded in the exe.+# Gates before the build: border check (Win32 only in host-win), embedded assets regenerated, host+# tests green. The exe version is include_str!(src/BRIDGE_VERSION), so write that first (ship.sh does).+set -euo pipefail+VER="${1:?usage: tools/rust-build.sh <version>}"+REPO="$(cd "$(dirname "$0")/.." && pwd)"; cd "$REPO"+ARCH="dist/${VER}"; ZIP="adom-bridge-puppeteer-v${VER}.zip"; EXE="rust/target/x86_64-pc-windows-gnu/release/adom-pup-bridge.exe"+[ "$(cat src/BRIDGE_VERSION)" = "$VER" ] || { echo "src/BRIDGE_VERSION is $(cat src/BRIDGE_VERSION), not $VER (ship.sh writes it first)"; exit 1; }++echo "== rust 0. gates =="+python3 tools/gen-assets.py+tools/check-borders.sh+( cd rust && sed -i "s/^version = \"[0-9.]*\"/version = \"${VER}\"/" Cargo.toml && grep -q "^version = \"${VER}\"" Cargo.toml )+TEST_OUT=$(cd rust && cargo test --workspace -q 2>&1) || { echo "$TEST_OUT" | grep -E "error|FAILED|panicked" | head -20; echo " TESTS FAILED"; exit 1; }+echo "$TEST_OUT" | grep -qE "test result: FAILED|panicked|error\[" && { echo "$TEST_OUT" | grep -E "error|FAILED|panicked" | head -20; echo " TESTS FAILED"; exit 1; }+echo " host tests green"++echo "== rust 1. release build (x86_64-pc-windows-gnu) =="+( cd rust && cargo build --release --target x86_64-pc-windows-gnu 2>&1 | grep -E "^(error|warning: unused)|Finished" )+[ -f "$EXE" ] || { echo " exe missing"; exit 1; }+grep -q "Puppeteer Bridge v" "$EXE" || { echo " exe lacks the boot banner string"; exit 1; }++echo "== rust 2. package =="+mkdir -p "$ARCH"+python3 - "$VER" "$EXE" "$ARCH/$ZIP" <<'PY'+import json, sys, zipfile, hashlib, os+ver, exe, out = sys.argv[1:4]+d = json.load(open('src/bridge.json'))+assert d['version'] == ver, f"src/bridge.json is {d['version']}, not {ver}"+d['spawn'] = { 'kind': 'exe', 'entrypoint': 'adom-pup-bridge.exe', 'port': d['spawn'].get('port', 8851), 'healthEndpoint': '/status', 'stopMethod': 'kill', 'killImageName': 'adom-pup-bridge.exe', 'persistent': True,+ '_hint': 'spawn.kind exe: one sha256-verified binary, no Node, no npm, no postinstall (docs/PLAN-rust-port.md). Port pinned low per ab#83 (WinNAT exclusion ranges in the ephemeral band).',+ '_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.') }+d['shell'] = 'rust'+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')+ z.writestr('BRIDGE_VERSION', ver)+json.dump(d, open(os.path.join(os.path.dirname(out), 'bridge.json'), 'w'), indent=2)+print(f" {out} sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()} size={os.path.getsize(out)} exe={os.path.getsize(exe)}")+PY
tools/ship.sh+40−4@@ -24,8 +24,17 @@ # # Env: FORCE_PUSH=1 pushes even when the working tree looks unchanged. set -euo pipefail-VER="${1:?usage: tools/ship.sh <version> \"<changelog>\"}"+VER="${1:?usage: tools/ship.sh <version> \"<changelog>\" [--shell rust|node] [--candidate | --insiders-from-candidate]}" LOG="${2:?changelog sentence required}"+# --shell rust (DEFAULT since Phase 4 of the Rust port, 2026-09-05): the package is the Rust exe.+# --shell node builds the Node zip (the public tier's shell until a Rust build is promoted).+# --candidate: everything EXCEPT the insiders manifest push; the manifest is pushed to the product+# page as adom-bridge-puppeteer-manifest-candidate.json so ONE desktop can bridge_install it and be+# tested thoroughly (John: "test thoroughly before sending to insiders").+# --insiders-from-candidate: only the insiders push, from the archived dist/<ver>/insiders-manifest.json.+SHELL_KIND="rust"; MODE="full"+shift 2 || true+while [ $# -gt 0 ]; do case "$1" in --shell) SHELL_KIND="$2"; shift 2;; --candidate) MODE="candidate"; shift;; --insiders-from-candidate) MODE="finalize"; shift;; *) echo "unknown arg $1"; exit 1;; esac; done REPO="$(cd "$(dirname "$0")/.." && pwd)" PAGE="adom/pup-bridge" INSIDERS_PAGE="adom/insiders"@@ -35,7 +44,14 @@ ZIP="adom-bridge-puppeteer-v${VER}.zip" ARCH="dist/${VER}" cd "$REPO" -echo "== 0. static gates =="+if [ "$MODE" = "finalize" ]; then+ [ -f "dist/${VER}/insiders-manifest.json" ] || { echo "no archived candidate for $VER (run --candidate first)"; exit 1; }+ echo "== finalize: pushing the archived candidate manifest for $VER to insiders =="+ mkdir -p "$INSIDERS_DIR/pup-bridge"; cp "dist/${VER}/insiders-manifest.json" "$INSIDERS_DIR/$INSIDERS_REL"+fi+if [ "$MODE" != "finalize" ]; then+echo "== 0. static gates ($SHELL_KIND) =="+if [ "$SHELL_KIND" = "node" ]; then node --check src/server.js node --check src/chrome.js node --check src/card_vault.js@@ -45,10 +61,11 @@ node tests/check_consent.js >/dev/null node tests/check_unpack.js >/dev/null node tests/check_icon.js >/dev/null echo " server/chrome/card_vault parse; dashboard script parses; card vault checks; dock favicon matches"+fi echo "== 1. version lockstep ==" printf '%s' "$VER" > src/BRIDGE_VERSION-python3 - "$VER" <<'PY'+python3 - "$VER" "$LOG" <<'PY' import json, sys v = sys.argv[1] p = 'src/bridge.json'@@ -61,11 +78,16 @@ print(f" src/BRIDGE_VERSION + src/bridge.json -> {v}") p2 = 'package.json' d2 = json.load(open(p2)) d2['version'] = v+d2['changelog'] = sys.argv[2] if len(sys.argv) > 2 else d2.get('changelog', '') # forces a pkg version row even when the skills tarball is byte-identical (PUBLISH_UNCHANGED), so the release-born row (#66, wiki#188) never sits on top without a tarball json.dump(d2, open(p2, 'w'), indent=2) open(p2, 'a').write('\n') print(f" package.json (skillpack) -> {v}") PY +if [ "$SHELL_KIND" = "rust" ]; then+echo "== 2. build the Rust exe zip (tools/rust-build.sh) =="+tools/rust-build.sh "$VER"+else echo "== 2. build the zip (src/ at zip root, no node_modules) ==" mkdir -p "$ARCH" python3 - "$ARCH/$ZIP" <<'PY'@@ -79,6 +101,7 @@ with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z: z.write(full, os.path.relpath(full, 'src')) print(f" {out} sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()} size={os.path.getsize(out)}") PY+fi echo "== 3a. skills pkg FIRST (a release-born version row never gets the pkg tarball attached: 2.0.398; #66) ==" PUBOUT=$(adom-wiki pkg publish --org adom 2>&1); PUBRC=$?@@ -96,7 +119,7 @@ echo "== 3. release on the product page ($PAGE) ==" adom-wiki release create "$PAGE" "$VER" >/dev/null 2>&1 || true UP=$(adom-wiki release upload "$PAGE" "$VER" "$ARCH/$ZIP" --json 2>&1 | tail -c 4000) mkdir -p "$INSIDERS_DIR/pup-bridge"-python3 - "$UP" "$VER" "$ZIP" "$ARCH" "$INSIDERS_DIR/$INSIDERS_REL" <<'PY'+PUP_SHELL_KIND="$SHELL_KIND" python3 - "$UP" "$VER" "$ZIP" "$ARCH" "$INSIDERS_DIR/$INSIDERS_REL" <<'PY' import json, sys, datetime, hashlib, os raw, ver, zipname, arch, ins_path = sys.argv[1:6] i = raw.find('{')@@ -112,6 +135,9 @@ if not (sha and size): base = json.load(open('adom-bridge-puppeteer-manifest.json')) for k in ('promotion_attestation', 'promoted_by', 'promoted_at', 'rollback_attestation', 'rolled_back_by', 'rolled_back_at', 'insiders_released_at'): base.pop(k, None)+bj = json.load(open('src/bridge.json'))+base.update({'verbPrefixes': bj.get('verbPrefixes', base.get('verbPrefixes')), 'statusVerb': bj.get('statusVerb', base.get('statusVerb')), 'healthEndpoint': bj.get('spawn', {}).get('healthEndpoint', '/status')})+base['shell'] = os.environ.get('PUP_SHELL_KIND', 'node') base.update({'version': ver, 'url': url, 'sha256': sha, 'size': size, 'tier': 'insiders', 'released_at': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z')}) json.dump(base, open(ins_path, 'w'), indent=2); open(ins_path, 'a').write('\n')@@ -119,6 +145,16 @@ json.dump(base, open(os.path.join(arch, 'insiders-manifest.json'), 'w'), indent= print(f" release asset -> {url}\n sha256={sha} size={size}\n insiders manifest staged at {ins_path}; archived {arch}/") PY +if [ "$MODE" = "candidate" ]; then+ echo "== 4c. CANDIDATE manifest -> $PAGE:adom-bridge-puppeteer-manifest-candidate.json (insiders untouched) =="+ cp "$ARCH/insiders-manifest.json" adom-bridge-puppeteer-manifest-candidate.json+ adom-wiki repo push "$PAGE" --files adom-bridge-puppeteer-manifest-candidate.json src/bridge.json src/BRIDGE_VERSION package.json -m "v${VER} candidate ($SHELL_KIND): ${LOG}" >/dev/null 2>&1 || { echo " CANDIDATE PUSH FAILED"; exit 1; }+ echo " install on ONE desktop: adom-bridge --target <box> bridge_install '{\"manifestUrl\":\"https://wiki.adom.inc/api/v1/pages/pup-bridge/files/adom-bridge-puppeteer-manifest-candidate.json\",\"force\":true}'"+ echo " then, when it is proven: tools/ship.sh $VER \"<log>\" --insiders-from-candidate"+ exit 0+fi+fi # MODE != finalize+ echo "== 4. insiders manifest + release log -> $INSIDERS_PAGE:pup-bridge/ ==" # Convention (adom-bridge/README.md, hydrogen/README.md on adom/insiders): every insiders publish # APPENDS a release-log entry to <product>/README.md before the publish is called done; promote