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
Stop handle theft between same-page windows, the wonk the hardened mimic ralph caught red-handed: mimic windows and a second hd-publish window stole real windows' taskbar handles after a respawn. Three causes fixed. The natural-title resolver asked for the WRONG title (pup's activeTabId bookkeeping says first tab while Chrome's window title mirrors the last-opened visible tab), making a session uniquely match someone else's window; it now asks Chrome which tab is actually visible. A title shared by two pup windows is now refused outright, counted over all pup rows BEFORE ownership exclusions, because excluding the true owner's row is exactly how a theft looked unique. And a persisted-claims map now loads every session file's handle at recovery start, so a handle is protected before its owner's session even finishes recovering, closing the recovery-storm race. The mimic harness now also asserts the real windows and never resets its collision map so cross-session theft can never self-grade OK again
4 files changed
+42−12
adom-bridge-puppeteer-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "2.0.44",- "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.44/adom-bridge-puppeteer-v2.0.44.zip",- "sha256": "adc37c336215ea9549cf8a1579e7495b803bca04469ae756511c1dd991586c9a",- "size": 647897,+ "version": "2.0.45",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.45/adom-bridge-puppeteer-v2.0.45.zip",+ "sha256": "ea3b015579f09496744f2695cd3fc11438381fd97745d494ab2b9e931fdec443",+ "size": 648772, "verbPrefixes": [ "browser_", "desktop_recorder_",
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-2.0.44+2.0.45
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "2.0.44",+ "version": "2.0.45", "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. pup_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. pup_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, pup_alert_window posts a native toast whose click focuses the window, pup_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.",
src/server.js+36−6@@ -1253,7 +1253,11 @@ function saveSessionFile(sessionId, profileName, url) { } } +function releasePersistedClaim(sessionId) {+ try { for (const [h, sid] of _persistedHwndClaims) { if (sid === sessionId) _persistedHwndClaims.delete(h); } } catch (e) {}+} function deleteSessionFile(sessionId) {+ releasePersistedClaim(sessionId); try { fs.unlinkSync(sessionFilePath(sessionId)); } catch {} } @@ -1269,6 +1273,9 @@ function persistSessionHwnd(sessionId, hwnd) { if (!fs.existsSync(f)) return; const d = JSON.parse(fs.readFileSync(f, 'utf8')); if (d._hwnd === (hwnd || null)) return;+ // v2.0.45: keep the persisted-claims map in lockstep (release the old handle, claim the new)+ try { if (d._hwnd && _persistedHwndClaims.get(Number(d._hwnd)) === sessionId) _persistedHwndClaims.delete(Number(d._hwnd)); } catch (e) {}+ try { if (hwnd) _persistedHwndClaims.set(Number(hwnd), sessionId); } catch (e) {} d._hwnd = hwnd || null; d.updatedAt = new Date().toISOString(); fs.writeFileSync(f, JSON.stringify(d, null, 2)); } catch (e) {}@@ -1816,7 +1823,10 @@ async function recoverSessions() { } } catch (e) {} if (files.length === 0) return;- console.log(`Recovering ${files.length} session(s) from disk...`);+ // v2.0.45: claim every persisted handle UP FRONT, before any recovery/resolve can race — this is+ // what stops an early-resolving session from stealing a handle whose owner recovers later.+ for (const sf0 of files) { try { if (sf0._hwnd && sf0.sessionId) _persistedHwndClaims.set(Number(sf0._hwnd), sf0.sessionId); } catch (e) {} }+ console.log(`Recovering ${files.length} session(s) from disk... (${_persistedHwndClaims.size} persisted handle claims pre-loaded)`); // v1.9.154: pages already bound in THIS recovery pass — two sessions must never share a page // (the espn double-bind: a poisoned session file URL-matched another session's page). const _recoveryClaimed = new Set();@@ -5480,8 +5490,16 @@ async function hwndOwnerPid(hwnd) { // exactly ONE session. This rejects a candidate handle already claimed by another live session's // _hwnd or birth __pupHwnd, so concurrent births/resolves on the shared profile can't cross-wire // (the bug where one thread stamped its identity onto another thread's taskbar button).+// v2.0.45 (mimic-ralph caught mk-* windows STEALING John's real windows' handles during the+// recovery storm): in-memory claims lag recovery — a session that resolves early can take a handle+// whose true owner hasn't restored its claim yet. This map is loaded from EVERY session file at the+// START of recovery (before any resolve can fire), so a persisted handle is protected from t=0.+// persistSessionHwnd keeps it current; deleteSessionFile releases it.+const _persistedHwndClaims = new Map(); // hwnd -> sessionId function hwndOwnedByOtherSession(hwnd, exceptSessionId) { const h = Number(hwnd);+ const pc = _persistedHwndClaims.get(h);+ if (pc && pc !== exceptSessionId) return true; for (const [k, s] of sessions) { if (k === exceptSessionId || !s) continue; if (Number(s._hwnd) === h) return true;@@ -5651,16 +5669,29 @@ async function resolveSessionHwndByBounds(session, sessionId) { // if he has the identical page open. Same-title siblings yield 2+ matches and are refused // (bare > wrong). This is what finally re-resolves recovered windows the geometry never could. try {- const _at = session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId);- const _pg0 = (_at && _at.page) || ((session.tabs || [])[0] && (session.tabs || [])[0].page);+ // v2.0.45 (the handle theft): the OS title mirrors the tab CHROME shows, which is NOT always+ // pup's activeTabId (tab adds leave Chrome on the last-opened tab while pup's bookkeeping says+ // the first). Asking the wrong title made this session uniquely match SOMEONE ELSE'S window.+ // Ask Chrome: the tab whose document is 'visible' is the one the window's title mirrors.+ let _pg0 = null;+ for (const _t of (session.tabs || [])) {+ if (!_t || !_t.page) continue;+ try { const vis = await Promise.race([_t.page.evaluate(() => document.visibilityState), new Promise(r => setTimeout(() => r(''), 1200))]); if (vis === 'visible') { _pg0 = _t.page; break; } } catch (e) {}+ }+ if (!_pg0) { const _at = session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId); _pg0 = (_at && _at.page) || ((session.tabs || [])[0] && (session.tabs || [])[0].page); } let _ptitle = ''; try { _ptitle = String(await Promise.race([_pg0.title(), new Promise(r => setTimeout(() => r(''), 2500))]) || '').trim(); } catch (e) {} if (_ptitle.length >= 5) { const _rows0 = await enumChromeWidgetWindows(); const _profPids0 = pupBrowserPidSet(session.profileName); const _okPid = (p) => _profPids0.has(p) || (session._recoveredHwndPid && Number(p) === Number(session._recoveredHwndPid));- const _tm = _rows0.filter(r => r.title && _okPid(r.pid) && !hwndOwnedByOtherSession(r.h, sessionId)- && (r.title === _ptitle || r.title.startsWith(_ptitle + ' - ') || r.title.startsWith(_ptitle + ' \u2013 ')));+ const _titleHit = (r) => r.title && (r.title === _ptitle || r.title.startsWith(_ptitle + ' - ') || r.title.startsWith(_ptitle + ' \u2013 '));+ // v2.0.45: count title matches over ALL pup rows FIRST — if two pup windows share this+ // title, identity is unknowable by title even when ownership exclusions leave one row+ // (excluding the true owner's row is exactly how the theft looked "unique"). Refuse.+ const _allHits = _rows0.filter(r => _okPid(r.pid) && _titleHit(r));+ const _tm = _allHits.length === 1 ? _allHits.filter(r => !hwndOwnedByOtherSession(r.h, sessionId)) : [];+ if (_allHits.length > 1) console.log(`[hwnd] "${sessionId}" natural-title "${_ptitle.slice(0, 40)}" matches ${_allHits.length} pup windows — ambiguous, refusing`); if (_tm.length === 1) { const got = Number(_tm[0].h); session._hwnd = got; persistSessionHwnd(sessionId, got);@@ -5669,7 +5700,6 @@ async function resolveSessionHwndByBounds(session, sessionId) { console.log(`[hwnd] "${sessionId}" resolved OS handle ${got} — NATURAL-title match ("${_ptitle.slice(0, 60)}")`); return got; }- if (_tm.length > 1) console.log(`[hwnd] "${sessionId}" natural-title "${_ptitle.slice(0, 40)}" matches ${_tm.length} windows — ambiguous, falling through`); } } catch (e) {} const page = (session.tabs || [])[0] && (session.tabs || [])[0].page;