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.5.0: Edge launch fix (relaunch-PID grace probe) + inter-candidate lock cleanup + startup self-warm + disk-space guards with specific error codes + browser_use install real Chrome (user-scoped, no UAC) + node UAC/PATH-staleness handoff + field learnings in dev skill
9 files changed
+307−34
HANDOFF-TO-ADOM-DESKTOP.md+38@@ -143,3 +143,41 @@ analogue of why kicad/fusion (Python, no node_modules) stream fine but pup can't with our `updateManifestUrl` persisted would pull the newer source-only zip into the cache and crash. The **skills pkg is unaffected** (separate path, works). Net for now: ship the pup RUNTIME via the bundled seed (#2); treat wiki-streaming as blocked on fix (A).++## ⭐ NEW TOP PRIORITY (found LIVE 2026-07-01 on 3 fresh Windows boxes): Node install = UAC + stale PATH dead-end++Tested cold-start on AdomLapper + two genuinely fresh boxes (AdomTestWin11 vbox, winvm Azure — both+Edge-only, no Node). The **browser** side is now solved by the bridge (v1.5.0: installed-browser-first,+spawn-verified fallback, self-warm, disk guards). The remaining cold-start blockers are **both in AD-core+`desktop_install_node`** and they hard-stop the exact "AI can't recover" scenario:++1. **UAC prompt.** `desktop_install_node` installs Node via the machine-wide MSI/winget, which is+ manifested `requireAdministrator` → a **UAC dialog pops on the user's desktop** ("Node.js — OpenJS+ Foundation, allow changes?"). A cloud AI cannot click it; the user must, and John explicitly does not+ want a UAC here.+2. **Stale PATH after a *successful* install.** Even once Node installs, the already-running AD process+ keeps its OLD environment, so the very next `browser_*` STILL returns `node_not_found` /+ `could not run npm for cache bridge`. Reinstalling doesn't help (winget then says "No available upgrade"+ = already installed). The bridge can never spawn until AD is relaunched.++**Both fixed by one change — install Node the portable, user-scoped way:**+- Download the **Node ZIP** (`node-vXX-win-x64.zip`), extract `node.exe` into a user-writable dir (e.g.+ `%LOCALAPPDATA%\Adom Desktop\node\`). **No installer, no UAC.**+- Spawn bridges with that `node.exe` **by absolute path** (AD already knows where it put it) — don't rely+ on `PATH` at all. This also permanently kills the stale-PATH class of bug.+- Even better: **bundle `node.exe` in the AD installer** (~50 MB) so a fresh AD already has Node — zero+ download, zero UAC, zero PATH dependency, instant first `browser_open_window`.+- If you keep a PATH-based flow, at minimum **probe common Node locations** (`%ProgramFiles%\nodejs`,+ `%LOCALAPPDATA%\...`, nvm dirs) before declaring `node_not_found`, and refresh PATH from the registry+ after install.++**Bridge-side hint already added (SKILL.md):** if `node_not_found` persists after a successful install, the+AI is told AD's PATH is stale → relaunch AD (`desktop_relaunch_self`) rather than looping the installer.++## RESOLVED: the wiki-streamed cache crash (node_modules) is fixed in AD ≥ 1.9.55++The earlier CRITICAL section (wiki-streamed pup CACHE copy crashes with MODULE_NOT_FOUND) is **no longer+reproducing** — on AD 1.9.55–1.9.62 the wiki-cache copy spawns cleanly (AD now reconstructs node_modules+on the cache bridge). All 3 test boxes ran the cache copy of pup 1.3.0→1.5.0 with `source:"cache"`. So the+"ship runtime only via the bundled seed" constraint is lifted: the `updateManifestUrl` auto-pull works.+(Still bump the bundled seed opportunistically so a truly offline first-run is current.)
SKILL.md+5−2@@ -26,9 +26,12 @@ When you run `browser_open_window`, one of these happens — handle each: | Response | What it means | What you do | |---|---|---| | `ok:true` / a real session | A browser (Chrome or Edge) was found and launched. | Proceed — the common case. |-| `errorCode: "node_not_found"` | Node.js isn't installed (the bridge can't even start). | Run `adom-desktop desktop_install_node '{}'`, wait, then retry. |+| `errorCode: "node_not_found"` (or `could not run npm`) | Node.js isn't on AD's PATH (the bridge can't even start). | Run `adom-desktop desktop_install_node '{}'`, wait ~1–2 min, then retry. **If it STILL says node_not_found after a successful install, AD's PATH is stale** — Node is installed but the running Adom Desktop process can't see it. Relaunch AD on that box (`adom-desktop desktop_relaunch_self '{}'`, or ask the user to quit + reopen Adom Desktop), then retry. Do NOT loop desktop_install_node — reinstalling won't fix a stale PATH. | | `errorCode: "chrome_for_testing_installing"` (`installing:true`) | **RARE** — the machine has NO Chrome AND no Edge, so pup is fetching Chrome for Testing as a fallback (~150 MB, in the background; it did NOT block). | Poll **`browser_readiness`** until `{ready:true}`, then re-issue the same `browser_open_window`. **Faster fix:** ask the user to install Chrome or Edge (Edge ships with Windows) — pup then uses it instantly, no download. Don't report failure. |-| `errorCode: "chrome_install_failed"` | No Chrome/Edge AND the CfT fallback download failed. | Read `readiness.lastError` (usually no network/proxy or low disk). Ask the user to install Chrome/Edge, or drive their real browser via native mode (below). |+| `errorCode: "chrome_install_no_disk"` | No Chrome/Edge and too little free disk to install Chrome for Testing (`readiness.diskFreeMb` shown). | Tell the user how much is free and that ~800 MB is needed; ask them to free space OR install Edge/Chrome (Edge needs ~nothing). Do NOT retry the download — it fails until disk is freed. |+| `errorCode: "chrome_install_failed"` / `chrome_download_network` / `chrome_install_permission` | No Chrome/Edge AND the CfT fallback download failed (network/proxy, permission, or other). | Read `readiness.lastError`. Ask the user to install Chrome/Edge, or drive their real browser via native mode (below). |++**Want real Google Chrome on an Edge-only box?** If the user asks for Chrome specifically, run `browser_use '{"browser":"chrome","install":true}'` — pup downloads Google's official installer and installs Chrome **user-scoped (no admin prompt in most cases)**, then pins it. Poll `browser_readiness` (`installingChrome` / `chromeStablePhase`) until `browserKind:"chrome"`. If it stalls, a UAC prompt may be waiting on the user's desktop — ask them to accept it. ### "What do I have / is it ready?"
adom-bridge-puppeteer-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "1.4.0",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.4.0/adom-bridge-puppeteer-v1.4.0.zip",- "sha256": "1d4db13428c81438b6116b1ff31b2a5602a62e34dd4d36ee6acce678e551fd48",- "size": 134284,+ "version": "1.5.0",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.5.0/adom-bridge-puppeteer-v1.5.0.zip",+ "sha256": "ca1e79057f398d7ae00bbaf3e9d3d5f80966e98acb986ec26581e4e37f741e92",+ "size": 138480, "verbPrefixes": [ "browser_", "desktop_recorder_",
dev-skills/pup-bridge-dev.md+40@@ -84,6 +84,46 @@ string. Now: - **The one thing this bridge can't do:** pre-warm before first use (it isn't running until the first call). AD/HD must call `browser_prewarm` on embedded first-run — tracked in `HANDOFF-TO-ADOM-DESKTOP.md`. +## Field-tested cold-start learnings (v1.3–1.5, from 3 real fresh Windows boxes)++Tested on AdomLapper (all browsers), AdomTestWin11 (fresh vbox, Edge-only), winvm (fresh Azure, Edge-only).+What we learned and baked into the code:++- **Prefer the installed browser, don't download CfT.** Every Windows PC ships with Edge; most have+ Chrome. `resolveBrowser()`/`launchCandidates()` return [pinned default → Chrome → Edge → cached CfT];+ the launch loop **spawn-verifies** each and falls through on failure. A fresh isolated `--user-data-dir`+ keeps the system browser CLEAN (never touches the user's real tabs). CfT is now a genuine last resort.+- **Edge "exits immediately" was a false negative.** Edge (and some Chrome channels) spawn a LAUNCHER that+ relaunches the real browser onto a DIFFERENT PID, then the launcher exits. The old poll treated+ launcher-death as failure → killed good Edge launches. Fix: probe CDP FIRST each tick, and on+ launcher-death do a final CDP grace probe before giving up (`tryDetachedLaunch`).+- **A failed candidate poisons the shared profile dir.** An aborted Edge leaves `SingletonLock` /+ `DevToolsActivePort` in the userDataDir → the next candidate (Chrome/CfT) then also "exits immediately."+ Fix: `killChromeHoldingProfile()` (lock cleanup) BETWEEN candidates, not just between attempts.+- **Persist the chosen browser** (`pup-browser-default.json` in the CfT cache dir). `markVerified()` caches+ the browser that actually launched (auto default); `browser_use` pins one (forced default). Avoids+ re-probing every open. Forced beats auto; launch still falls back so a broken pin never hard-fails.+- **Node install has a PATH-staleness trap (AD-core, but guide the AI).** `desktop_install_node` installs+ Node, but the already-running AD process keeps its OLD PATH — so the very next `browser_*` STILL returns+ `node_not_found` / `could not run npm`. Reinstalling doesn't help. AD must be relaunched+ (`desktop_relaunch_self`) to pick up the new PATH. This is the exact "installed node, still broken"+ dead-end. Documented in SKILL.md + HANDOFF (AD should probe common node install dirs / refresh PATH, not+ rely on the stale env). winget also returns non-zero when Node is ALREADY present ("No available upgrade")+ — that's success, not failure.+- **Self-warm on spawn** (`chrome.warmup()` from `server.listen`): detect browsers + disk the moment AD+ installs/updates the bridge, so the first open is instant. Only background-fetches CfT if the box has NO+ Chrome/Edge AND no cached CfT (never on an Edge-only box — Edge is right there).+- **Disk + specific error codes.** `diskFreeMb()` gates the CfT install and the cold-start; failures map to+ `chrome_install_no_disk` / `chrome_download_network` / `chrome_install_permission` (not a generic string)+ so the AI's hint is actionable. `readiness()` surfaces `diskFreeMb`/`lowDisk`.+- **Install REAL Chrome on request.** `browser_use {browser:"chrome", install:true}` downloads Google's+ official offline installer and runs it silently; run as the (non-admin) user it installs USER-SCOPED into+ `%LOCALAPPDATA%\Google\Chrome` (no UAC), which `listSystemBrowsers()` already finds. Elevated context may+ install machine-wide + raise UAC — surfaced via `chrome_stable_not_detected` hint.+- **Verbose hints everywhere.** Every open returns `browser` (what we drove) + `availableBrowsers` ++ `defaultBrowser` + an `_hint` explaining how to switch/install. Goal: the AI resolves any browser+ situation in as few turns as possible without a human.+ ## Publishing (the wiki has TWO storage layers — don't mix them) Full recipe in `PUBLISHING.md`. The load-bearing rules:
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-desktop-puppeteer-bridge",- "version": "1.4.0",+ "version": "1.5.0", "type": "app", "description": "CONTAINER-SIDE install \u2014 the everyday `pup` user skill only. This pkg drops the `pup` skill (drive the browser bridge: open/close/tabs/screenshot/record, background-by-default) into ~/.claude/skills/ AND ~/.codex/skills/. It does NOT install the developer/publishing skills (pup-bridge-dev, pup-bridge-publish) \u2014 those are source-only; clone the repo (adom-wiki repo clone adom/adom-desktop-puppeteer-bridge) to get them from dev-skills/. It also does NOT contain the bridge runtime: the bridge (Node + Chrome for Testing) lives on the DESKTOP \u2014 bundled inside Adom Desktop and published here on the Releases tab as adom-bridge-puppeteer-v<ver>.zip, which Adom Desktop streams into its bridges-cache. TL;DR: `pkg install` = the pup user skill; dev skills = clone the source; the bridge = the Release zip + bundled in Adom Desktop.", "keywords": [
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-1.4.0+1.5.0
src/bridge.json+2−2@@ -2,8 +2,8 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "1.4.0",- "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. 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, 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. browser_use pins a specific browser (chrome | edge | cft | auto); browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the current default. Every open returns a verbose _hint: what was driven, what else is available, and how to switch or install full CfT. 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. v1.0.6+ optional native-drive: pass nativeBrowser+nativeProfile (from desktop_list_browsers) to browser_open_window to drive the user's REAL signed-in Chrome/Edge/Brave (their actual profile, org policies, device enrollment) — with the smart relaunch/consent flow (native_relaunch_required / native_cdp_blocked). The generic path (no nativeBrowser) always works.",+ "version": "1.5.0",+ "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. 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. v1.0.6+ optional native-drive: pass nativeBrowser+nativeProfile (from desktop_list_browsers) to browser_open_window to drive the user's REAL signed-in Chrome/Edge/Brave (their actual profile, org policies, device enrollment) — with the smart relaunch/consent flow (native_relaunch_required / native_cdp_blocked). The generic path (no nativeBrowser) always works.", "homepage": "https://wiki.adom.inc/adom/adom-desktop", "author": "Adom Inc.", "license": "MIT",
src/chrome.js+163−11@@ -55,6 +55,19 @@ function fileExists(p) { try { return !!p && fs.statSync(p).isFile(); } catch { return false; } } +// Free disk (MB) on the volume holding `dir` (defaults to the CfT cache). Walks up+// to a path that exists (cacheDir may not exist yet on a fresh box). null if we+// can't tell (very old Node without statfsSync) — callers must treat null as "unknown",+// never as "zero", so we don't block installs on boxes where we simply can't measure.+function diskFreeMb(dir) {+ for (const d of [dir, cacheDir(), os.homedir()].filter(Boolean)) {+ try { const s = fs.statfsSync(d); return Math.floor((s.bavail * s.bsize) / (1024 * 1024)); } catch { /* try next */ }+ }+ return null;+}+// Chrome for Testing is a ~150 MB download that unpacks to ~600 MB — need headroom.+const CFT_MIN_FREE_MB = 800;+ // Puppeteer's default cache (AD sets no PUPPETEER_CACHE_DIR) → ~/.cache/puppeteer. function cacheDir() { return process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');@@ -203,7 +216,7 @@ function resolveBrowser() { // browser: 'auto' | 'chrome' | 'edge' | 'cft' | 'chrome-for-testing' (or pass executablePath) // 'cft' installs Chrome for Testing in the background if it isn't cached, and pins it // as the default so once it lands every open uses it. Returns a pickSummary.-function setDefaultBrowser({ browser, executablePath } = {}) {+function setDefaultBrowser({ browser, executablePath, install } = {}) { const b = String(browser || '').toLowerCase().replace(/[\s_-]/g, ''); if (executablePath) { if (!fileExists(executablePath)) return { ok: false, errorCode: 'browser_not_found', error: `No executable at ${executablePath}.`, ...pickSummary() };@@ -216,12 +229,21 @@ function setDefaultBrowser({ browser, executablePath } = {}) { } if (b === 'chrome' || b === 'edge') { const cand = listSystemBrowsers().find(c => c.kind === b);- if (!cand) {- const avail = listSystemBrowsers().map(c => c.kind).join(', ') || 'none';- return { ok: false, errorCode: 'browser_not_found', error: `No installed ${b} found. Installed system browsers: ${avail}. Use browser:'cft' to fetch Chrome for Testing.`, ...pickSummary() };+ if (cand) {+ writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: 'system', forced: true, verifiedAt: Date.now() });+ return { ok: true, ...pickSummary() }; }- writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: 'system', forced: true, verifiedAt: Date.now() });- return { ok: true, ...pickSummary() };+ // Not installed. For Chrome we can download+install the REAL browser on request.+ if (b === 'chrome' && install) {+ writeDefault({ kind: 'chrome', executablePath: null, source: 'system', forced: true, pendingInstall: true, verifiedAt: Date.now() });+ installChromeStable(); // background; browser_use polling + readiness track it+ return { ok: true, installing: true, installingChrome: true, ...pickSummary() };+ }+ const avail = listSystemBrowsers().map(c => c.kind).join(', ') || 'none';+ const extra = b === 'chrome'+ ? " Pass {browser:'chrome', install:true} to download & install Google Chrome (~90 MB, user-scoped, no admin prompt), or use browser:'cft' / browser:'edge'."+ : " Edge ships with every Windows PC — if it's genuinely missing, use browser:'chrome' (install:true) or browser:'cft'.";+ return { ok: false, errorCode: 'browser_not_found', error: `No installed ${b} found. Installed system browsers: ${avail}.${extra}`, ...pickSummary() }; } if (b === 'cft' || b === 'chromefortesting') { const det = detectChrome();@@ -284,8 +306,20 @@ function installChrome({ onProgress } = {}) { state.error = 'Could not determine which Chrome-for-Testing build to install (puppeteer.executablePath() gave no build id and resolveBuildId failed — network or a broken puppeteer install).'; return { ok: false, errorCode: state.errorCode, error: state.error }; }+ // Disk-space pre-flight — a CfT download that runs the volume out of space+ // fails deep in the unzip with a cryptic error. Catch it up front with an+ // actionable hint. null free (can't measure) → proceed (don't block).+ const freeMb = diskFreeMb(cacheDir());+ if (freeMb != null && freeMb < CFT_MIN_FREE_MB) {+ state.phase = 'failed';+ state.errorCode = 'chrome_install_no_disk';+ state.error = `Not enough free disk to install Chrome for Testing: ${freeMb} MB free on the drive holding ${cacheDir()}, need ~${CFT_MIN_FREE_MB} MB (150 MB download unpacks to ~600 MB). Free up space, or just install/use Chrome or Edge (Edge already ships with Windows) — that needs no download.`;+ console.error(`[chrome] ${state.error}`);+ return { ok: false, errorCode: state.errorCode, error: state.error, diskFreeMb: freeMb };+ }+ state.buildId = buildId;- console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} ...`);+ console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} (disk free: ${freeMb == null ? 'unknown' : freeMb + ' MB'}) ...`); const installed = await install({ browser: Browser.CHROME,@@ -316,10 +350,19 @@ function installChrome({ onProgress } = {}) { return { ok: true, executablePath: ep, buildId }; } catch (e) { state.phase = 'failed';- state.errorCode = state.errorCode || 'chrome_download_failed';- state.error = (e && e.message) || String(e);+ const msg = (e && e.message) || String(e);+ // Map the common low-level failures to actionable codes so the AI's hint is specific.+ if (!state.errorCode) {+ if (/ENOSPC|no space left|not enough space/i.test(msg)) state.errorCode = 'chrome_install_no_disk';+ else if (/EACCES|EPERM|permission/i.test(msg)) state.errorCode = 'chrome_install_permission';+ else if (/ENOTFOUND|ETIMEDOUT|ECONNRESET|network|getaddrinfo|proxy|tunneling/i.test(msg)) state.errorCode = 'chrome_download_network';+ else state.errorCode = 'chrome_download_failed';+ }+ state.error = state.errorCode === 'chrome_install_no_disk'+ ? `Ran out of disk installing Chrome for Testing (${diskFreeMb(cacheDir())} MB free). Free up space, or use Chrome/Edge (no download). Raw: ${msg}`+ : msg; state.finishedAt = Date.now();- console.error(`[chrome] install failed: ${state.error}`);+ console.error(`[chrome] install failed (${state.errorCode}): ${state.error}`); return { ok: false, errorCode: state.errorCode, error: state.error }; } finally { _installInFlight = null;@@ -393,12 +436,121 @@ function readiness() { lastError: state.error, lastErrorCode: state.errorCode, cacheDir: cacheDir(),+ diskFreeMb: diskFreeMb(), // free space on the CfT-cache volume (null = unmeasurable)+ lowDisk: (() => { const f = diskFreeMb(); return f != null && f < CFT_MIN_FREE_MB; })(),+ installingChrome: !!_chromeStableInFlight, // a real-Google-Chrome install is in flight+ chromeStablePhase: chromeStableState.phase,+ chromeStableError: chromeStableState.error, puppeteerLoadError: _puppeteerLoadError, }; } +// ── Real Google Chrome (stable) installer ─────────────────────────────────────+// When the user explicitly wants CHROME (not CfT) on an Edge-only box, fetch Google's+// official offline installer and run it silently. Run as the (non-admin) user, Chrome's+// installer drops a USER-SCOPED install into %LOCALAPPDATA%\Google\Chrome — no UAC —+// which listSystemBrowsers() already finds. An elevated/admin context may install+// machine-wide and could raise a UAC prompt on the desktop (surfaced via the hint).+const chromeStableState = { phase: 'idle', error: null, errorCode: null, startedAt: null, finishedAt: null };+let _chromeStableInFlight = null;+const CHROME_STABLE_URL = 'https://dl.google.com/chrome/install/standalonesetup64.exe';++function _download(url, destPath, redirects = 0) {+ return new Promise((resolve, reject) => {+ if (redirects > 5) return reject(new Error('too many redirects fetching the Chrome installer'));+ const https = require('https');+ const file = fs.createWriteStream(destPath);+ https.get(url, (resp) => {+ if ([301, 302, 303, 307, 308].includes(resp.statusCode) && resp.headers.location) {+ file.close(); try { fs.unlinkSync(destPath); } catch {}+ return resolve(_download(resp.headers.location, destPath, redirects + 1));+ }+ if (resp.statusCode !== 200) { file.close(); try { fs.unlinkSync(destPath); } catch {} return reject(new Error(`HTTP ${resp.statusCode} fetching ${url}`)); }+ resp.pipe(file);+ file.on('finish', () => file.close(() => resolve(destPath)));+ file.on('error', (e) => { try { fs.unlinkSync(destPath); } catch {} reject(e); });+ }).on('error', (e) => { try { fs.unlinkSync(destPath); } catch {} reject(e); });+ });+}++function installChromeStable() {+ if (_chromeStableInFlight) return _chromeStableInFlight;+ _chromeStableInFlight = (async () => {+ chromeStableState.phase = 'installing'; chromeStableState.error = null; chromeStableState.errorCode = null;+ chromeStableState.startedAt = Date.now(); chromeStableState.finishedAt = null;+ try {+ const pre = listSystemBrowsers().find(x => x.kind === 'chrome');+ if (pre) { chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now(); return { ok: true, executablePath: pre.executablePath, alreadyInstalled: true }; }+ if (process.platform !== 'win32') {+ chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_unsupported_os';+ chromeStableState.error = 'Auto-installing Google Chrome is only wired up for Windows.';+ return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };+ }+ const freeMb = diskFreeMb();+ if (freeMb != null && freeMb < 500) {+ chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_install_no_disk';+ chromeStableState.error = `Not enough disk to install Chrome: ${freeMb} MB free, need ~500 MB.`;+ return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error, diskFreeMb: freeMb };+ }+ const tmp = path.join(os.tmpdir(), `chrome_installer_${process.pid}.exe`);+ console.log(`[chrome] downloading Google Chrome installer → ${tmp}`);+ await _download(CHROME_STABLE_URL, tmp);+ console.log('[chrome] running Chrome installer silently (/silent /install) ...');+ const { spawn } = require('child_process');+ await new Promise((resolve, reject) => {+ let done = false;+ const child = spawn(tmp, ['/silent', '/install'], { windowsHide: true });+ child.on('error', (e) => { if (!done) { done = true; reject(e); } });+ child.on('exit', () => { if (!done) { done = true; resolve(); } });+ setTimeout(() => { if (!done) { done = true; resolve(); } }, 180000); // installer may background; we poll next+ });+ let found = null;+ for (let i = 0; i < 40; i++) { found = listSystemBrowsers().find(x => x.kind === 'chrome'); if (found) break; await new Promise(r => setTimeout(r, 1500)); }+ try { fs.unlinkSync(tmp); } catch {}+ if (!found) {+ chromeStableState.phase = 'failed'; chromeStableState.errorCode = 'chrome_stable_not_detected';+ chromeStableState.error = 'Ran the Chrome installer but chrome.exe did not appear. A Windows UAC/elevation prompt may be waiting on the user\'s desktop — ask them to accept it — or the install was blocked. pup still works with Edge in the meantime.';+ return { ok: false, errorCode: chromeStableState.errorCode, error: chromeStableState.error };+ }+ chromeStableState.phase = 'ready'; chromeStableState.finishedAt = Date.now();+ console.log(`[chrome] Google Chrome installed at ${found.executablePath}`);+ return { ok: true, executablePath: found.executablePath };+ } catch (e) {+ chromeStableState.phase = 'failed';+ const msg = (e && e.message) || String(e);+ chromeStableState.errorCode = /ENOSPC|space/i.test(msg) ? 'chrome_install_no_disk'+ : /ENOTFOUND|ETIMEDOUT|ECONNRESET|network|proxy|getaddrinfo/i.test(msg) ? 'chrome_download_network'+ : 'chrome_stable_install_failed';+ chromeStableState.error = msg;+ console.error(`[chrome] Chrome stable install failed (${chromeStableState.errorCode}): ${msg}`);+ return { ok: false, errorCode: chromeStableState.errorCode, error: msg };+ } finally { _chromeStableInFlight = null; }+ })();+ return _chromeStableInFlight;+}+function chromeStableStatus() { return { ...chromeStableState, inFlight: !!_chromeStableInFlight }; }++// ── Startup self-warm ─────────────────────────────────────────────────────────+// Called once when the bridge process boots (server.js). Cheap browser detection ++// disk logging so the first open is instant. If the box has NO Chrome/Edge AND no+// cached CfT — the only case that would otherwise force a mid-open download — start+// fetching CfT NOW in the background (guarded on disk) so the user doesn't wait later.+function warmup() {+ try {+ const cands = launchCandidates();+ const free = diskFreeMb();+ console.log(`[warmup] browsers: ${cands.map(c => `${c.kind}(${c.source})`).join(', ') || 'NONE'} | disk free: ${free == null ? '?' : free + 'MB'}`);+ if (!cands.length) {+ if (free != null && free < CFT_MIN_FREE_MB) { console.log(`[warmup] no browser + low disk (${free}MB) — NOT auto-fetching CfT; readiness reports lowDisk`); return; }+ console.log('[warmup] no Chrome/Edge and no cached CfT — background-fetching Chrome for Testing now so the first open is instant');+ ensureChromeReady({ background: true });+ }+ } catch (e) { console.error(`[warmup] ${e && e.message}`); }+}+ module.exports = { detectChrome, detectSystemBrowser, listSystemBrowsers, launchCandidates, resolveBrowser,- installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir,+ installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir, diskFreeMb, setDefaultBrowser, readDefault, clearDefault, markVerified, pickSummary,+ installChromeStable, chromeStableStatus, warmup, };
src/server.js+53−13@@ -1544,25 +1544,40 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer }); child.unref(); - // Wait for Chrome to start and CDP to be ready (30s — Chrome cold start can be slow)+ // Wait for the browser to start and CDP to be ready (30s — cold start can be slow).+ // IMPORTANT (Edge + some Chrome channels): the process we spawned is often just a+ // LAUNCHER that relaunches the real browser onto a DIFFERENT PID and then exits. So+ // we probe CDP FIRST every tick, and on launcher-death we give CDP one last chance+ // before declaring failure — otherwise a perfectly-good Edge reads as "exited+ // immediately". (The old order killed Edge launches dead.) let connected = false; let childDied = false; for (let i = 0; i < 60; i++) { await new Promise(r => setTimeout(r, 500));- // Did the child process die unexpectedly?+ // CDP up? Then we're connected regardless of which PID now hosts the browser.+ try {+ const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);+ if (resp.ok) { connected = true; break; }+ } catch {}+ // Is the process we spawned still alive?+ let alive = true; try { if (process.platform === 'win32') { const out = execSync(`tasklist /FI "PID eq ${child.pid}" /NH /FO CSV`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });- if (!out.includes(`"${child.pid}"`)) { childDied = true; break; }+ alive = out.includes(`"${child.pid}"`); } else { process.kill(child.pid, 0); // throws if dead }- } catch { childDied = true; break; }-- try {- const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);- if (resp.ok) { connected = true; break; }- } catch {}+ } catch { alive = false; }+ if (!alive) {+ // Launcher exited — the browser may be up on another PID. Final CDP grace probe.+ await new Promise(r => setTimeout(r, 700));+ try {+ const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);+ if (resp.ok) { connected = true; break; }+ } catch {}+ childDied = true; break;+ } } if (!connected) {@@ -1619,7 +1634,13 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer } if (browser) break; triedList.push({ kind: cand.kind, source: cand.source, executablePath: cand.executablePath || null, error: lastErr && lastErr.message });- if (launchList.length > 1) console.log(`[launch] ${cand.kind}(${cand.source}) exhausted ${MAX_ATTEMPTS} attempts — trying next browser`);+ if (launchList.length > 1) {+ console.log(`[launch] ${cand.kind}(${cand.source}) exhausted ${MAX_ATTEMPTS} attempts — trying next browser`);+ // A failed candidate (e.g. an Edge that aborted) can leave SingletonLock /+ // DevToolsActivePort in the shared userDataDir that would poison the NEXT+ // browser. Clear them before the next candidate so the fallback is clean.+ await killChromeHoldingProfile();+ } } if (browser && chosen) {@@ -3027,7 +3048,7 @@ const server = http.createServer(async (req, res) => { V('desktop_record_stop', 'Stop the desktop screen recording; returns the file.', {}, 30, {}), V('browser_readiness', 'Readiness + which browser pup will drive (installed Chrome/Edge, or cached Chrome for Testing) + the full candidate list and current default. READ-ONLY; never downloads.', {}, 10, {}), V('browser_prewarm', 'Install Chrome for Testing WITHOUT opening a window, and pin it as the default. Use when you specifically want CfT rather than the machine\'s installed Chrome/Edge.', { wait: 'optional bool — block until installed', setDefault: 'optional bool (default true) — pin CfT as the default' }, 240, {}),- V('browser_use', 'Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). "cft" fetches Chrome for Testing then pins it. Persists across restarts; launch still spawn-verifies + falls back.', { browser: '"chrome" | "edge" | "cft" | "auto"', executablePath: 'optional — pin an exact browser binary' }, 30, {}),+ V('browser_use', 'Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). "cft" fetches Chrome for Testing then pins it. With {browser:"chrome", install:true} it DOWNLOADS & INSTALLS real Google Chrome (user-scoped, no admin) on an Edge-only box, then pins it. Persists across restarts; launch still spawn-verifies + falls back.', { browser: '"chrome" | "edge" | "cft" | "auto"', install: 'optional bool — with browser:"chrome", install real Chrome if missing', executablePath: 'optional — pin an exact browser binary' }, 240, {}), V('browser_describe', 'This list — the bridge\'s verbs + schemas, for AD\'s Verbs tab.', {}, 10, {}), ] }); return;@@ -3065,7 +3086,19 @@ const server = http.createServer(async (req, res) => { }); return; }- // No Chrome/Edge AND no cached CfT — fetch CfT as a last resort, in the background.+ // No Chrome/Edge AND no cached CfT. If the disk is too low to hold CfT,+ // don't kick off a doomed 150 MB download — fail fast with a disk hint.+ if (rd.lowDisk) {+ sendJSON(res, {+ success: false,+ errorCode: 'chrome_install_no_disk',+ readiness: rd,+ error: `No Chrome/Edge on this machine and only ${rd.diskFreeMb} MB free — not enough to install Chrome for Testing (~800 MB needed).`,+ _hint: `This box has no Chrome or Edge, and only ${rd.diskFreeMb} MB free disk — too low to download Chrome for Testing (~150 MB download, ~600 MB unpacked). Ask the user to free up disk space, then retry; OR install Edge/Chrome (Edge ships with Windows and needs almost nothing). Do NOT keep retrying the download — it will fail until disk is freed.`,+ });+ return;+ }+ // Otherwise fetch CfT as a last resort, in the background. if (!rd.installing) chrome.ensureChromeReady({ background: true }); sendJSON(res, { success: false,@@ -4224,7 +4257,7 @@ const server = http.createServer(async (req, res) => { // for Testing in the background if not cached, then pins it. Persists across // bridge restarts. The launch path still spawn-verifies + falls back, so a // pinned browser that later breaks won't hard-fail an open.- const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath });+ const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath, install: !!(args && args.install) }); const rd = chrome.readiness(); const availTxt = (result.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none'; let hint;@@ -4232,6 +4265,8 @@ const server = http.createServer(async (req, res) => { hint = `${result.error} Available on this machine: ${availTxt}. Use browser_use {browser:"chrome"|"edge"|"cft"|"auto"}.`; } else if (result.cleared) { hint = `Default cleared → AUTO. pup will drive the first browser that launches (${availTxt}) and cache it. Next browser_open_window applies it.`;+ } else if (result.installingChrome) {+ hint = `Downloading & installing REAL Google Chrome (~90 MB, user-scoped — no admin prompt in most cases) and pinning it as the default. Poll browser_readiness: watch installingChrome/chromeStablePhase until Chrome appears (browserKind:"chrome", source:"system"). Until then pup uses ${availTxt}. If it stalls, a UAC prompt may be waiting on the user's desktop — ask them to accept it.`; } else if (result.installing) { hint = `Pinned Chrome for Testing as the default and started the ~150 MB download in the background. Poll browser_readiness until ready:true — until then pup falls back to installed Chrome/Edge (${availTxt}). Once cached, every open uses CfT.`; } else {@@ -4694,6 +4729,11 @@ server.listen(PORT, async () => { console.log(`Puppeteer Bridge running on http://localhost:${PORT}`); console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir'); console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');+ // Self-warm: detect browsers + disk the moment the bridge spawns (right after AD+ // installs/updates it), so the FIRST open is instant. Only fetches CfT if the box+ // has literally no Chrome/Edge and no cached CfT — otherwise it's a cheap no-op.+ try { chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }+ // Recover sessions from disk (reconnect to Chrome windows that survived bridge restart) try { await recoverSessions();