← Commit history

0.3.8 + nbe 0.2.1: dialogs auto-answered so a driven confirm() can never wedge the page (#21); click-by-text self-heals a missed dispatch (#20)

John Lauer ·49711aba10 ·2mo ago ·parent d1c3b13
8 files changed +77−22
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.3.7+0.3.8
bridge/bridge.json+2−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser",-  "version": "0.3.7",-  "expectedExtensionVersion": "0.2.0",+  "version": "0.3.8",+  "expectedExtensionVersion": "0.2.1",   "description": "Drives the user's REAL signed-in Chrome/Edge, in two layers. Layer 1 (nb) needs NOTHING installed and works on any machine: nbrowser_open_os_window (open a URL in the user's real profile, background+flashing, the OAuth/consent path), nbrowser_os_windows, nbrowser_profiles, nbrowser_wake_profile, nbrowser_force_close. Layer 2 (nbe, the Adom Native Browser Extension) unlocks the rest: CDP driving (navigate/click/read/screenshot) inside their logged-in profile. The bridge binds an ephemeral loopback port and the native-messaging host pumps stdio <-> bridge.",   "homepage": "https://wiki.adom.inc/adom/nb-bridge",   "author": "Adom Inc.",
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.3.7",+  "version": "0.3.8",   "private": true,   "description": "Native-browser bridge for Bridge (ab): layer 1 (nb) plus layer 2 via nbe, the Adom Native Browser Extension (Chrome + Edge).",   "main": "server.js",
bridge/server.js+2−2
@@ -1330,7 +1330,7 @@ const VERB_META = {   nbrowser_flash_mode: { hint: 'Set auto-flash verbosity: quiet (come-look moments: page load, screenshot, error/refusal, human-gate, spawned window) | chatty (every action) | off (no auto-flash at all). Flash manually with nbrowser_flash at task-done.', related: ['nbrowser_flash'], pitfalls: ['off is the user opt-out - if the user says the orange flashing is annoying, set flash_mode:"off" and it stops entirely', 'quiet is the default; the flash is a "come look" cue, so keep it for human-gates even if you dial the rest down'] },   nbrowser_taskbar: { hint: 'Paint progress/overlay/flash on the window taskbar button (e.g. paused=yellow for a human gate). The "Adom is driving" badge is AUTOMATIC: the bridge composites the profile\'s circle avatar + the Adom favicon at open/drive, and idle-expires it (~3 min) back to the plain native avatar - you rarely need to touch overlay yourself.', related: ['nbrowser_flash'], pitfalls: ['clear progress+flash at task end: {progress:{state:"none"},flash:"stop"} - the badge clears itself (idle-expiry / close)', 'overlay.badge:"none" RESTORES the native avatar look (the bridge never blanks the overlay slot - blanking would erase the user\'s profile avatar)'] },   nbrowser_describe: { hint: 'Returns per-verb schemas + hint/related/pitfalls for ab\'s Verbs tab.', related: ['nbrowser_help'], pitfalls: ['this is the catalog - a few extension-only verbs (e.g. nbrowser_login_form) are real but resolved in the extension, not listed here'] },-  nbrowser_handle_dialog: { hint: 'Press OK/Cancel (or answer a prompt) on a native JS dialog (alert/confirm/prompt/beforeunload) BLOCKING the page. accept:true=OK/Yes/Leave · accept:false=Cancel/Stay · text:"…" answers a prompt.', related: ['nbrowser_screenshot', 'nbrowser_click'], next: 'nbrowser_screenshot', pitfalls: ['these dialogs are NOT in a screenshot and have NO hwnd (Chrome renders them) - you learn of them via a modal_open error or a screenshot _modal note', 'the page is FROZEN until you handle it: clicks/typing/eval return modal_open until you do', 'if the AI "keeps failing to fill a form", suspect an open dialog - handle it, then re-screenshot'] },+  nbrowser_handle_dialog: { hint: 'Answer a native JS dialog (alert/confirm/prompt/beforeunload) - and, more usefully, SET THE POLICY for the next one. By default dialogs are AUTO-answered the instant they open (alert/beforeunload accepted, confirm/prompt dismissed) so a dialog can never block the page mid-drive; each is reported in nbrowser_events. To answer one yourself, arm the tab FIRST with {policy:"manual"}, do the action, then call again with {accept:true|false}.', related: ['nbrowser_events', 'nbrowser_screenshot'], pitfalls: ['a native dialog BLOCKS the page main thread - before auto-handling existed, one confirm() from a driven click froze every later eval/click until the window closed (nb-bridge #21)', 'auto mode DISMISSES confirm/prompt (the safe answer) - if you needed OK, arm policy:"manual" BEFORE the action, not after', 'arming is per TAB and sticks until you set policy:"auto" again - restore it when done or later dialogs will block'] },   nbrowser_state: { hint: 'ONE call for "what windows am I driving, and what do they look like": every session with its EXACT hwnd + tabId + live url/title, and (screenshot:true) the page bitmap inline as base64. Use this instead of matching a browser window by TITLE - a title is the ACTIVE TAB, it changes while loading, and several windows can share a substring (that is exactly what broke the ab web-control demo, issue #22).', related: ['nbrowser_open_window', 'nbrowser_screenshot', 'nbrowser_os_windows'], pitfalls: ['hwnd is null until the window is correlated (a page grouped into an existing window has no new hwnd) - check before passing it to desktop_* verbs', 'this reports only THIS bridge\'s sessions; for every browser window on the machine use nbrowser_os_windows', 'screenshot:true costs a real CDP capture - omit it when you only need the logical state'] },   nbrowser_demo: { hint: 'ONE-CALL SHOWCASE of layer 2: picks a live profile, opens a representative page in the user\'s REAL signed-in browser (background + badged), and returns that exact window\'s hwnd/tabId plus an inline base64 screenshot with per-step progress. Built for embedding "drive my real browser" in a web demo without any window hunting.', related: ['nbrowser_state', 'nbrowser_open_window', 'nbrowser_close_window'], pitfalls: ['it OPENS a real window on the user\'s desktop - clean up with nbrowser_close_window {sessionId} (default "nbdemo") when the demo ends', 'idempotent: re-running closes its previous demo window first', 'needs nbe connected in some profile - if readiness says needs-extension it returns no drivable profile'] },   nbrowser_uninstall: { hint: 'CASCADE HOOK, not for normal use: ab calls this itself during bridge_uninstall (ab 2.0.35+) with {cascade:true} BEFORE deleting the bridge cache, so nb can remove artifacts that live outside it - above all the HKCU native-messaging host registration, which would otherwise leave every profile dialing a deleted binary forever ("Native host has exited"). Also clears ~/.adom/bridges/native-browser, the activity trail, and the icon cache.', related: ['nbrowser_readiness'], pitfalls: ['you almost never call this directly - run ab\'s bridge_uninstall and let it cascade', 'it deliberately does NOT remove the nbe extension from the user\'s browser (their profile, their choice) - it only stops working; use uninstalling-the-extension to remove it properly', 'best-effort by contract: a failure here still lets ab delete the cache, so check removed[]/failed[] rather than assuming'] },@@ -1562,7 +1562,7 @@ function dispatch(verb, args) {         d('nbrowser_add_extension_origin', 'Add an extension ID to the native-messaging host allowed_origins (so Edge/other-ID loads can reach the host). Reload the extension after.', { id: 'required extension id from edge://extensions' }, { ok: 'bool', origin: 'str', updatedManifests: '[str]' }, 15, { example: { id: 'abcdefghijklmnopabcdefghijklmnop' } }),         d('nbrowser_type', 'Type text into the focused field (isTrusted).', { sessionId: S, tabId: 'optional str', text: 'required str' }, { ok: 'bool' }, 30, { example: { sessionId: 'work', text: 'hello world' } }),         d('nbrowser_press_key', 'Press a key (Enter, Tab, etc.).', { sessionId: S, tabId: 'optional str', key: 'required str' }, { ok: 'bool' }, 30, { example: { sessionId: 'work', key: 'Enter' } }),-        d('nbrowser_handle_dialog', 'Dismiss a native JS dialog (alert/confirm/prompt/beforeunload) blocking the page. accept:true=OK/Yes/Leave, false=Cancel/Stay; text answers a prompt.', { sessionId: S, tabId: 'optional str', accept: 'optional bool (default true)', text: 'optional str (prompt answer)' }, { ok: 'bool', handled: 'bool', accepted: 'bool' }, 15, { example: { sessionId: 'work', accept: true } }),+        d('nbrowser_handle_dialog', 'Answer a native JS dialog, or set the per-tab POLICY. Default auto: dialogs are answered instantly (alert/beforeunload accept, confirm/prompt dismiss) so the page never wedges; policy:"manual" arms the tab so the next dialog stays open for you to answer.', { sessionId: S, accept: 'optional bool (true = OK/Yes/Leave, false = Cancel/Stay)', text: 'optional str (answer a prompt)', policy: 'optional "manual"|"auto" - arm/disarm manual answering for this tab' }, { ok: 'bool', handled: 'bool?', alreadyHandled: 'bool?', policy: 'str?', was: 'obj?' }, 30, { example: { sessionId: 'work', policy: 'manual' } }),         d('nbrowser_fetch_url', "Fetch a URL using the profile's real cookies/session; optional save to desktop.", { url: 'required str', profile: P, desktopSaveTo: 'optional Windows path' }, { ok: 'bool', status: 'int', body: 'str?', savedTo: 'str?' }, 120, { longRunning: true, example: { url: 'https://example.com/api.json' } }),         d('nbrowser_download_wait', 'Wait for a download to complete; returns the landed filename(s). Optional saveToSubfolder routes downloads into a Downloads subfolder.', { sessionId: S, sinceMs: 'optional int (look back window, default 60000)', timeoutMs: 'optional int (default 60000, max 300000)', saveToSubfolder: 'optional str' }, { ok: 'bool', files: '[str]', count: 'int', timedOut: 'bool' }, 120, { longRunning: true, example: { sessionId: 'work', timeoutMs: 60000 } }),         d('nbrowser_errors', 'Return buffered page/console errors for the tab.', { sessionId: S, tabId: 'optional str' }, { ok: 'bool', errors: '[...]' }, 20, { example: { sessionId: 'work' } }),
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom Native Browser Extension",   "short_name": "Adom",-  "version": "0.2.0",+  "version": "0.2.1",   "description": "Layer 2 of Adom Native Browser (nb): lets your Adom AI drive your real, signed-in Chrome or Edge, using your own logins.",   "minimum_chrome_version": "116",   "background": {
extension/src/cdp.js+64−9
@@ -741,15 +741,31 @@ export async function detectLoginForm(tabId) { // We catch Page.javascriptDialogOpening (the debugger is attached during any CDP drive), record it, and // surface it on the session's NEXT verb (modal_open) with an easy way to dismiss it. const _openDialogs = new Map(); // tabId -> { type, message, defaultPrompt, ts } (currently OPEN)+const _dialogPolicy = new Map(); // tabId -> 'auto' (default: never wedge) | 'manual' (caller answers) const _recentDialogs = [];      // persistent ring: every dialog we SAW (survives auto-dismiss) for nbrowser_events export function dialogFor(tabId) { return _openDialogs.get(Number(tabId)) || null; } export function drainDialogs() { const d = _recentDialogs.splice(0); return d; } // nbrowser_handle_dialog — click OK/Cancel (or answer a prompt) on the native dialog. accept:true = OK/Yes/Leave.-export async function handleDialog(tabId, accept, promptText) {+export async function handleDialog(tabId, accept, promptText, policy) {   await attach(tabId);-  const dlg = _openDialogs.get(Number(tabId));+  const id = Number(tabId);+  // policy:'manual' ARMS this tab so the NEXT dialog is left open for you to answer; 'auto' restores+  // the default (never wedge). Arming is the supported way to control a confirm() you are about to fire.+  if (policy === 'manual' || policy === 'auto') {+    _dialogPolicy.set(id, policy);+    return { ok: true, policy, tabId: String(id),+      _hint: policy === 'manual'+        ? 'Manual mode ARMED for this tab: the next native dialog stays OPEN and BLOCKS the page until you call nbrowser_handle_dialog {sessionId, accept:true|false}. Do the action now, then answer it. Restore safety with policy:"auto".'+        : 'Auto mode restored: dialogs are answered instantly (alert/beforeunload accepted, confirm/prompt dismissed) so the page can never wedge; each one is reported in nbrowser_events.' };+  }+  const dlg = _openDialogs.get(id);   try { await send(tabId, 'Page.handleJavaScriptDialog', { accept: accept !== false, promptText: promptText != null ? String(promptText) : undefined }); }-  catch (e) { return { ok: false, error: 'no dialog to handle (it may have already closed): ' + ((e && e.message) || e), _hint: 'The modal may have closed. Re-check with a nbrowser_screenshot.' }; }+  catch (e) {+    const auto = [..._recentDialogs].reverse().find((x) => x.tabId === String(id) && x.autoHandled);+    if (auto) return { ok: true, handled: false, alreadyHandled: true, was: auto, tabId: String(id),+      _hint: `No dialog was open: a ${auto.dialogType} ("${String(auto.message).slice(0, 80)}") was AUTO-${auto.accepted ? 'ACCEPTED' : 'DISMISSED'} the moment it opened, so the page never blocked. To answer one yourself, arm the tab FIRST: nbrowser_handle_dialog {sessionId, policy:"manual"}, then do the action, then answer.` };+    return { ok: false, error: 'no dialog to handle (it may have already closed): ' + ((e && e.message) || e), _hint: 'The modal may have closed. Re-check with a nbrowser_screenshot.' };+  }   _openDialogs.delete(Number(tabId));   return { ok: true, handled: true, accepted: accept !== false, was: dlg || null, tabId: String(tabId), _hint: 'Dialog dismissed — the page is unblocked. Continue driving; re-screenshot to see the current state.' }; }@@ -772,7 +788,28 @@ chrome.debugger.onEvent.addListener((source, method, params) => {   if (method === 'Page.javascriptDialogOpening') {     const d = { type: params.type || 'alert', message: params.message || '', defaultPrompt: params.defaultPrompt || '', ts: Date.now() };     _openDialogs.set(tabId, d);-    _recentDialogs.push({ event: 'dialog', tabId: String(tabId), dialogType: d.type, message: d.message, ts: d.ts });+    // AUTO-HANDLE (nb-bridge #21). A native dialog BLOCKS the page's main thread, so if nobody answers+    // it every later eval/click/screenshot on that tab hangs until the window is closed - a driven+    // click on any confirm() used to kill the whole drive. The docs always promised auto-dismissal+    // while driving; this is it. Policy per dialog TYPE, biased to the SAFE answer:+    //   alert  -> accept (only has OK)         confirm -> DISMISS (do not perform the guarded action)+    //   prompt -> dismiss (never invent input) beforeunload -> accept (we asked to navigate)+    // A caller that genuinely wants OK sets policy:'manual' (nbrowser_handle_dialog) and answers itself.+    const policy = _dialogPolicy.get(tabId) || 'auto';+    const accept = d.type === 'alert' || d.type === 'beforeunload';+    if (policy === 'auto') {+      send(tabId, 'Page.handleJavaScriptDialog', { accept }).then(() => {+        _openDialogs.delete(tabId);+        _recentDialogs.push({ event: 'dialog', tabId: String(tabId), dialogType: d.type, message: d.message, autoHandled: true, accepted: accept, ts: d.ts,+          note: `auto-${accept ? 'accepted' : 'dismissed'} so the page could not wedge; set policy:"manual" via nbrowser_handle_dialog BEFORE the action if you need to answer it yourself` });+        while (_recentDialogs.length > 20) _recentDialogs.shift();+      }).catch(() => {+        _recentDialogs.push({ event: 'dialog', tabId: String(tabId), dialogType: d.type, message: d.message, autoHandled: false, ts: d.ts });+        while (_recentDialogs.length > 20) _recentDialogs.shift();+      });+      return;+    }+    _recentDialogs.push({ event: 'dialog', tabId: String(tabId), dialogType: d.type, message: d.message, autoHandled: false, ts: d.ts });     while (_recentDialogs.length > 20) _recentDialogs.shift();     return;   }@@ -846,13 +883,31 @@ export async function pageSig(tabId) { } export async function clickChanged(tabId, args) {   const before = await pageSig(tabId);-  const r = await inputDispatch(tabId, { ...args, type: 'click' });+  let r = await inputDispatch(tabId, { ...args, type: 'click' });   if (r && r.ok === false) return r;   await sleep(Number(args.settleMs) || 500);-  const after = await pageSig(tabId);-  const changed = (before.u !== after.u) || Math.abs((after.n || 0) - (before.n || 0)) > 20 || (before.r !== after.r && after.r !== 'complete');-  return { ...r, changed, urlBefore: before.u, urlAfter: after.u,-    _hint: changed ? undefined : 'The click DISPATCHED but the page did not visibly change (same URL + DOM). The element may be disabled/covered or the click missed. Re-resolve the selector, nbrowser_wait_for a follow-up element, or retry.' };+  let after = await pageSig(tabId);+  const didChange = (b, a) => (b.u !== a.u) || Math.abs((a.n || 0) - (b.n || 0)) > 20 || (b.r !== a.r && a.r !== 'complete');+  let changed = didChange(before, after), retried = false;+  // SELF-HEAL (nb-bridge #20). A click-by-text that resolves fine and then lands nowhere is the+  // single most confusing failure this bridge had: ok:true, matchedText set, changed:false, DOM+  // untouched - because the coordinates were resolved a beat before the page settled (late layout,+  // font swap, lazy image) so the point no longer hit the element. Re-resolving and dispatching once+  // more costs ~600ms only in the failure case and turns a silent miss into a hit.+  if (!changed && args.retry !== false) {+    await sleep(350);+    const r2 = await inputDispatch(tabId, { ...args, type: 'click' });+    if (!(r2 && r2.ok === false)) {+      await sleep(Number(args.settleMs) || 500);+      const after2 = await pageSig(tabId);+      if (didChange(before, after2)) { r = r2; after = after2; changed = true; }+      retried = true;+    }+  }+  return { ...r, changed, retried, urlBefore: before.u, urlAfter: after.u,+    _hint: changed+      ? (retried ? 'Landed on the SECOND dispatch - the first missed because the page was still settling when the coordinates were resolved. If you see this often on one site, prefer a selector over click-by-text there.' : undefined)+      : 'The click DISPATCHED TWICE and the page still did not visibly change (same URL + DOM). The element is probably disabled, covered, or the handler does nothing visible. Re-resolve the selector, nbrowser_wait_for a follow-up element, or verify with nbrowser_eval.' }; }  // ── #7 set_date: fill a react-aria segmented date field (role=spinbutton MM/DD/YYYY) ──
extension/src/sw.js+1−1
@@ -297,7 +297,7 @@ async function dispatch(verb, args) {       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);+      return cdp.handleDialog(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_handle_dialog' }), args.accept, args.text ?? args.promptText, args.policy);      default:       return { ok: false, error: `verb not implemented yet: ${verb}`,
native-browser-bridge-manifest.json+5−5
@@ -1,8 +1,8 @@ {   "manifest_version": 1,-  "version": "0.3.7",-  "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.3.7/native-browser-bridge-v0.3.7.zip",-  "sha256": "199a0a09460968473c351a1ae8e058b3fcf0e5d16499a409b209ea3f54b24639",-  "size": 108228,-  "released_at": "2026-08-19T08:54:21Z"+  "version": "0.3.8",+  "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.3.8/native-browser-bridge-v0.3.8.zip",+  "sha256": "4ac34fc47c941f90bd6a70152ae3edacedc9e8c634071aa5512b6059cdf689e2",+  "size": 108442,+  "released_at": "2026-08-19T19:06:40Z" }