app
Adom Browser Extension
Public Made by Adomby adom
Your AI's hands in your real, signed-in browser. The Adom Browser Extension (abe) works with both Chrome and Edge: build, drive, and test web apps, and log past vendor walls, in the browser you already use - cookies, SSO, saved logins and captcha trust intact. The nbrowser_* bridge (pup's counterpart) runs in your actual logged-in profile.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
#!/usr/bin/env node
'use strict';
/*
* adom-browser-extension :: native-messaging host
* ------------------------------------------------
* Chrome/Edge launch this process when the extension calls
* chrome.runtime.connectNative('inc.adom.native_browser')
*
* It is a thin pump between two transports:
* - stdio : Chrome native-messaging framing (4-byte LE length prefix + JSON), bidirectional
* - TCP : the bridge's host-loopback (newline-delimited JSON), discovered from host.json
*
* ext --stdio--> host --tcp--> bridge (results, events, pong)
* ext <--stdio-- host <--tcp-- bridge (commands)
*
* The extension never learns a port — that's the whole point (PORTS.md: no port survives HNS, and an
* MV3 extension can't read a discovery file anyway). The host CAN read disk, so it does the lookup.
*
* A ~20s keepalive ping to the extension keeps the MV3 service worker resident.
*/
const fs = require('fs');
const net = require('net');
const os = require('os');
const path = require('path');
const BRIDGE_DIR = path.join(os.homedir(), '.adom', 'bridges', 'native-browser');
const HOSTFILE = path.join(BRIDGE_DIR, 'host.json');
// ---- PER-PROFILE SINGLETON guard -------------------------------------------
// Native-messaging hosts must NOT accumulate. The 2026-06-19 machine-wide port exhaustion was caused
// by N hosts each retrying connect()/sec. The rule is now ONE host PER BROWSER PROFILE (not one per
// machine) so several profiles ([email protected], [email protected], …) can each drive concurrently — but a
// single profile still can't spawn a pile of hosts. The profile id arrives in the extension's `hello`
// handshake (see pumpStdin); until then no lock is held, and a fallback timer takes a 'default' lock if
// no hello ever comes (old extension), preserving the original single-host loop-safety.
// Stale-PID-aware (a dead PID's file is reclaimed). Must be alive AND actually a node process — a stale
// pidfile whose PID got reused by an unrelated app otherwise makes every new host defer forever.
let PIDFILE = null; // set once we know our profile
function isLiveHost(pid) {
if (!pid || pid === process.pid) return false;
try { process.kill(pid, 0); } catch (e) { if (e.code !== 'EPERM') return false; } // dead -> reclaim
try {
const { execSync } = require('child_process');
if (process.platform === 'win32') {
return /"node\.exe"/i.test(execSync(`tasklist /fi "pid eq ${pid}" /fo csv /nh`, { encoding: 'utf8', timeout: 4000, windowsHide: true }));
}
return /node/i.test(execSync(`ps -p ${pid} -o comm=`, { encoding: 'utf8', timeout: 4000 }));
} catch { return false; } // can't verify -> assume reused -> take over
}
function profileSlug(key) {
return String(key || 'default').replace(/[^a-z0-9._@-]+/gi, '_').slice(0, 64) || 'default';
}
let singletonDone = false;
function acquireSingleton(profileKey) {
if (singletonDone) return; singletonDone = true;
PIDFILE = path.join(BRIDGE_DIR, `host.${profileSlug(profileKey)}.pid`);
try { fs.mkdirSync(BRIDGE_DIR, { recursive: true }); } catch {}
try {
const existing = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10);
if (isLiveHost(existing)) {
process.stderr.write(`[native-host] another host (pid ${existing}) already drives profile ${profileSlug(profileKey)} — exiting (singleton)\n`);
process.exit(0);
}
} catch {} // no/garbage pidfile -> we take ownership
try { fs.writeFileSync(PIDFILE, String(process.pid)); } catch {}
}
function releaseSingleton() {
if (!PIDFILE) return;
try {
const cur = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10);
if (cur === process.pid) fs.unlinkSync(PIDFILE); // only delete OUR pidfile, never a sibling's
} catch {}
}
process.on('exit', releaseSingleton);
// Fallback: if the extension never sends a `hello` (old build mid-upgrade), take a UNIQUE per-process
// lock — NOT a shared one. A shared 'default' lock would let one no-hello profile BLOCK every other
// profile's host (they'd fight over it and churn). Unique-per-pid means no cross-profile conflict; the
// bridge's provisional registration + circuit breaker + this host's own backoff/give-up still bound any
// runaway. Once every profile is on the hello build, this path is never taken.
const helloFallback = setTimeout(() => acquireSingleton('nohello-' + process.pid), 4000);
// ---- reconnect policy: exponential backoff + give-up (NO fixed-rate spin) ---
const BASE_DELAY = 500; // first retry
const MAX_DELAY = 30000; // backoff cap
const MAX_ATTEMPTS = 60; // give up after this many attempts WITHOUT ever connecting
const MAX_UNREACHABLE_MS = 10 * 60 * 1000; // ...or this long unreachable, whichever first
const STABLE_MS = 5000; // a connection up >= this long is "proven good" -> reset budget
let backoff = BASE_DELAY;
let attempts = 0;
let everConnected = false;
let connectedAt = 0;
const startedAt = Date.now();
// ---- Chrome native-messaging framing (stdout to the extension) -------------
function writeToExtension(obj) {
const json = Buffer.from(JSON.stringify(obj), 'utf8');
const len = Buffer.alloc(4);
len.writeUInt32LE(json.length, 0);
process.stdout.write(len);
process.stdout.write(json);
}
// Which browser launched THIS host? The extension's hello carries `browser` on new builds — prefer it.
// Otherwise (old extension mid-upgrade) detect it from our process ancestry: the host is
// node.exe <- (native-host.bat) cmd.exe <- chrome.exe | msedge.exe. Walk parents until we hit a browser.
// Result is cached; only runs at all for an extension that didn't report its browser.
let _hostBrowser = null;
function hostBrowser(msg) {
if (msg && msg.browser) return msg.browser;
if (_hostBrowser) return _hostBrowser;
_hostBrowser = detectBrowserFromTree();
return _hostBrowser;
}
function detectBrowserFromTree() {
try {
const out = require('child_process').execSync(
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation"',
{ timeout: 5000, windowsHide: true }
).toString();
const byPid = new Map();
for (const line of out.split(/\r?\n/)) {
const m = line.match(/^"?(\d+)"?,"?(\d+)"?,"?([^"]+)"?$/);
if (m) byPid.set(Number(m[1]), { ppid: Number(m[2]), name: String(m[3]).toLowerCase() });
}
let pid = process.ppid, hops = 0;
while (pid && hops++ < 8) {
const p = byPid.get(pid);
if (!p) break;
if (p.name.includes('msedge')) return 'edge';
if (p.name.includes('chrome')) return 'chrome';
pid = p.ppid;
}
} catch { /* fall through to default */ }
return 'chrome';
}
// ---- stdin (from the extension) -> bridge ----------------------------------
let bridge = null;
let inbuf = Buffer.alloc(0);
// ---- saved-password decryption (runs in CHROME's REAL context) --------------------------------------
// The AD-spawned bridge is sandboxed: its DPAPI master-key scope differs from John's real profile, so it
// gets "parameter is incorrect" on Chrome's blobs (verified 2026-06-23). THIS host is spawned by Chrome,
// so it shares Chrome's DPAPI scope and CAN decrypt. SECURITY: the plaintext only ever flows DOWNSTREAM
// to the extension via stdio (this same process → Chrome → extension); it never goes back to the bridge,
// AD, the relay, or the AI. Only {ok,loggedIn,...} returns upstream.
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const LOCALAPP = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
const BROWSER_UD = { chrome: path.join(LOCALAPP, 'Google', 'Chrome', 'User Data'), edge: path.join(LOCALAPP, 'Microsoft', 'Edge', 'User Data') };
const PWSH = (() => { try { const p = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); return fs.existsSync(p) ? p : 'powershell'; } catch { return 'powershell'; } })();
const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch {} };
// Warm powershell + System.Security ONCE at host startup so the first real decryption isn't a cold call.
// Cold powershell does "Preparing modules for first use"; rapid fresh spawns race on the module-analysis
// cache and exit 1 until it settles (verified 2026-06-24). Building the cache up front fixes it. Blocking
// ~1-2s at startup is fine; the result is discarded — its only job is to trigger module analysis.
let _pwshWarm = false;
function warmPowershell() {
if (_pwshWarm) return; _pwshWarm = true;
try { execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-Command', 'Add-Type -AssemblyName System.Security; [void][System.Security.Cryptography.ProtectedData]'], { windowsHide: true, timeout: 25000, stdio: 'ignore' }); } catch {}
}
const _aesKeyCache = {};
let myBrowser = 'chrome', myEmail = ''; // captured from the hello; used to locate this profile's Login Data
// Run a powershell that prints base64. INHERIT the host's env (it's in Chrome's real DPAPI context).
// RETRY the cold-start: a fresh powershell does "Preparing modules for first use" and intermittently
// exits 1 until the module-analysis cache settles (verified 2026-06-24).
function runPsB64(ps) {
warmPowershell();
const enc = Buffer.from(ps, 'utf16le').toString('base64');
let lastErr;
for (let attempt = 0; attempt < 4; attempt++) {
if (attempt) sleepSync(350);
try { const out = execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 }); const b = Buffer.from(out.trim(), 'base64'); if (b && b.length) return b; lastErr = new Error('empty'); }
catch (e) { lastErr = e; }
}
throw new Error('ps[' + (lastErr && lastErr.status) + ']');
}
// os_crypt key: read Local State + DPAPI-decrypt the key ENTIRELY in powershell (passing a node-read key to
// a separate DPAPI call failed "parameter is incorrect"; doing it all in-PS works — verified 2026-06-24).
function chromeAesKey(browser) {
if (_aesKeyCache[browser]) return _aesKeyCache[browser];
const lsPath = path.join(BROWSER_UD[browser], 'Local State');
const ps = "Add-Type -AssemblyName System.Security; "
+ "$ls = Get-Content -Raw '" + lsPath.replace(/'/g, "''") + "' | ConvertFrom-Json; "
+ "$b = [Convert]::FromBase64String($ls.os_crypt.encrypted_key); $b = $b[5..($b.Length-1)]; "
+ "$k = [System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser); "
+ "[Convert]::ToBase64String($k)";
const k = runPsB64(ps);
if (k.length !== 32) throw new Error('badkeylen=' + k.length);
return (_aesKeyCache[browser] = k);
}
function decryptBlob(blob, browser) {
if (!blob || !blob.length) return null;
const prefix = blob.slice(0, 3).toString('latin1');
if (prefix === 'v20') throw new Error('v20-app-bound'); // Chrome 127+ app-bound: no external process can decrypt — use the browser autofill flow
if (prefix === 'v10' || prefix === 'v11') {
const key = chromeAesKey(browser);
const iv = blob.slice(3, 15), tag = blob.slice(blob.length - 16), ct = blob.slice(15, blob.length - 16);
const d = crypto.createDecipheriv('aes-256-gcm', key, iv); d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
}
// very old DPAPI-only blob
return runPsB64("Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String('" + blob.toString('base64') + "'); [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))").toString('utf8');
}
function profilesOf(browser) {
try { const ls = JSON.parse(fs.readFileSync(path.join(BROWSER_UD[browser], 'Local State'), 'utf8')); return Object.entries(ls.profile.info_cache || {}).map(([dir, info]) => ({ dir, email: info.user_name || '' })); } catch { return []; }
}
function loginRowsWithBlob(file) {
const buf = fs.readFileSync(file);
let pageSize = buf.readUInt16BE(16); if (pageSize === 1) pageSize = 65536;
const n = Math.floor(buf.length / pageSize), out = [];
const varint = (off) => { let r = 0n, i = 0; for (; i < 9; i++) { const b = buf[off + i]; if (i === 8) { r = (r << 8n) | BigInt(b); i++; break; } r = (r << 7n) | BigInt(b & 0x7f); if (!(b & 0x80)) { i++; break; } } return [r, i]; };
for (let p = 1; p <= n; p++) {
const base = (p - 1) * pageSize, hdr = p === 1 ? base + 100 : base;
if (buf[hdr] !== 0x0d) continue;
const cells = buf.readUInt16BE(hdr + 3), ptrArr = hdr + 8;
for (let c = 0; c < cells; c++) {
let o = base + buf.readUInt16BE(ptrArr + c * 2);
let [, l1] = varint(o); o += l1; let [, l2] = varint(o); o += l2;
const recStart = o; let [hLen, hb] = varint(o); const hEnd = recStart + Number(hLen); o += hb;
const serials = []; while (o < hEnd) { const [s, sb] = varint(o); o += sb; serials.push(Number(s)); }
let d = hEnd; const cols = [];
for (const s of serials) { let v = null, sz = 0; if (s === 0 || s === 8 || s === 9) sz = 0; else if (s >= 1 && s <= 4) sz = s; else if (s === 5) sz = 6; else if (s === 6 || s === 7) sz = 8; else if (s >= 12 && s % 2 === 0) { sz = (s - 12) / 2; v = buf.slice(d, d + sz); } else if (s >= 13) { sz = (s - 13) / 2; v = buf.toString('utf8', d, d + sz); } cols.push(v); d += sz; }
if (cols[0] && /^https?:\/\//.test(cols[0])) out.push({ host: cols[0].split('/')[2], username: cols[3] || '', blob: cols[5] || null });
}
}
return out;
}
function decryptSavedPassword({ browser, profileEmail, host, username }) {
browser = browser || 'chrome';
if (!BROWSER_UD[browser]) return { error: 'unknown browser' };
const profs = profilesOf(browser);
const pr = profileEmail ? profs.find((p) => p.email.toLowerCase() === String(profileEmail).toLowerCase()) : (profs.find((p) => p.dir === 'Default') || profs[0]);
if (!pr) return { error: 'profile not found' };
const src = path.join(BROWSER_UD[browser], pr.dir, 'Login Data');
const tmp = path.join(os.tmpdir(), `nb-al-${browser}-${Date.now()}.db`);
try {
fs.copyFileSync(src, tmp);
const rows = loginRowsWithBlob(tmp);
const h = String(host).toLowerCase(), core = h.split('.').slice(-2).join('.');
let cand = rows.filter((r) => r.host && r.blob && r.blob.length && (r.host.toLowerCase().includes(h) || r.host.toLowerCase().includes(core)));
cand.sort((a, b) => { const um = (r) => (username && (r.username || '').toLowerCase() === String(username).toLowerCase()) ? 1 : 0; if (um(b) !== um(a)) return um(b) - um(a); return (/(^|\.)login\./.test(a.host) ? 1 : 0) - (/(^|\.)login\./.test(b.host) ? 1 : 0); });
let v20 = false;
for (const row of cand) { try { const pw = decryptBlob(row.blob, browser); if (pw && pw.length) return { username: row.username, password: pw }; } catch (e) { if (String((e && e.message) || '').includes('v20')) v20 = true; } }
return { error: v20 ? 'v20-app-bound (Chrome 127+ app-bound encryption — no external process can decrypt; use the browser autofill flow)' : 'no decryptable saved password for ' + host };
} catch (e) { return { error: String((e && e.message) || e).slice(0, 80) }; }
finally { try { fs.unlinkSync(tmp); } catch {} }
}
function pumpStdin() {
while (inbuf.length >= 4) {
const len = inbuf.readUInt32LE(0);
if (inbuf.length < 4 + len) break;
const json = inbuf.slice(4, 4 + len);
inbuf = inbuf.slice(4 + len);
let msg;
try { msg = JSON.parse(json.toString('utf8')); } catch { continue; }
if (msg && msg.pong) continue; // keepalive ack — don't forward
// First frame is the profile handshake → take this profile's singleton lock, then forward it on so
// the bridge registers the connection under this profile.
if (msg && msg.type === 'hello') {
clearTimeout(helloFallback);
// BROWSER + profile keying so the same account in Chrome AND Edge each get their own host (instead
// of the 2nd browser's host exiting on a shared lock — the "Native host has exited" collision).
// Prefer the browser the extension reported; else detect it from THIS host's process ancestry
// (chrome.exe vs msedge.exe) so an OLD extension that doesn't send `browser` still tags correctly.
// TAG the hello with it so the bridge keys the connection the SAME way (all coexist in parallel).
const browser = hostBrowser(msg);
msg.browser = browser;
const _id = (msg.profile && (msg.profile.email || msg.profile.id)) || 'default';
myBrowser = browser; myEmail = (msg.profile && msg.profile.email) || ''; // for autologin decryption
acquireSingleton(`${browser}:${_id}`);
// Cache the tagged hello so we can REPLAY it whenever we (re)connect to the bridge. The extension
// only sends hello ONCE (when its connectNative port first opens); if the BRIDGE later restarts
// (e.g. an update/refresh_bridges), the SW's port is unbroken so it never re-hellos - without a
// replay the restarted bridge would leave this profile stuck as a provisional "pending-N" (unknown
// version, not drivable by its real key) until the SW happens to restart minutes later.
lastHello = msg;
}
sendToBridge(msg);
}
}
process.stdin.on('data', (c) => { inbuf = Buffer.concat([inbuf, c]); pumpStdin(); });
process.stdin.on('end', () => process.exit(0));
process.stdin.on('error', () => process.exit(0));
// Outbound frames (ext -> bridge). If the bridge TCP isn't up yet, QUEUE them — otherwise an early
// frame like the `hello` handshake (the extension sends it the instant it connects) gets dropped and
// the bridge never learns this profile. Flushed on bridge 'connect'.
let outQueue = [];
let lastHello = null; // last tagged hello, re-sent on every bridge (re)connect so a restarted bridge re-learns us
function sendToBridge(msg) {
const line = JSON.stringify(msg) + '\n';
if (bridge && !bridge.destroyed) bridge.write(line);
else { outQueue.push(line); if (outQueue.length > 200) outQueue.shift(); } // bound it
}
function flushOut() { if (bridge && !bridge.destroyed) { const q = outQueue; outQueue = []; for (const l of q) bridge.write(l); } }
// ---- bridge connection (newline JSON) -> extension -------------------------
function readHostPort() {
try { return JSON.parse(fs.readFileSync(HOSTFILE, 'utf8')).hostPort; } catch { return null; }
}
// Schedule the next attempt with exponential backoff + jitter, OR give up (exit) if the bridge has
// been unreachable past the caps. Giving up is SAFE: stdin stays open, so Chrome keeps the extension's
// port; the SW's own bounded keepalive will re-launch a fresh host later if the bridge comes back. The
// one thing we must NEVER do is spin at a fixed fast rate — that is what exhausted the machine's ports.
function scheduleReconnect() {
if (!everConnected) {
if (attempts >= MAX_ATTEMPTS) {
process.stderr.write(`[native-host] bridge unreachable after ${attempts} attempts — exiting\n`);
process.exit(0);
}
if (Date.now() - startedAt > MAX_UNREACHABLE_MS) {
process.stderr.write(`[native-host] bridge unreachable for >${MAX_UNREACHABLE_MS}ms — exiting\n`);
process.exit(0);
}
}
const jitter = Math.floor(Math.random() * 250);
const delay = Math.min(backoff, MAX_DELAY) + jitter;
backoff = Math.min(backoff * 2, MAX_DELAY); // double every miss; reset only on a proven-good connect
setTimeout(connectBridge, delay);
}
function connectBridge() {
const port = readHostPort();
if (!port) { attempts++; scheduleReconnect(); return; } // bridge not up yet — backoff, don't spin
attempts++;
const sock = net.connect(port, '127.0.0.1');
let buf = '';
sock.on('connect', () => {
bridge = sock; everConnected = true; connectedAt = Date.now();
// Re-announce ourselves FIRST so a freshly (re)started bridge immediately re-learns this profile +
// version, instead of leaving us provisional until the SW next restarts. Idempotent on the bridge.
if (lastHello) { try { sock.write(JSON.stringify(lastHello) + '\n'); } catch {} }
flushOut();
});
sock.on('data', (chunk) => {
buf += chunk.toString('utf8');
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
if (!line.trim()) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
// AUTOLOGIN: the bridge (sandboxed, can't DPAPI) flags __decryptHere; decrypt in THIS host (Chrome's
// real context) and attach __secret for the extension to inject. The secret never leaves this hop.
if (msg && msg.verb === 'nbrowser_autologin' && msg.args && msg.args.__decryptHere) {
delete msg.args.__decryptHere;
try {
const cred = decryptSavedPassword({ browser: myBrowser, profileEmail: myEmail, host: msg.args.host, username: msg.args.username });
if (cred && cred.password) { msg.args.__secret = cred.password; if (!msg.args.username && cred.username) msg.args.username = cred.username; }
else msg.args.__secretError = (cred && cred.error) || 'decrypt failed';
} catch (e) { msg.args.__secretError = String((e && e.message) || e).slice(0, 120); }
}
writeToExtension(msg); // command -> extension
}
});
let done = false;
const onDone = () => {
if (done) return; done = true; // 'close' + 'error' both fire — schedule once
if (bridge === sock) bridge = null;
if (connectedAt && Date.now() - connectedAt >= STABLE_MS) { backoff = BASE_DELAY; attempts = 0; }
connectedAt = 0;
scheduleReconnect();
};
sock.on('close', onDone);
sock.on('error', onDone);
}
connectBridge();
// ---- keepalive (keeps the MV3 service worker alive) ------------------------
setInterval(() => { try { writeToExtension({ ping: true }); } catch {} }, 15000);