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.641 (insiders): background birth measured to one frame: park while invisible + bottomed the instant Chrome first s…
aee3a87
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
//! pup-core: types, settings, the response envelope, caller identity and the AbClient trait.
//! No OS calls live here; everything desktop-shaped goes through `AbClient` (ab's verbs) or
//! the `pup-host` traits. This crate is the root of the dependency tree and changes rarely.
use serde_json::{json, Map, Value};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// The bridge version, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both).
pub const VERSION_RAW: &str = include_str!("../../../src/BRIDGE_VERSION");
pub fn version() -> &'static str { VERSION_RAW.trim() }
// ─── Caller identity (ab stamps it on every inbound verb) ───────────────────────────────
/// Who this verb is acting FOR. ab forwards the caller's identity as X-Adom-Caller-* headers and
/// stamps the transport it verified in X-Adom-Verified-Origin (relay | local | bridge).
#[derive(Debug, Clone, Default)]
pub struct Caller {
pub thread: String,
pub container: String,
pub reason: String,
pub delegate: String,
pub verified_origin: String,
}
impl Caller {
pub fn from_headers<'a>(headers: impl Iterator<Item = (&'a str, &'a str)>) -> Caller {
let mut c = Caller::default();
for (k, v) in headers {
match k.to_ascii_lowercase().as_str() {
"x-adom-caller-thread" => c.thread = v.to_string(),
"x-adom-caller-container" => c.container = v.to_string(),
"x-adom-caller-reason" => c.reason = v.to_string(),
"x-adom-caller-delegate" => c.delegate = v.to_string(),
"x-adom-verified-origin" => c.verified_origin = v.to_string(),
_ => {}
}
}
c
}
pub fn is_relayed(&self) -> bool { self.verified_origin == "relay" || !self.container.is_empty() }
}
// ─── The response envelope (the contract the Node build established) ────────────────────
/// Success: `{ok:true, status:"ok", ...fields}`.
pub fn ok(fields: Value) -> Value {
let mut m = Map::new();
m.insert("ok".into(), json!(true));
m.insert("status".into(), json!("ok"));
if let Value::Object(o) = fields { for (k, v) in o { m.insert(k, v); } }
Value::Object(m)
}
/// Failure: `{success:false, status:"error", errorCode, error, _hint?}`.
pub fn err(code: &str, error: impl Into<String>, hint: Option<&str>) -> Value {
let mut v = json!({ "success": false, "status": "error", "errorCode": code, "error": error.into() });
if let Some(h) = hint { v["_hint"] = json!(h); }
v
}
/// The blocked-on-a-human shape (ab BRIDGE_SDK 2.1.30 rule, pup-bridge#92): never a bare timeout.
pub fn blocked_on_user(still_running: bool, blocked_by: &str, hint: &str, extra: Value) -> Value {
let mut v = json!({ "success": false, "status": "error", "errorCode": "blocked_on_user", "stillRunning": still_running, "blockedBy": blocked_by, "_hint": hint });
if let Value::Object(o) = extra { for (k, x) in o { v[k] = x; } }
v
}
pub fn merge(mut base: Value, extra: Value) -> Value {
if let (Some(b), Value::Object(e)) = (base.as_object_mut(), extra) { for (k, v) in e { b.insert(k, v); } }
base
}
// ─── AbClient: the only way pup touches the desktop ─────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum AbError {
#[error("ab loopback API unreachable: {0}")]
Unreachable(String),
#[error("verb {verb} refused: {code}: {message}")]
Refused { verb: String, code: String, message: String },
#[error("verb {0} failed: {1}")]
Failed(String, String),
}
pub trait AbClient: Send + Sync {
fn call(&self, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError>;
/// Like `call`, but routed to another desktop of the same account (ab cross-desktop egress). The
/// default ignores `target` (single-box / NoAb). Used by pup_vault_sync to reach the peer's pup.
fn call_target(&self, target: &str, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> { let _ = target; self.call(verb, args, caller, timeout) }
/// The ab version this bridge is talking to, if known (bridge_info).
fn version(&self) -> Option<String> { None }
}
/// The loopback direct-API client (identical wire contract to the Node build's abCommand).
pub struct Loopback { pub base: String, pub token: String }
impl Loopback {
pub fn from_env() -> Option<Loopback> {
let mut base = std::env::var("ADOM_DIRECT_API_URL").unwrap_or_default().trim().trim_end_matches('/').to_string();
if base.is_empty() {
let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();
if let Ok(port) = std::fs::read_to_string(Path::new(&home).join(".adom").join("direct-api-port")) {
let port = port.trim();
// the file carries either a bare port or host:port (ab 2.1.x writes "127.0.0.1:24680")
if !port.is_empty() { base = if port.contains(':') { format!("http://{port}") } else { format!("http://127.0.0.1:{port}") }; }
}
}
if base.is_empty() { return None; }
Some(Loopback { base, token: std::env::var("ADOM_BRIDGE_TOKEN").unwrap_or_default() })
}
}
/// ab wraps a bridge verb's JSON in an envelope whose `output` is a JSON STRING; unwrap it, keeping
/// the envelope's own keys (inner wins on a clash).
pub fn unwrap_output(mut v: Value) -> Value {
if let Some(inner) = v.get("output").and_then(|o| o.as_str()) {
if inner.trim_start().starts_with('{') {
if let Ok(mut parsed) = serde_json::from_str::<Value>(inner) {
if let (Some(po), Some(eo)) = (parsed.as_object_mut(), v.as_object()) {
for (k, val) in eo { if k != "output" { po.entry(k.clone()).or_insert(val.clone()); } }
}
v = parsed;
}
}
}
v
}
impl AbClient for Loopback {
fn call(&self, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> {
let agent = ureq::AgentBuilder::new().timeout(timeout.max(Duration::from_secs(1))).build();
let mut req = agent.post(&format!("{}/command", self.base))
.set("Content-Type", "application/json")
.set("X-Adom-Caller-Delegate", "puppeteer");
if !self.token.is_empty() { req = req.set("X-Adom-Bridge-Token", &self.token); }
if !caller.thread.is_empty() { req = req.set("X-Adom-Caller-Thread", &caller.thread); }
if !caller.container.is_empty() { req = req.set("X-Adom-Caller-Container", &caller.container); }
if !caller.reason.is_empty() { req = req.set("X-Adom-Caller-Reason", &caller.reason); }
let resp = req.send_json(json!({"command": verb, "args": args})).map_err(|e| AbError::Unreachable(e.to_string()))?;
let v: Value = resp.into_json().map_err(|e| AbError::Failed(verb.into(), e.to_string()))?;
let v = unwrap_output(v);
if v.get("success") == Some(&Value::Bool(false)) || v.get("status") == Some(&Value::String("error".into())) {
let code = v.get("errorCode").and_then(|c| c.as_str()).unwrap_or("error").to_string();
let message = v.get("error").and_then(|c| c.as_str()).unwrap_or("").to_string();
return Err(AbError::Refused { verb: verb.into(), code, message });
}
Ok(v)
}
fn call_target(&self, target: &str, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> {
// The direct API reads a top-level `target` and egresses to that peer's ab over the relay/mesh.
let agent = ureq::AgentBuilder::new().timeout(timeout.max(Duration::from_secs(2))).build();
let mut req = agent.post(&format!("{}/command", self.base)).set("Content-Type", "application/json").set("X-Adom-Caller-Delegate", "puppeteer");
if !self.token.is_empty() { req = req.set("X-Adom-Bridge-Token", &self.token); }
if !caller.thread.is_empty() { req = req.set("X-Adom-Caller-Thread", &caller.thread); }
if !caller.reason.is_empty() { req = req.set("X-Adom-Caller-Reason", &caller.reason); }
let resp = req.send_json(json!({ "command": verb, "args": args, "target": target })).map_err(|e| AbError::Unreachable(e.to_string()))?;
let v = unwrap_output(resp.into_json().map_err(|e| AbError::Failed(verb.into(), e.to_string()))?);
if v.get("success") == Some(&Value::Bool(false)) || v.get("status") == Some(&Value::String("error".into())) {
return Err(AbError::Refused { verb: verb.into(), code: v.get("errorCode").and_then(|c| c.as_str()).unwrap_or("error").to_string(), message: v.get("error").and_then(|c| c.as_str()).unwrap_or("").to_string() });
}
Ok(v)
}
}
/// No ab at all (hand-launched exe): every desktop verb reports unreachable, nothing panics.
pub struct NoAb;
impl AbClient for NoAb {
fn call(&self, _verb: &str, _args: Value, _caller: &Caller, _timeout: Duration) -> Result<Value, AbError> {
Err(AbError::Unreachable("ADOM_DIRECT_API_URL not set (bridge launched outside ab)".into()))
}
}
// ─── Settings (~/.adom/pup-settings.json, same file and keys as the Node build) ─────────
pub struct Settings { path: PathBuf, cache: std::sync::Mutex<Option<Value>> }
impl Settings {
pub fn new(state_dir: &Path) -> Settings { Settings { path: state_dir.join("pup-settings.json"), cache: std::sync::Mutex::new(None) } }
pub fn all(&self) -> Value {
let mut c = self.cache.lock().unwrap();
if c.is_none() {
let v = std::fs::read_to_string(&self.path).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));
*c = Some(if v.is_object() { v } else { json!({}) });
}
c.clone().unwrap()
}
/// `"on"` / `"off"` style keys; a missing key returns the default the Node build uses.
pub fn get(&self, key: &str, default: &str) -> String {
self.all().get(key).and_then(|v| v.as_str().map(|s| s.to_string()).or_else(|| v.as_bool().map(|b| if b { "on".into() } else { "off".into() }))).unwrap_or_else(|| default.to_string())
}
pub fn set(&self, key: &str, value: Value) -> std::io::Result<()> {
let mut all = self.all();
all[key] = value;
std::fs::create_dir_all(self.path.parent().unwrap_or(Path::new(".")))?;
std::fs::write(&self.path, serde_json::to_string_pretty(&all).unwrap_or_default())?;
*self.cache.lock().unwrap() = Some(all);
Ok(())
}
}
// ─── Logging (stdout, ab tails it into ~/.adom/bridge-logs/puppeteer.log) ───────────────
static LOG_FILE: std::sync::OnceLock<std::sync::Mutex<Option<std::path::PathBuf>>> = std::sync::OnceLock::new();
/// 2.0.445: pup prints to stdout AND appends to a rolling file (ab wipes its per-spawn capture, so
/// there was no trace to read after "it showed and then hid"). 2 MB, one rotation (pup.log.1).
pub fn set_log_file(path: std::path::PathBuf) {
if let Some(d) = path.parent() { let _ = std::fs::create_dir_all(d); }
*LOG_FILE.get_or_init(|| std::sync::Mutex::new(None)).lock().unwrap() = Some(path);
}
pub fn log_file() -> Option<std::path::PathBuf> { LOG_FILE.get().and_then(|m| m.lock().unwrap().clone()) }
static LAST_LOG: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
/// The most recent log line (2.0.636): lets a watcher say what pup had just done when something moved.
pub fn last_log() -> String { LAST_LOG.lock().map(|l| l.clone()).unwrap_or_default() }
pub fn log(line: impl AsRef<str>) {
let line = line.as_ref();
if !line.starts_with("[zwatch]") { if let Ok(mut l) = LAST_LOG.lock() { *l = line.chars().take(120).collect(); } }
println!("{line}");
let Some(path) = log_file() else { return };
use std::io::Write;
if std::fs::metadata(&path).map(|m| m.len() > 2_000_000).unwrap_or(false) { let _ = std::fs::rename(&path, path.with_extension("log.1")); }
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { let _ = writeln!(f, "{} {line}", iso_now()); }
}
pub fn now_ms() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) }
pub fn iso_now() -> String {
// No chrono dependency: ISO-8601 UTC from the unix clock.
let dur = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = dur.as_secs(); let ms = dur.subsec_millis();
let days = secs / 86400; let rem = secs % 86400;
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
// civil-from-days (Howard Hinnant)
let z = days as i64 + 719468; let era = z.div_euclid(146097); let doe = z.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1; let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}.{ms:03}Z")
}
/// Background-loop cadence stats (Node: _loopStats / loopReport) for pup_status.loops.
pub mod loops {
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default, Clone)]
struct Stat { cadence_ms: u64, ticks: u64, total_ms: u64, last_ms: u64, max_ms: u64, slow_ticks: u64, last_at: u64, minute_at: u64, minute_ticks: u64 }
static STATS: Mutex<Option<HashMap<String, Stat>>> = Mutex::new(None);
pub fn tick(name: &str, cadence_ms: u64, dur_ms: u64) {
let now = super::now_ms();
let mut g = STATS.lock().unwrap(); let m = g.get_or_insert_with(HashMap::new);
let s = m.entry(name.to_string()).or_default();
s.cadence_ms = cadence_ms; s.ticks += 1; s.total_ms += dur_ms; s.last_ms = dur_ms; s.max_ms = s.max_ms.max(dur_ms); s.last_at = now;
if dur_ms > cadence_ms / 2 { s.slow_ticks += 1; }
if now.saturating_sub(s.minute_at) > 60_000 { s.minute_at = now; s.minute_ticks = 0; }
s.minute_ticks += 1;
}
pub fn report() -> Value {
let now = super::now_ms();
let g = STATS.lock().unwrap();
let mut out = serde_json::Map::new();
if let Some(m) = g.as_ref() { for (n, s) in m { out.insert(n.clone(), json!({ "cadenceMs": s.cadence_ms, "ticks": s.ticks, "lastMin": if now.saturating_sub(s.minute_at) <= 60_000 { s.minute_ticks } else { 0 }, "avgMs": if s.ticks > 0 { s.total_ms / s.ticks } else { 0 }, "lastMs": s.last_ms, "maxMs": s.max_ms, "slowTicks": s.slow_ticks, "lastAt": s.last_at })); } }
Value::Object(out)
}
}
/// Download a URL to a file, following redirects, via ureq (used by the real-Chrome installer). The
/// TLS + redirect handling lives here so host-win needs no HTTP dependency of its own.
pub fn http_download(url: &str, dest: &std::path::Path) -> Result<(), String> {
let agent = ureq::AgentBuilder::new().timeout(std::time::Duration::from_secs(120)).redirects(8).build();
let resp = agent.get(url).call().map_err(|e| e.to_string())?;
let mut r = resp.into_reader();
let mut f = std::fs::File::create(dest).map_err(|e| e.to_string())?;
std::io::copy(&mut r, &mut f).map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn envelope_shapes() {
let o = ok(json!({"a": 1})); assert_eq!(o["ok"], json!(true)); assert_eq!(o["status"], json!("ok")); assert_eq!(o["a"], json!(1));
let e = err("x", "boom", Some("h")); assert_eq!(e["success"], json!(false)); assert_eq!(e["errorCode"], json!("x")); assert_eq!(e["_hint"], json!("h"));
let b = blocked_on_user(true, "click", "wait", json!({"consentId": "c1"})); assert_eq!(b["errorCode"], json!("blocked_on_user")); assert_eq!(b["stillRunning"], json!(true)); assert_eq!(b["consentId"], json!("c1"));
}
#[test] fn unwraps_output() {
let v = unwrap_output(json!({"output": "{\"ok\":true,\"x\":1}", "hwnd": 5, "x": 9}));
assert_eq!(v["x"], json!(1)); assert_eq!(v["hwnd"], json!(5));
}
#[test] fn caller_headers() {
let c = Caller::from_headers([("X-Adom-Caller-Thread", "t1"), ("x-adom-verified-origin", "local")].into_iter());
assert_eq!(c.thread, "t1"); assert_eq!(c.verified_origin, "local"); assert!(!c.is_relayed());
}
#[test] fn iso() { assert!(iso_now().ends_with("Z")); assert_eq!(iso_now().len(), 24); }
}
// ── Runtime data dir (pup-bridge#98 / adom-bridge#153) ──────────────────────────────────────────
/// ab passes every bridge `ADOM_BRIDGE_DATA_DIR` (%LOCALAPPDATA%\Adom Bridge\bridge-data\<bridge>): a
/// dir that survives cache swaps and version bumps. Runtime data (profiles, sessions, screenshots,
/// recordings) lives there when it is set; without it (an older ab, a hand-launched exe) every
/// resolver falls back to its legacy location beside the code / under ~/.adom.
pub fn data_dir() -> Option<std::path::PathBuf> {
let v = std::env::var("ADOM_BRIDGE_DATA_DIR").ok()?.trim().to_string();
if v.is_empty() { return None; }
let p = std::path::PathBuf::from(v);
std::fs::create_dir_all(&p).ok()?;
Some(p)
}
/// `<data>/<kind>` when the data dir is set, else `legacy`. The first time the new dir is resolved and
/// the legacy one exists, the legacy dir is MOVED (rename, same volume) so the user's logged-in
/// profiles and session files carry over; a failed rename keeps the legacy path (never two copies).
pub fn data_subdir(kind: &str, legacy: std::path::PathBuf) -> std::path::PathBuf {
let Some(root) = data_dir() else { return legacy };
let new = root.join(kind);
if !new.exists() && legacy.exists() {
match std::fs::rename(&legacy, &new) {
Ok(()) => log(format!("[data] moved {} -> {} (ADOM_BRIDGE_DATA_DIR, pup-bridge#98)", legacy.display(), new.display())),
Err(e) => { log(format!("[data] could not move {} -> {}: {e}; staying on the legacy path", legacy.display(), new.display())); return legacy; }
}
}
let _ = std::fs::create_dir_all(&new);
new
}
/// Retention for ephemeral output dirs (screenshots, recordings): delete files older than `max_age_days`,
/// then oldest-first until the dir is under `max_bytes`. Files touched in the last 10 minutes are never
/// removed (a recording still being written). Returns (files removed, bytes freed).
pub fn retention_sweep(dir: &std::path::Path, max_age_days: u64, max_bytes: u64) -> (usize, u64) {
fn walk(d: &std::path::Path, out: &mut Vec<(std::path::PathBuf, u64, std::time::SystemTime)>) {
let Ok(rd) = std::fs::read_dir(d) else { return };
for e in rd.flatten() {
let p = e.path();
let Ok(md) = e.metadata() else { continue };
if md.is_dir() { walk(&p, out); } else { out.push((p, md.len(), md.modified().unwrap_or(std::time::UNIX_EPOCH))); }
}
}
let mut files = Vec::new(); walk(dir, &mut files);
files.sort_by_key(|f| f.2);
let now = std::time::SystemTime::now();
let mut total: u64 = files.iter().map(|f| f.1).sum();
let (mut removed, mut freed) = (0usize, 0u64);
let recent = std::time::Duration::from_secs(600);
for (p, len, m) in &files {
let age = now.duration_since(*m).unwrap_or_default();
if age < recent { continue; }
let too_old = age.as_secs() > max_age_days * 86_400;
if !too_old && total <= max_bytes { break; }
if std::fs::remove_file(p).is_ok() { removed += 1; freed += len; total = total.saturating_sub(*len); }
}
// drop now-empty frame dirs left behind by tab recordings
if let Ok(rd) = std::fs::read_dir(dir) { for e in rd.flatten() { if e.path().is_dir() { let _ = std::fs::remove_dir(e.path()); } } }
(removed, freed)
}
#[cfg(test)]
mod data_dir_tests {
use super::*;
fn tmp(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("pup-core-test-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d }
#[test]
fn data_subdir_moves_legacy_once_and_falls_back_without_env() {
let root = tmp("data"); let legacy = root.join("legacy-profiles"); std::fs::create_dir_all(legacy.join("adom-you")).unwrap(); std::fs::write(legacy.join("adom-you").join("Cookies"), b"x").unwrap();
std::env::remove_var("ADOM_BRIDGE_DATA_DIR");
assert_eq!(data_subdir("profiles", legacy.clone()), legacy, "no env: legacy path");
std::env::set_var("ADOM_BRIDGE_DATA_DIR", root.join("bridge-data").to_string_lossy().to_string());
let new = data_subdir("profiles", legacy.clone());
assert_eq!(new, root.join("bridge-data").join("profiles"));
assert!(new.join("adom-you").join("Cookies").exists(), "legacy profile moved over");
assert!(!legacy.exists(), "legacy dir gone after the move");
assert_eq!(data_subdir("profiles", legacy.clone()), new, "second resolve is stable");
std::env::remove_var("ADOM_BRIDGE_DATA_DIR");
}
#[test]
fn retention_sweep_drops_old_then_oldest_over_cap_and_keeps_recent() {
let d = tmp("ret");
let old = d.join("old.png"); std::fs::write(&old, vec![0u8; 100]).unwrap();
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(20 * 86_400);
std::fs::File::open(&old).unwrap().set_modified(t).unwrap();
let mid = d.join("mid.png"); std::fs::write(&mid, vec![0u8; 600]).unwrap();
std::fs::File::open(&mid).unwrap().set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(3 * 86_400)).unwrap();
let fresh = d.join("fresh.png"); std::fs::write(&fresh, vec![0u8; 600]).unwrap();
let (n, b) = retention_sweep(&d, 14, 500);
assert!(!old.exists(), "older than 14 d removed"); assert!(!mid.exists(), "oldest over the cap removed"); assert!(fresh.exists(), "touched in the last 10 min kept");
assert_eq!(n, 2); assert_eq!(b, 700);
}
}