← Commit history

v0.16.65 (insiders): File upload fixes (pup-bridge#145): nbrowser_set_file_input gives the page one input and one change event per call, where a page that uploads on change could send the file twice, also on a retry with the same file; two files on a single-file input are refused instead of silently keeping the first; key and password files, AppData, system folders and network paths are refused. Extension 0.2.23: refresh the loaded folder to get the event fix; the path rule works without it.

Colby Knox ·504f890221 ·5d ago ·parent f604744
8 files changed +63−11
bin/nb-dashboard+1−1
@@ -20,7 +20,7 @@ const { execFile, spawn } = require('child_process'); const fs = require('fs'); const crypto = require('crypto'); -const VERSION = '0.16.64';           // keep in lockstep with package.json (pkg version)+const VERSION = '0.16.65';           // keep in lockstep with package.json (pkg version) // SEND TO AI IS UN-PARKED (John: "yeah, we are re-enabling the send to ai. it seems to be good now"). // The ah build thread certified the crash family dead on the gate-fixed build and the delivery leg is // proven end to end. The park const is DELETED rather than flipped, so no dead branch can resurrect it.
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.16.64\ No newline at end of file+0.16.65\ No newline at end of file
bridge/bridge.json+2−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser",-  "version": "0.16.64",-  "expectedExtensionVersion": "0.2.22",+  "version": "0.16.65",+  "expectedExtensionVersion": "0.2.23",   "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.16.64",+  "version": "0.16.65",   "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+44−1
@@ -1724,6 +1724,43 @@ const NBE_PITCH = 'WORTH SAYING TO THE USER: you can drive their browser with th   'to set up (an unpacked extension + a native-messaging host), and it is worth it; the install can be run ' +   'FOR them in about a minute (installing-the-extension) - offer it, never make them do it themselves.'; const NO_FG = 'Open in the BACKGROUND — users automating the browser almost always have OTHER things in the foreground, and a raised window STEALS their keyboard focus (it has typed a user\'s own keystrokes into a username field and dismissed Chrome\'s password dropdown mid-task). Background is where automation SUCCEEDS. NEVER fall back to OS-level launches (Start-Process/launch/msedge/chrome --new-window) or bring_to_front/maximize to surface a window. To genuinely foreground (some auth pages / browser features need it) you must do real work: (1) FIRST warn the user with a Bridge notify (adom-bridge notify_user {message:"Adom is about to bring a browser window forward for <reason>"}), then (2) pass userRequestedForeground:true AND foregroundReason:"<why>". A bare userRequestedForeground with no foregroundReason STAYS background; any driven window that lands topmost is auto-reverted + logged (focus_violation / foreground_raised).';+// nbrowser_set_file_input: a file handed to a page is a file a website can read, and the AI driving the page may be+// following what a page told it. Secrets live INSIDE the user's profile, so "stay under the user's folders" would not+// protect them: the rule is this PC's local files, minus system folders, other people's profiles and where secrets are+// kept. Same rule as pup_upload_file (pup-bridge#145). No override argument: an AI could just pass it.+const UPLOAD_SECRET_DIRS = ['.ssh', '.gnupg', '.aws', '.azure', '.kube', '.docker', '.git', '$recycle.bin', 'system volume information'];+const UPLOAD_SECRET_NAMES = ['.env', '.netrc', '_netrc', '.git-credentials', '.npmrc', '.pypirc', 'login data', 'login data for account', 'cookies', 'web data', 'local state', 'ntuser.dat', 'sam', 'security'];+const UPLOAD_SECRET_PREFIXES = ['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519', '.env.'];+const UPLOAD_SECRET_EXTS = ['pem', 'ppk', 'key', 'pfx', 'p12', 'kdbx', 'keystore'];+function uploadNorm(p) { return String(p || '').replace(/\\/g, '/').toLowerCase().replace(/^\/\/\?\//, '').replace(/\/+$/, ''); }+function uploadUnder(p, root) { return !!root && (p === root || p.startsWith(root + '/')); }+function uploadPathPrecheck(raw) { // before the disk is touched: merely looking at \\server\x signs in to that server as the user+  const p = String(raw || '').trim();+  if (/^[\\/]{2}/.test(p)) return { errorCode: 'path_refused', error: `${raw} is a network or device path`, _hint: 'Only files on this PC\'s own drives can be uploaded: opening a \\\\server path would make Windows sign in to that server as the user. Copy the file to a local folder first.' };+  const abs = process.platform === 'win32' ? /^[a-z]:[\\/]/i.test(p) : p.startsWith('/');+  if (!abs) return { errorCode: 'file_missing', error: `file_missing: ${raw} is not an absolute path on this PC`, _hint: 'Paths must be ABSOLUTE paths on the machine running the browser. Push files there first with adom-bridge send_files, or pass files:[{name, contentBase64}] and the bridge stages them for you.' };+  const rest = process.platform === 'win32' ? p.slice(2) : p;+  if (rest.includes(':') || rest.split(/[\\/]/).includes('..')) return { errorCode: 'path_refused', error: `${raw} is not a plain file path`, _hint: 'Pass the file\'s direct path, with no .. and no :stream in it.' };+  return null;+}+function uploadPathRefusal(real) { // on the REAL path: links and 8.3 names resolved. Returns why, or null.+  const p = uploadNorm(real), comps = p.split('/'), name = comps[comps.length - 1] || '', ext = name.includes('.') ? name.split('.').pop() : '';+  if (UPLOAD_SECRET_NAMES.includes(name) || UPLOAD_SECRET_PREFIXES.some((x) => name.startsWith(x)) || UPLOAD_SECRET_EXTS.includes(ext)) return `${name} looks like a key, password or login store`;+  const d = comps.slice(0, -1).find((c) => UPLOAD_SECRET_DIRS.includes(c)); if (d) return `it is inside ${d}, where keys and credentials are kept`;+  if ([process.env.TEMP, process.env.TMP, os.tmpdir()].some((t) => t && uploadUnder(p, uploadNorm(t)))) return null;+  const sys = process.platform === 'win32' ? ['SystemRoot', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'ProgramData'].map((k) => uploadNorm(process.env[k])) : ['/etc', '/var', '/usr', '/bin', '/sbin', '/boot', '/root', '/proc', '/sys', '/system', '/library', '/private'];+  const hitSys = sys.find((r) => uploadUnder(p, r)); if (hitSys) return `it is inside a system folder (${hitSys})`;+  const home = uploadNorm(os.homedir());+  if (uploadUnder(p, home)) {+    const first = p.slice(home.length).replace(/^\//, '').split('/')[0] || '';+    if (first === 'appdata' || first === 'library') return 'it is inside AppData, where browsers and apps keep logins, tokens and settings';+    if (first.startsWith('.')) return `it is inside ${first}, a hidden settings folder in the home folder where tools keep tokens`;+    return null;+  }+  const users = home.slice(0, home.lastIndexOf('/'));+  if (users && uploadUnder(p, users) && !uploadUnder(p, users + '/public') && !uploadUnder(p, users + '/shared')) return 'it is inside another person\'s profile';+  return null;+} const VERB_META = {   nbrowser_status: { hint: 'Connectivity check: connected profiles + active browser. Call this first if any verb errors "not connected".', related: ['nbrowser_profiles', 'nbrowser_ping', 'nbrowser_open_window'], pitfalls: ['a profile\'s extension SLEEPS when that profile has no open windows - it then shows disconnected; open a window to wake it'] },   nbrowser_readiness: { hint: 'READ-ONLY cold-start probe: is the native-browser extension connected + ready to drive? NEVER spawns work or installs anything. state:"ready" = drive now; state:"needs-extension" = offer to install it (Level-2, see installing-the-extension) or it is asleep (open a window in that profile). Also reports chromeInstalled: false does NOT mean install Chrome: nb drives whatever native browser is there (Edge ships with Windows); read nativeBrowser.chromeNeeded, which is true only when NO supported browser exists, and offer nbrowser_install_chrome only then or when the user asks for Chrome by name.', related: ['nbrowser_status', 'nbrowser_open_window', 'nbrowser_install_chrome'], pitfalls: ['"needs-extension" can mean not-installed OR installed-but-asleep - nbrowser_status\'s tooltip distinguishes', 'this verb never installs; to install, run the installing-the-extension flow', 'for a machine-wide "what do I have" answer prefer ab\'s bridge_readiness (aggregates all bridges, spawns none)'] },@@ -1775,7 +1812,7 @@ const VERB_META = {   nbrowser_scroll: { hint: 'Real isTrusted mouse WHEEL at a point: {sessionId, x, y | selector | text, deltaX?, deltaY, steps?}. deltaY:-300 zooms a canvas camera in (or scrolls up), +300 out (or down). This is the verb for Babylon/three.js canvas zoom and for scrolling a pane the page owns; press_key cannot do it and page-level scrolling is not it.', related: ['nbrowser_input_dispatch', 'nbrowser_hover', 'nbrowser_screenshot'], pitfalls: ['pageScrolled:false is NORMAL on a canvas that consumed the wheel (a zoom is not a scroll): verify with a screenshot or an eval of the app state', 'no freshness gate: a wheel at a stale point is harmless motion, unlike a click', 'split a big delta with steps (e.g. steps:5) when an app clamps per-event zoom'] },   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. The finder PIERCES open shadow roots, so custom-element controls (Schwab/QBO) are clickable by text/selector.', related: ['nbrowser_select', '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', 'to PICK an option from a custom dropdown, use nbrowser_select (waits for options + returns the value) - never coordinate-click a virtualized list'] },   nbrowser_select: { hint: 'Pick an option from a dropdown/combobox BY ITS VISIBLE TEXT - the reliable primitive for custom + native selects. Give {label} (the control\'s accessible name) OR {near} (a visible label beside it) to find the trigger, plus {optionText}. It opens the control with an isTrusted click, WAITS for the options to render, clicks the EXACT text match (piercing shadow roots), and RETURNS the newly-selected value so you can verify. Fixes coordinate-clicks landing on the wrong row of a virtualized list.', related: ['nbrowser_click', 'nbrowser_wait_for'], pitfalls: ['pass label OR near to locate the trigger; optionText must match the option\'s exact visible text (case-insensitive)', 'ALWAYS check the returned `selected` value before continuing - confirm it is the row you meant (mis-selecting a bank account by one row is the exact bug this prevents)', 'raise timeoutMs for a slow/virtualized option list (default 4000, max 20000)'] },-  nbrowser_set_file_input: { hint: 'Populate an <input type=file> over CDP - NO OS file picker, fully background. Direct mode: {sessionId, selector|text, filePaths:[abs laptop paths]} - the finder walks a Browse button/label to its input (hidden inputs fine). Transient-input UIs (input created inside the Browse handler): {armForNextChooser:true, filePaths} then nbrowser_click the Browse control - the chooser is satisfied silently; confirm with {chooserStatus:true}. Cloud shortcut: files:[{name, contentBase64}] and the bridge stages them on the laptop for you.', related: ['nbrowser_click', 'nbrowser_wait_for', 'nbrowser_screenshot'], pitfalls: ['filePaths are LAPTOP paths - send_files first (or use contentBase64)', 'after injection many UIs still need a Submit/Upload click - screenshot to see the state', 'sites using the File System Access API (showOpenFilePicker) CANNOT be driven over CDP at all - use their drag-drop zone manually', 'input+change are dispatched for you; do not synthesize extra events'] },+  nbrowser_set_file_input: { hint: 'Populate an <input type=file> over CDP - NO OS file picker, fully background. Direct mode: {sessionId, selector|text, filePaths:[abs laptop paths]} - the finder walks a Browse button/label to its input (hidden inputs fine). Transient-input UIs (input created inside the Browse handler): {armForNextChooser:true, filePaths} then nbrowser_click the Browse control - the chooser is satisfied silently; confirm with {chooserStatus:true}. Cloud shortcut: files:[{name, contentBase64}] and the bridge stages them on the laptop for you. The page gets one input and one change event per call, also on a retry with the same file. Two files on an input without multiple are refused (not_multiple). Key, password and browser-login files, AppData, hidden folders in the home folder, system folders and network paths are refused (path_refused).', related: ['nbrowser_click', 'nbrowser_wait_for', 'nbrowser_screenshot'], pitfalls: ['filePaths are LAPTOP paths - send_files first (or use contentBase64)', 'after injection many UIs still need a Submit/Upload click - screenshot to see the state', 'sites using the File System Access API (showOpenFilePicker) CANNOT be driven over CDP at all - use their drag-drop zone manually', 'input+change are dispatched for you; do not synthesize extra events'] },   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'] },@@ -2993,8 +3030,14 @@ function dispatch(verb, args) {           delete args.files;         }         if (Array.isArray(args.filePaths) && args.filePaths.length && args.chooserStatus !== true) { // both real modes read the files at serve time; a status probe needs no files+          for (const fp of args.filePaths) { const bad = uploadPathPrecheck(fp); if (bad) return resolve({ success: false, ...bad, filePath: fp }); }           const missing = (args.filePaths || []).filter((fp) => { try { return !fs.existsSync(fp); } catch { return true; } });           if (missing.length) return resolve({ success: false, errorCode: 'file_missing', error: `file_missing: ${missing.join(', ')} not found on the laptop`, _hint: 'Paths must be ABSOLUTE paths on the machine running the browser. Push files there first with adom-bridge send_files, or pass files:[{name, contentBase64}] and the bridge stages them for you.' });+          for (const fp of args.filePaths) {+            let real = fp; try { real = fs.realpathSync.native(fp); } catch {}+            const why = uploadPathRefusal(real);+            if (why) return resolve({ success: false, errorCode: 'path_refused', error: `nb will not hand ${fp} to a web page: ${why}`, filePath: fp, _hint: 'Copy the file to Downloads or a project folder first if the user really means to send it.' });+          }         }       } catch (e) { return resolve({ success: false, error: 'upload staging failed: ' + ((e && e.message) || e) }); }     }
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom Native Browser Extension",   "short_name": "Adom",-  "version": "0.2.22",+  "version": "0.2.23",   "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+7−2
@@ -1204,9 +1204,14 @@ export async function setFileInput(tabId, args) {     const diag = await evaluate(tabId, `(function(){ return { fileInputs: document.querySelectorAll('input[type=file]').length, selMatched: ${args.selector ? `!!document.querySelector(${JSON.stringify(args.selector)})` : 'null'} }; })()`).catch(() => ({}));     return { ok: false, errorCode: 'input_not_found', error: `no <input type=file> resolved from ${args.selector ? 'selector ' + args.selector : args.text ? 'text "' + args.text + '"' : 'the page'}`, diag, _hint: diag && diag.fileInputs === 0 ? 'This page has NO file input right now. If the input is created by the Browse button\'s click handler, use {armForNextChooser:true, filePaths} then click the button. If the site uses showOpenFilePicker (File System Access API), CDP cannot inject files at all - use the site\'s drag-drop zone manually.' : 'A file input exists but your selector/text did not lead to it - target the input directly (selector:"input[type=file]") or the visible Browse control.' };   }+  // Two files on an input without `multiple` silently keep only the first; say so instead (pup-bridge#145).+  const facts = await send(tabId, 'Runtime.callFunctionOn', { objectId: ro.objectId, functionDeclaration: 'function(){ return { multiple: this.multiple, disabled: this.disabled }; }', returnByValue: true }).then((r) => (r && r.result && r.result.value) || {}).catch(() => ({}));+  if (files.length > 1 && facts.multiple === false) return { ok: false, errorCode: 'not_multiple', error: `that input takes one file and ${files.length} were given`, _hint: 'Chrome would silently keep only the first. Zip them, or send one at a time if the page lists each upload.' };+  // Chrome fires change only when the file list changes, so a retry with the same file would do nothing: empty it first (no events).+  await send(tabId, 'Runtime.callFunctionOn', { objectId: ro.objectId, functionDeclaration: `function(){ if (this.files && this.files.length) this.value = ''; }` }).catch(() => {});   try { await send(tabId, 'DOM.enable').catch(() => {}); await send(tabId, 'DOM.setFileInputFiles', { files, objectId: ro.objectId }); }   catch (e) { return { ok: false, error: 'DOM.setFileInputFiles failed: ' + ((e && e.message) || e), _hint: 'Verify every path exists ON THE LAPTOP (the bridge stats them, but a file may have been moved) and that the resolved element truly is an input[type=file].' }; }-  try { await send(tabId, 'Runtime.callFunctionOn', { objectId: ro.objectId, functionDeclaration: `function(){ this.dispatchEvent(new Event('input',{bubbles:true,composed:true})); this.dispatchEvent(new Event('change',{bubbles:true,composed:true})); }` }); } catch {}+  // Chrome itself fires input+change once. Firing them again made a page that uploads on change send the file twice (pup-bridge#145).   const names = await send(tabId, 'Runtime.callFunctionOn', { objectId: ro.objectId, functionDeclaration: 'function(){ return Array.prototype.map.call(this.files||[], function(f){return f.name;}); }', returnByValue: true }).then((r) => (r && r.result && r.result.value) || []).catch(() => []);-  return { ok: true, inputFound: true, via: 'direct', files: names, dispatchedEvents: ['input', 'change'], _hint: 'Files injected + input/change dispatched. Screenshot to confirm the page shows the selected file(s); many UIs need a separate Submit/Upload click next.' };+  return { ok: true, inputFound: true, via: 'direct', files: names, dispatchedEvents: ['input', 'change'], _hint: 'Files injected; the page got one input and one change event, fired by the browser. Screenshot to confirm the page shows the selected file(s); many UIs need a separate Submit/Upload click next.' }; }
package.json+6−2
@@ -1,6 +1,6 @@ {   "slug": "nb-bridge",-  "version": "0.16.64",+  "version": "0.16.65",   "type": "app",   "title": "NB - Native Browser Bridge",   "description": "This package installs the SKILLS into your container so your AI knows how to drive your real signed-in Chrome and Edge; Bridge loads the bridge runtime itself from the release zip.",@@ -105,7 +105,11 @@     "nb edge",     "open it in nb",     "open it in my real browser",-    "native browser"+    "native browser",+    "upload a file in my real chrome",+    "upload a file with nb",+    "set file input in my browser",+    "attach a file in my signed-in browser"   ],   "discovery_pitch": "nbe (the Adom Native Browser Extension, layer 2 of nb) drives the user's REAL signed-in Chrome/Edge via nbrowser_* verbs: use it for LOGGING IN, filling forms, and downloading behind auth with their OWN saved passwords - pup cannot (it is a fresh Chrome-for-Testing with no logins). Next moves: run 'adom-bridge bridge_list' to check it is installed; if not, OFFER to install it (see the installing-the-extension skill) - never make the user do it. If this container lacks the skills, 'adom-wiki pkg install adom/nb-bridge'. Check readiness with 'adom-bridge nbrowser_readiness' (state:needs-extension means offer the install).",   "sample_prompts": [