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
Batch-fix 8 open wiki issues. #434: shared-Chrome kill now checks live sessions+in-flight verbs on the profile (not the drift-prone refCount) so it can't kill Chrome out from under a live session. #437: wait-for-focus caption backs off (max 3 shows then silent), gated on fgCaption + honors fgCaptionMs (no more permanent nag). #435: add --disable-session-crashed-bubble/--hide-crash-restore-bubble so a crashed shared Chrome never restores dead-session tabs into the wrong window. #425: findTabForTarget resolves iframe/OOPIF-originated target=_blank popups by opener-frame URL (unique match) so they adopt as tabs. #338: sessions are never unowned (synthesize a stable auto-<sessionId> owner) closing the no-caller-identity ownership hole. #293: raceCrash trims the last 20s hang when an in-flight screenshot/eval races a renderer crash (detection+fast-fail already existed). #350: adCommand caller shorthand + sessionCaller forwards reason (chokepoint already forwards X-Adom-Caller-* + Delegate:pup). #280: self-install now sets PUPPETEER_SKIP_DOWNLOAD so it never hangs on the failing CfT fetch (self-heal already existed).
5 files changed
+126−22
adom-bridge-puppeteer-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "1.9.334",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.334/adom-bridge-puppeteer-v1.9.334.zip",- "sha256": "2a9340102ab08c90f83d296dc4ddb0b01a15024ffaaaf7ce4d64a548fada6bf7",- "size": 626470,+ "version": "1.9.335",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.335/adom-bridge-puppeteer-v1.9.335.zip",+ "sha256": "a86ed70d4ef11106e510e7a8e9371ed484b5db8384d2a07218a37c199f912c0d",+ "size": 629391, "verbPrefixes": [ "browser_", "desktop_recorder_",
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.334+1.9.335
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "1.9.334",+ "version": "1.9.335", "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. browser_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. browser_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, browser_alert_window posts a native toast whose click focuses the window, browser_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-desktop", "author": "Adom Inc.",
src/chrome.js+7−1@@ -623,7 +623,13 @@ async function _resolveVerifiedAdBase() { // read honestly as pup's own work). All of it is self-reported and authorizes nothing. const AD_DELEGATE_NAME = 'pup'; function adCommand(command, args, opts = {}) {- const { target, timeoutMs = 8000, noToken = false, caller = null } = opts;+ let { target, timeoutMs = 8000, noToken = false, caller = null } = opts;+ // #350: accept a bare thread string (opts.callerThread/Container/Reason) as shorthand for opts.caller,+ // so a handler holding only _callerThread can attribute a callback without building an object. The+ // chokepoint below already turns caller into X-Adom-Caller-* headers + X-Adom-Caller-Delegate: pup.+ if (!caller && (opts.callerThread || opts.callerContainer || opts.callerReason)) {+ caller = { thread: opts.callerThread || null, container: opts.callerContainer || null, reason: opts.callerReason || null };+ } return new Promise(async (resolve) => { let base; // v1.9.75: prefer the VERIFIED-AD base (see _resolveVerifiedAdBase) so an HD-spawned
src/server.js+113−15@@ -129,8 +129,14 @@ function ensureDepsInstalled() { const started = Date.now(); try { const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';+ // #280: do NOT let puppeteer's postinstall block/fail on a Chrome-for-Testing download here. It+ // reliably fails behind npm's allow-scripts gate and on half-populated CfT cache dirs, and pup+ // drives system Chrome/Edge without it. Skip it so the self-install finishes fast and pup is+ // immediately usable via browser_use; CfT is fetched lazily (last resort) only when genuinely no+ // system browser exists. execFileSync(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: dir, stdio: 'inherit', timeout: 300000, windowsHide: true,+ env: { ...process.env, PUPPETEER_SKIP_DOWNLOAD: '1', PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: '1' }, }); require.resolve('puppeteer'); console.error(`[deps] self-install SUCCEEDED in ${Math.round((Date.now() - started) / 1000)}s — pup is starting normally. No human npm install needed.`);@@ -777,13 +783,40 @@ function attachTab(session, sessionId, page, { makeActive = true, opener = 'user async function findTabForTarget(target) { let openerPage = null; try { openerPage = await target.page(); } catch {}- if (!openerPage) return null;+ if (openerPage) {+ for (const [sid, s] of sessions) {+ for (const t of s.tabs) {+ if (t.page === openerPage) return { sessionId: sid, tabId: t.tabId };+ }+ }+ return null;+ }+ // #425: iframe-originated popup. When a page opens target=_blank from INSIDE a cross-origin/sandboxed+ // iframe (e.g. the wiki README iframe with allow-popups), target.opener() points at the SUBFRAME+ // (OOPIF) target, whose .page() is null — so the identity match above can't see it and the popup gets+ // dropped ("opener target unknown to any session"). Resolve the owning tracked page by matching the+ // opener frame's URL against each page's frames. frames()/url() are synchronous cached data (no new+ // await target.page(), so the CDP-pipe-hang guard in wirePopupTracking is unaffected). Require a+ // UNIQUE match so ambiguous about:blank/about:srcdoc frames never mis-route the popup.+ let openerUrl = '';+ try { openerUrl = target.url() || ''; } catch {}+ if (!openerUrl || openerUrl === 'about:blank' || openerUrl === 'about:srcdoc') return null;+ let hit = null; for (const [sid, s] of sessions) { for (const t of s.tabs) {- if (t.page === openerPage) return { sessionId: sid, tabId: t.tabId };+ if (!t.page) continue;+ let frames = [];+ try { frames = t.page.frames(); } catch { continue; }+ for (const f of frames) {+ let fu = ''; try { fu = f.url() || ''; } catch {}+ if (fu && fu === openerUrl) {+ if (hit && (hit.sessionId !== sid || hit.tabId !== t.tabId)) return null; // ambiguous → drop+ hit = { sessionId: sid, tabId: t.tabId };+ }+ } } }- return null;+ return hit; } // Wire popup auto-tracking on a freshly launched (or reconnected) browser.@@ -1068,6 +1101,23 @@ function _throwIfCrashed(t, sessionId) { throw err; } +// #293 — close the ONE residual 20s hang: when a screenshot/eval op is already IN FLIGHT and the+// renderer crashes mid-op, page.on('error') flips t._crashed a beat later. Race the op against that+// flag so we abandon the doomed op the instant the crash lands and return the structured crash error,+// instead of waiting out the full withTimeout budget. The losing op promise is swallowed so a late+// rejection can't surface as an unhandledRejection. (Crash detection + pre-op fast-fail via+// resolveTabOrError already exist; this only trims the racing-op latency.)+function raceCrash(t, sessionId, opPromise) {+ opPromise.catch(() => {});+ let iv;+ const crashWatch = new Promise((_, reject) => {+ iv = setInterval(() => {+ if (t && t._crashed) { try { _throwIfCrashed(t, sessionId); } catch (e) { reject(e); } }+ }, 250);+ });+ return Promise.race([opPromise, crashWatch]).finally(() => clearInterval(iv));+}+ // Send a structured-or-plain error from resolveTabOrError. If the error // message is the structured JSON we throw above, parse it back out and // surface the fields directly in the response. Otherwise plain string.@@ -2104,7 +2154,15 @@ async function sessionHwnd(sessionId) { async function waitForUserReady(sessionId, hwnd, { maxMs = 120000 } = {}) { if (process.platform !== 'win32' || !hwnd) return true; const start = Date.now();- let stableSince = 0, lastCap = 0;+ let stableSince = 0, lastCap = 0, capCount = 0;+ // #437 (John: "an ad caption is disturbing my work"): BACKOFF, not a keep-alive loop. Show the+ // wait-for-focus assist a couple of times then go SILENT — an unattended wait can be hours, and a+ // permanent on-screen "I am waiting" nag is exactly the disturbance the caption TTL model exists to+ // prevent. Gated on the user's fgCaption pref and honoring their fgCaptionMs duration (the call site+ // already showed an initial notify_user toast + caption, so 'off' never leaves the user unaware).+ const _capOn = pupSettings().fgCaption === 'on';+ const _capMs = Math.max(500, Math.min(5000, Number(pupSettings().fgCaptionMs) || 2000));+ const MAX_CAPS = 3; // initial reminder + at most 2 more, then stop while (Date.now() - start < maxMs) { const fg = osGetForegroundWindow(); if (fg === hwnd) {@@ -2112,7 +2170,10 @@ async function waitForUserReady(sessionId, hwnd, { maxMs = 120000 } = {}) { if (Date.now() - stableSince >= 1200) return true; } else { stableSince = 0;- if (Date.now() - lastCap > 3500) { lastCap = Date.now(); pupCaption('Adom is waiting: click back into THIS window when you are ready, and I will continue. (Nothing happens until you do.)', 6000).catch(() => {}); }+ if (_capOn && capCount < MAX_CAPS && Date.now() - lastCap > 3500) {+ lastCap = Date.now(); capCount++;+ pupCaption('Adom is waiting: click back into THIS window when you are ready, and I will continue. (Nothing happens until you do.)', _capMs, 'pup-assist', _capMs).catch(() => {});+ } } await new Promise(r => setTimeout(r, 400)); }@@ -3902,7 +3963,7 @@ function sessionCaller(session) { if (!session) return null; const thread = session._ownerThread || session._lastThread || null; if (!thread) return null;- return { thread, container: session._ownerContainer || session._lastContainer || null };+ return { thread, container: session._ownerContainer || session._lastContainer || null, reason: session._openReason || null }; // #350: forward reason too } catch (e) { return null; } } async function updateThumbnailTooltip(session, sessionId) {@@ -7058,6 +7119,15 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer '--no-first-run', '--no-default-browser-check', '--disable-default-apps', '--disable-sync', '--disable-background-networking', '--disable-infobars',+ // #435 — CRASH-RESTORE SUPPRESSION. pup profiles are AI-driven and never want Chrome's "Restore+ // pages? Chrome didn't shut down correctly" flow. After a shared-Chrome crash it reopens the DEAD+ // session's tabs (URL dupes + about:blank) into whatever window relaunches, and pup then adopts+ // them into the WRONG live session (John: "why are there about:blank tabs in my review window?").+ // prepareCleanProfile() resets exit_type=Normal + wipes the session blobs before a FRESH launch,+ // but can't run on the RECONNECT relaunch path (Chrome already up). These flags kill the crash+ // bubble/restore on EVERY launch path (they flow into the detached chromeArgs via the launchOpts+ // .args spread) so there are no stray restored tabs to misroute in the first place.+ '--disable-session-crashed-bubble', '--hide-crash-restore-bubble', // The "controlled by automated test software" infobar is keyed on the PRESENCE of // --enable-automation (stripped via ignoreDefaultArgs; never pass it). With the // unsupported flags gone from default launches (v1.9.129) there is no warning bar@@ -10358,11 +10428,21 @@ const server = http.createServer(async (req, res) => { // the check and the set — which is what closes the cold-start race where two agents were // both admitted for the same sessionId. {- const _wantOwner = (typeof args.owner === 'string' && args.owner.trim())- ? args.owner.trim()- : (_callerThread ? String(_callerThread).trim() : null);+ // #338: a session must NEVER be unowned. Owner precedence: explicit `owner` arg -> calling+ // AI thread (caller-identity) -> a SYNTHETIC owner derived from the sessionId. That last+ // rung is what #338 adds: a caller with no identity at all (older AD sending no+ // X-Adom-Caller-* headers — the exact repro) previously produced _wantOwner=null, which+ // silently disabled the claim, left the session owner:null, and turned every cross-owner+ // grab into a reprimand instead of a refusal. Deriving the fallback from the sessionId (NOT+ // a random id) keeps it STABLE: the same anonymous caller reusing its own sessionId+ // synthesizes the SAME owner, so single-agent reuse (the common case) is never falsely+ // refused — while a caller that DECLARES a different owner still mismatches and is refused.+ const _explicitOwner = (typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null;+ const _wantOwner = _explicitOwner+ || (_callerThread ? String(_callerThread).trim() : null)+ || ('auto-' + sessionId); const _held = _sessionOwnerClaims.get(sessionId) || null;- if (_held && _wantOwner && _held !== _wantOwner && !args.takeover && !sessions.has(sessionId)) {+ if (_held && _held !== _wantOwner && !args.takeover && !sessions.has(sessionId)) { sendJSON(res, { success: false, errorCode: 'session_owned_by_another_thread',@@ -10879,15 +10959,33 @@ const server = http.createServer(async (req, res) => { clearSessionBusy(sessionId); // v1.9.57: drop any pending progress timer unregisterPupAumid(_closingAppId).catch(() => {}); // v1.9.2/#207 cleanup (appId captured pre-delete) - // Decrement refCount; kill browser if no sessions left using this profile+ // #434: decide the kill from GROUND TRUTH, not the cached refCount. The old code killed the+ // profile's Chrome whenever `be.refCount` hit 0. That counter DRIFTS: a session record that+ // was transiently lost / being restored (_lostBrowser), or one whose increment was missed,+ // leaves refCount at 0 while ANOTHER session is still open on the same SHARED profile and+ // actively receiving verbs. Result: closing session A (e.g. ralph-reap) force-killed the+ // shared "adom-you" Chrome out from under live session B (cowboy-order). So never let the+ // refcount alone authorize a destructive kill: count the live session map (this session was+ // already deleted above) for ANY entry on this profile, and refuse while a verb is in flight. let _reap = { killed: 0, remaining: 0 }; const be = browsers.get(profileName); if (be) {- be.refCount = Math.max(0, be.refCount - 1);- if (be.refCount <= 0) {+ be.refCount = Math.max(0, be.refCount - 1); // keep the counter honest for telemetry only+ // Any OTHER session still bound to this profile? Includes _lostBrowser entries on purpose —+ // rescan may re-adopt the still-open window, so a disconnected record must still veto the kill.+ let _otherSessions = 0;+ for (const _s of sessions.values()) { if (_s.profileName === profileName) _otherSessions++; }+ // Any verb in flight on this profile right now?+ let _busyOnProfile = 0;+ for (const [_sid, _st] of _busyState) {+ if (_st && _st.busy) { const _bs = sessions.get(_sid); if (_bs && _bs.profileName === profileName) _busyOnProfile++; }+ }+ if (_otherSessions === 0 && _busyOnProfile === 0) { console.log(`No more sessions using profile "${profileName}" — killing browser`); _reap = await killBrowserProcess(be.browser, profileName); browsers.delete(profileName);+ } else {+ console.log(`Keeping browser for profile "${profileName}" — ${_otherSessions} other session(s)${_busyOnProfile ? ` and ${_busyOnProfile} verb(s) in flight` : ''} still using it (refCount=${be.refCount}); NOT killing`); } } @@ -11702,7 +11800,7 @@ const server = http.createServer(async (req, res) => { const maxWidth = args.maxWidth || MAX_SCREENSHOT_DIM; const ts = Date.now(); let screenshotBuf = await withTimeout(- tab.page.screenshot({ type: 'png', fullPage }),+ raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage })), 20_000, 'page.screenshot' ); // v1.8.21: fullPage only covers DOCUMENT-level scroll. On SPAs that scroll inside@@ -12052,7 +12150,7 @@ const server = http.createServer(async (req, res) => { let result; try { result = await withTimeout(- _evalCtx.evaluate(new Function('return (' + args.expr + ')')),+ raceCrash(tab, resolvedId, _evalCtx.evaluate(new Function('return (' + args.expr + ')'))), 20_000, 'page.evaluate' ); } catch (e) {