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.
master
John Lauer
v2.0.727 (insiders): A pup window waiting on a slow page now explains itself instead of showing a blank PUPBIRTH tab: w…
b75d102
22d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
//! adom-pup-bridge: the pup exe (spawn.kind exe). The plumbing only: bind ADOM_BIND_HOST on the
//! port ab passes as --port, answer /health and /status at once (ab reaps a bridge whose port is
//! not up in ~3s), route POST /command into pup-verbs with the caller identity ab forwards, and
//! run the boot work (cache prune, Chrome for Testing warmup, stale session files) off the
//! request path. Every request runs on its own thread; a panic costs one request.
use pup_core::{Caller, Loopback, NoAb, Settings};
use pup_host::Host;
use pup_verbs::Ctx;
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tiny_http::{Header, Method, Response, Server};
#[cfg(windows)]
fn host() -> Arc<dyn Host> { Arc::new(pup_host_win::WinHost) }
#[cfg(target_os = "macos")]
fn host() -> Arc<dyn Host> { Arc::new(pup_host_mac::MacHost) }
#[cfg(not(any(windows, target_os = "macos")))]
fn host() -> Arc<dyn Host> { Arc::new(pup_host_win::WinHost) } // dev box: the stubbed impl, so the exe links and /health answers
fn json_response(status: u16, v: &Value) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_data(serde_json::to_vec(v).unwrap_or_default()).with_status_code(status)
.with_header(Header::from_bytes("Content-Type", "application/json").unwrap())
.with_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap())
}
fn caller_from(req: &tiny_http::Request) -> Caller {
Caller::from_headers(req.headers().iter().map(|h| (h.field.as_str().as_str(), h.value.as_str())))
}
/// `adom-bridge-puppeteer.exe --jumplist <verb> [<base64 json args>]`: the jump-list task / taskbar
/// relaunch callback. Reads the port file the running bridge wrote, POSTs `{command, args}` to its
/// loopback /command as caller user-taskbar-menu, and exits. If no bridge answers (asleep), hands the
/// verb to the ab CLI, which wakes pup. Replaces the Node-era pup-jumplist-call.ps1 (2.0.423).
fn jumplist_call(verb: &str, b64: &str) -> i32 {
let mut args: serde_json::Value = if b64.is_empty() { serde_json::json!({}) } else { pup_cdp::base64_decode(b64).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_else(|| serde_json::json!({})) };
args["caller"] = serde_json::json!({ "aiThread": "user-taskbar-menu" });
let body = serde_json::json!({ "command": verb, "args": args }).to_string();
let lad = std::env::var("LOCALAPPDATA").map(std::path::PathBuf::from).unwrap_or_default();
let port = std::fs::read_to_string(lad.join("Adom Bridge").join("pup-bridge-port.json")).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()).and_then(|v| v["port"].as_u64());
if let Some(port) = port {
let r = ureq::AgentBuilder::new().timeout(std::time::Duration::from_secs(60)).build().post(&format!("http://127.0.0.1:{port}/command"))
.set("Content-Type", "application/json").set("x-adom-caller-thread", "user-taskbar-menu").set("x-adom-caller-reason", "jump-list click").send_string(&body);
if r.is_ok() { return 0; }
}
// pup is not listening: the ab CLI spawns it and relays the verb (same fallback the .ps1 had).
for name in ["adom-bridge.exe", "adom-bridge-cli.exe"] {
let cli = lad.join("Adom Bridge").join(name);
if cli.is_file() {
let mut c = std::process::Command::new(cli); c.arg(verb).arg(args.to_string());
#[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } // CREATE_NO_WINDOW: no console flash
let _ = c.status(); return 0;
}
}
1
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(|a| a == "--jumplist").unwrap_or(false) {
std::process::exit(jumplist_call(args.get(2).map(String::as_str).unwrap_or(""), args.get(3).map(String::as_str).unwrap_or("")));
}
let port: u16 = args.iter().position(|a| a == "--port").and_then(|i| args.get(i + 1)).and_then(|p| p.parse().ok()).unwrap_or(8851);
let bind = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".into());
let h = host();
let bridge_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let state_dir = h.state_dir();
let _ = std::fs::create_dir_all(&state_dir);
pup_core::set_log_file(pup_core::data_subdir("logs", state_dir.join("pup-logs")).join("pup.log"));
pup_core::log(format!("[pup] {} starting; log file {}", pup_core::version(), pup_core::log_file().map(|p| p.display().to_string()).unwrap_or_default()));
let ab: Arc<dyn pup_core::AbClient> = match Loopback::from_env() { Some(l) => Arc::new(l), None => { pup_core::log("[ab] ADOM_DIRECT_API_URL not set: desktop verbs degrade (hand-launched exe?)"); Arc::new(NoAb) } };
let _ = pup_verbs::CAPTURE_HOST.set(h.clone());
let ctx = Arc::new(Ctx {
host: h.clone(), ab, settings: Arc::new(Settings::new(&state_dir)), installer: Arc::new(pup_browser::Installer::new()),
sessions: Arc::new(pup_session::Sessions::new(&pup_browser::sessions_dir(&*h))), browsers: Mutex::new(Default::default()), focus_emul: Mutex::new(Default::default()), pending_press: Mutex::new(Default::default()), browser_stall: Mutex::new(Default::default()), browser_idle_since: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(),
bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None), self_ref: Mutex::new(std::sync::Weak::new()), toasts: Mutex::new(Vec::new()),
vault: pup_vault::Vault::new(&state_dir), cards: pup_vault::Cards::new(&state_dir), consent: pup_verbs::consent::Registry::new(), tab_recs: Default::default(), screen_rec: Default::default(), highfps: Default::default(), last_audit: Mutex::new(None),
});
pup_verbs::install_event_hook(ctx.clone());
*ctx.self_ref.lock().unwrap() = Arc::downgrade(&ctx);
// 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");
// Boot work off the request path: ab's spawn-ready probe must see the port first.
{
let c = ctx.clone();
std::thread::spawn(move || {
let pruned = pup_browser::prune_broken_cache_dirs(&*c.host, None);
if !pruned.is_empty() { pup_core::log(format!("[warmup] pruned {} broken Chrome-for-Testing folder(s)", pruned.len())); }
let cands = pup_browser::launch_candidates(&*c.host, c.prefer_cft());
let cft = pup_browser::detect_cft(&*c.host).is_some();
pup_core::log(format!("[warmup] browsers: {} | cft cached: {cft} | disk free: {}MB", cands.iter().map(|x| format!("{}({})", x.kind, x.source)).collect::<Vec<_>>().join(", "), pup_browser::disk_free_mb(&c.host.cft_cache_dir()).map(|m| m.to_string()).unwrap_or("?".into())));
if !cft {
if c.prefer_cft() { pup_core::log("[warmup] the toolbar policy wants Chrome for Testing — background-fetching so launches carry the toolbar"); }
else if cands.is_empty() { pup_core::log("[warmup] no installed Chromium on this box — background-fetching Chrome for Testing as the last-resort browser"); }
if c.prefer_cft() || cands.is_empty() { c.installer.ensure_background(c.host.clone()); }
}
pup_verbs::identity::boot(&c);
pup_verbs::restore_extdrive(&c);
pup_verbs::recovery::recover_at_boot(&c);
pup_verbs::recovery::start_health_loop(c.clone());
pup_verbs::highfps::start_loop(c.clone());
pup_verbs::extras::start_identity_audit(c.clone());
pup_verbs::extras::start_profile_janitor(c.clone());
{ let c2 = c.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(60)); pup_verbs::identity::sweep_dead_identities(&c2, "boot");
// 2.0.680: a first-install download can still be unpacking at +60 s; wait for it (up to 20 min) so the
// prune never deletes the build being written.
let t0 = std::time::Instant::now();
while c2.installer.installing() && t0.elapsed() < std::time::Duration::from_secs(1200) { std::thread::sleep(std::time::Duration::from_secs(10)); }
let _ = pup_verbs::extras::cache_prune(&c2, false); }); }
if let Ok(v) = c.ab.call("bridge_info", json!({}), &Caller::default(), Duration::from_secs(4)) { *c.ad_version.lock().unwrap() = v.get("version").and_then(|x| x.as_str()).map(|s| s.to_string()); }
});
}
for req in server.incoming_requests() {
let c = ctx.clone();
std::thread::spawn(move || {
let url = req.url().to_string();
let path = url.split('?').next().unwrap_or("").to_string();
match (req.method().clone(), path.as_str()) {
(Method::Get, "/health") => { let _ = req.respond(json_response(200, &pup_dashboard::health(&c))); }
(Method::Get, "/status") | (Method::Get, "/dashboard/status") | (Method::Get, "/dashboard/health") => { let _ = req.respond(json_response(200, &pup_dashboard::status(&c))); }
(Method::Options, _) => { let _ = req.respond(Response::empty(204)); }
// pup-bridge#109: the extension-driven control channel. The pup extension polls for one
// command and posts the result; this is how pup drives a browser that carries NO CDP port.
(Method::Get, "/extcmd") => {
let q = url.split('?').nth(1).unwrap_or("");
let lane = q.split('&').find_map(|kv| kv.strip_prefix("lane=")).unwrap_or("").to_string();
let removed: Vec<i64> = q.split('&').find_map(|kv| kv.strip_prefix("removed=")).unwrap_or("").split(',').filter_map(|x| x.parse().ok()).collect();
let wait: u64 = q.split('&').find_map(|kv| kv.strip_prefix("wait=")).and_then(|x| x.parse().ok()).unwrap_or(0);
let _ = req.respond(json_response(200, &pup_verbs::extdrive::take_command_for(&lane, &removed, wait)));
}
// 2.0.602: the extension lane's login-capture rail. The CDP lane got captures through a
// DevTools binding; the default lane had none, so logins typed in normal pup windows were lost.
(Method::Post, "/extcred") => {
let mut raw = String::new();
let mut req = req;
let _ = req.as_reader().read_to_string(&mut raw);
pup_verbs::vault::on_capture_ext(&c, &raw);
let _ = req.respond(json_response(200, &json!({ "ok": true })));
}
// 2.0.644 (pup-bridge#119): the extension asks for an HTTP auth credential (Basic/Digest) from the vault.
(Method::Post, "/extauth") => {
let mut raw = String::new();
let mut req = req;
let _ = req.as_reader().read_to_string(&mut raw);
let out = pup_verbs::vault::on_auth_required(&c, &raw);
let _ = req.respond(json_response(200, &out));
}
(Method::Post, "/exthello") => {
let mut raw = String::new();
let mut req = req;
let _ = req.as_reader().read_to_string(&mut raw);
pup_verbs::extdrive::hello(serde_json::from_str(&raw).unwrap_or(json!({})));
let _ = req.respond(json_response(200, &json!({ "ok": true })));
}
(Method::Post, "/extresult") => {
let mut raw = String::new();
let mut req = req;
let _ = req.as_reader().read_to_string(&mut raw);
pup_verbs::extdrive::put_result(serde_json::from_str(&raw).unwrap_or(json!({})));
let _ = req.respond(json_response(200, &json!({ "ok": true })));
}
(Method::Post, "/command") => {
let caller = caller_from(&req);
let mut raw = String::new();
let mut req = req;
let _ = req.as_reader().read_to_string(&mut raw);
let parsed: Value = serde_json::from_str(&raw).unwrap_or(json!({}));
let verb = parsed.get("command").and_then(|x| x.as_str()).unwrap_or("").to_string();
let args = parsed.get("args").cloned().unwrap_or(json!({}));
pup_core::log(format!("[verb] {verb}{}", if caller.thread.is_empty() { String::new() } else { format!(" thread={}", caller.thread) }));
let reply = pup_verbs::dispatch(&c, &caller, &verb, &args);
let _ = req.respond(json_response(200, &reply));
}
(method, p) => {
let query = url.split('?').nth(1).unwrap_or("").to_string();
let mut req = req;
let mut body = String::new();
if method == Method::Post { let _ = req.as_reader().read_to_string(&mut body); }
let caller = caller_from(&req);
match pup_dashboard::route(&c, &caller, method.as_str(), p, &query, &body) {
Some(pup_dashboard::Reply::Json(status, v)) => { let _ = req.respond(json_response(status, &v)); }
Some(pup_dashboard::Reply::Bytes(status, ct, bytes, cache)) => {
let mut r = Response::from_data(bytes).with_status_code(status).with_header(Header::from_bytes("Content-Type", ct).unwrap());
if let Some(cc) = cache { r = r.with_header(Header::from_bytes("Cache-Control", cc).unwrap()); }
let _ = req.respond(r);
}
Some(pup_dashboard::Reply::Stream(reader)) => {
let r = Response::new(tiny_http::StatusCode(200), vec![Header::from_bytes("Content-Type", "text/event-stream").unwrap(), Header::from_bytes("Cache-Control", "no-cache").unwrap(), Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()], reader, None, None);
let _ = req.respond(r);
}
None => { let _ = req.respond(json_response(404, &json!({ "success": false, "errorCode": "not_found", "path": p }))); }
}
}
}
});
}
}