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
v1.8.26: real background-by-default (minimize->show-no-activate drops focus, self-verifying 'background' field); stop navigate/reload/switch_tab from raising bg windows (foreground-state gate); localhost reprimand (desktop-side port probe); finite flash
4 files changed
+141−47
adom-bridge-puppeteer-manifest.json+5−5@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "1.8.25",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.8.25/adom-bridge-puppeteer-v1.8.25.zip",- "sha256": "67a25463ac14550b02676ddc1b79e67b603f5c6bb33e47f6f96544f0ee5efe72",- "size": 154368,+ "version": "1.8.26",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.8.26/adom-bridge-puppeteer-v1.8.26.zip",+ "sha256": "e5f071c0e73444ca9b1ae28cbfefbaaf699cbc4cf5d8a12e64a1f395bb020142",+ "size": 156777, "verbPrefixes": [ "browser_", "desktop_recorder_",@@ -12,5 +12,5 @@ ], "healthEndpoint": "/status", "statusVerb": "browser_readiness",- "released_at": "2026-07-13T11:52:31Z"+ "released_at": "2026-07-14T18:41:59Z" }
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-1.8.25+1.8.26
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "1.8.25",+ "version": "1.8.26", "description": "Drives a browser ALREADY on the machine — installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) — 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 → Edge → 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, …). 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 — 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 — a separate bridge, not pup's job.", "homepage": "https://wiki.adom.inc/adom/adom-desktop", "author": "Adom Inc.",
src/server.js+134−40@@ -147,6 +147,11 @@ function createSession(profileName) { // and reprimand anonymous ones with a loud hint. owner: null, createdAt: Date.now(),+ // v1.8.26: is this window CURRENTLY meant to be on the user's screen? false =+ // background (the hard default) — pup must never raise it (no bringToFront on+ // navigate/reload/switch_tab). Set true only when opened foreground:true or after+ // browser_raise_os_window, false again after browser_lower_os_window.+ _foreground: false, // Ring buffer of recently-closed tabs (capped at 20). Used by // resolveTabOrError to enrich tab_not_found errors with `lastKnownUrl` // when the bogus tabId matches a tab that closed in the last few seconds.@@ -1506,21 +1511,21 @@ function osGetForegroundWindow() { } catch (e) { return null; } } -// v1.8.24: THE real "background by default" enforcement. osSendWindowToBack only-// lowers z-order — but a freshly-launched --start-maximized browser KEEPS foreground-// focus, so the window still sits on top of the user's work. This pushes the pup-// window (the given pid + its chrome/msedge children) to the BOTTOM without-// activating AND, if pup currently holds the foreground, hands focus straight back-// to `restoreHwnd` (the window the user was in). Called the instant the window-// appears (not after navigation), so there's no visible foreground pop.-// Takes a raw pid so it can run DURING launch, before the session is in `browsers`.-// cdpPort (optional) is the browser's CDP debug port: the process LISTENING on it is-// unambiguously the real browser with the window, which is more reliable than the-// spawned pid (Edge/Chrome relaunch to a different pid that isn't a child of it).-function osBackgroundWindowByPid(pid, restoreHwnd, cdpPort) {- if (process.platform !== 'win32' || (!pid && !cdpPort)) return;+// v1.8.26: THE real "background by default" enforcement, done RIGHT. A freshly-launched+// --start-maximized browser grabs foreground; osSendWindowToBack only lowers z-order and+// SetForegroundWindow(userWindow) is BLOCKED by Windows' foreground lock — so the earlier+// attempts left pup on top, stealing focus. The reliable primitive: minimize the pup+// window (ShowWindow SW_MINIMIZE) — Windows then AUTOMATICALLY hands foreground to the+// user's previous window, no lock to fight — then re-show it WITHOUT activating+// (SW_SHOWNOACTIVATE) so it still composites for screenshots, and drop it to z-bottom.+// We only ever touch OUR OWN window, which is always allowed. SELF-VERIFYING: returns+// 'backgrounded' | 'still-foreground' | 'no-window' | 'error' by re-reading the+// foreground window after, so callers (and tests) know for CERTAIN whether it worked.+// cdpPort (the browser's CDP debug port) resolves the real browser via its LISTENING+// process — reliable across the launcher→browser pid relaunch that Edge/Chrome do.+function osBackgroundWindowByPid(pid, cdpPort) {+ if (process.platform !== 'win32' || (!pid && !cdpPort)) return 'error'; try {- const restore = (restoreHwnd && /^\d+$/.test(String(restoreHwnd))) ? String(restoreHwnd) : '0'; const port = Number(cdpPort) || 0; const scriptPath = path.join(require('os').tmpdir(), `_adom_bgpid_${pid || port}.ps1`); const ps = [@@ -1528,20 +1533,20 @@ function osBackgroundWindowByPid(pid, restoreHwnd, cdpPort) { 'using System; using System.Runtime.InteropServices;', 'public class WinBgP {', ' [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);',- ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',+ ' [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);', ' [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();', '}', '"@', `$targetPid = ${Number(pid) || 0}`, `$cdpPort = ${port}`,- `$restore = [IntPtr]${restore}`, '$BOTTOM = [IntPtr]1', // HWND_BOTTOM- '$F = 0x1 -bor 0x2 -bor 0x10', // SWP_NOSIZE|SWP_NOMOVE|SWP_NOACTIVATE- 'function Back($h){ if($h -ne [IntPtr]::Zero){ [WinBgP]::SetWindowPos($h,$BOTTOM,0,0,0,0,$F) | Out-Null } }',+ '$SWP = 0x1 -bor 0x2 -bor 0x10', // SWP_NOSIZE|SWP_NOMOVE|SWP_NOACTIVATE+ '$SW_MINIMIZE = 6',+ '$SW_SHOWNOACTIVATE = 4', '$handles = New-Object System.Collections.ArrayList', '$pids = New-Object System.Collections.ArrayList', 'if ($targetPid -ne 0) { [void]$pids.Add($targetPid) }',- // The process LISTENING on the CDP port is the real browser — most reliable anchor.+ // The process LISTENING on the CDP port is unambiguously the real browser. 'if ($cdpPort -ne 0) { try { $own = Get-NetTCPConnection -LocalPort $cdpPort -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess; if ($own) { [void]$pids.Add([int]$own) } } catch {} }', // For each anchor pid, take its own window + any chrome/msedge child windows. 'foreach ($tp in $pids) {',@@ -1549,16 +1554,66 @@ function osBackgroundWindowByPid(pid, restoreHwnd, cdpPort) { ' if ($p -and $p.MainWindowHandle -ne 0) { [void]$handles.Add($p.MainWindowHandle) }', ' Get-CimInstance Win32_Process -Filter "ParentProcessId=$tp" -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @("chrome.exe","msedge.exe") } | ForEach-Object { $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue; if ($cp -and $cp.MainWindowHandle -ne 0) { [void]$handles.Add($cp.MainWindowHandle) } }', '}',+ 'if ($handles.Count -eq 0) { Write-Output "no-window"; exit }',+ 'foreach($h in $handles){',+ ' [WinBgP]::ShowWindow($h, $SW_MINIMIZE) | Out-Null', // drop foreground → OS gives it back to the user+ ' [WinBgP]::ShowWindow($h, $SW_SHOWNOACTIVATE) | Out-Null', // re-show without activating (composites for screenshots)+ ' [WinBgP]::SetWindowPos($h, $BOTTOM, 0,0,0,0, $SWP) | Out-Null', // and pin to the bottom of z-order+ '}',+ // Verify: is any pup window STILL the foreground window? '$fg = [WinBgP]::GetForegroundWindow()',- '$pupHadFg = $false',- 'foreach($h in $handles){ if($h -eq $fg){ $pupHadFg = $true }; Back $h }',- // Hand focus back to the user's window ONLY if a pup window currently holds it.- 'if ($restore -ne [IntPtr]::Zero -and $pupHadFg) { [WinBgP]::SetForegroundWindow($restore) | Out-Null }',- 'Write-Output "ok"',+ '$stillFg = $false',+ 'foreach($h in $handles){ if($h -eq $fg){ $stillFg = $true } }',+ 'if ($stillFg) { Write-Output "still-foreground" } else { Write-Output "backgrounded" }', ].join('\r\n'); fs.writeFileSync(scriptPath, ps);- execSync(`powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`, { timeout: 6000, stdio: 'ignore' });- } catch (e) { console.error(`[bg-restore] pid ${pid} port ${cdpPort}: ${e.message}`); }+ const out = execSync(`powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`, { encoding: 'utf8', timeout: 6000 }).trim();+ return out || 'error';+ } catch (e) { console.error(`[bg] pid ${pid} port ${cdpPort}: ${e.message}`); return 'error'; }+}++// v1.8.26: is anything actually LISTENING on host:port (from the desktop, where pup+// runs)? A quick TCP connect. Used to sanity-check localhost URLs — see localhostReachability.+function isPortListening(host, port, timeoutMs = 1200) {+ return new Promise((resolve) => {+ const net = require('net');+ const sock = new net.Socket();+ let done = false;+ const finish = (ok) => { if (done) return; done = true; try { sock.destroy(); } catch (e) {} resolve(ok); };+ sock.setTimeout(timeoutMs);+ sock.once('connect', () => finish(true));+ sock.once('timeout', () => finish(false));+ sock.once('error', () => finish(false));+ try { sock.connect(port, host); } catch (e) { finish(false); }+ });+}++// v1.8.26: pup opens URLs on the USER'S DESKTOP, so a `localhost` URL points at the+// DESKTOP's localhost — NOT the caller's cloud container. A cloud-container AI that+// serves something on its OWN localhost and asks pup to open it hits a dead port on the+// user's machine. Since pup runs ON the desktop, it can just check whether anything is+// listening there. Returns { isLocalhost, host, port, reachable }. reachable is null when+// the URL isn't localhost (nothing to warn about — e.g. a normal https site or a public+// container URL). Hydrogen Desktop + genuine desktop servers ARE reachable, so they never+// trip the warning; this only flags the container-localhost mistake.+async function localhostReachability(urlStr) {+ let host, port;+ try {+ const u = new URL(urlStr);+ host = u.hostname.replace(/^\[|\]$/g, '');+ const LH = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);+ const isLocalhost = LH.has(host.toLowerCase()) || /\.localhost$/i.test(host);+ port = u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80);+ if (!isLocalhost) return { isLocalhost: false, host, port, reachable: null };+ } catch (e) { return { isLocalhost: false, reachable: null }; }+ const probeHost = (host === '0.0.0.0' || host === '::1') ? '127.0.0.1' : host;+ const reachable = await isPortListening(probeHost, port);+ return { isLocalhost: true, host, port, reachable };+}++// The reprimand text attached when a localhost URL is NOT reachable from the desktop.+function localhostReprimandHint(urlStr, host, port) {+ return `⚠ LOCALHOST ON THE WRONG MACHINE: you opened ${urlStr}, but pup opens the browser on the USER'S DESKTOP — so "localhost" is THEIR machine, and nothing is listening on ${host}:${port} there. If you're a cloud container, localhost:${port} is YOUR container (the desktop can't reach it), so this will just show a connection error. To actually surface this to the user: (1) if it's a FILE you generated, send it to the desktop (adom-desktop file transfer) and open it as file:///C:/path/... ; or (2) expose your server at your container's PUBLIC url and open THAT. If you genuinely started a server ON the desktop via AD and it's still booting, retry shortly. (Reachable localhost servers — e.g. Hydrogen Desktop — get NO warning; you're seeing this because the port is dead here.)`; } // v1.8.23: REAL taskbar flash (FlashWindowEx) for a session's window — the orange@@ -1856,10 +1911,6 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer `--user-data-dir=${userDataDir}`, ]; console.log(`Chrome launch attempt ${attemptNum} for profile "${profileName}" on CDP port ${debugPort}`);- // v1.8.24: remember the user's foreground window RIGHT BEFORE we launch, so we- // can hand focus straight back once pup's window appears (background-by-default- // must never steal focus, and --start-maximized grabs it on launch).- const priorForeground = (!foreground) ? osGetForegroundWindow() : null; const child = spawn(chromePath, chromeArgs, { detached: true, stdio: 'ignore',@@ -1923,9 +1974,12 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer // can re-raise itself during startup (first paint, restore prompts). Resolve the // window via the CDP-port owner (reliable across launcher→browser pid relaunches). if (!foreground && process.platform === 'win32') {- osBackgroundWindowByPid(child.pid, priorForeground, debugPort);+ b._bgResult = osBackgroundWindowByPid(child.pid, debugPort);+ console.log(`[bg] initial background result for "${profileName}": ${b._bgResult}`);+ // Retry a few times: Chrome/Edge can re-raise itself during startup (first paint,+ // restore prompts). Each retry re-verifies and updates the recorded result. for (const delay of [250, 700, 1500]) {- setTimeout(() => { try { osBackgroundWindowByPid(child.pid, priorForeground, debugPort); } catch (e) {} }, delay);+ setTimeout(() => { try { b._bgResult = osBackgroundWindowByPid(child.pid, debugPort); } catch (e) {} }, delay); } } return b;@@ -2697,6 +2751,10 @@ async function getSessionInfo(sessionId, session) { // v1.8.23: last thing the agent did here {tabId, verb, note, ts} — for the user + // Session Monitor to see WHICH window, WHAT, and WHEN. lastAgentUpdate: session._lastAgentUpdate || null,+ // v1.8.26: did background-by-default actually take? 'backgrounded' (good),+ // 'still-foreground' (focus steal — a bug), 'no-window', or null (foreground open+ // or non-Windows). Self-verified by re-reading the OS foreground window post-open.+ background: (() => { try { return browsers.get(session.profileName)?.browser?._bgResult || null; } catch (e) { return null; } })(), }; // One-shot: surface launch-time metadata (restored-tab cleanup count) // so callers can verify session-restore prevention worked. Drop after read.@@ -2974,7 +3032,20 @@ const server = http.createServer(async (req, res) => { // innerWidth/innerHeight deterministic, which is what fullPage/element shots need. try { await session.page.setViewport({ width: Math.round(w), height: Math.round(h), deviceScaleFactor: Number(args.deviceScaleFactor) || 1 }); } catch (e) {} }- if (!foreground) { try { osSendWindowToBack(args.profile); } catch (e) {} }+ // v1.8.26: re-assert background AFTER any sizing (CDP setWindowBounds can+ // re-activate the window). Use the real minimize-based backgrounder (drops+ // focus), not the old z-order-only one, resolving the window via the browser's+ // pid + CDP port.+ if (!foreground) {+ try {+ const be = browsers.get(args.profile);+ if (be && be.browser) be.browser._bgResult = osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort);+ else osSendWindowToBack(args.profile);+ } catch (e) {}+ }+ // v1.8.26: record whether this window is meant to be on screen — gates every+ // later bringToFront (navigate/reload/switch_tab must not raise a bg window).+ if (session) session._foreground = foreground; // v1.8.23: session-level auto-flash default (autoFlash:false → this window never // auto-flashes, e.g. a chatty automation loop the user isn't watching). if (session && args.autoFlash === false) session._flashSilent = true;@@ -3004,10 +3075,24 @@ const server = http.createServer(async (req, res) => { } else if (reuse && reuse.reused) { ownHint = ' Reused the existing window (same/no url — benign refresh).'; }+ // v1.8.26: localhost sanity — pup opens on the DESKTOP, so a localhost URL the+ // desktop can't reach is almost always the CALLER'S container localhost. Warn+ // (don't block — it might be a desktop server that's still booting).+ let lhHint = '', lhInfo = {};+ try {+ if (args.url) {+ const lh = await localhostReachability(args.url);+ if (lh.isLocalhost && lh.reachable === false) {+ lhHint = ' ' + localhostReprimandHint(args.url, lh.host, lh.port);+ lhInfo = { localhostUnreachable: true, probedHost: lh.host, probedPort: lh.port };+ }+ }+ } catch (e) {} sendJSON(res, { success: true, output: JSON.stringify({ ok: true,+ ...lhInfo, defaultPermissionsApplied: !args.strictPermissions, defaultPermissions: args.strictPermissions ? [] : PERMISSIVE_DEFAULT_PERMISSIONS.map(p => `${p.name}=${p.setting}`), downloadsEnabled: !args.strictPermissions,@@ -3023,7 +3108,7 @@ const server = http.createServer(async (req, res) => { // v1.8.16: reuse/ownership verdict (null = fresh window). reused: reuse ? { navigatedAway: reuse.navigatedAway, previousUrl: reuse.previousUrl, previousOwner: reuse.previousOwner, takeover: reuse.takeover } : null, owner: session.owner || null,- _hint: `${bgHint}${ownHint} ${brep._hint}`,+ _hint: `${bgHint}${ownHint} ${brep._hint}${lhHint}`, ...info, }), });@@ -3203,7 +3288,9 @@ const server = http.createServer(async (req, res) => { return; } session.activeTabId = tab.tabId;- try { await tab.page.bringToFront(); } catch {}+ // v1.8.26: only raise the OS window for a session the user is watching. For a+ // background window, just track the active tab (pup targets tabs by id).+ if (session._foreground) { try { await tab.page.bringToFront(); } catch {} } const info = await describeTab(tab, session.activeTabId); sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, ...info }) }); return;@@ -3282,7 +3369,11 @@ const server = http.createServer(async (req, res) => { return; } const session = sessions.get(sessionId);- const ok = osSendWindowToBack(session.profileName);+ // v1.8.26: use the real focus-dropping backgrounder (minimize→show-no-activate),+ // not the z-order-only one, so lowering actually relinquishes focus if held.+ const be = browsers.get(session.profileName);+ const ok = (be && be.browser) ? (osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort) === 'backgrounded') : osSendWindowToBack(session.profileName);+ session._foreground = false; sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, placement: 'background', applied: ok, _hint: 'Window sent behind the user\'s windows (still drivable/screenshottable). Keep pup in the background unless the user asked to watch.' }) }); return; }@@ -3305,6 +3396,7 @@ const server = http.createServer(async (req, res) => { // First activate the tab within Chrome await session.page.bringToFront(); activeSessionId = sessionId;+ session._foreground = true; // v1.8.26: user asked to watch — allow later raises // Get Chrome PID from the browser process const browserProcess = browsers.get(session.profileName)?.browser?.process();@@ -4235,8 +4327,9 @@ const server = http.createServer(async (req, res) => { tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }), 30_000, 'page.reload' );- // Flash taskbar to signal reload to the user- try { await tab.page.bringToFront(); } catch {}+ // v1.8.26: the user is signalled by the auto-flash, NOT by raising the window.+ // Only raise if this session is one the user is actively watching.+ if (session._foreground) { try { await tab.page.bringToFront(); } catch {} } sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId }) }); return; }@@ -4284,8 +4377,9 @@ const server = http.createServer(async (req, res) => { obs.observe(document.querySelector('title') || document.head, { childList: true, subtree: true, characterData: true }); }, titleSuffix); } catch {}- // Flash taskbar to signal navigation to the user- try { await tab.page.bringToFront(); } catch {}+ // v1.8.26: signal via auto-flash, NOT by raising the window (that stole focus+ // on every navigate). Only raise for a session the user is actively watching.+ if (session._foreground) { try { await tab.page.bringToFront(); } catch {} } // Only persist URL for single-tab sessions — the session file is a // per-session snapshot and multi-tab URLs can't fit. if (session.tabs.length === 1) {