← Commit history

feature batch 2 (roadmap #81): robust nbrowser_click (changed:bool) #5, nbrowser_set_date #7, nbrowser_download_wait #6, nbrowser_totp + nbrowser_register_totp #3 (RFC6238, seed 0600/never-returned), nbrowser_add_extension_origin #1; bridge 0.0.32, ext 0.0.18

John Lauer ·a3df295f6e ·4mo ago ·parent 069c6bf
9 files changed +226−21
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.0.31\ No newline at end of file+0.0.32\ No newline at end of file
bridge/bridge.json+7−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser (Chrome + Edge)",-  "version": "0.0.31",+  "version": "0.0.32",   "description": "Drives the user's REAL signed-in Chrome/Edge via an installed extension (native-messaging transport). Mirrors the puppeteer bridge's verb surface as nbrowser_* but runs inside the actual logged-in profile, so cookies/SSO/saved-logins/captcha human-trust all work. The bridge binds an ephemeral loopback port (PORTS.md compliant) and writes a discovery file; the native-messaging host pumps stdio <-> this bridge; the extension never touches a TCP port.",   "homepage": "https://wiki.adom.inc/adom/adom-browser-extension",   "author": "Adom Inc.",@@ -68,7 +68,12 @@     "nbrowser_flash_stop",     "nbrowser_flash_mode",     "nbrowser_taskbar",-    "nbrowser_describe"+    "nbrowser_describe",+    "nbrowser_set_date",+    "nbrowser_download_wait",+    "nbrowser_totp",+    "nbrowser_register_totp",+    "nbrowser_add_extension_origin"   ],   "dependencies": {     "node": ">=18"
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.0.31",+  "version": "0.0.32",   "private": true,   "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)",   "main": "server.js",
bridge/server.js+86−2
@@ -601,6 +601,43 @@ function chromeStatus() {   return { installed: !!exe, executablePath: exe || null, phase: exe ? 'ready' : chromeState.phase, error: chromeState.error, errorCode: chromeState.errorCode, installing: !!_chromeInFlight, awaitingUac: chromeState.phase === 'awaiting_uac', notifyChannel: chromeState.notifyChannel }; } +// ============================================================================================+// #3 TOTP — per-host 2FA. The seed is stored 0600 on the laptop and NEVER returned; the bridge (Node)+// computes the current RFC-6238 code and the extension types it into the focused field, so the AGENT+// never sees the seed OR the code. This is the #1 unblock for unattended vendor automation.+// ============================================================================================+const crypto = require('crypto');+const TOTP_FILE = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'totp-seeds.json');+function _b32decode(s) {+  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';+  s = String(s).toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, '');+  let bits = 0, val = 0; const out = [];+  for (const ch of s) { val = (val << 5) | A.indexOf(ch); bits += 5; if (bits >= 8) { bits -= 8; out.push((val >>> bits) & 0xff); } }+  return Buffer.from(out);+}+function _totpCode(seed, { step = 30, digits = 6, t = Date.now() } = {}) {+  const key = _b32decode(seed);+  if (!key.length) throw new Error('empty/invalid base32 seed');+  const counter = Math.floor(t / 1000 / step);+  const buf = Buffer.alloc(8); buf.writeBigUInt64BE(BigInt(counter));+  const h = crypto.createHmac('sha1', key).update(buf).digest();+  const off = h[h.length - 1] & 0xf;+  const bin = ((h[off] & 0x7f) << 24) | ((h[off + 1] & 0xff) << 16) | ((h[off + 2] & 0xff) << 8) | (h[off + 3] & 0xff);+  return String(bin % (10 ** digits)).padStart(digits, '0');+}+function _readTotp() { try { return JSON.parse(fs.readFileSync(TOTP_FILE, 'utf8')); } catch { return {}; } }+function _writeTotp(o) { fs.mkdirSync(path.dirname(TOTP_FILE), { recursive: true }); fs.writeFileSync(TOTP_FILE, JSON.stringify(o, null, 2), { mode: 0o600 }); try { fs.chmodSync(TOTP_FILE, 0o600); } catch {} }+const _hostKey = (h) => String(h || '').replace(/^https?:\/\//, '').split('/')[0].toLowerCase().trim();++// #1 native-messaging host manifest locations we may need to append an extension origin to.+function _hostManifestPaths() {+  const home = process.env.USERPROFILE || os.homedir();+  return [+    path.join(home, '.adom', 'native-browser', 'host-manifest.json'),+    path.join(home, 'adom-browser-extension', 'host', 'host-manifest.json'),+  ];+}+ // ============================================================================================ // VERB_META — ONE source of truth for per-verb hint/related/pitfalls. Feeds BOTH (a) nbrowser_describe // (so AD's Verbs tab shows guidance + chaining + traps) AND (b) the rich `_hint` attached to EVERY@@ -645,10 +682,15 @@ const VERB_META = {   nbrowser_eval: { hint: 'Run JS in the page and get the result. Powerful - use read-only expressions to inspect state.', related: ['nbrowser_screenshot'], pitfalls: ['runs arbitrary JS on the user\'s LIVE session - never run destructive scripts'] },   nbrowser_evaluate: { hint: 'Alias of nbrowser_eval.', related: ['nbrowser_eval'], pitfalls: ['runs arbitrary JS on the user\'s LIVE session - never run destructive scripts'] },   nbrowser_input_dispatch: { hint: 'Low-level isTrusted CDP input (click/move/type/key).', related: ['nbrowser_click', 'nbrowser_type'], pitfalls: ['prefer nbrowser_click (smart selector) over raw xy'] },-  nbrowser_click: { hint: 'Click by visible text / selector / xy (isTrusted). Optional gliding cursor + intent label.', related: ['nbrowser_screenshot', 'nbrowser_type'], next: 'nbrowser_screenshot', pitfalls: ['attaches the debugger (yellow "debugging" bar; idle-detaches ~8s)', 'verify it landed with a screenshot - SPAs can swallow synthetic-looking events'] },+  nbrowser_click: { hint: 'Click by visible text / selector / xy (isTrusted). Scrolls the target into view, re-resolves its rect, and RETURNS changed:bool - whether the URL/DOM actually changed after the click - so you can detect "click landed but nothing happened" and retry. Optional gliding cursor + intent label.', related: ['nbrowser_wait_for', 'nbrowser_screenshot', 'nbrowser_type'], next: 'nbrowser_wait_for', pitfalls: ['attaches the debugger (yellow "debugging" bar; idle-detaches ~8s)', 'changed:false means the click dispatched but the page did not visibly change (disabled/covered element, missed target) - re-resolve or retry', 'for a deterministic post-click wait use nbrowser_wait_for, not a fixed sleep'] },+  nbrowser_set_date: { hint: 'Fill a react-aria SEGMENTED date field (role=spinbutton month/day/year, common in finance apps) - focuses it and types each digit as a real keydown so the segments advance. date="YYYY-MM-DD". Pass order:"DMY" for day-first locales (default MDY).', related: ['nbrowser_type', 'nbrowser_click'], pitfalls: ['for react-aria/segmented fields; a plain <input type=date> may just take nbrowser_type', 'US MDY order by default - pass order:"DMY" if the field is day-first', 'check fieldValue in the response; screenshot if it looks off'] },+  nbrowser_totp: { hint: 'Type the current 6-digit 2FA code for a host into the FOCUSED field (or pass selector). The bridge computes the RFC-6238 code from the stored seed and the extension types it - the agent NEVER sees the seed or the code. Pass submit:true to press Enter after. Register the seed once with nbrowser_register_totp. The #1 unblock for unattended vendor automation.', related: ['nbrowser_register_totp', 'nbrowser_autologin', 'nbrowser_login_state'], pitfalls: ['errorCode:"no_totp_seed" = register it first with nbrowser_register_totp', 'focus the 2FA input first (click it) or pass selector', 'codes rotate every 30s - call this right when you need it'] },+  nbrowser_register_totp: { hint: 'One-time: register a base32 TOTP secret for a host (the "manual entry / setup key" the vendor shows by the 2FA QR). Stored 0600 on the laptop, NEVER returned. Then nbrowser_totp {host} types codes.', related: ['nbrowser_totp'], pitfalls: ['base32 only (A-Z, 2-7)', 'stored on the laptop; the agent never sees it back', 'one seed per host - re-registering replaces it'] },+  nbrowser_add_extension_origin: { hint: 'Add an extension ID to the native-messaging host manifest\'s allowed_origins so a browser whose loaded-unpacked ID differs (esp. Edge) can reach the host + appear in nbrowser_profiles. Pass {id} from edge://extensions. RELOAD the extension afterward so the browser re-reads it.', related: ['nbrowser_profiles', 'nbrowser_rescan'], pitfalls: ['MUST reload the extension after (edge://extensions -> Reload) for the browser to re-read allowed_origins', 'our manifest pins a key so IDs SHOULD match across browsers - if Edge diverged, add its id here'] },   nbrowser_type: { hint: 'Type into the focused field (isTrusted).', related: ['nbrowser_click', 'nbrowser_press_key'], pitfalls: ['click the field FIRST so it has focus'] },   nbrowser_press_key: { hint: 'Press a key (Enter/Tab/etc.).', related: ['nbrowser_type'], pitfalls: [] },   nbrowser_fetch_url: { hint: 'Authed fetch using the user\'s REAL cookies - perfect for logged-in JSON/APIs that pup cannot reach.', related: ['nbrowser_navigate'], pitfalls: ['results come from the user\'s live session - treat as sensitive'] },+  nbrowser_download_wait: { hint: 'Wait for a browser DOWNLOAD to complete and return the landed filename(s) via chrome.downloads events (not timestamp-polling the Downloads folder). Optional saveToSubfolder routes subsequent downloads into a subfolder of Downloads. Call it right after you trigger an export.', related: ['nbrowser_click', 'nbrowser_fetch_url'], pitfalls: ['timedOut:true = nothing landed in the window - verify the export click fired, then call again', 'saveToSubfolder takes effect for downloads started AFTER this call', 'the buffer is in-memory; a service-worker restart clears history (use a fresh sinceMs)'] },   nbrowser_errors: { hint: 'Buffered page/console errors for a tab.', related: ['nbrowser_eval'], pitfalls: [] },   nbrowser_close: { hint: 'Not an extension verb - close a specific window/tab instead.', related: ['nbrowser_close_window', 'nbrowser_close_tab'], pitfalls: ['there is no global detach-all; the debugger idle-detaches on its own'] },   nbrowser_record_start: { hint: RECORD_WGC_HINT, related: ['nbrowser_record_stop', 'nbrowser_record_status'], next: 'nbrowser_record_status', pitfalls: ['⭐ prefer method:"wgc" (delegates to core desktop_record_window_start)', 'getdisplaymedia/tabcapture are REFUSED without fallbackConfirmed:true - a browser extension cannot silently screen-record'] },@@ -767,10 +809,15 @@ function dispatch(verb, args) {         d('nbrowser_eval', 'Run JS in the page context and return the result.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: 'document.title' } }),         d('nbrowser_evaluate', 'Alias of nbrowser_eval.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: '1+1' } }),         d('nbrowser_input_dispatch', 'Low-level isTrusted CDP input.', { sessionId: S, tabId: 'optional str', type: 'required "click"|"move"|"type"|"key"', x: 'int?', y: 'int?', text: 'str?', key: 'str?' }, { ok: 'bool' }, 120, { example: { sessionId: 'work', type: 'click', x: 200, y: 300 } }),-        d('nbrowser_click', 'Click by visible text / selector / xy; optional gliding cursor + intent label.', { sessionId: S, tabId: 'optional str', text: 'optional str', selector: 'optional str', x: 'int?', y: 'int?', cursor: 'optional bool', intent: 'optional str' }, { ok: 'bool', clicked: 'bool' }, 30, { example: { sessionId: 'work', text: 'Sign in', cursor: true, intent: 'Clicking Sign in' } }),+        d('nbrowser_click', 'Click by visible text / selector / xy; scrolls into view + returns changed:bool (did the URL/DOM actually change). Optional gliding cursor + intent label.', { sessionId: S, tabId: 'optional str', text: 'optional str', selector: 'optional str', x: 'int?', y: 'int?', cursor: 'optional bool', intent: 'optional str', settleMs: 'optional int (default 500)' }, { ok: 'bool', changed: 'bool', urlBefore: 'str', urlAfter: 'str' }, 30, { example: { sessionId: 'work', text: 'Sign in', cursor: true, intent: 'Clicking Sign in' } }),+        d('nbrowser_set_date', 'Fill a react-aria segmented date field (role=spinbutton MM/DD/YYYY). Types each digit as a real keydown so segments advance.', { sessionId: S, selector: 'required CSS selector of the date field/segment', date: 'required "YYYY-MM-DD"', order: 'optional "MDY"(default)|"DMY"' }, { ok: 'bool', fieldValue: 'str?' }, 30, { example: { sessionId: 'work', selector: '[aria-label="Start date"]', date: '2026-03-15' } }),+        d('nbrowser_totp', 'Type the current 6-digit 2FA code for a host into the focused field (agent never sees seed or code). Register the seed once with nbrowser_register_totp.', { sessionId: S, host: 'required host, e.g. "gusto.com"', selector: 'optional 2FA field selector (else the focused field)', submit: 'optional bool (press Enter after)' }, { ok: 'bool', typed: 'bool', digits: 'int' }, 20, { example: { sessionId: 'work', host: 'gusto.com', submit: true } }),+        d('nbrowser_register_totp', 'One-time: register a base32 TOTP secret for a host (stored 0600 on the laptop, never returned).', { host: 'required host', seed: 'required base32 secret' }, { ok: 'bool', host: 'str' }, 15, { example: { host: 'gusto.com', seed: 'JBSWY3DPEHPK3PXP' } }),+        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_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' } }),         d('nbrowser_close', 'Detach the debugger / close managed windows. No sessionId = all.', { sessionId: 'optional str' }, { ok: 'bool' }, 20, { example: {} }),         d('nbrowser_record_start', '⭐ Record a browser window. STRONGLY prefer method:"wgc" (delegates to the core adom-desktop verb desktop_record_window_start: background, up to 60fps, NO picker, NO banner, captures occluded windows, no focus theft). method:"getdisplaymedia"/"tabcapture" is a LAST-RESORT fallback (Chrome forces a manual share picker + banner) and is REFUSED unless you also pass fallbackConfirmed:true. Calling with no method returns the WGC guidance hint.', { method: 'required "wgc" (recommended) | "getdisplaymedia" | "tabcapture"', hwnd: 'int? (or titleContains)', titleContains: 'str?', fps: 'optional int (default 30)', codec: 'optional "h264"|"hevc"', fallbackConfirmed: 'required true ONLY for getdisplaymedia/tabcapture, to acknowledge the picker/banner tradeoff' }, { ok: 'bool', recordingId: 'str', path: 'str' }, 120, { statusVerb: 'nbrowser_record_status', longRunning: true, example: { method: 'wgc', titleContains: 'chip-fetcher' } }),@@ -834,6 +881,43 @@ function dispatch(verb, args) {       args = { ...args, host, __decryptHere: true };       // fall through to the normal targetConn + forward below.     }+    // #3 register a TOTP seed for a host (bridge-local; seed stored 0600, NEVER returned).+    if (verb === 'nbrowser_register_totp') {+      const host = _hostKey(args && args.host); const seed = args && args.seed;+      if (!host || !seed) return resolve({ success: false, error: 'need {host, seed} (the base32 TOTP secret, e.g. from the vendor QR "manual entry" key)', _hint: 'One-time setup per host. The seed is stored 0600 on the laptop and never returned; nbrowser_totp {host} then types the current code into the focused 2FA field.' });+      try { _totpCode(seed); } catch { return resolve({ success: false, error: 'that is not a valid base32 TOTP secret', _hint: 'Use the base32 "manual entry / setup key" the vendor shows next to the 2FA QR code (letters A-Z + digits 2-7).' }); }+      const o = _readTotp(); o[host] = { seed: String(seed).replace(/[^A-Za-z2-7]/g, ''), addedAt: Date.now() }; _writeTotp(o);+      return resolve({ success: true, output: JSON.stringify({ ok: true, host, _hint: `TOTP seed for ${host} stored 0600 on the laptop - the agent never sees it. When a 2FA prompt appears, focus the code field (or pass selector) and call nbrowser_totp {host} to type the current 6-digit code.` }) });+    }+    // #3 compute the current TOTP code for a host and forward it to the extension to TYPE (as __code -+    // never returned to the agent). Compute-then-fall-through, like autologin.+    if (verb === 'nbrowser_totp') {+      const host = _hostKey(args && args.host);+      if (!host) return resolve({ success: false, error: 'host required (e.g. host:"gusto.com")', _hint: 'Which host to type the 2FA code for. Register its seed once with nbrowser_register_totp.' });+      const rec = _readTotp()[host];+      if (!rec || !rec.seed) return resolve({ success: false, error: `no TOTP seed registered for ${host}`, errorCode: 'no_totp_seed', _hint: `Register it once: nbrowser_register_totp {host:"${host}", seed:"<base32>"}.` });+      let code; try { code = _totpCode(rec.seed); } catch { return resolve({ success: false, error: 'failed to compute TOTP (stored seed invalid) - re-register it' }); }+      dlog(`totp: host=${host} (code injected downstream, never returned)`);+      args = { ...args, __code: code }; // forward to the extension; it types __code, returns only {typed}+      // fall through to the normal targetConn + forward below.+    }+    // #1 append an extension ORIGIN to the native-messaging host manifest(s) so a browser whose+    // loaded-unpacked ID differs (esp. Edge) can reach the host. Bridge-local file edit.+    if (verb === 'nbrowser_add_extension_origin') {+      const id = (args && args.id) || '';+      if (!id) return resolve({ success: false, error: 'need {id} - the extension ID from edge://extensions or chrome://extensions', _hint: 'Load the extension, copy its ID off the extensions page, then call this; then RELOAD the extension so the browser re-reads allowed_origins.' });+      const origin = /^chrome-extension:\/\//.test(id) ? id.replace(/\/?$/, '/') : `chrome-extension://${id}/`;+      const updated = [], present = [];+      for (const mp of _hostManifestPaths()) {+        try {+          if (!fs.existsSync(mp)) continue;+          const m = JSON.parse(fs.readFileSync(mp, 'utf8')); m.allowed_origins = Array.isArray(m.allowed_origins) ? m.allowed_origins : [];+          if (m.allowed_origins.includes(origin)) { present.push(mp); continue; }+          m.allowed_origins.push(origin); fs.writeFileSync(mp, JSON.stringify(m, null, 2)); updated.push(mp);+        } catch {}+      }+      return resolve({ success: true, output: JSON.stringify({ ok: updated.length > 0 || present.length > 0, origin, updatedManifests: updated, alreadyPresent: present, _hint: (updated.length || present.length) ? 'Origin is in the host manifest now. RELOAD the extension in that browser (edge://extensions -> Reload) so it re-reads allowed_origins and can reach the native host; then nbrowser_profiles should list it.' : 'No host-manifest.json found (checked ~/.adom/native-browser and ~/adom-browser-extension/host). Re-run the install, or the manifest is elsewhere.' }) });+    }     // --- taskbar flash (bridge-local; targets the cached driven-window hwnd) ---     if (verb === 'nbrowser_flash_mode') {       const m = String((args && args.mode) || '').toLowerCase();
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom for Chrome & Edge",   "short_name": "Adom",-  "version": "0.0.17",+  "version": "0.0.18",   "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the native-browser counterpart to pup.",   "minimum_chrome_version": "116",   "background": {
extension/src/cdp.js+84−8
@@ -73,18 +73,44 @@ export async function screenshot(tabId, { fullPage = false, maxWidth = 1568, res // ---- nbrowser_eval / nbrowser_evaluate ------------------------------------ export async function evaluate(tabId, expr) {   await attach(tabId);-  const res = await send(tabId, 'Runtime.evaluate', {-    expression: `(${expr})`,-    returnByValue: true,-    awaitPromise: true,-    userGesture: true,-  });+  // SPA route changes (e.g. Gusto's wizard steps) tear down the execution+  // context; for a short window Runtime.evaluate can throw "Cannot find+  // context with specified id" or come back with neither result nor+  // exceptionDetails. Retry a few times so callers don't see a phantom+  // empty/undefined right after an in-app navigation.+  let res;+  for (let attempt = 0; attempt < 4; attempt++) {+    try {+      res = await send(tabId, 'Runtime.evaluate', {+        expression: `(${expr})`,+        returnByValue: true,+        awaitPromise: true,+        userGesture: true,+      });+    } catch (err) {+      const msg = String((err && err.message) || err);+      if (/context|detached|target closed|destroyed/i.test(msg) && attempt < 3) {+        await attach(tabId);                 // re-resolve the context+        await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));+        continue;+      }+      throw err;+    }+    // A torn-down context yields an empty envelope (no result, no exception).+    if (!res || (!res.result && !res.exceptionDetails)) {+      if (attempt < 3) {+        await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));+        continue;+      }+    }+    break;+  }   bumpIdle(tabId);-  if (res.exceptionDetails) {+  if (res && res.exceptionDetails) {     const e = res.exceptionDetails;     return { __error: (e.exception && (e.exception.description || e.exception.value)) || e.text };   }-  return res.result ? res.result.value : undefined;+  return res && res.result ? res.result.value : undefined; }  // ---- input dispatch (CDP Input domain → isTrusted events) ------------------@@ -575,3 +601,53 @@ async function resizePng(b64, maxWidth) {   for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);   return { base64: btoa(bin), width: w, height: h, origWidth: width, origHeight: height, resized: true }; }++// ── #5 Robust click: click, then report whether the page ACTUALLY changed ──────+// Captures a light page signature (url, readyState, visible-text length) before and after the click,+// so callers can detect "click dispatched but nothing happened" (disabled/covered element, missed+// target) and retry. Also scrolls the target into view (resolveCoords already centers it).+export async function pageSig(tabId) {+  try { const r = await evaluate(tabId, "(function(){return {u:location.href, r:document.readyState, n:(document.body?document.body.innerText.length:0)};})()"); return r || {}; } catch { return {}; }+}+export async function clickChanged(tabId, args) {+  const before = await pageSig(tabId);+  const 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.' };+}++// ── #7 set_date: fill a react-aria segmented date field (role=spinbutton MM/DD/YYYY) ──+// Focus the field, then type each digit as a real keydown (react-aria segments advance on digit+// keydown - Input.insertText does NOT trigger them). US MM/DD/YYYY order. date = "YYYY-MM-DD".+export async function setDate(tabId, args) {+  await attach(tabId);+  const selector = args.selector, date = args.date;+  if (!selector || !date) return { ok: false, error: 'set_date needs {selector, date:"YYYY-MM-DD"}' };+  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(date));+  if (!m) return { ok: false, error: 'date must be "YYYY-MM-DD"' };+  const [, Y, M, D] = m;+  const c = await resolveCoords(tabId, { selector }, false);+  if (!c.found) return { ok: false, error: `date field not found: ${selector}`, _hint: 'check the selector or wait for the field' };+  await mouseClick(tabId, c.x, c.y, 'left', 1); // focus the first segment+  await sleep(90);+  const order = (String(args.order || 'MDY').toUpperCase() === 'DMY') ? [D, M, Y] : [M, D, Y];+  for (const seg of order) { for (const ch of seg) { await pressKey(tabId, ch); await sleep(35); } await sleep(40); }+  const val = await evaluate(tabId, `(function(){var e=document.querySelector(${JSON.stringify(selector)});if(!e)return null;return e.value||e.getAttribute('aria-valuetext')||(e.textContent||'').trim().slice(0,40);})()`);+  return { ok: true, dispatched: 'set_date', date, order: (String(args.order || 'MDY').toUpperCase() === 'DMY') ? 'DMY' : 'MDY', fieldValue: val, _hint: 'Typed each digit as a real keydown so react-aria segments advance. If fieldValue looks wrong, the field may use DMY order (pass order:"DMY") or a custom widget - screenshot to check.' };+}++// ── #3 TOTP: type the current 6-digit code (computed by the BRIDGE, injected as __code) into the+// focused 2FA field. The agent never sees the code; we only report {typed}. ──+export async function typeTotp(tabId, args) {+  const code = args && args.__code;+  if (!code) return { ok: false, error: 'no code (register a seed first: nbrowser_register_totp {host, seed})' };+  await attach(tabId);+  if (args.selector) { const f = await evaluate(tabId, focusExpr(args.selector)); if (!f || !f.found) return { ok: false, error: `2FA field not found: ${args.selector}` }; }+  await send(tabId, 'Input.insertText', { text: String(code) });+  if (args.submit === true) await pressKey(tabId, 'Enter');+  return { ok: true, typed: true, digits: String(code).length, submitted: args.submit === true };+}
extension/src/native.js+34
@@ -514,3 +514,37 @@ export async function focus(args = {}) {  // shared resolver for CDP verbs (screenshot/eval/input target the same tab) export { resolveTabId };++// ── #6 Downloads: buffer completed downloads + optional dest subfolder ─────────+// Listeners registered at module load so downloads are caught even before download_wait is called.+const _downloads = [];              // {id, filename, finishedAt, bytes}+let _dlSubfolder = null;            // pending saveToSubfolder (relative to the Downloads dir)+try {+  chrome.downloads.onChanged.addListener((delta) => {+    if (delta && delta.state && delta.state.current === 'complete') {+      try { chrome.downloads.search({ id: delta.id }, (items) => { const it = items && items[0]; if (it) _downloads.push({ id: it.id, filename: it.filename, finishedAt: Date.now(), bytes: it.fileSize }); }); } catch {}+    }+  });+  if (chrome.downloads.onDeterminingFilename) {+    chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {+      if (_dlSubfolder) { const base = (item.filename || 'download').split(/[\\/]/).pop(); suggest({ filename: String(_dlSubfolder).replace(/[\\/]+$/, '') + '/' + base, conflictAction: 'uniquify' }); }+      else suggest();+    });+  }+} catch {}++// nbrowser_download_wait - return filenames that COMPLETED within the last sinceMs (or wait up to+// timeoutMs for one to land). Set saveToSubfolder to route subsequent downloads into a subfolder of+// the Downloads dir. Beats polling the Downloads folder by timestamp.+export async function downloadWait(args = {}) {+  if (args.saveToSubfolder !== undefined) _dlSubfolder = args.saveToSubfolder || null;+  const sinceMs = Math.min(Number(args.sinceMs) || 60000, 3600000);+  const timeoutMs = Math.min(Number(args.timeoutMs) || 60000, 300000);+  const cutoff = Date.now() - sinceMs;+  const collect = () => _downloads.filter((x) => x.finishedAt >= cutoff);+  const t0 = Date.now();+  let hits = collect();+  while (!hits.length && Date.now() - t0 < timeoutMs) { await new Promise((r) => setTimeout(r, 500)); hits = collect(); }+  return { ok: true, files: hits.map((x) => x.filename), count: hits.length, timedOut: !hits.length, saveToSubfolder: _dlSubfolder || undefined,+    _hint: hits.length ? undefined : 'No download completed in the window. Increase timeoutMs, or the click that triggers the export may not have fired - verify it, then call download_wait again.' };+}
extension/src/sw.js+7−1
@@ -174,7 +174,13 @@ async function dispatch(verb, args) {     case 'nbrowser_input_dispatch':       return cdp.inputDispatch(await native.resolveTabId(args), args);     case 'nbrowser_click':-      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'click' });+      return cdp.clickChanged(await native.resolveTabId(args), args); // #5 robust: reports changed:bool+    case 'nbrowser_set_date':      // #7 react-aria segmented date field+      return cdp.setDate(await native.resolveTabId(args), 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), 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), { ...args, type: 'type' });     case 'nbrowser_press_key':
native-browser-bridge-manifest.json+5−5
@@ -1,8 +1,8 @@ {   "manifest_version": 1,-  "version": "0.0.31",-  "url": "native-browser-bridge-v0.0.31.bin",-  "sha256": "57a4d6e47f8ccc4e8eff532481cd4f822d7fb436054573cec2f836ed970a26a7",-  "size": 35815,-  "released_at": "2026-07-05T17:27:13Z"+  "version": "0.0.32",+  "url": "native-browser-bridge-v0.0.32.bin",+  "sha256": "65405dd017434bf905d4fcc20b8729ba2889aa169eecc1b0b8f15d833cf6c958",+  "size": 39815,+  "released_at": "2026-07-05T18:08:37Z" }\ No newline at end of file