← 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
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));