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
pup demo tour driver (tools/pup-demo.py) + the 8-beat Play-the-demo prompt; consent toast reason/aiThread; aumidShortcut setting; recorder misroute wording
7 files changed
+325−15
CHANGELOG.md+43@@ -1,5 +1,48 @@ # Changelog — Puppeteer Bridge +## 2.0.419 — 2026-09-05 — the pup demo tour, consent toast metadata, honest recorder routing++The dashboard "Play the demo" prompt is now the eight-beat pup tour John asked for (window, taskbar+identity + progress, jump lists, toolbar/annotate/adom-you, Google login to its 2FA wall, screenshots,+UltraLibrarian login, close-all), with `tools/pup-demo.py` as the runnable reference driver (demo+panel + captions + pup screen recorder). Consent toasts (reveal, card, vault sync) now carry a+structured `reason`, `aiThread` and `delegate` so ab can forward them into ah (adom-bridge#157). A+misrouted `desktop_record_*` answers `wrong_bridge` with the pup_screen_record_* fallback instead of+"not in Rust yet" (adom-bridge#150). pup_login reports credentialSource on the 2FA/captcha path.++## 2.0.418 — 2026-09-05 — aumidShortcut setting (the ab#155 jump-list anchor)++New setting `aumidShortcut` (default off): when on, pup registers each taskbar identity WITH the+Start Menu shortcut that anchors its custom jump list, so the menu stays on the FIRST right-click+(ab#155: registry-only identities make Windows resolve the list lazily and dismiss the first menu).+Off keeps the no-Start-Menu-clutter default. Enable with pup_configure {aumidShortcut:"on"}.++## 2.0.417 — 2026-09-05 — pup_vault_sync accepts the same-account desktop hop++Fix: the peer refused pup_vault_import with relay_forbidden because the desktop-to-desktop egress+arrives as verified_origin="relay". A same-account desktop hop has NO container; a cloud container+does. Gate on container-empty only, so cross-desktop sync works while cloud callers stay refused.++## 2.0.416 — 2026-09-05 — pup_vault_sync cross-desktop routing fix++Fix: pup_vault_sync egress hardcoded app:"puppeteer", so the peer ab refused with "Unknown app:+puppeteer". Omit app; the peer ab infers "dynamic" from the pup_ prefix and routes to the bridge.++## 2.0.415 — 2026-09-05 — pup_vault_sync toast Allow/Deny buttons++Fix: the 2.0.414 pup_vault_sync consent toast was missing its `buttons`, so it rendered with no+Allow/Deny for the user to click (it could only time out). Added `buttons:[Allow, Deny]`.++## 2.0.414 — 2026-09-05 — pup_vault_sync (bidirectional credential union across desktops)++John: a one-way push is not a sync, and the AI must never be asked for a password it can serve+itself. New verb `pup_vault_sync {peer, reason}`: one consent toast on this box, then a BIDIRECTIONAL+merge with the named peer desktop (`targets` names them). Both boxes end with the UNION of their+adom-you logins, newest-wins on a conflict. Passwords move pup-to-pup over ab cross-desktop egress+and never touch an AI. Internal `pup_vault_export` / `pup_vault_import` are refused for cloud/relay+callers. Pairs with the browser-login import (`pup_import_browser_logins`) as the two ways the+adom-you vault grows and stays shared.+ ## 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
rust/dashboard/src/hydrogen.rs+10−7@@ -14,13 +14,16 @@ use std::time::{Duration, Instant}; const DISCOVERY_PORTS: [u16; 4] = [47080, 48080, 49180, 50280]; const SEND_MIN_RELEASE: [u64; 3] = [1, 0, 142];-pub const DEMO_PROMPT: &str = "Run the pup demo and show me what pup can do on my machine.\n\n\-1. Open a pup window to https://wiki.adom.inc/adom/pup-bridge. Tell me it opened in the BACKGROUND, pup never steals my screen, and that I can ask you to open one in the foreground any time I want to watch, or just click its taskbar button myself.\n\-2. Screenshot it and show me the result, so I can see you can read the page without touching my browser.\n\-3. Record a short clip of the window. Mention highFps mode, which records demo video at full frame rate while the window stays in the background.\n\-4. Annotate the page: draw on it and capture it, and show me the gliding cursor with its intent narration so I can see what you are doing and why.\n\-5. Read the pup dashboard LED status and tell me each light. If anything is not green, name the exact verb that fixes it and offer to run it.\n\n\-Then close the demo window so nothing is left on my desktop.";+pub const DEMO_PROMPT: &str = "Run the pup demo tour on my machine and narrate it. It is eight beats; use ab's demo kit (desktop_demo_panel for pacing, desktop_caption for narration) and keep every window in the background unless a beat says otherwise. tools/pup-demo.py in the pup-bridge repo is the reference script.\n\n\+1. THE PUP WINDOW: pup_open_window {foreground:true, foregroundReason} to https://wiki.adom.inc/adom/pup-bridge. Tell me it is a real Chrome for Testing in a profile pup owns, driven over CDP, normally born in the background. Point out the Adom toolbar and the owner chip.\n\+2. TASKBAR IDENTITY: point at the taskbar button: the AI thread name on the Adom tile plus the page favicon badge. Then pup_progress {mode:value, value:30, note}, then 75, then indeterminate, then off, and explain a long task paints the button.\n\+3. JUMP LISTS: tell me to right-click that button: switch wiki view, annotate, open the dashboard, close all pup windows. Every task is a pup verb.\n\+4. TOOLBAR, ANNOTATE, ADOM-YOU: pup_annotate {tool:pen}, draw a stroke with pup_input_dispatch, and explain the capture goes to the clipboard and the owning AI. Then explain adom-you: one durable profile every window shares, whose logins grow a keychain vault.\n\+5. GOOGLE LOGIN: navigate to accounts.google.com/signin, pup_click #identifierId, pup_type the vault username, pup_click #identifierNext, then pup_login {submit:true, submitReason}. If it answers blocked_on_user with a wall, say so honestly: pup filled and submitted, Google asked to verify, pup hands me the wheel.\n\+6. SCREENSHOTS FOR THE AI: pup_screenshot and pup_screenshot_element {selector:h1}; explain these are how you read what I see, resized for a vision model.\n\+7. ULTRALIBRARIAN LOGIN: navigate to https://app.ultralibrarian.com/Account/Login?returnUrl=%252fsearch and pup_login {submit:true, submitReason}; read the Hello line back to me.\n\+8. CLOSE ALL FROM THE JUMP LIST: explain the Close ALL Adom Pup windows task, then pup_close {all:true, everyone:true}. Confirm nothing is left on my desktop.\n\n\+Never ask me for a password: every login comes from the adom-you vault (pup_credentials lists it; pup_vault_sync brings another desktop's logins over)."; static CTL: Mutex<Option<(String, Instant)>> = Mutex::new(None); static GATE: Mutex<Option<(Instant, bool, String)>> = Mutex::new(None);
rust/verbs/src/identity.rs+15−2@@ -25,6 +25,19 @@ fn icons_dir(ctx: &Ctx) -> PathBuf { ctx.host.temp_dir().join("pup-icons").join( fn tiles_dir(ctx: &Ctx) -> PathBuf { ctx.host.temp_dir().join("pup-thread-tiles").join(id::TILE_GEN) } /// Boot: icons on disk, the jump-list launcher, the port-discovery file, the base AUMID.+/// The register_app_identity args. Registry-only by default (no Start Menu clutter). With the+/// `aumidShortcut` setting on, ab also writes the Start Menu .lnk carrying the AUMID: the anchor+/// Windows reads to resolve a custom jump list on the FIRST right-click (ab#155: without it the+/// menu paints, re-resolves and dismisses, and only the second click sticks).+fn register_args(ctx: &Ctx, app_id: &str, display: &str, icon: &str) -> Value {+ if ctx.settings.get("aumidShortcut", "off") == "on" {+ let exe = std::env::current_exe().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();+ json!({ "appId": app_id, "displayName": display, "iconPath": icon, "shortcut": true, "target": exe, "shortcutName": display })+ } else {+ json!({ "appId": app_id, "displayName": display, "iconPath": icon, "shortcut": false })+ }+}+ pub fn boot(ctx: &Ctx) { if let Err(e) = id::write_icons(&icons_dir(ctx)) { pup_core::log(format!("[identity] icons not written: {e}")); } let lad = local_app_data(ctx);@@ -33,7 +46,7 @@ pub fn boot(ctx: &Ctx) { let script = id::launcher_script(); if std::fs::read_to_string(&launcher).ok().as_deref() != Some(script.as_str()) { let _ = std::fs::write(&launcher, script); } let _ = std::fs::write(id::port_file_path(&lad), json!({ "port": ctx.port, "pid": std::process::id(), "version": pup_core::version(), "host": "127.0.0.1", "assignedPort": ctx.port, "shell": "rust" }).to_string());- match ctx.ab.call("desktop_register_app_identity", json!({ "appId": id::APP_ID, "displayName": id::DISPLAY_NAME, "iconPath": icons_dir(ctx).join("adom-pup.ico").to_string_lossy(), "shortcut": false }), &internal_caller(), AB_T) {+ match ctx.ab.call("desktop_register_app_identity", register_args(ctx, id::APP_ID, id::DISPLAY_NAME, &icons_dir(ctx).join("adom-pup.ico").to_string_lossy()), &internal_caller(), AB_T) { Ok(_) => pup_core::log("[identity] base AUMID registered (Adom.Pup)"), Err(e) => pup_core::log(format!("[identity] base AUMID register skipped: {e}")), }@@ -94,7 +107,7 @@ pub fn stamp(ctx: &Ctx, sid: &str, caller: &Caller, full: bool) { if grouped { (id::category_aumid(cat.unwrap_or("default")), format!("Adom Pup {}", cat.unwrap_or("window")), icon) } else { (id::session_aumid(&s.id), format!("{} (pup)", name), icon) } }; 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; }+ if let Err(e) = ctx.ab.call("desktop_register_app_identity", register_args(ctx, &app_id, &display, &icon.to_string_lossy()), caller, AB_T) { pup_core::log(format!("[identity] \"{sid}\" register {app_id} failed: {e}")); return; } let launcher = id::launcher_path(&local_app_data(ctx)); 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) {
rust/verbs/src/lib.rs+6−2@@ -181,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_assisted_click", "pup_import_scan", "pup_import_browser_logins"];+ "pup_screenshot_element", "pup_screenshot_full_res", "pup_download", "pup_launch", "pup_deps", "pup_install_toolbar", "pup_reap_profiles", "pup_uninstall", "pup_move_to_workspace", "pup_assisted_click", "pup_import_scan", "pup_import_browser_logins", "pup_vault_sync", "pup_vault_export", "pup_vault_import"]; pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { let t0 = Instant::now();@@ -275,7 +275,11 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "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.")),+ "pup_vault_sync" => vault::vault_sync(ctx, caller, args),+ "pup_vault_export" => vault::vault_export(ctx, caller),+ "pup_vault_import" => vault::vault_import(ctx, caller, args),+ v if v.starts_with("desktop_record") || v.starts_with("desktop_recorder") => err("wrong_bridge", format!("{v} is ab-core's native desktop recorder (ab 2.1.36+), not a pup verb; this ab routed it to pup by a stale prefix claim (adom-bridge#150)"), Some("Update Adom Bridge so desktop_record_* routes to ab-core. Until then, pup's own screen recorder works: pup_recorder_open {reason} then pup_screen_record_start {confirmDesktopNotTabRecording:true, reason}.")),+ v if v.starts_with("pup_") || 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.")), }; // lastAgentUpdate {tabId, verb, note, ts}: every driving verb that names a window (Node: touch sites)
rust/verbs/src/settings.rs+1−1@@ -14,7 +14,7 @@ pub const KEYS: &[(&str, &str, &str)] = &[ ("toolbarExtension", "on", "on|off"), ("tbSnipHotkey", "on", "on|off"), ("tbFloatyPencil", "off", "on|off"), ("floatyHitPx", "24", "int"), ("tbOwnerBadge", "on", "on|off"), ("annCaptionBar", "on", "on|off"), ("openFlash", "on", "on|off"), ("autoFlash", "on", "on|off"), ("agentActivity", "on", "on|off"), ("taskbarActivity", "progress", "progress|flash|off"),- ("fgWatch", "on", "on|off"), ("fgCaption", "on", "on|off"), ("fgCaptionMs", "3500", "int"), ("notMineCaption", "off", "on|off"),+ ("fgWatch", "on", "on|off"), ("aumidShortcut", "off", "on|off"), ("fgCaption", "on", "on|off"), ("fgCaptionMs", "3500", "int"), ("notMineCaption", "off", "on|off"), ("captureLogins", "on", "on|off"), ("autoLogin", "on", "on|off"), ("ownerChip", "on", "on|off"), ("titleTag", "off", "on|off"), ("titlePrefix", "off", "on|off"), ("sleepRecovery", "on", "on|off"), ("reportIssues", "on", "on|off"), ("minimalTouch", "on", "on|off"),
rust/verbs/src/vault.rs+64−3@@ -99,7 +99,7 @@ pub fn login(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { if let Some(w) = detect_wall(&b.cdp, &t.cdp_session) { let url_now = b.cdp.target_info(&t.cdp_session).ok().and_then(|i| i["url"].as_str().map(|s| s.to_string())).unwrap_or(cur_url.clone()); pup_core::log(format!("[login] {live_host}: submitted, now on a {} wall — blocked_on_user", w["kind"].as_str().unwrap_or("?")));- return blocked_on_user(false, if w["kind"] == json!("captcha") { "The user must solve the captcha in the pup window" } else { "The user must enter the one-time / two-factor code in the pup window" }, &format!("The login was filled and submitted; the site now needs the HUMAN ({}). Raise the window with pup_raise_os_window {{foregroundReason:\"finish the sign-in for {live_host}\"}} and tell them what to do, then continue (screenshot to confirm). Do not re-submit the password.", w["kind"].as_str().unwrap_or("?")), json!({ "reason": w["kind"], "evidence": w["evidence"], "output": json!({ "ok": false, "url": url_now, "submitted": true, "filledUsername": filled_user, "filledPassword": filled_pass, "wall": w["kind"] }).to_string() }));+ return blocked_on_user(false, if w["kind"] == json!("captcha") { "The user must solve the captcha in the pup window" } else { "The user must enter the one-time / two-factor code in the pup window" }, &format!("The login was filled and submitted; the site now needs the HUMAN ({}). Raise the window with pup_raise_os_window {{foregroundReason:\"finish the sign-in for {live_host}\"}} and tell them what to do, then continue (screenshot to confirm). Do not re-submit the password.", w["kind"].as_str().unwrap_or("?")), json!({ "credentialSource": source, "filledUsername": filled_user, "filledPassword": filled_pass, "reason": w["kind"], "evidence": w["evidence"], "output": json!({ "ok": false, "url": url_now, "submitted": true, "filledUsername": filled_user, "filledPassword": filled_pass, "wall": w["kind"] }).to_string() })); } } let react_miss = !keystrokes && !filled_pass;@@ -120,7 +120,7 @@ pub fn reveal(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let Some(cred) = ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), None) else { return err("no_credential", format!("no saved login for {host}"), Some("pup_credentials {} lists the hosts the vault knows.")) }; let who = if caller.thread.is_empty() { "an unnamed local caller".to_string() } else { caller.thread.clone() }; let ask = ctx.consent.ask(ctx.ab.clone(), caller, &format!("pup_credential_reveal|{host}|{}", caller.thread), "pup_credential_reveal", &format!("Waiting for the user to allow revealing the {host} login to {who}"), "credreveal",- json!({ "level": "warning", "scenario": "reminder", "title": format!("Reveal your {host} login to {who}?"), "body": format!("{who} is asking pup for the saved login for {host} (username {}). Reason given: \"{reason}\". Reveal sends the password to that caller ONCE. Deny sends nothing. This request came from a process on THIS desktop.", cred.username), "buttons": [{ "label": "Reveal" }, { "label": "Deny" }] }));+ json!({ "level": "warning", "scenario": "reminder", "title": format!("Reveal your {host} login to {who}?"), "body": format!("{who} is asking pup for the saved login for {host} (username {}). Reason given: \"{reason}\". Reveal sends the password to that caller ONCE. Deny sends nothing. This request came from a process on THIS desktop.", cred.username), "buttons": [{ "label": "Reveal" }, { "label": "Deny" }], "reason": reason, "aiThread": caller.thread, "delegate": "puppeteer" })); match ask { Ask::Pending { consent_id, remaining_sec, .. } => blocked_on_user(true, &format!("The user must click Reveal or Deny on the desktop toast \"Reveal your {host} login to {who}?\""), &format!("Not an error. The toast stays up for {remaining_sec}s more. Call pup_credential_reveal again with the SAME args to re-attach and collect the decision (no second toast), or poll pup_readiness {{}} and watch active[] for consentId {consent_id}."), json!({ "consentId": consent_id, "remainingSec": remaining_sec })), Ask::Decided { decision: Some(d), .. } if d == "Reveal" => {@@ -150,7 +150,7 @@ pub fn fill_card(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let who = if caller.thread.is_empty() { "an unnamed caller".to_string() } else { caller.thread.clone() }; let (brand, last4) = (meta["brand"].as_str().unwrap_or("Card").to_string(), meta["last4"].as_str().unwrap_or("????").to_string()); let ask = ctx.consent.ask(ctx.ab.clone(), caller, &format!("pup_fill_card|{nickname}|{merchant}|{}", caller.thread), "pup_fill_card", &format!("Waiting for the user to approve the {brand} …{last4} fill on {merchant}"), "cardfill",- json!({ "level": "warning", "scenario": "reminder", "title": format!("Pay with {brand} …{last4} on {merchant}?"), "body": format!("{who} wants to fill your card \"{nickname}\" ({brand} ending {last4}) into the checkout on {merchant}{}. Approve fills the card fields in the pup window (the digits never leave this machine). Deny fills nothing.", if amount.is_empty() { String::new() } else { format!(" for {amount}") }), "buttons": [{ "label": "Approve" }, { "label": "Deny" }] }));+ json!({ "level": "warning", "scenario": "reminder", "title": format!("Pay with {brand} …{last4} on {merchant}?"), "body": format!("{who} wants to fill your card \"{nickname}\" ({brand} ending {last4}) into the checkout on {merchant}{}. Approve fills the card fields in the pup window (the digits never leave this machine). Deny fills nothing.", if amount.is_empty() { String::new() } else { format!(" for {amount}") }), "buttons": [{ "label": "Approve" }, { "label": "Deny" }], "reason": args["reason"].as_str().unwrap_or(""), "aiThread": caller.thread, "delegate": "puppeteer" })); match ask { Ask::Pending { consent_id, remaining_sec, .. } => return blocked_on_user(true, &format!("The user must click Approve or Deny on the desktop toast \"Pay with {brand} …{last4} on {merchant}?\""), &format!("Not an error. The toast stays up for {remaining_sec}s more. Call pup_fill_card again with the SAME args to re-attach and collect the decision, or poll pup_readiness active[] for consentId {consent_id}."), json!({ "consentId": consent_id, "remainingSec": remaining_sec })), Ask::Decided { decision: Some(d), .. } if d == "Approve" => {}@@ -193,3 +193,64 @@ pub fn autofill_on_load(ctx: &Ctx, s: &Session, cdp_session: &str, url: &str) -> if v["plen"].as_u64().unwrap_or(0) == 0 { return None; } Some(format!("pup AUTO-FILLED this login for saved host \"{}\" (username {}) in the BACKGROUND, NOT submitted. CONFIRM the live domain is the intended site, then pup_login {{submit:true, submitReason}} to sign in.", cred.host, cred.username)) }++// ── pup-to-pup vault sync (John: a real bidirectional union, not a one-way push) ─────────────+// Same-account desktops each build up different logins; sync merges BOTH directions so both boxes+// end with the union. Passwords move pup->pup over ab's cross-desktop egress and never touch an AI.++/// Refuse a cloud/relay caller (a container). A LOCAL caller or a same-account bridge egress is fine+/// (both boxes are the user's own, authenticated to the same account through ab).+fn vault_sync_origin_ok(caller: &Caller) -> bool { caller.container.is_empty() } // a cloud container sets `container`; a same-account desktop egress does not (it is trusted even though the transport is the relay)++pub fn vault_export(ctx: &Ctx, caller: &Caller) -> Value {+ if !vault_sync_origin_ok(caller) { return err("relay_forbidden", "pup_vault_export hands out passwords and is refused for cloud/relay callers; it is for a same-account desktop sync only.", None); }+ let entries = ctx.vault.export_all(&*ctx.host);+ pup_core::log(format!("[vault-sync] exported {} credential(s) to a same-account peer (caller \"{}\")", entries.len(), caller.thread));+ ok(json!({ "success": true, "count": entries.len(), "entries": entries, "output": json!({ "ok": true, "count": entries.len(), "entries": entries }).to_string() }))+}++pub fn vault_import(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ if !vault_sync_origin_ok(caller) { return err("relay_forbidden", "pup_vault_import is refused for cloud/relay callers; it is for a same-account desktop sync only.", None); }+ let entries: Vec<Value> = args["entries"].as_array().cloned().unwrap_or_default();+ let (added, updated, skipped) = ctx.vault.import_merge(&*ctx.host, &entries);+ pup_core::log(format!("[vault-sync] merged a peer's vault: +{added} new, {updated} updated, {skipped} skipped"));+ ok(json!({ "success": true, "added": added, "updated": updated, "skipped": skipped, "output": json!({ "ok": true, "added": added, "updated": updated, "skipped": skipped }).to_string() }))+}++/// pup_vault_sync {peer, reason}: one consent toast on THIS box, then a bidirectional merge with the+/// named peer desktop. Both boxes end with the union of their adom-you logins (newest-wins on conflict).+pub fn vault_sync(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let peer = args["peer"].as_str().unwrap_or("").trim().to_string();+ let reason = args["reason"].as_str().unwrap_or("").trim().to_string();+ if peer.is_empty() || reason.len() < 8 { return err("args_required", "pup_vault_sync needs {peer, reason} (peer = the other desktop's name from `targets`; reason 8+ chars, shown to the user)", None); }+ let mine = ctx.vault.list().len();+ let ask = ctx.consent.ask(ctx.ab.clone(), caller, &format!("pup_vault_sync|{peer}"), "pup_vault_sync",+ &format!("Waiting for you to allow syncing your adom-you logins with {peer}"), "pup-vault-sync",+ json!({ "level": "warning", "scenario": "reminder", "title": format!("Sync your saved logins with {peer}?"), "body": format!("pup will merge this desktop's {mine} saved logins with {peer}'s, both directions, so both boxes have all of them. Passwords move pup-to-pup and are never shown. Reason: {reason}"), "buttons": [{ "label": "Allow" }, { "label": "Deny" }], "reason": reason, "aiThread": caller.thread, "delegate": "puppeteer" }));+ match ask {+ Ask::Pending { consent_id, remaining_sec, .. } => return blocked_on_user(true, &format!("The user must click Allow on the \"Sync your saved logins with {peer}?\" toast"), &format!("No sync yet. Re-call pup_vault_sync with the same args to re-attach (no second toast); it decides within {remaining_sec}s."), json!({ "consentId": consent_id })),+ Ask::Decided { decision: Some(d), .. } if d == "Allow" || d == "Sync" || d == "Reveal" => {}+ Ask::Decided { decision, .. } => return err(if decision.is_some() { "consent_declined" } else { "consent_timeout" }, format!("Vault sync with {peer} was not authorized."), None),+ }+ // 1) push MINE to the peer (peer merges)+ let my_entries = ctx.vault.export_all(&*ctx.host);+ let internal = crate::identity::internal_caller();+ let push = match ctx.ab.call_target(&peer, "pup_vault_import", json!({ "entries": my_entries }), &internal, std::time::Duration::from_secs(30)) {+ Ok(v) => v, Err(e) => return err("peer_unreachable", format!("could not reach {peer}'s pup to push the vault: {e}"), Some("Is the peer desktop online and on a pup build with vault sync? `targets` lists connected desktops.")),+ };+ let push_in = push.get("output").and_then(|v| v.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()).unwrap_or(push.clone());+ // 2) pull the PEER's and merge locally+ let pulled = match ctx.ab.call_target(&peer, "pup_vault_export", json!({}), &internal, std::time::Duration::from_secs(30)) {+ Ok(v) => v, Err(e) => return err("peer_unreachable", format!("pushed to {peer} but could not pull its vault back: {e}"), None),+ };+ let peer_entries: Vec<Value> = pulled.get("entries").and_then(|v| v.as_array()).cloned()+ .or_else(|| pulled.get("output").and_then(|v| v.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()).and_then(|v| v["entries"].as_array().cloned())).unwrap_or_default();+ let (added, updated, skipped) = ctx.vault.import_merge(&*ctx.host, &peer_entries);+ let total = ctx.vault.list().len();+ pup_core::log(format!("[vault-sync] with {peer}: pushed {} mine, pulled {} theirs (+{added} new, {updated} updated here); union now {total}", my_entries.len(), peer_entries.len()));+ ok(json!({ "success": true, "output": json!({ "ok": true, "peer": peer,+ "pushedToPeer": { "sent": my_entries.len(), "added": push_in["added"], "updated": push_in["updated"], "skipped": push_in["skipped"] },+ "pulledFromPeer": { "received": peer_entries.len(), "added": added, "updated": updated, "skipped": skipped },+ "unionHere": total,+ "_hint": format!("Both desktops now hold the union of their adom-you logins ({total} here). Newest-wins settled any conflicts. Run pup_vault_sync again anytime a box has learned new logins.") }).to_string() }))+}
tools/pup-demo.pyadded+186@@ -0,0 +1,186 @@+#!/usr/bin/env python3+"""The pup demo tour (John, 2026-09-05): eight beats, driven at the user's pace through ab's demo kit+(transport panel + captions + the Adom cursor), recorded with pup's own screen recorder, on a desktop+the AI may take over. Modeled on kicad's demo: every beat narrates, points, and leaves a screenshot.++ tools/pup-demo.py --target arav-rog [--no-record] [--no-panel] [--beats 1,2,3]++Beats: 1 the pup window · 2 taskbar identity (tile, favicon badge, progress bar) · 3 jump lists ·+4 the toolbar, annotate, and the adom-you profile · 5 automated Google login (to the 2FA wall) ·+6 screenshots for the AI · 7 UltraLibrarian login · 8 close-all from the jump list.+Rules from the demo skills: foreground ONCE then yield; one big centred window (98% of the work+area); cursor hidden after the last prelude click; never pace with sleeps the user cannot pause.+"""+import argparse, json, subprocess, sys, time++THREAD = "pup demo"+WIKI = "https://wiki.adom.inc/adom/pup-bridge"+GOOGLE_USER = "[email protected]"+STOPS = ["The pup window", "Taskbar identity", "Jump lists", "Toolbar, annotate, adom-you", "Google login", "Screenshots for the AI", "UltraLibrarian login", "Close all from the jump list"]++class Demo:+ def __init__(self, target, record=True, panel=True):+ self.t, self.record, self.panel = target, record, panel+ self.rec_id = None; self.hwnd = None; self.stage = 0; self.shots = []+ def ab(self, verb, args, timeout=120):+ p = subprocess.run(['adom-bridge', '--target', self.t, '--ai-thread', THREAD, verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout + 30)+ try: d = json.loads(p.stdout)+ except Exception: return {'error': (p.stdout + p.stderr)[-200:]}+ o = d.get('output'); inner = json.loads(o) if isinstance(o, str) and o.startswith('{') else {}+ return {**inner, **d}+ def cap(self, text, ms=6000):+ print(f' ▶ {text}'); self.ab('desktop_caption', {'id': 'pup-demo', 'text': text, 'duration': ms, 'position': 'bottom', 'size': 'large'})+ def say(self, text, ms=6000, hold=None):+ self.cap(text, ms); self.wait(hold if hold is not None else min(ms / 1000, 5))+ def wait(self, sec):+ # pace through the panel: a paused user holds us; a stop ends the show+ end = time.time() + sec+ while time.time() < end:+ time.sleep(min(2, max(0.2, end - time.time())))+ if self.panel: self.poll()+ def poll(self):+ r = self.ab('desktop_demo_panel', {'action': 'poll', 'id': 'pup-tour'})+ ev = r.get('event') or (r.get('events') or [None])[-1]+ if ev == 'stop': raise SystemExit('user stopped the tour')+ while r.get('state') == 'paused':+ time.sleep(2); r = self.ab('desktop_demo_panel', {'action': 'poll', 'id': 'pup-tour'})+ if (r.get('event') or '') == 'stop': raise SystemExit('user stopped the tour')+ def stop(self, n):+ self.stage = n; print(f'\n=== {n+1}. {STOPS[n]}')+ if self.panel: self.ab('desktop_demo_panel', {'action': 'show', 'id': 'pup-tour', 'title': 'pup tour', 'stops': STOPS, 'stage': n, 'state': 'playing', 'reason': 'Guided tour of pup, driven at your pace.'})+ def shot(self, label, **kw):+ r = self.ab('pup_screenshot', {'window': 'pup-demo', **kw}); fp = r.get('filePath')+ if fp: self.shots.append({'label': label, 'path': fp}); print(f' 📷 {label}: {fp}')+ return r++ # ── recording ──+ def rec_start(self):+ if not self.record: return+ self.ab('pup_recorder_open', {'reason': 'Recording the pup demo tour.'}); time.sleep(4)+ r = self.ab('pup_screen_record_start', {'confirmDesktopNotTabRecording': True, 'reason': 'Recording the pup demo tour.', 'fps': 30, 'maxDurationMs': 15 * 60 * 1000})+ self.rec_id = r.get('recordingId'); print(f' ● recording {self.rec_id} -> {r.get("filePath")}')+ def rec_stop(self):+ if not self.rec_id: return None+ r = self.ab('pup_screen_record_stop', {}); self.ab('pup_recorder_close', {})+ print(f' ■ recording saved: {r.get("filePath")} ({r.get("sizeKB")} KB)'); return r.get('filePath')++ # ── beats ──+ def beat1_window(self):+ self.stop(0)+ self.cap('This is a pup window: a real Chrome for Testing, in a profile pup owns, driven by the AI over CDP. It opens in the background by default; today it comes forward because we are watching.', 9000)+ r = self.ab('pup_open_window', {'window': 'pup-demo', 'url': WIKI, 'owner': THREAD, 'foreground': True, 'foregroundReason': 'the pup demo tour: the user asked to watch pup on this screen'}, timeout=150)+ self.hwnd = r.get('hwnd'); print(f' window hwnd={self.hwnd} browser={ (r.get("browser") or {}).get("kind") }')+ # one big centred window: 98% of the primary work area+ mons = self.ab('desktop_list_monitors', {}); ms = mons.get('monitors') or []+ wa = next((m.get('workArea') for m in ms if m.get('primary')), None) or (ms[0].get('workArea') if ms else None)+ if wa and self.hwnd:+ w, h = int(wa['width'] * 0.98), int(wa['height'] * 0.98); x, y = wa['x'] + (wa['width'] - w) // 2, wa['y'] + (wa['height'] - h) // 2+ self.ab('desktop_set_window_bounds', {'hwnd': self.hwnd, 'x': x, 'y': y, 'w': w, 'h': h})+ self.ab('desktop_cursor', {'action': 'hide'})+ self.wait(3); self.shot('window')+ self.say('Notice the toolbar: pup ships small extensions that ride every launch, and the owner chip bottom-left names the AI thread that owns this window.', 8000)++ def beat2_taskbar(self):+ self.stop(1)+ self.say('Look at the taskbar. That button is not "Google Chrome for Testing": pup stamps it with the AI thread name on the Adom tile, plus the page favicon as a badge. Every thread gets its own button.', 9000)+ self.cap('A long task paints a progress bar on the button, so you see the work without opening the window.', 6000)+ self.ab('pup_progress', {'window': 'pup-demo', 'mode': 'value', 'value': 30, 'note': 'demo: 3 of 10 pages'}); self.wait(2.5)+ self.ab('pup_progress', {'window': 'pup-demo', 'mode': 'value', 'value': 75, 'note': 'demo: 8 of 10 pages'}); self.wait(2.5)+ self.ab('pup_progress', {'window': 'pup-demo', 'mode': 'indeterminate', 'note': 'demo: pulsing'}); self.wait(2.5)+ self.ab('pup_progress', {'window': 'pup-demo', 'mode': 'off'}); self.wait(1)++ def tray_hwnd(self):+ if getattr(self, '_tray', None): return self._tray+ ps = 'Add-Type -Namespace W -Name U -MemberDefinition \'[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string c, string t);\'\n$h = [W.U]::FindWindow("Shell_TrayWnd", $null); "TRAY_HWND=" + [int64]$h\n'+ self.ab('desktop_write_file', {'path': 'C:/Users/Public/pup-tray.ps1', 'content': ps, 'reason': 'find the taskbar hwnd for the demo tour'})+ r = self.ab('shell_execute', {'command': 'powershell -NoProfile -ExecutionPolicy Bypass -File C:/Users/Public/pup-tray.ps1', 'timeoutSeconds': 30, 'reason': 'find the taskbar hwnd for the demo tour'})+ import re; m = re.search(r'TRAY_HWND=(\d+)', r.get('stdout') or ''); self._tray = int(m.group(1)) if m else None; return self._tray++ def taskbar_button(self):+ """The pup window's taskbar button rect: the tile named after the thread, else the stock CfT button."""+ tray = self.tray_hwnd()+ if not tray: return None+ t = self.ab('desktop_ui_tree', {'hwnd': tray, 'maxDepth': 6}); found = {}+ def walk(n):+ if isinstance(n, dict):+ nm = str(n.get('name') or ''); rect = n.get('rect') or n.get('bounds')+ if (n.get('role') or n.get('controlType')) == 'Button' and rect:+ if nm.lower().startswith(THREAD.lower() + ' - '): found.setdefault('tile', rect)+ elif nm.startswith('Google Chrome for Testing - '): found.setdefault('cft', rect)+ for c in (n.get('children') or []): walk(c)+ elif isinstance(n, list):+ for c in n: walk(c)+ walk(t.get('root') or t.get('tree') or t)+ rect = found.get('tile') or found.get('cft'); print(f' taskbar button: {"tile" if found.get("tile") else ("cft" if rect else "none")} {rect}'); return rect++ def screen(self, label):+ r = self.ab('desktop_screenshot_screen', {}); fp = r.get('localSafePath') or r.get('path') or r.get('filePath')+ if fp: self.shots.append({'label': label, 'path': fp, 'screen': True}); print(f' 🖥 {label}: {fp}')+ return fp++ def beat3_jumplists(self):+ self.stop(2)+ self.say('Right-click that taskbar button and pup gives you a jump list: switch a wiki page between logged-in and public, annotate the page, open the dashboard, or close every pup window. Go ahead and right-click it now if you like; every task in it is a pup verb, which is what the last beat uses.', 11000)+ self.screen('taskbar-tile')++ def beat4_toolbar_annotate(self):+ self.stop(3)+ self.say('The pen in the toolbar arms annotate: draw on any page, and the moment you finish the marked-up region is captured, copied to your clipboard, and sent to the owning AI.', 8000)+ self.ab('pup_annotate', {'window': 'pup-demo', 'tool': 'pen'}); self.wait(2)+ pts = [(420, 320), (520, 330), (640, 360), (760, 350), (880, 330)]+ self.ab('pup_input_dispatch', {'window': 'pup-demo', 'type': 'mousePressed', 'x': pts[0][0], 'y': pts[0][1], 'button': 'left'})+ for x, y in pts[1:]: self.ab('pup_input_dispatch', {'window': 'pup-demo', 'type': 'mouseMoved', 'x': x, 'y': y, 'button': 'left'}); time.sleep(0.12)+ self.ab('pup_input_dispatch', {'window': 'pup-demo', 'type': 'mouseReleased', 'x': pts[-1][0], 'y': pts[-1][1], 'button': 'left'})+ self.wait(4); self.shot('annotate')+ self.say('And the identity tile: every pup window surfs as adom-you, ONE durable profile shared by every window. Each login it makes grows a vault in the OS keychain, so over time pup can sign you into everything it has ever seen.', 10000)++ def beat5_google(self):+ self.stop(4)+ self.say('Automated Google login. The credential came from the vault, imported from your own browser and synced from your laptop; pup types nothing you have to give it.', 8000)+ self.ab('pup_navigate', {'window': 'pup-demo', 'url': 'https://accounts.google.com/signin/v2/identifier'}); self.wait(4)+ self.ab('pup_click', {'window': 'pup-demo', 'selector': '#identifierId'}); self.ab('pup_type', {'window': 'pup-demo', 'text': GOOGLE_USER})+ self.ab('pup_click', {'window': 'pup-demo', 'selector': '#identifierNext'}); self.wait(5)+ r = self.ab('pup_login', {'window': 'pup-demo', 'submit': True, 'preferAccount': GOOGLE_USER, 'submitReason': 'accounts.google.com is Google itself; the demo signs into the user own Google account from the adom-you vault'}, timeout=90)+ self.wait(5); self.shot('google')+ if r.get('errorCode') == 'blocked_on_user':+ self.say(f'pup filled and submitted the password, then Google asked to verify it is really you ({r.get("wall")}). pup detects that wall and hands you the wheel instead of pretending. A real login, honestly reported.', 9000)+ else:+ self.say('Signed in. No dropdown, no foregrounding, and the domain was confirmed before the secret went out.', 6000)++ def beat6_screenshots(self):+ self.stop(5)+ self.ab('pup_navigate', {'window': 'pup-demo', 'url': WIKI}); self.wait(4)+ self.say('Screenshots for the AI. This is how the AI reads what you see: a full window capture, resized to the size a vision model reads best, saved on this machine.', 7000)+ self.shot('page')+ r = self.ab('pup_screenshot_element', {'window': 'pup-demo', 'selector': 'h1'})+ if r.get('filePath'): self.shots.append({'label': 'element', 'path': r['filePath']})+ self.say('Or just one element, cropped tight, so the AI spends its attention on the thing that matters.', 6000)++ def beat7_ultralibrarian(self):+ self.stop(6)+ self.say('UltraLibrarian, the CAD library vendor. Same vault, a real sign-in: pup fills, confirms the live domain, submits.', 7000)+ self.ab('pup_navigate', {'window': 'pup-demo', 'url': 'https://app.ultralibrarian.com/Account/Login?returnUrl=%252fsearch'}); self.wait(6)+ r = self.ab('pup_login', {'window': 'pup-demo', 'submit': True, 'submitReason': 'sso.ultralibrarian.com is the UltraLibrarian sign-in the user imported a login for; the demo signs in from the adom-you vault'}, timeout=90)+ self.wait(7); self.shot('ultralibrarian')+ who = self.ab('pup_eval', {'window': 'pup-demo', 'expr': '(document.body.innerText.match(/Hello [^\\n]+/)||[""])[0]'}).get('result') or ''+ self.say(f'Signed in{": " + who if who else ""}. That is a vendor portal pup can now use for you: search, download a part, all as you.', 7000)++ def beat8_close_all(self):+ self.stop(7)+ self.say('Last: the jump list task "Close ALL Adom Pup windows". One click there, or the same verb from an AI, and every pup window on this desktop is gone. Nothing left on your desktop.', 7000)+ self.ab('pup_close', {'all': True, 'everyone': True}); self.wait(3); self.screen('after-close-all')+ self.say('That is pup. Every window, tab, login and screenshot you saw was the AI driving a browser as you, without ever taking your desktop away.', 8000)++ def run(self, beats):+ self.rec_start()+ try:+ for n in beats: getattr(self, ['beat1_window','beat2_taskbar','beat3_jumplists','beat4_toolbar_annotate','beat5_google','beat6_screenshots','beat7_ultralibrarian','beat8_close_all'][n])()+ finally:+ if self.panel: self.ab('desktop_demo_panel', {'action': 'hide', 'id': 'pup-tour'})+ self.ab('pup_close', {'all': True, 'everyone': True}); self.ab('desktop_cursor', {'action': 'hide'})+ video = self.rec_stop()+ print('\nSHOTS:', json.dumps(self.shots)); print('VIDEO:', video)++if __name__ == '__main__':+ ap = argparse.ArgumentParser(); ap.add_argument('--target', required=True); ap.add_argument('--no-record', action='store_true'); ap.add_argument('--no-panel', action='store_true'); ap.add_argument('--beats', default='0,1,2,3,4,5,6,7')+ a = ap.parse_args(); Demo(a.target, record=not a.no_record, panel=not a.no_panel).run([int(x) for x in a.beats.split(',')])