← Commit history

0.4.0 + nbe 0.2.4: #21 dialogs auto-answered (page can never wedge) + policy:manual; #20 root-caused - finder ignored off-viewport targets, offered body/html, and dispatched at stale coords

John Lauer ·9ba2d37dad ·2mo ago ·parent 096ee3c
7 files changed +54−19
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.3.9+0.4.0
bridge/bridge.json+2−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser",-  "version": "0.3.9",-  "expectedExtensionVersion": "0.2.2",+  "version": "0.4.0",+  "expectedExtensionVersion": "0.2.4",   "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.9",+  "version": "0.4.0",   "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",
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom Native Browser Extension",   "short_name": "Adom",-  "version": "0.2.2",+  "version": "0.2.4",   "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+30−6
@@ -289,8 +289,32 @@ function findFn(query, limit, rich) {     })(root || document);     return acc;   }+  // Scroll the winner to centre and re-read its geometry: the coordinates we hand back are viewport+  // coordinates, so a candidate that was off-screen when matched must be brought on-screen BEFORE its+  // x/y are computed. Without this the dispatch fires at stale/offscreen coordinates and silently+  // misses (nb-bridge #20: right element resolved, changed:false, DOM untouched).+  function centre(list) {+    if (!list.length) return list;+    var top = list[0];+    if (top && top._el && top._el.scrollIntoView) {+      try { top._el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {}+      var r = top._el.getBoundingClientRect();+      top.rect = { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };+      top.x = Math.round(r.left + r.width / 2); top.y = Math.round(r.top + r.height / 2);+    }+    for (var i = 0; i < list.length; i++) delete list[i]._el;+    return list;+  }   var els = deepQ(document, sel);-  function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01 && r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth; }+  // RENDERED, not necessarily ON-SCREEN. Requiring the element to be inside the viewport made every+  // target the user had scrolled past invisible to click-by-text (nb-bridge #20): the interactive pass+  // found nothing and the fallback returned <body>/<html>. resolveCoords scrolls the chosen element to+  // centre before dispatching, so off-viewport-but-rendered is perfectly clickable.+  function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01; }+  // NEVER a click target: the document/root containers match almost any text and clicking them does+  // nothing (or hits whatever is underneath). Excluding them also removes the phantom second+  // "candidate" that produced false ambiguous_target refusals.+  function clickable(el) { var t = (el.tagName || '').toUpperCase(); return t !== 'HTML' && t !== 'BODY' && t !== 'HEAD' && t !== 'SCRIPT' && t !== 'STYLE' && t !== 'NOSCRIPT'; }   function txt(el) { return ((el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('title') || '') + '').replace(/\s+/g, ' ').trim(); }   function cssPath(el) { try { if (!el || el.nodeType !== 1) return ''; if (el.id) return '#' + el.id; var p = el.tagName.toLowerCase(); var cn = el.className; cn = (cn && cn.baseVal !== undefined ? cn.baseVal : cn) || ''; var cls = ('' + cn).trim().split(/\s+/).filter(Boolean).slice(0, 2); if (cls.length) p += '.' + cls.join('.'); var par = el.parentElement; if (par) { var same = [].slice.call(par.children).filter(function (x) { return x.tagName === el.tagName; }); if (same.length > 1) p += ':nth-of-type(' + (same.indexOf(el) + 1) + ')'; } return p; } catch (e) { return ''; } }   function richOf(el, t, al) {@@ -305,10 +329,10 @@ function findFn(query, limit, rich) {     var occlusionPenalty = occluded ? -30 : 0;     return { score: textMatch + clickableTag + visibility + occlusionPenalty, scoreBreakdown: { textMatch: textMatch, clickableTag: clickableTag, visibility: visibility, occlusionPenalty: occlusionPenalty, area: Math.round(r.width * r.height) }, visible: visible, occluded: occluded, occludedBy: occludedBy, inViewport: inViewport, selector: cssPath(el) };   }-  function mk(el, t, r) { var o = { text: (t || '').slice(0, 80), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', type: el.type || '', x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), centerX: Math.round(r.left + r.width / 2), centerY: Math.round(r.top + r.height / 2) } }; if (rich) { var rf = richOf(el, t, (el.getAttribute('aria-label') || '').toLowerCase()); for (var k in rf) o[k] = rf[k]; } return o; }+  function mk(el, t, r) { var o = { _el: el, text: (t || '').slice(0, 80), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', type: el.type || '', x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), centerX: Math.round(r.left + r.width / 2), centerY: Math.round(r.top + r.height / 2) } }; if (rich) { var rf = richOf(el, t, (el.getAttribute('aria-label') || '').toLowerCase()); for (var k in rf) o[k] = rf[k]; } return o; }   var out = [];   for (var i = 0; i < els.length; i++) {-    var el = els[i]; if (!vis(el)) continue; var t = txt(el); var al = (el.getAttribute('aria-label') || '').toLowerCase();+    var el = els[i]; if (!vis(el) || !clickable(el)) continue; var t = txt(el); var al = (el.getAttribute('aria-label') || '').toLowerCase();     if (q && t.toLowerCase().indexOf(q) < 0 && al.indexOf(q) < 0) continue;     out.push(mk(el, t, el.getBoundingClientRect()));   }@@ -320,7 +344,7 @@ function findFn(query, limit, rich) {     var all = deepQ(document, '*');     var fb = [];     for (var j = 0; j < all.length; j++) {-      var e2 = all[j]; if (!vis(e2)) continue;+      var e2 = all[j]; if (!vis(e2) || !clickable(e2)) continue;       var t2 = ((e2.innerText || e2.textContent || '') + '').replace(/\s+/g, ' ').trim();       if (!t2 || t2.toLowerCase().indexOf(q) < 0) continue;       var r2 = e2.getBoundingClientRect();@@ -329,10 +353,10 @@ function findFn(query, limit, rich) {     // exact text match wins, else smallest area (most specific leaf) — return directly,     // skipping the interactive-pass sort below (which prefers LARGEST, wrong for leaves).     fb.sort(function (a, b) { var ae = a.text.toLowerCase() === q ? 0 : 1, be = b.text.toLowerCase() === q ? 0 : 1; if (ae !== be) return ae - be; return a.area - b.area; });-    return fb.map(function (o) { delete o.area; return o; }).slice(0, limit || 40);+    return centre(fb.map(function (o) { delete o.area; return o; }).slice(0, limit || 40));   }   out.sort(function (a, b) { var ae = a.text.toLowerCase() === q ? 0 : 1, be = b.text.toLowerCase() === q ? 0 : 1; if (ae !== be) return ae - be; return (b.rect.w * b.rect.h) - (a.rect.w * a.rect.h); });-  return out.slice(0, limit || 40);+  return centre(out.slice(0, limit || 40)); } export async function find(tabId, query, limit, rich) {   try {
native-browser-bridge-manifest.json+5−5
@@ -1,8 +1,8 @@ {   "manifest_version": 1,-  "version": "0.3.9",-  "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.3.9/native-browser-bridge-v0.3.9.zip",-  "sha256": "51a1e06f0472ceeb7322450e44782e0a4354c0e6dfc35693ac712da6550fc9e8",-  "size": 108641,-  "released_at": "2026-08-19T19:13:02Z"+  "version": "0.4.0",+  "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.4.0/native-browser-bridge-v0.4.0.zip",+  "sha256": "a3dea602801f8685509f16f113e23836917b7374680f63e120b8aed6baf1022c",+  "size": 108640,+  "released_at": "2026-08-19T19:29:00Z" }
tools/verb-battery.mjs+14−3
@@ -25,11 +25,16 @@ const SID = 'nbbattery'; const HARNESS = 'https://wiki.adom.inc/blob/app/nb-bridge/demo/test-harness.html'; const CB = Date.now(); +let TARGET_LOST = null; function call(verb, args = {}, timeoutMs = 90000) {+  // A daily test that reports 27 fake failures because the laptop slept is worse than no test. The+  // moment the relay says the target is gone, stop grading: every later assertion would be noise.+  if (TARGET_LOST) return { _targetLost: true, error: TARGET_LOST };   try {     const out = execFileSync('adom-bridge', ['--target', TARGET, '--ai-thread', THREAD, verb, JSON.stringify(args)], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 });     let d; try { d = JSON.parse(out); } catch { return { _raw: out, _parse: 'outer' }; }-    if (typeof d.output === 'string' && d.output.trim().startsWith('{')) { try { return { ...d, ...JSON.parse(d.output) }; } catch {} }+    if (typeof d.output === 'string' && d.output.trim().startsWith('{')) { try { d = { ...d, ...JSON.parse(d.output) }; } catch {} }+    if (d && (d.errorCode === 'target_not_found' || /No connected Adom Bridge matches/i.test(JSON.stringify(d)))) TARGET_LOST = `target '${TARGET}' left the relay mid-run`;     return d;   } catch (e) {     const out = String(e.stdout || '');@@ -223,14 +228,14 @@ export const COVERAGE = {   window_state: 'live', switch_window: 'live', navigate: 'live', reload: 'live',   back: 'live', forward: 'live', wait: 'live', wait_for: 'live', keepalive: 'live',   eval: 'live', evaluate: 'live', screenshot: 'live', screenshot_full_res: 'live',-  errors: 'live', fetch_url: 'live', click: 'quarantine:nb-bridge#20 click-by-text misses under battery pacing', double_click: 'live',+  errors: 'live', fetch_url: 'live', click: 'live', double_click: 'live',   right_click: 'live', hover: 'live', type: 'live', press_key: 'live', select: 'live',   set_date: 'live', input_dispatch: 'live', set_file_input: 'live', caption: 'live',   cursor: 'live', focus: 'live', flash: 'live', flash_stop: 'live', flash_mode: 'live',   taskbar: 'live', fullscreen: 'live', open_tab: 'live', list_tabs: 'live',   switch_tab: 'live', close_tab: 'live', record_start: 'live', record_status: 'live',   record_stop: 'live', record_list: 'live', download: 'live', download_wait: 'live',-  handle_dialog: 'quarantine:nb-bridge#21 confirm() wedges the page', login_state: 'live', login_form: 'live', credentials: 'live',+  handle_dialog: 'live', login_state: 'live', login_form: 'live', credentials: 'live',   totp: 'live', clear_cookies: 'live', add_extension_origin: 'live', owned: 'live',   cleanup: 'live', close_window: 'live', profile_block: 'live', profile_unblock: 'live',   open_os_window: 'live', state: 'live', demo: 'live',@@ -273,6 +278,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {     'state','demo','open_os_window','guard_autologin','guard_record_fallback','guard_force_close','guard_close_all','profile_block','profile_unblock',     'events_and_owned','handle_dialog','cleanup','owned','close_window'];   for (const name of order) {+    if (TARGET_LOST) { record(name, 'skip', true, `not run - ${TARGET_LOST}`); continue; }     const fn = T[name]; if (!fn) { record(name, '??', false, 'no test fn'); continue; }     const q = Object.entries(COVERAGE).find(([v, m]) => (v === name) && String(m).startsWith('quarantine:'));     try { const [ok, note] = fn(); const mode = q ? 'quarantine' : (name.startsWith('guard') ? 'guard' : 'live');@@ -280,6 +286,11 @@ if (import.meta.url === `file://${process.argv[1]}`) {     catch (e) { record(name, q ? 'quarantine' : 'live', !!q, 'threw: ' + String(e.message).slice(0, 160)); }   }   for (const [v, mode] of Object.entries(COVERAGE)) if (mode.startsWith('skip:')) record(v, 'skip', true, mode.slice(5));+  if (TARGET_LOST) {+    console.log(`\n⚠️  RUN ABORTED: ${TARGET_LOST}. Results above are NOT a verdict on the bridge - re-run when the desktop is back (adom-bridge targets).`);+    writeFileSync(join(root, 'tools', 'verb-battery-report.json'), JSON.stringify({ at: new Date().toISOString(), target: TARGET, aborted: TARGET_LOST, results }, null, 1));+    process.exit(2);+  }   const fails = results.filter((r) => !r.ok);   const report = { at: new Date().toISOString(), target: TARGET, profile: PROFILE, pass: results.length - fails.length, fail: fails.length, results };   writeFileSync(join(root, 'tools', 'verb-battery-report.json'), JSON.stringify(report, null, 1));