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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
// adom-browser-extension :: MV3 service worker
// ---------------------------------------------
// Holds the native-messaging port to the AD bridge (via the native host), routes nbrowser_* commands
// to native.js (chrome.tabs/windows — no banner) or cdp.js (chrome.debugger — screenshot/eval), and
// answers the host's keepalive ping (which keeps this service worker resident).
import * as native from './native.js';
import * as cdp from './cdp.js';
import * as record from './record.js';
const HOST = 'inc.adom.native_browser';
const VERSION = chrome.runtime.getManifest().version;
let port = null;
let connecting = false;
let gotMessage = false;
let _safetySent = false;
// Bounded reconnect: exponential backoff + a give-up state. NEVER a fixed-rate spin — a fast reconnect
// loop here re-spawns native-host processes, which is what exhausted the machine's sockets on
// 2026-06-19. After SW_GIVEUP misses we stop the fast loop; the 0.5-min keepalive alarm then re-arms
// exactly ONE attempt per tick (a slow heartbeat), so we still recover without hammering connectNative.
const SW_BASE = 1500, SW_MAX = 60000, SW_GIVEUP = 40;
let swBackoff = SW_BASE;
let swAttempts = 0;
let givenUp = false;
let reconnectTimer = null;
function setStatus(connected) {
chrome.storage.session.set({ connected, since: Date.now() }).catch(() => {});
}
function scheduleConnect() {
if (givenUp || port || connecting || reconnectTimer) return; // never stack timers / parallel attempts
if (swAttempts >= SW_GIVEUP) { givenUp = true; setStatus(false); return; }
const delay = Math.min(swBackoff, SW_MAX) + Math.floor(Math.random() * 400);
swBackoff = Math.min(swBackoff * 2, SW_MAX);
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);
}
// Idempotent: exactly ONE native port / one in-flight attempt at a time. Multiple callers (onDisconnect
// + keepalive alarm + onStartup) must NOT open parallel ports — that spawns multiple host processes and
// the bridge flaps between them ("newest host wins"). The guards below make concurrent calls no-ops.
function connect() {
if (port || connecting || givenUp) return;
connecting = true;
swAttempts++;
let p;
try {
p = chrome.runtime.connectNative(HOST);
} catch (e) {
connecting = false;
setStatus(false);
scheduleConnect();
return;
}
port = p;
connecting = false;
gotMessage = false;
setStatus(true);
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(() => {
if (port === p) port = null;
helloSent = false;
setStatus(false);
scheduleConnect(); // backoff, NOT a fixed 1.5s retry
});
sendHello(); // identify THIS profile so the bridge can route per-profile (multi-profile support)
}
// Tell the host+bridge which browser PROFILE this is, so one bridge can hold a connection per profile
// ([email protected], [email protected], …) and the AI can target/switch between them. Email comes from
// chrome.identity; if the profile isn't signed into Google, fall back to a stable random id.
let helloSent = false;
let _emailResolved = false, _emailTries = 0;
async function stableId() {
try {
const got = await chrome.storage.local.get('_nbProfileId');
let pid = got && got._nbProfileId;
if (!pid) { pid = 'p_' + Math.random().toString(36).slice(2, 10); await chrome.storage.local.set({ _nbProfileId: pid }); }
return pid;
} catch { return 'p_unknown'; }
}
async function resolveEmail() {
try {
const info = await Promise.race([
new Promise((res) => { try { chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }, res); } catch { res(null); } }),
new Promise((res) => setTimeout(() => res(null), 1500)),
]);
return (info && info.email) || '';
} catch { return ''; }
}
function postHello(profile) { try { if (port) { port.postMessage({ type: 'hello', profile, version: VERSION, browser: detectBrowser() }); helloSent = true; } } catch {} }
async function sendHello() {
// Stable id FIRST — always available, so the hello can ALWAYS be sent even if identity misbehaves.
const profile = { email: await resolveEmail(), id: await stableId(), name: '' };
if (profile.email) _emailResolved = true;
postHello(profile);
// If the email didn't resolve (identity slow, or briefly signed-out), retry and RE-announce once it
// lands - so the bridge re-keys "pending/id" -> the real "chrome:[email protected]" WITHOUT the caller
// doing anything. This is what removes the "pending-N / empty email until driven" gap.
if (!_emailResolved) scheduleEmailRetry();
}
function scheduleEmailRetry() {
if (_emailResolved || _emailTries >= 6) return;
_emailTries++;
setTimeout(async () => {
if (_emailResolved) return;
const email = await resolveEmail();
if (email) { _emailResolved = true; postHello({ email, id: await stableId(), name: '' }); }
else scheduleEmailRetry();
}, Math.min(1500 * _emailTries, 10000));
}
function reply(id, ok, payload, error, hint) {
if (!port) return;
port.postMessage(ok ? { id, ok: true, payload } : { id, ok: false, error, _hint: hint });
}
async function onMessage(msg) {
if (!msg) return;
if (!gotMessage) { gotMessage = true; swAttempts = 0; swBackoff = SW_BASE; if (!helloSent) sendHello(); } // proven-good connection → ensure the profile handshake went out
if (msg.ping) { try { port && port.postMessage({ pong: true }); } catch {} return; }
const { id, verb, args } = msg;
if (id == null || !verb) return;
try {
let payload = await dispatch(verb, args || {});
// First-contact safety banner: teach the cardinal rule once per SW lifetime, in-band.
if (!_safetySent && payload && typeof payload === 'object' && !Array.isArray(payload)) {
_safetySent = true;
payload._safety = '\u26d4 Cardinal rules: (1) REGISTER every window: nbrowser_open_window needs sessionId + thread + purpose ' +
'(who is driving + why) or it is refused (session_unregistered). (2) Only drive windows YOU open; bare mutating verbs ' +
'and targets in user-opened windows are refused (no_target / not_owned). (3) A page you drive can SPAWN windows ' +
'(a Gmail-Contacts email click opens a compose window) \u2014 those are auto-adopted; your next verb is refused ' +
'(unacknowledged_spawns) until you nbrowser_owned then nbrowser_cleanup. At task end, close everything you own. ' +
'(4) Work in the BACKGROUND. NEVER raise/maximize over the user\u2019s work or fall back to OS-level launches \u2014 it is auto-reverted + ' +
'logged as a focus_violation. Foregrounding is the user\u2019s choice alone (userRequestedForeground:true). ' +
'Start with nbrowser_profiles to pick the right profile.';
}
reply(id, true, payload);
} catch (e) {
// GuardError (ownership/no-target refusals) carries a teaching hint — deliver it verbatim so
// the calling AI is corrected at the moment of need, not pointed at a skill it may never read.
reply(id, false, undefined, String((e && e.message) || e), (e && e.hintText) || 'verb threw in extension');
}
}
async function dispatch(verb, args) {
switch (verb) {
// --- meta -------------------------------------------------------------
case 'nbrowser_status': {
const own = native.ownershipSummary(); // dirty_session nag: surfaced while owned/spawned windows remain
return { ok: true, ext: 'adom-browser-extension', version: VERSION, engine: 'native', browser: detectBrowser(), ownedWindows: own.ownedWindows, spawnedWindows: own.spawnedWindows, sessions: own.sessions, _hint: own._nag };
}
case 'nbrowser_whoami': {
// Force a fresh Google-email resolve + RE-announce, so the bridge re-keys this connection to
// "chrome:[email protected]" eagerly (fixes "pending-N / empty email until driven"). Returns the email.
_emailResolved = false; _emailTries = 0;
const email = await resolveEmail();
if (email) _emailResolved = true;
const id = await stableId();
postHello({ email, id, name: '' });
return { ok: true, email, id, browser: detectBrowser(), signedIn: !!email,
_hint: email ? 'Resolved this profile\'s Google email and re-announced it to the bridge.' : 'This browser profile is NOT signed into Google, so it has no email - target it by its id-key or by browser:.' };
}
case 'nbrowser_ping':
return { ok: true, pong: true };
case 'nbrowser_dev_reload': {
// HARD-GATED. chrome.runtime.reload() restarts the SW, which re-launches the native host. This is
// a bridge-DISPATCHABLE verb, so a looping/buggy bridge could call it repeatedly → hosts
// accumulate → port exhaustion (the 2026-06-19 incident). Throttle it, persisted in storage so an
// SW restart cannot reset the clock and bypass it. 90s is plenty: a reload-per-90s cannot exhaust
// ports (that needed many/second), and the per-profile host singleton + bridge circuit-breaker
// (20 connects/10s → trip) are the real backstops. 10min was too coarse for iteration.
const MIN_INTERVAL = 90 * 1000;
const now = Date.now();
const { _lastReload = 0 } = await chrome.storage.local.get('_lastReload');
if (now - _lastReload < MIN_INTERVAL) {
return { ok: false, error: 'dev_reload throttled',
_hint: `last reload ${Math.round((now - _lastReload) / 1000)}s ago; min interval ${MIN_INTERVAL / 1000}s. If you truly need it now, reload from chrome://extensions.` };
}
// 2-pass reload: chrome.runtime.reload() does a SOFT reload first (serves cached extension
// resources, so a single call kept the OLD code on disk-change), and only a 2nd reload re-reads
// files. Arm a one-shot flag so the SW completes the HARD pass itself on restart — a single
// dev_reload then reliably picks up on-disk changes. Completion logic is in the init at EOF.
await chrome.storage.local.set({ _lastReload: now, _devReload2: now });
setTimeout(() => chrome.runtime.reload(), 150); // reply first, then reload
return { ok: true, reloading: true,
_hint: 'Reloading in 2 self-completing passes (soft now → hard on restart) to load your on-disk changes; reconnects in ~6-10s. Poll nbrowser_status until version reflects the edit.' };
}
// --- windows / sessions ----------------------------------------------
case 'nbrowser_open_window': return native.openWindow(args);
case 'nbrowser_close_window': return native.closeWindow(args);
case 'nbrowser_switch_window': return native.switchWindow(args);
case 'nbrowser_list_windows': return native.listWindows(args);
case 'nbrowser_owned': return native.owned(args); // opened + spawned windows this session owns
case 'nbrowser_cleanup': return native.cleanup(args); // close ONLY this session's spawned windows
case 'nbrowser_force_close': return native.forceClose(args); // last-resort orphan override (two-step attestation)
case 'nbrowser_window_state': return native.windowState(args);
case 'nbrowser_fullscreen': return native.windowState({ ...args, state: args.state || 'fullscreen' });
case 'nbrowser_login_state': return native.loginState(args);
case 'nbrowser_clear_cookies': return native.clearCookies(args); // BACKGROUND logout — drops a site's cookies (incl. httpOnly)
case 'nbrowser_rescan': return native.rescan();
case 'nbrowser_resync_sessions': return native.resyncSessions(); // lightweight session-tracking rebuild (no dev_reload)
// --- demo aids -------------------------------------------------------
case 'nbrowser_caption': return native.caption(args);
case 'nbrowser_focus': return native.focus(args);
// --- tabs -------------------------------------------------------------
case 'nbrowser_open_tab': return native.openTab(args);
case 'nbrowser_close_tab': return native.closeTab(args);
case 'nbrowser_switch_tab': return native.switchTab(args);
case 'nbrowser_list_tabs': return native.listTabs(args);
// --- navigation -------------------------------------------------------
case 'nbrowser_navigate': return native.navigate(args);
case 'nbrowser_reload': return native.reload(args);
case 'nbrowser_back': return native.back(args);
case 'nbrowser_forward': return native.forward(args);
case 'nbrowser_wait': return native.wait(args);
case 'nbrowser_wait_for': return native.waitFor(args);
case 'nbrowser_keepalive': return native.keepalive(args);
// --- input (CDP, isTrusted) ------------------------------------------
case 'nbrowser_input_dispatch':
return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_input_dispatch' }), args);
case 'nbrowser_click':
return cdp.clickChanged(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_click' }), args); // #5 robust: reports changed:bool
case 'nbrowser_select': // open a custom/native dropdown, pick option by VISIBLE TEXT, return selected value
return cdp.selectOption(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_select' }), args);
case 'nbrowser_set_date': // #7 react-aria segmented date field
return cdp.setDate(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_date' }), args);
case 'nbrowser_set_file_input': // CDP file upload - no OS picker (DOM.setFileInputFiles); armForNextChooser for transient inputs
return cdp.setFileInput(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_file_input' }), args);
case 'nbrowser_totp': // #3 type the bridge-computed 6-digit code (args.__code) into the focused 2FA field
return cdp.typeTotp(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_totp' }), args);
case 'nbrowser_download_wait': // #6 wait for a download to complete; optional saveToSubfolder
return native.downloadWait(args);
case 'nbrowser_type':
return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_type' }), { ...args, type: 'type' });
case 'nbrowser_press_key':
return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_press_key' }), { ...args, type: 'key' });
case 'nbrowser_hover':
return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_hover' }), { ...args, type: 'hover' });
case 'nbrowser_double_click':
return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_double_click' }), { ...args, type: 'dblclick' });
case 'nbrowser_right_click':
return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_right_click' }), { ...args, type: 'rightclick' });
case 'nbrowser_login_form':
// Detect a login form's state (autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call
// before driving a login: if the form is already autofilled, just submit (background) — no popup/SSO.
return cdp.detectLoginForm(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_login_form' }));
case 'nbrowser_autologin':
// Bridge injected args.__secret (the laptop-decrypted saved password). The extension injects it via
// CDP and returns ONLY {ok,loggedIn,needs2FA} — never the secret. See cdp.autologin.
return cdp.autologin(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_autologin' }), args);
case 'nbrowser_cursor': {
const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_cursor' });
await cdp.cursor(tabId, args.action || 'move', args.x, args.y, args.label || args.intent);
return { ok: true, tabId: String(tabId), action: args.action || 'move', label: args.label || args.intent || 'Adom' };
}
// --- errors / lifecycle events ---------------------------------------
case 'nbrowser_errors':
return cdp.getErrors(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_errors' }), args.clear !== false);
case 'nbrowser_events': {
// window/tab closes (incl. byUser) + spawned_window/focus_violation since last drain — so the AI
// learns the user closed things, a page spawned a window, or a native JS dialog fired.
const e = await native.drainEvents();
const dlgs = cdp.drainDialogs();
if (dlgs.length) { e.events = (e.events || []).concat(dlgs); e.count = (e.events || []).length; }
return e;
}
// --- data (M4: authed fetch via the user's cookies, downloads) --------
case 'nbrowser_fetch_url': return native.fetchUrl(args);
case 'nbrowser_download': return native.download(args);
// --- recording (tab → WebM via offscreen MediaRecorder) ---------------
case 'nbrowser_record_start': return record.recordStart(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_record_start' }), args);
case 'nbrowser_record_stop': return record.recordStop(args);
case 'nbrowser_record_status': return record.recordStatus(args);
case 'nbrowser_record_list': return record.recordList();
// --- CDP: screenshot / eval ------------------------------------------
case 'nbrowser_screenshot':
return cdp.screenshot(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_screenshot' }), { fullPage: !!args.fullPage, maxWidth: args.maxWidth || 1568, resize: true });
case 'nbrowser_screenshot_full_res':
return cdp.screenshot(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_screenshot_full_res' }), { fullPage: !!args.fullPage, resize: false });
case 'nbrowser_eval':
case 'nbrowser_evaluate': {
const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_eval' });
const m = cdp.modalGuard(tabId); if (m) return m; // a native JS dialog is blocking the page
const result = await cdp.evaluate(tabId, String(args.expr ?? args.expression ?? ''));
return { ok: true, result, tabId: String(tabId), sessionId: args.sessionId };
}
case 'nbrowser_handle_dialog': // press OK/Cancel (or answer a prompt) on a native JS dialog blocking the page
return cdp.handleDialog(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_handle_dialog' }), args.accept, args.text ?? args.promptText);
default:
return { ok: false, error: `verb not implemented yet: ${verb}`,
_hint: 'Milestone 2 ships windows/tabs/navigate/reload/screenshot/eval; input_dispatch/errors/fetch_url/record land in M3–M4.' };
}
}
function detectBrowser() {
try { return navigator.userAgent.includes('Edg') ? 'edge' : 'chrome'; } catch { return 'unknown'; }
}
chrome.runtime.onStartup.addListener(connect);
chrome.runtime.onInstalled.addListener(connect);
// Popup-driven "Record this tab" (tabCapture, no picker). The user's icon-click grants activeTab.
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (!msg || !msg.type) return;
if (msg.type === 'popup-record-start') {
record.recordStart(msg.tabId, { method: 'tabcapture', fps: 30 }).then(sendResponse);
return true;
}
if (msg.type === 'popup-record-stop') {
(async () => {
const r = await record.recordStop({});
if (r.ok && r.base64) {
try {
await chrome.downloads.download({ url: `data:${r.mimeType};base64,${r.base64}`, filename: `adom-tab-recording-${Date.now()}.webm` });
r.downloaded = true;
} catch (e) { r.downloadError = String((e && e.message) || e); }
delete r.base64; // don't ship the big payload back to the popup
}
sendResponse(r);
})();
return true;
}
if (msg.type === 'popup-record-state') { sendResponse(record.recordStatus({})); return true; }
});
// Keepalive + self-heal. The host's ping keeps the SW warm WHILE connected, but if the SW is ever
// terminated the host dies too and nothing restarts it — so an alarm (which wakes a terminated MV3
// service worker) re-opens the native port. This is what makes the bridge reliably reachable.
chrome.alarms.create('nb-keepalive', { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((a) => {
if (a.name !== 'nb-keepalive') return;
// If the fast reconnect loop gave up, this tick re-arms exactly ONE attempt (a 0.5-min heartbeat) so
// we still recover when the bridge returns — bounded, never a spin.
if (givenUp) { givenUp = false; swAttempts = 0; swBackoff = SW_BASE; }
if (!port) { connect(); return; }
try { port.postMessage({ keepalive: true }); } catch (e) { port = null; setStatus(false); scheduleConnect(); }
});
// dev_reload 2-pass completion (see nbrowser_dev_reload). chrome.runtime.reload() runs a SOFT reload
// first; arming _devReload2 makes the SW do ONE more HARD reload here on restart so a single dev_reload
// reliably re-reads disk. Guards: cleared BEFORE reloading (no loop), and a >15s-old flag is treated as
// stale and dropped (a normal SW wake never triggers a stray reload). Otherwise connect as usual.
(async () => {
try {
const { _devReload2 = 0 } = await chrome.storage.local.get('_devReload2');
if (_devReload2) {
await chrome.storage.local.remove('_devReload2');
if (Date.now() - _devReload2 < 15000) { chrome.runtime.reload(); return; } // hard pass, then stop
}
} catch { /* fall through to a normal connect */ }
connect();
})();