← Commit history
nb 0.4.11 / nbe 0.2.8: dialog pre-answer arming (Chrome cancels dialogs under a debugger; the hold-open design was impossible) + truthful hints
8 files changed
+54−30
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.4.10+0.4.11
bridge/bridge.json+2−2@@ -2,8 +2,8 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser",- "version": "0.4.10",- "expectedExtensionVersion": "0.2.7",+ "version": "0.4.11",+ "expectedExtensionVersion": "0.2.8", "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.4.10",+ "version": "0.4.11", "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@@ -1384,7 +1384,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: '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_handle_dialog: { hint: 'Control native JS dialogs (alert/confirm/prompt). GROUND TRUTH: while nbe is driving, CHROME cancels dialogs the instant they open (confirm/prompt return the dismiss value, alerts close) - the page can never wedge, but a dialog can also never be caught open to answer later. To ANSWER one deliberately, arm a PRE-ANSWER first: {policy:"manual", accept:true|false, text?} makes the page answer the NEXT dialog(s) with your preset the moment they fire; do the action; then {policy:"auto"} disarms and returns `seen` (every dialog answered).', related: ['nbrowser_events', 'nbrowser_screenshot'], pitfalls: ['a bare {accept:true} AFTER the dialog fired is too late - Chrome already cancelled it; arm the pre-answer BEFORE the action, not after', 'the pre-answer is per TAB and does NOT survive a page navigation - re-arm after navigating', 'disarm with {policy:"auto"} when done - it also restores the page\'s own confirm/alert/prompt functions'] }, 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'] },@@ -1631,7 +1631,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', '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_handle_dialog', 'Control native JS dialogs. While driving, Chrome auto-cancels dialogs the instant they open (page never wedges). To ANSWER one: arm a pre-answer BEFORE the action with {policy:"manual", accept, text?}; the next dialog(s) get your preset the moment they fire; {policy:"auto"} disarms and returns `seen`.', { sessionId: S, accept: 'optional bool (true = OK/Yes, false = Cancel) - with policy:"manual" this is the preset answer', text: 'optional str (preset answer for a prompt)', policy: 'optional "manual"|"auto" - arm/disarm the pre-answer for this tab' }, { ok: 'bool', armed: 'bool?', disarmed: 'bool?', seen: '[{type,message,answered,ts}]?', alreadyHandled: 'bool?', policy: 'str?', was: 'obj?' }, 30, { example: { sessionId: 'work', policy: 'manual', accept: true } }), 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.7",+ "version": "0.2.8", "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+34−9@@ -845,18 +845,35 @@ const _lastDialog = new Map(); // tabId -> { dialogType, message, autoHandled, export function dialogFor(tabId) { return _openDialogs.get(Number(tabId)) || null; } export function lastDialogFor(tabId) { return _lastDialog.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.+// nbrowser_handle_dialog — control native JS dialogs. GROUND TRUTH (measured 2026-08-22, Chrome 139,+// john's laptop): while a chrome.debugger client is attached, CHROME ITSELF cancels page dialogs -+// Page.javascriptDialogOpening never reaches the extension (confirm() returns false instantly, our+// listener sees nothing). So the CDP arming design ("dialog stays open, answer it later") CANNOT work,+// and it silently didn't: policy:'manual' armed a listener Chrome never called. The honest, working+// design is a PAGE-LEVEL PRE-ANSWER: arming overrides window.confirm/alert/prompt in the page to record+// the call and return YOUR preset answer, and disarming restores the originals and reports what was+// seen. The CDP path below stays for the (non-attached / other-browser) case where it does fire. export async function handleDialog(tabId, accept, promptText, policy) { await attach(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.' };+ if (policy === 'manual') {+ const armed = await evaluate(tabId,+ `(function(){ if (window.__nbDlg && window.__nbDlg.armed) { window.__nbDlg.accept=${accept !== false}; window.__nbDlg.text=${JSON.stringify(promptText != null ? String(promptText) : null)}; return 're-armed'; }+ var w=window; w.__nbDlg={armed:true, accept:${accept !== false}, text:${JSON.stringify(promptText != null ? String(promptText) : null)}, seen:[], orig:{c:w.confirm,a:w.alert,p:w.prompt}};+ w.confirm=function(m){ w.__nbDlg.seen.push({type:'confirm',message:String(m),answered:w.__nbDlg.accept,ts:Date.now()}); return w.__nbDlg.accept; };+ w.alert=function(m){ w.__nbDlg.seen.push({type:'alert',message:String(m),answered:true,ts:Date.now()}); };+ w.prompt=function(m,d){ var ans=w.__nbDlg.accept ? (w.__nbDlg.text!=null?w.__nbDlg.text:(d||'')) : null; w.__nbDlg.seen.push({type:'prompt',message:String(m),answered:ans,ts:Date.now()}); return ans; };+ return 'armed'; })()`);+ return { ok: true, policy: 'manual', armed: true, mode: String(armed), preAnswer: { accept: accept !== false, text: promptText != null ? String(promptText) : null }, tabId: String(id),+ _hint: `Manual mode ARMED as a pre-answer: the NEXT dialog(s) on this tab are answered ${accept !== false ? 'ACCEPT (OK/Yes)' : 'DISMISS (Cancel)'} the instant they fire${promptText != null ? `, prompts answered "${String(promptText).slice(0, 40)}"` : ''}. This is a page-level override (Chrome cancels dialogs itself while a debugger is attached, so a dialog can never be held open for a later answer). Do the action now, then policy:"auto" to disarm and get the list of dialogs seen. The override does NOT survive a page navigation - re-arm after navigating.` };+ }+ // policy:'auto' - restore the page's own dialog functions and report what the arming saw.+ const seen = await evaluate(tabId,+ `(function(){ var w=window, d=w.__nbDlg; if (!d) return []; if (d.orig){ w.confirm=d.orig.c; w.alert=d.orig.a; w.prompt=d.orig.p; } var s=d.seen||[]; delete w.__nbDlg; return s; })()`).catch(() => []);+ return { ok: true, policy: 'auto', disarmed: true, seen: Array.isArray(seen) ? seen : [], tabId: String(id),+ _hint: 'Auto mode restored (page dialog functions un-overridden). While driving, Chrome auto-cancels dialogs the moment they open (confirm/prompt return the dismiss value, alerts just close) so the page can never wedge - to ANSWER one deliberately, arm the pre-answer first: {policy:"manual", accept:true|false, text?}. `seen` lists every dialog the arming answered.' }; } const dlg = _openDialogs.get(id); try { await send(tabId, 'Page.handleJavaScriptDialog', { accept: accept !== false, promptText: promptText != null ? String(promptText) : undefined }); }@@ -864,8 +881,16 @@ export async function handleDialog(tabId, accept, promptText, policy) { const auto = ((_lastDialog.get(id) || {}).autoHandled ? _lastDialog.get(id) : null) || [..._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.' };+ _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 PRE-ANSWER first: nbrowser_handle_dialog {sessionId, policy:"manual", accept:true}, then do the action, then policy:"auto" to disarm.` };+ // Pre-answer memory: the page-level arming records every dialog it answered in window.__nbDlg.seen.+ const seen = await evaluate(tabId, '(window.__nbDlg && window.__nbDlg.seen) || []').catch(() => []);+ if (Array.isArray(seen) && seen.length) {+ const last = seen[seen.length - 1];+ return { ok: true, handled: false, alreadyHandled: true, was: last, tabId: String(id),+ _hint: `No dialog is open: your ARMED pre-answer already answered a ${last.type} ("${String(last.message).slice(0, 80)}") with ${JSON.stringify(last.answered)}. Disarm + collect the full list with {policy:"auto"}.` };+ }+ return { ok: false, error: 'no dialog to handle (it may have already closed): ' + ((e && e.message) || e),+ _hint: 'No dialog is open. NOTE: while nbe is driving, Chrome cancels page dialogs the moment they fire (confirm returns false, alerts close) - the page can never wedge, but a dialog also can never be caught open. To ANSWER the next dialog deliberately, arm a pre-answer BEFORE the action: nbrowser_handle_dialog {sessionId, policy:"manual", accept:true|false, text?}, do the action, then {policy:"auto"} to disarm and see what was answered.' }; } _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.' };
native-browser-bridge-manifest.json+5−5@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.4.10",- "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.4.10/native-browser-bridge-v0.4.10.zip",- "sha256": "d42f582712b23449090059a541a1ae9d822b8bddb059a10f0adc4beb2ee3c270",- "size": 106217,- "released_at": "2026-08-22T14:45:21Z"+ "version": "0.4.11",+ "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.4.11/native-browser-bridge-v0.4.11.zip",+ "sha256": "15333d3b55a391509fdde42468363e44e673a2aa1c23f46cede4d4f977529e7d",+ "size": 106296,+ "released_at": "2026-08-22T14:55:58Z" }
tools/verb-battery.mjs+8−9@@ -163,19 +163,18 @@ const T = { return A(sawSpawn, `spawn tracked (${spawned} owned.spawned)`, `no spawn recorded: owned=${JSON.stringify(own).slice(0, 120)} events=${JSON.stringify(ev).slice(0, 120)}`); }, handle_dialog: () => {- // DETERMINISTIC via the arming path (racing the auto-dismiss made this test flaky): arm manual so- // the confirm STAYS OPEN, answer it ourselves, then restore auto. Tests policy arming + the answer- // + the restore in one pass, with no race against the auto-handler.- const arm = call('nbrowser_handle_dialog', { sessionId: SID, policy: 'manual' });+ // PRE-ANSWER contract (Chrome cancels dialogs itself while a debugger is attached, so "hold it+ // open and answer later" is impossible - measured 2026-08-22): arm accept:true BEFORE the click,+ // the page answers the confirm with true the moment it fires, disarm returns what was seen.+ const arm = call('nbrowser_handle_dialog', { sessionId: SID, policy: 'manual', accept: true }); call('nbrowser_click', { sessionId: SID, selector: '#confirm-btn', retry: false });- const r = call('nbrowser_handle_dialog', { sessionId: SID, accept: true });- call('nbrowser_handle_dialog', { sessionId: SID, policy: 'auto' }); // never leave a tab armed- settle(600);+ settle(400);+ const off = call('nbrowser_handle_dialog', { sessionId: SID, policy: 'auto' }); let v = evalIn('document.getElementById("dlg-note").textContent'); if (v.result === undefined) { settle(900); v = evalIn('document.getElementById("dlg-note").textContent'); } const note = String(v.result);- // manual+accept must land confirm:true; tolerate confirm:false only if the auto-handler beat the arming- return A((arm.ok !== false) && (note === 'confirm:true' || note === 'confirm:false'), `armed manual, answered: ${note}`, `arm:${JSON.stringify(arm).slice(0, 80)} handle:${JSON.stringify(r).slice(0, 80)} note:${JSON.stringify(v).slice(0, 200)}`);+ const sawConfirm = Array.isArray(off.seen) && off.seen.some((x) => x.type === 'confirm');+ return A(arm.armed === true && note === 'confirm:true' && sawConfirm, `pre-answer landed: ${note}; seen:${(off.seen || []).length}`, `arm:${JSON.stringify(arm).slice(0, 100)} off:${JSON.stringify(off).slice(0, 140)} note:${JSON.stringify(v).slice(0, 140)}`); }, cleanup: () => { const r = call('nbrowser_cleanup', { sessionId: SID }); const own = call('nbrowser_owned', { sessionId: SID }); return A((own.spawned || []).length === 0, `spawns closed (was ${ctx.spawned})`, JSON.stringify(r).slice(0, 140)); },