← 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

John Lauer ·682bca739d · ·parent 5f96992
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" }\ No newline at end of file
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) {