app
Adom Desktop - Puppeteer Bridge
Public Made by Adomby adom
The Adom Desktop Puppeteer (pup) bridge — drive a real headful browser on the user's desktop from the cloud: open/screenshot/eval/record. Cold-start drives the browser ALREADY on the machine (installed Chrome, else Microsoft Edge — every Windows PC ships with Edge) with a fresh isolated profile, so the common case needs NO download; Chrome for Testing is fetched only as a last resort. Two artifacts: the Releases zip is the bridge runtime (also bundled in Adom Desktop); the pkg installs the container-side Claude skills.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841
// chrome.js — Chrome-for-Testing (CfT) self-heal for the Adom Desktop Puppeteer bridge.
//
// WHY THIS EXISTS (the cold-start bug it fixes):
// HD bundles AD; installing HD installs AD; AD ships THIS bridge as a pre-installed
// seed. But the HD/AD installer pre-installs NEITHER Node NOR Chrome for Testing.
// So on a fresh PC, the first "open chip-fetcher in pup" used to:
// - (Node missing) → AD returns node_not_found → desktop_install_node [already good]
// - (Node present, CfT missing) → server.js blindly spawned a nonexistent Chrome
// binary 3×, then threw a plain dev-string ("...run npx puppeteer browsers
// install chrome..."). No errorCode, no auto-install, no actionable hint → the
// AI flailed and a human had to guess "they have no Chrome for Testing".
//
// WHAT THIS MODULE DOES:
// - detectChrome(): validates the ACTUAL executable exists (not "some dir exists" —
// the old coarse check let a partial/corrupt cache read as present and deferred
// failure to puppeteer.launch()).
// - installChrome(): installs CfT PROGRAMMATICALLY via the bundled @puppeteer/browsers
// (no npx, no npm-registry round-trip; only the CfT bytes come from Google's CDN),
// pinned to the EXACT buildId this puppeteer expects so launch() can find it.
// - ensureChromeReady(): the gate the bridge calls before opening a window — returns
// immediately with {state:'installing'} on a cold box (non-blocking) so the first
// call answers with a pollable status instead of hanging 2–3 min.
// - readiness(): the snapshot powering browser_readiness + the /status led/summary/tooltip.
//
// Everything here is self-contained in the bridge (no adom-desktop core changes).
'use strict';
const path = require('path');
const fs = require('fs');
const os = require('os');
let puppeteer = null;
try { puppeteer = require('puppeteer'); } catch (e) { /* surfaced via readiness().puppeteerLoadError */ }
const _puppeteerLoadError = puppeteer ? null : 'puppeteer module failed to require — node_modules likely missing (AD runs `npm install` on bridge spawn; if this persists the bundled deps are broken)';
// ── install-state singleton ──────────────────────────────────────────────────
// Shared across every command so concurrent open calls don't double-install and
// browser_readiness / the status chip can report live progress.
const state = {
phase: 'unknown', // 'unknown' | 'detecting' | 'installing' | 'ready' | 'failed'
buildId: null,
executablePath: null,
progressPct: 0, // 0..100 download progress
bytesDownloaded: 0,
bytesTotal: 0,
startedAt: null,
finishedAt: null,
error: null, // last failure message (cleared on success)
errorCode: null, // 'chrome_download_failed' | 'chrome_unsupported_platform' | ...
};
let _installInFlight = null; // dedup: the in-flight install promise, if any
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');
}
// The buildId THIS puppeteer is pinned to. executablePath() returns the EXPECTED
// path even when the browser isn't installed yet; the build dir encodes the id,
// e.g. .../chrome/win64-140.0.7259.2/chrome-win64/chrome.exe. Installing 'stable'
// instead could fetch a DIFFERENT version than this puppeteer supports → launch()
// would look for the pinned id and not find it. So we pin to exactly this.
function expectedBuildId() {
try {
const ep = puppeteer && puppeteer.executablePath ? puppeteer.executablePath() : null;
if (!ep) return null;
const m = ep.replace(/\\/g, '/').match(/\/chrome\/(?:win64|win32|mac(?:_arm)?|linux)-([0-9][0-9.]*)\//i);
return m ? m[1] : null;
} catch { return null; }
}
// Validate a USABLE Chrome for Testing is present (the actual exe, not a stray dir).
function detectChrome() {
// 1) Where puppeteer expects it for the pinned build.
try {
const ep = puppeteer && puppeteer.executablePath ? puppeteer.executablePath() : null;
if (fileExists(ep)) return { installed: true, executablePath: ep, source: 'puppeteer.executablePath', buildId: expectedBuildId() };
} catch { /* fall through */ }
// 2) Any installed Chrome in the cache, via @puppeteer/browsers (newest wins).
try {
const { computeExecutablePath, Browser } = require('@puppeteer/browsers');
const bid = expectedBuildId();
if (bid) {
const ep = computeExecutablePath({ browser: Browser.CHROME, buildId: bid, cacheDir: cacheDir() });
if (fileExists(ep)) return { installed: true, executablePath: ep, source: 'computeExecutablePath', buildId: bid };
}
} catch { /* fall through */ }
return { installed: false, executablePath: null, buildId: expectedBuildId() };
}
// ── System browsers (the FAST path — no download) ─────────────────────────────
// Every Windows PC ships with Edge, and most have Chrome — both are Chromium and
// both are drivable by puppeteer. Preferring an ALREADY-INSTALLED browser (launched
// with a FRESH isolated profile) means the common case needs NO ~150 MB Chrome-for-
// Testing download. listSystemBrowsers probes the standard install locations and
// returns EVERY drivable system browser it finds, in preference order (Chrome, then Edge).
function listSystemBrowsers() {
const pf = process.env['PROGRAMFILES'] || 'C:\\Program Files';
const pf86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
const lad = process.env['LOCALAPPDATA'] || '';
const j = (base, ...rest) => (base ? path.join(base, ...rest) : null);
const chromePaths = [
j(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
j(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
j(lad, 'Google', 'Chrome', 'Application', 'chrome.exe'),
].filter(Boolean);
const edgePaths = [
j(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
j(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
].filter(Boolean);
const posix = [
['chrome', '/usr/bin/google-chrome'], ['chrome', '/usr/bin/chromium'], ['chrome', '/usr/bin/chromium-browser'],
['chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],
['edge', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
];
const out = [];
const seen = new Set();
const push = (kind, p) => { if (p && !seen.has(p) && fileExists(p)) { seen.add(p); out.push({ kind, executablePath: p, source: 'system' }); } };
for (const p of chromePaths) push('chrome', p);
for (const p of edgePaths) push('edge', p);
for (const [kind, p] of posix) push(kind, p);
return out;
}
// Back-compat: the FIRST system browser (or {found:false}).
function detectSystemBrowser() {
const list = listSystemBrowsers();
return list.length ? { found: true, kind: list[0].kind, executablePath: list[0].executablePath } : { found: false };
}
// ── Persisted browser default ─────────────────────────────────────────────────
// So the bridge doesn't re-probe/re-verify on every open. Two ways it gets set:
// • markVerified(): after a launch actually SUCCEEDS, we cache that exact browser
// (forced:false) — the auto default. Cheap re-use next time.
// • setDefaultBrowser(): the AI PINS a browser via browser_use (forced:true) — we
// honor it strictly (but still fall back if it can't spawn, so it stays airtight).
// Stored as JSON next to the CfT cache so it survives bridge restarts.
function defaultFile() { return path.join(cacheDir(), 'pup-browser-default.json'); }
function readDefault() {
try { return JSON.parse(fs.readFileSync(defaultFile(), 'utf8')); } catch { return null; }
}
function writeDefault(obj) {
try { fs.mkdirSync(cacheDir(), { recursive: true }); fs.writeFileSync(defaultFile(), JSON.stringify(obj, null, 2)); return true; }
catch (e) { console.error(`[chrome] could not persist browser default: ${e.message}`); return false; }
}
function clearDefault() { try { fs.unlinkSync(defaultFile()); } catch {} }
// Cache the browser that just launched successfully as the auto-default. Never
// overrides an explicit (forced) choice the AI made via browser_use.
function markVerified(cand) {
if (!cand || !cand.executablePath) return;
const d = readDefault();
if (d && d.forced) return;
writeDefault({ kind: cand.kind, executablePath: cand.executablePath, source: cand.source || 'system', buildId: cand.buildId || null, forced: false, verifiedAt: Date.now() });
}
// Every drivable browser, in the order pup will TRY them:
// [stored default, if still present] → installed Chrome → installed Edge → cached CfT.
// Each entry is a fully-launchable {kind, executablePath, source}. The launch path
// tries them top-to-bottom and spawn-verifies each, so a broken top pick falls through.
function launchCandidates() {
// v1.8.71 (wiki issue #202 + the Aditya/Google-auth incident): cached CfT comes FIRST.
// CfT is the automation-first binary with NO profile/sync/sign-in machinery — the branded
// browsers' identity features bleed into pup's "anonymous" windows (sign-in → "create a
// profile?" → duplicate profiles → consolidation prompts → lost user). System browsers are
// the zero-download fallback for a fresh box; a fallback launch triggers a background CfT
// prewarm (server.js) so every box converges to CfT by its second session without ever
// stalling a first open. browser_use still pins either direction explicitly.
const all = [];
const cft = detectChrome();
if (cft.installed) all.push({ kind: 'chrome-for-testing', executablePath: cft.executablePath, source: 'cache', buildId: cft.buildId });
all.push(...listSystemBrowsers());
const def = readDefault();
if (def) {
let idx = -1;
if (def.executablePath) idx = all.findIndex(c => c.executablePath === def.executablePath);
if (idx < 0 && def.kind) idx = all.findIndex(c => c.kind === def.kind);
if (idx > 0) { const [d] = all.splice(idx, 1); all.unshift(d); }
else if (idx < 0 && def.executablePath && fileExists(def.executablePath)) {
all.unshift({ kind: def.kind || 'custom', executablePath: def.executablePath, source: def.source || 'default', buildId: def.buildId });
}
// A forced-but-not-yet-present default (e.g. 'cft' still downloading) simply
// doesn't reorder — the system browsers serve as the fallback until it arrives.
}
return all;
}
// The browser pup will actually launch, in preference order:
// [pinned/verified default] → installed Chrome → installed Edge → cached Chrome-for-Testing.
// Launch uses a fresh --user-data-dir, so a system browser is driven CLEAN — it never
// touches the user's real tabs/logins (that's native-mode's job). `candidates` is the
// full ordered fallback list; the launch path spawn-verifies each in turn.
function resolveBrowser() {
const candidates = launchCandidates();
const def = readDefault();
const top = candidates[0];
if (!top) return { found: false, kind: 'none', executablePath: null, source: null, candidates: [], forced: !!(def && def.forced) };
return {
found: true, kind: top.kind, executablePath: top.executablePath, source: top.source, buildId: top.buildId || null,
candidates, forced: !!(def && def.forced), defaultBrowser: def || null,
};
}
// browser_use: set (or clear) the browser pup drives by default.
// 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, install, elevate } = {}) {
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() };
writeDefault({ kind: 'custom', executablePath, source: 'explicit', forced: true, verifiedAt: Date.now() });
return { ok: true, ...pickSummary() };
}
if (b === 'auto' || b === 'clear' || b === 'reset' || b === 'default' || b === '') {
clearDefault();
return { ok: true, cleared: true, ...pickSummary() };
}
if (b === 'chrome' || b === 'edge') {
const cand = listSystemBrowsers().find(c => c.kind === b);
if (cand) {
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({ elevate }); // background; browser_use polling + readiness track it
return {
ok: true, installing: true, installingChrome: true,
_hint: "Installing Google Chrome in the background. On a normal PC this is silent with NO prompt. On a locked-down box (a VM, managed device) it needs a Windows approval — the bridge will pop a native notification asking the user to click YES on the UAC, and if it expires it re-notifies with a one-tap 'Approve now'. Poll browser_readiness: chromeStablePhase goes installing → awaiting_uac (waiting on the user's click) → ready. pup drives Edge meanwhile, so nothing is blocked.",
...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 — silent on a normal PC; on a locked-down box it prompts the user for a Windows approval via a notification), 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();
if (det.installed) {
writeDefault({ kind: 'chrome-for-testing', executablePath: det.executablePath, source: 'cache', buildId: det.buildId, forced: true, verifiedAt: Date.now() });
return { ok: true, ...pickSummary() };
}
// Not cached — pin the intent and start the download; once it lands it becomes default.
writeDefault({ kind: 'chrome-for-testing', executablePath: null, source: 'cache', forced: true, pendingInstall: true, verifiedAt: Date.now() });
ensureChromeReady({ background: true });
return { ok: true, installing: true, ...pickSummary() };
}
return { ok: false, errorCode: 'bad_browser', error: `Unknown browser '${browser}'. Use 'chrome', 'edge', 'cft', or 'auto' (or pass an explicit executablePath).`, ...pickSummary() };
}
// Compact snapshot of the browser decision — reused by readiness + every browser_* hint.
function pickSummary() {
const r = resolveBrowser();
const d = readDefault();
return {
picked: r.found ? { kind: r.kind, source: r.source, executablePath: r.executablePath } : null,
candidates: (r.candidates || []).map(c => ({ kind: c.kind, source: c.source, executablePath: c.executablePath })),
defaultBrowser: d ? { kind: d.kind, source: d.source || null, forced: !!d.forced, pendingInstall: !!d.pendingInstall } : null,
};
}
// Programmatic install of the pinned CfT build. Resolves to { ok, executablePath, buildId }
// or { ok:false, errorCode, error }. Deduped via _installInFlight.
function installChrome({ onProgress } = {}) {
if (_installInFlight) return _installInFlight;
state.phase = 'installing';
state.progressPct = 0;
state.bytesDownloaded = 0;
state.bytesTotal = 0;
state.error = null;
state.errorCode = null;
state.startedAt = Date.now();
state.finishedAt = null;
_installInFlight = (async () => {
try {
const browsers = require('@puppeteer/browsers');
const { install, Browser, detectBrowserPlatform, resolveBuildId } = browsers;
const platform = detectBrowserPlatform();
if (!platform) {
state.phase = 'failed';
state.errorCode = 'chrome_unsupported_platform';
state.error = `Could not detect a Chrome-for-Testing platform for ${process.platform}/${process.arch}.`;
return { ok: false, errorCode: state.errorCode, error: state.error };
}
// Pin to puppeteer's expected build; fall back to the channel's stable id.
let buildId = expectedBuildId();
if (!buildId) {
try { buildId = await resolveBuildId(Browser.CHROME, platform, 'stable'); } catch { /* leave null */ }
}
if (!buildId) {
state.phase = 'failed';
state.errorCode = 'chrome_buildid_unresolved';
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()} (disk free: ${freeMb == null ? 'unknown' : freeMb + ' MB'}) ...`);
const installed = await install({
browser: Browser.CHROME,
buildId,
cacheDir: cacheDir(),
downloadProgressCallback: (downloaded, total) => {
state.bytesDownloaded = downloaded;
state.bytesTotal = total;
state.progressPct = total ? Math.round((downloaded / total) * 100) : 0;
if (onProgress) { try { onProgress(state.progressPct, downloaded, total); } catch {} }
},
});
const ep = (installed && installed.executablePath) || detectChrome().executablePath;
if (!fileExists(ep)) {
state.phase = 'failed';
state.errorCode = 'chrome_install_incomplete';
state.error = `Chrome for Testing install reported success but no executable was found at ${ep || '(unknown path)'}.`;
return { ok: false, errorCode: state.errorCode, error: state.error };
}
state.phase = 'ready';
state.executablePath = ep;
state.progressPct = 100;
state.finishedAt = Date.now();
state.error = null;
state.errorCode = null;
console.log(`[chrome] Chrome for Testing ${buildId} ready at ${ep}`);
return { ok: true, executablePath: ep, buildId };
} catch (e) {
state.phase = 'failed';
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.errorCode}): ${state.error}`);
return { ok: false, errorCode: state.errorCode, error: state.error };
} finally {
_installInFlight = null;
}
})();
return _installInFlight;
}
// The gate the bridge calls before launching a window.
// background:true → kick the install off and return immediately (non-blocking),
// so the FIRST cold-start call answers with a pollable status
// instead of hanging 2–3 min while CfT downloads.
// background:false → await the install (used by browser_prewarm when the caller
// explicitly wants to block until ready).
// Returns { ready, phase, progressPct, executablePath, errorCode, error }.
async function ensureChromeReady({ background = true, onProgress } = {}) {
const det = detectChrome();
if (det.installed) {
state.phase = 'ready';
state.executablePath = det.executablePath;
state.buildId = det.buildId || state.buildId;
return { ready: true, phase: 'ready', progressPct: 100, executablePath: det.executablePath };
}
if (_installInFlight) {
// already installing — return live progress
return { ready: false, phase: 'installing', progressPct: state.progressPct, executablePath: null };
}
if (background) {
installChrome({ onProgress }); // fire-and-forget; readiness() tracks it
return { ready: false, phase: 'installing', progressPct: 0, executablePath: null };
}
const r = await installChrome({ onProgress });
return {
ready: !!r.ok,
phase: state.phase,
progressPct: state.progressPct,
executablePath: r.executablePath || null,
errorCode: r.ok ? null : r.errorCode,
error: r.ok ? null : r.error,
};
}
// Snapshot for browser_readiness + the /status chip. nodeInstalled is implicitly
// true (this module only runs inside the node bridge process), but we surface it
// so the AI's readiness check is a single source of truth.
function readiness() {
const b = resolveBrowser();
const det = detectChrome();
const installing = !!_installInFlight || state.phase === 'installing';
const ready = b.found && !installing; // a drivable browser is available (system OR cached CfT)
const d = readDefault();
return {
ready,
browserKind: b.kind, // 'chrome' | 'edge' | 'chrome-for-testing' | 'none'
browserSource: b.source || null, // 'system' | 'cache' | null
browserExecutablePath: b.executablePath || null,
// Full ordered fallback list the launcher will spawn-verify, + the persisted default.
candidates: (b.candidates || []).map(c => ({ kind: c.kind, source: c.source, executablePath: c.executablePath })),
defaultBrowser: d ? { kind: d.kind, source: d.source || null, forced: !!d.forced, pendingInstall: !!d.pendingInstall } : null,
nodeInstalled: true,
nodeVersion: process.version,
chromeForTestingInstalled: det.installed,
chromeExecutablePath: det.executablePath || state.executablePath || null,
chromeBuildId: det.buildId || state.buildId || null,
installing,
installPhase: state.phase,
installProgressPct: state.progressPct,
bytesDownloaded: state.bytesDownloaded,
bytesTotal: state.bytesTotal,
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, // idle|installing|awaiting_uac|ready|failed
chromeStableAwaitingApproval: chromeStableState.phase === 'awaiting_uac', // a UAC is up on the machine running pup; the user must click YES there
chromeStableNotifyChannel: chromeStableState.notifyChannel === undefined ? null : !!chromeStableState.notifyChannel, // could the bridge actually notify the user? (false on AD <1.9.84 that doesn't expose notify_user to the direct-API — the AI must relay the approval ask)
chromeStableNotifyPeers: chromeStableState.notifyPeers == null ? null : chromeStableState.notifyPeers, // # of peer ADs also pinged (cross-AD target:"all") so the user is told even if attending another device
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. Two phases (installChromeStable):
// 1. QUIET user-scoped `/silent /install` → drops into %LOCALAPPDATA%\Google\Chrome
// with NO prompt on a normal PC (this is the common, unchanged path).
// 2. If that doesn't land (a locked-down box / VM where the installer needs admin),
// ESCALATE: raise the UAC elevated AND drive the user to it with an AD toast fired
// from this bridge via the direct-API — re-notifying with a sticky one-tap
// 'Approve now' if the prompt expires. See _elevatedInstallWithNotify.
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); });
});
}
// ── Talking back to Adom Desktop from inside the bridge ───────────────────────
// AD spawned us; it also runs a loopback-only direct-API on the SAME machine
// (`POST /command {app,command,args}`, same dispatcher as the CLI/WS path). That
// lets THIS bridge fire a native toast + poll the clicked button on its OWN — no
// AI turn in the loop — which is exactly what an elevation prompt needs: nudge
// the user to the UAC, and if they walk away, re-nudge with a one-tap retry.
// Resolution order: env ADOM_DIRECT_API_URL → ~/.adom/direct-api-port → default.
let _adApiBase = undefined;
function _resolveAdApiBase() {
if (_adApiBase !== undefined) return _adApiBase;
_adApiBase = null;
try {
const env = process.env.ADOM_DIRECT_API_URL;
if (env) { _adApiBase = env.replace(/\/+$/, ''); return _adApiBase; }
const home = process.env.USERPROFILE || os.homedir();
const pf = path.join(home, '.adom', 'direct-api-port');
if (fileExists(pf)) {
const hp = fs.readFileSync(pf, 'utf8').trim(); // "127.0.0.1:47200"
if (hp) { _adApiBase = `http://${hp}`; return _adApiBase; }
}
} catch {}
_adApiBase = 'http://127.0.0.1:47200'; // documented default; harmless if unreachable
return _adApiBase;
}
// Call an AD verb via the direct-API. AD ≥1.9.84: `app` is inferred and the FULL verb
// surface is reachable (notify_user, notify_response, targets, …). Best-effort: resolves
// null on any failure so a missing/old AD never blocks the install (the UAC still appears).
// opts.target → route to a PEER AD: "<clientName>" | "all" | "self"/"local" (default).
// opts.noToken → internal: retry unattributed after a stale-token 403.
// Sends the spawn-time ADOM_BRIDGE_TOKEN as X-Adom-Bridge-Token for Activity-Log
// attribution (OPTIONAL — a stale token 403s, so we retry once without it).
function adCommand(command, args, opts = {}) {
const { target, timeoutMs = 8000, noToken = false } = opts;
return new Promise((resolve) => {
let base;
try { base = _resolveAdApiBase(); } catch { return resolve(null); }
if (!base) return resolve(null);
try {
const http = require('http');
const payload = { command, args: args || {} };
if (target) payload.target = target;
const body = JSON.stringify(payload);
const u = new URL(base + '/command');
const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) };
const tok = process.env.ADOM_BRIDGE_TOKEN;
if (tok && !noToken) headers['X-Adom-Bridge-Token'] = tok;
const req = http.request(
{ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', headers },
(resp) => {
let d = ''; resp.on('data', c => d += c);
resp.on('end', () => {
if (resp.statusCode === 403 && tok && !noToken) return resolve(adCommand(command, args, { ...opts, noToken: true }));
try { resolve(JSON.parse(d)); } catch { resolve(null); }
});
}
);
req.on('error', () => resolve(null));
req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch {}; resolve(null); });
req.write(body); req.end();
} catch { resolve(null); }
});
}
function _adGet(pathname, timeoutMs = 6000) {
return new Promise((resolve) => {
let base; try { base = _resolveAdApiBase(); } catch { return resolve(null); }
if (!base) return resolve(null);
try {
const http = require('http'); const u = new URL(base + pathname);
const req = http.get({ hostname: u.hostname, port: u.port, path: u.pathname },
(resp) => { let d = ''; resp.on('data', c => d += c); resp.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(null); } }); });
req.on('error', () => resolve(null));
req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch {}; resolve(null); });
} catch { resolve(null); }
});
}
// Probe whether this AD exposes notify_user through the direct-API. AD ≥1.9.84 lists it
// in /commands.topLevel; AD ≤1.9.79 didn't expose it at all (top-level verbs returned
// "Unknown desktop command"), so a bridge couldn't notify. We cache the answer: {ok} if
// reachable, null if not. This gates the notify-first UAC flow vs. the honest degradation
// (raise once + relay hint) on an AD too old to notify.
let _adNotifyCap = undefined; // undefined=unprobed | null=unavailable | {ok:true}
async function _probeAdNotifyCap() {
if (_adNotifyCap !== undefined) return _adNotifyCap;
_adNotifyCap = null;
try {
const cmds = await _adGet('/commands');
if (cmds) {
const top = cmds.topLevel || [];
const dlist = (cmds.desktop && (cmds.desktop.commands || cmds.desktop)) || [];
const has = (a) => Array.isArray(a) && (a.includes('notify_user') || a.includes('desktop_notify_user'));
if (has(top) || has(dlist)) _adNotifyCap = { ok: true };
}
} catch {}
return _adNotifyCap;
}
const adNotify = (args, target) => adCommand('notify_user', args, { target });
const adNotifyResponse = (id) => adCommand('notify_response', { id });
// Peer ADs on the same relay (OTHER machines the user might be sitting at). [] if none/old
// AD. The direct-API `targets` returns {clients:[…]} and INCLUDES self, so we exclude our
// own hostname/clientName. (Shape-tolerant: also accepts targets/data.* for other builds.)
async function adPeerNames() {
try {
const r = await adCommand('targets', {});
const list = (r && (r.clients || r.targets || (r.data && (r.data.clients || r.data.targets)))) || [];
let self = ''; try { self = String(os.hostname() || '').toLowerCase(); } catch {}
const names = list.map(t => t && (t.clientName || t.name || t.hostname)).filter(Boolean)
.filter(n => String(n).toLowerCase() !== self);
return [...new Set(names)];
} catch { return []; }
}
const _sleep = (ms) => new Promise(r => setTimeout(r, ms));
// PowerShell single-quote escaping (double any embedded quote).
function _psq(s) { return `'${String(s).replace(/'/g, "''")}'`; }
// Raise a UAC by launching the installer ELEVATED. Start-Process -Verb RunAs pops
// the Windows security prompt on the user's desktop. Resolves an exit code:
// 0 → prompt accepted (or already elevated); installer launched
// 3 → user declined / cancelled / the prompt expired
// <=0? → spawn failure
function _raiseElevatedInstall(installerPath) {
return new Promise((resolve) => {
try {
const { spawn } = require('child_process');
const ps = `try { Start-Process -FilePath ${_psq(installerPath)} -ArgumentList '/silent','/install' -Verb RunAs -WindowStyle Hidden -ErrorAction Stop; exit 0 } catch { exit 3 }`;
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { windowsHide: true });
let done = false; const fin = (c) => { if (!done) { done = true; resolve(c); } };
child.on('error', () => fin(-1));
child.on('exit', (code) => fin(code == null ? 0 : code));
setTimeout(() => fin(0), 20000); // Start-Process returns fast; guard a hung shell
} catch { resolve(-1); }
});
}
// Locked-down box (e.g. a VM) path: the quiet user-scoped install didn't land, so
// Chrome needs elevation. TWO modes, gated on whether AD actually lets us notify:
//
// (A) AD notify IS reachable → notify-FIRST: fire a sticky "Approve now" toast and
// WAIT for the user to tap it BEFORE raising the UAC. This is the correct order —
// raising the UAC first puts up the secure desktop, which HIDES the toast (the bug
// that made the first cut fire prompts the user was never told about). On tap we
// raise one UAC; if it expires we re-notify (sticky) and wait again. Autonomous,
// zero AI turns.
//
// (B) AD notify is NOT reachable (AD ≤1.9.79 doesn't expose notify_user to the direct-
// API) → we CANNOT tell the user, so we DO NOT spam UACs. Raise the prompt exactly
// ONCE, mark awaiting_uac with a clear hint the AI/caller must relay, and poll. No
// blind re-raise loop. (Pending the AD change, this is the honest degradation.)
async function _elevatedInstallWithNotify(installerPath) {
const id = 'pup-chrome-install';
chromeStableState.phase = 'awaiting_uac';
chromeStableState.error = null; chromeStableState.errorCode = null;
const cap = await _probeAdNotifyCap();
chromeStableState.notifyChannel = !!cap;
const pollChrome = async (ms) => {
const end = Date.now() + ms;
while (Date.now() < end) { await _sleep(2500); const c = listSystemBrowsers().find(x => x.kind === 'chrome'); if (c) return c; }
return null;
};
// (B) No way to notify — one prompt, no spam, clear state for the caller to relay.
if (!cap) {
console.log('[chrome] AD notify NOT reachable via direct-API — raising the UAC ONCE (no re-arm loop) and surfacing awaiting_uac for the caller to relay');
chromeStableState.awaitingApprovalSince = Date.now();
await _raiseElevatedInstall(installerPath);
return await pollChrome(150 * 1000); // ~2.5 min for the user to click the single prompt
}
// (A) Notify-first with re-arm on tap. The UAC lives on THIS box (you can only approve
// it here), so the actionable "Approve now" toast is LOCAL. If the user is attending a
// different device (peer AD on the relay — e.g. pup on a VM, user on their laptop), we
// ALSO ping every peer so they're told where to go. Cross-AD `target:"all"` routes the
// ping via the relay. The local toast's tap re-raises the UAC here; peer toasts are
// informational ("go to <host>"). Autonomous — zero AI turns.
const host = (() => { try { return os.hostname(); } catch { return 'this PC'; } })();
const peers = await adPeerNames();
chromeStableState.notifyPeers = peers.length;
const pingPeers = (title, body, level) => peers.length
? adNotify({ id: 'pup-chrome-peer', level: level || 'warning', scenario: 'reminder', buttons: [{ label: 'OK' }], title, body }, 'all')
: Promise.resolve();
const doneLocal = () => adNotify({ id, level: 'success', title: 'Chrome is installed ✓', body: 'Google Chrome is ready — pup will drive it from now on.' });
const donePeers = () => pingPeers('Chrome installed ✓', `Chrome finished installing on ${host}.`, 'success');
const OVERALL_MS = 12 * 60 * 1000;
const t0 = Date.now();
let first = true;
while (Date.now() - t0 < OVERALL_MS) {
await adNotify({
id, level: 'warning', scenario: 'reminder', buttons: [{ label: 'Approve now' }],
title: first ? 'Chrome needs your approval' : 'Chrome still needs your approval',
body: `Tap 'Approve now', then click YES on the Windows security prompt to finish installing Google Chrome on ${host}.`,
});
await pingPeers(`Chrome needs approval on ${host}`,
`pup is installing Chrome on ${host}. Go to that machine, tap 'Approve now', then click YES on the Windows prompt.`);
first = false;
// WAIT for the LOCAL tap before raising the UAC (so the toast isn't hidden by the secure desktop).
let tapped = false;
const waitEnd = Date.now() + 4 * 60 * 1000; // re-notify (sticky) if untouched for 4 min
while (Date.now() < waitEnd) {
await _sleep(2500);
const c = listSystemBrowsers().find(x => x.kind === 'chrome');
if (c) { await doneLocal(); await donePeers(); return c; }
const resp = await adNotifyResponse(id);
if (resp && resp.pending === false && resp.action) { tapped = true; break; }
}
if (!tapped) continue; // untouched → loop top re-fires the sticky toasts
await _raiseElevatedInstall(installerPath); // they opted in → one UAC now
const c = await pollChrome(130 * 1000);
if (c) { await doneLocal(); await donePeers(); return c; }
// UAC expired/declined → loop top re-notifies and waits for another tap.
}
return null;
}
// opts.elevate: 'auto' (default) = quiet first, escalate to the notified UAC path if
// that doesn't land; true = skip quiet, go straight to the UAC path; false = quiet
// only, no prompt (old behaviour — fail with a hint if it needs admin).
function installChromeStable(opts = {}) {
if (_chromeStableInFlight) return _chromeStableInFlight;
const elevate = opts.elevate === undefined ? 'auto' : opts.elevate;
_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);
let found = null;
// Phase 1 — quiet, user-scoped silent install. On a normal box this lands with
// NO prompt; only if it doesn't do we escalate. (Skipped when elevate===true.)
if (elevate !== true) {
console.log('[chrome] running Chrome installer silently (/silent /install) ...');
const { spawn } = require('child_process');
try { spawn(tmp, ['/silent', '/install'], { windowsHide: true }); } catch {}
for (let i = 0; i < 30; i++) { found = listSystemBrowsers().find(x => x.kind === 'chrome'); if (found) break; await _sleep(1500); } // ~45s
}
// Phase 2 — escalate: Chrome needs elevation, so drive the user to the UAC with
// AD toasts + a re-arm-on-tap loop. (elevate:false opts out and just fails.)
if (!found && elevate !== false) {
console.log('[chrome] quiet install did not yield chrome.exe — escalating to an elevated install with a user prompt');
found = await _elevatedInstallWithNotify(tmp);
}
try { fs.unlinkSync(tmp); } catch {}
if (!found) {
chromeStableState.phase = 'failed';
if (elevate === false) {
chromeStableState.errorCode = 'chrome_stable_not_detected';
chromeStableState.error = 'Ran the Chrome installer but chrome.exe did not appear (elevation was declined via elevate:false). pup still works with Edge in the meantime.';
} else if (chromeStableState.notifyChannel) {
chromeStableState.errorCode = 'chrome_stable_needs_approval';
chromeStableState.error = 'Chrome needs a Windows approval (UAC) that was not accepted in time. A sticky "Approve now" notification is on the user\'s desktop — tapping it re-opens the prompt. pup still works with Edge in the meantime.';
await adNotify({ id: 'pup-chrome-install', level: 'warning', scenario: 'reminder', buttons: [{ label: 'Approve now' }], title: 'Chrome install paused', body: "Ran out of time waiting for approval. Tap 'Approve now' whenever you're back and click YES — Chrome finishes in seconds." });
} else {
// No notify channel on this AD — we raised ONE UAC and could not tell the user.
// The CALLER (AI) must relay this; do NOT silently spam more prompts.
chromeStableState.errorCode = 'chrome_stable_needs_approval_uncced';
chromeStableState.error = 'Chrome needs a Windows approval (UAC) on THIS machine, but this AD build does not expose notify_user to bridges via the direct-API, so pup could not pop a notification. A UAC was raised once. RELAY TO THE USER: "Go to the machine running pup and click YES on the Google Chrome installer prompt", then re-call browser_use {browser:"chrome", install:true}. pup drives Edge meanwhile.';
}
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;
// Reconcile the pending-chrome default with the ACTUAL outcome, so readiness never
// keeps advertising `default:chrome, pendingInstall:true` for a Chrome that never
// landed. On success → pin the real exe; on failure → revert to auto (Edge/CfT).
try {
const d = readDefault();
if (d && d.forced && d.pendingInstall && d.kind === 'chrome') {
if (chromeStableState.phase === 'ready') {
const c = listSystemBrowsers().find(x => x.kind === 'chrome');
if (c) writeDefault({ kind: 'chrome', executablePath: c.executablePath, source: 'system', forced: true, verifiedAt: Date.now() });
} else {
clearDefault();
}
}
} catch {}
}
})();
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() {
// v1.8.77: pup OWNS its browser provisioning (John: "isn't that your decision? why would
// you need ad to change anything?"). CfT is pup's browser, PERIOD (demarcation doctrine),
// so warmup ensures it exists on EVERY box — not only browser-less ones. The old
// skip-when-Chrome-exists logic here was the dead installed-browser-first doctrine living
// on in pup's own code. warmup runs at every bridge spawn (AD install/update, machine
// start), so a fresh box downloads CfT in the background at INSTALL time and the user's
// first "open in pup" finds it cached — no first-use wait, no AD policy involved.
// AD's job ends at "pup is installed"; the rest is ours. (Its v1.9.76 prewarmer policy is
// now redundant for pup either way — the install singleton dedupes if both fire.)
try {
const cands = launchCandidates();
const free = diskFreeMb();
const cft = detectChrome();
console.log(`[warmup] browsers: ${cands.map(c => `${c.kind}(${c.source})`).join(', ') || 'NONE'} | cft cached: ${cft.installed} | disk free: ${free == null ? '?' : free + 'MB'}`);
if (cft.installed) return; // pup's browser is ready — nothing to do
if (free != null && free < CFT_MIN_FREE_MB) {
console.log(`[warmup] CfT missing + low disk (${free}MB) — NOT auto-fetching; readiness reports lowDisk and opens will surface the disk hint`);
return;
}
console.log('[warmup] Chrome for Testing (pup\'s browser) not cached — background-fetching now so the first open never waits');
ensureChromeReady({ background: true });
} catch (e) { console.error(`[warmup] ${e && e.message}`); }
}
module.exports = {
detectChrome, detectSystemBrowser, listSystemBrowsers, launchCandidates, resolveBrowser,
installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir, diskFreeMb,
setDefaultBrowser, readDefault, clearDefault, markVerified, pickSummary,
installChromeStable, chromeStableStatus, warmup,
adCommand, // v1.8.40: reused by server.js to call AD verbs (e.g. desktop_taskbar) via the direct-API
};