← Commit history
v0.16.62 (insiders): nb-bridge#55 plan step 1: a preflight that refuses a doomed export instead of flailing. nbrowser_export_readiness (read-only) and an automatic preflight on nbrowser_export_saved_logins detect the browser running as administrator (terminal: Windows blocks all input and UIA to its dialog) and a competing computer-use agent holding the input queue (warning: UIA should survive it). An elevated browser now stops the run before anything opens.
7 files changed
+64−10
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.61'; // keep in lockstep with package.json (pkg version)+const VERSION = '0.16.62'; // 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.61+0.16.62
bridge/bridge.json+2−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser",- "version": "0.16.61",+ "version": "0.16.62", "expectedExtensionVersion": "0.2.22", "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",@@ -98,6 +98,7 @@ "nbrowser_demo", "nbrowser_uninstall", "nbrowser_dev_reload",+ "nbrowser_export_readiness", "nbrowser_export_saved_logins", "nbrowser_export_site_session", "nbrowser_drop_handoff",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.16.61",+ "version": "0.16.62", "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+54−3@@ -1802,6 +1802,7 @@ const VERB_META = { 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 a screenshot delivered as a file (fullPath; ab pulls it to you) 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'] }, nbrowser_dev_reload: { hint: 'MAINTAINER-only: reload the extension to pick up on-disk code changes (2-pass, reconnects in ~10s).', related: ['nbrowser_status'], pitfalls: ['throttled to 1/90s (a reload restarts the SW + native host)', 'not for normal driving - only after editing the extension source', 'reloads ONE profile; other profiles pick up the new code on their own next reload'] },+ nbrowser_export_readiness: { hint: 'READ-ONLY preflight for nbrowser_export_saved_logins (nb-bridge#55): can nb drive the browser\'s save dialog on this machine right now? Reports browser_elevated (terminal - the browser is running as administrator, Windows blocks everything) and input_contended (another computer-use agent is holding the input queue - a warning, UIA should survive it). Opens nothing, exports nothing.', related: ['nbrowser_export_saved_logins', 'nbrowser_export_site_session'], pitfalls: ['canDriveDialog:false means the browser is elevated - tell the user to restart it normally before exporting', 'input_contended is a warning not a blocker; it means SendInput may be refused, so the export leans on UIA'] }, nbrowser_export_saved_logins: { hint: "Hand pup EVERY login saved in the user's real browser profile, in ONE pass, through CHROME'S OWN consented export (adom/nb-bridge#55). Chrome decrypts it with the user present (one Windows Hello), so nothing outside Chrome touches the sealed store. Needs {confirm:true, reason}. Passwords are NEVER in the reply: the file is written owner-only on the user's machine and you get counts, hostnames and a handoffPath for the LOCAL tool to import. Notes/TOTP are stripped.", related: ['nbrowser_export_site_session', 'nbrowser_drop_handoff', 'nbrowser_credentials'], pitfalls: ['ASK THE USER FIRST and tell them Hello will appear ONCE: confirm:true is your attestation that they agreed', 'do NOT call this per site - the whole point is one Hello for the whole profile; `sites` exists for a caller that really wants a subset and an AI should not reach for it', 'the user must be AT the machine: Hello cannot be answered for them, and awaiting_user means the prompt is sitting there unanswered', 'the passwords live on the USER\'S disk, not in your container: hand handoffPath to the local tool and call nbrowser_drop_handoff the moment it has imported', 'Windows only today - on macOS tell the user to export by hand from the password manager page', 'export_control_not_found means the browser moved its own UI: read the screenshot it returns and file it rather than clicking blind', 'browser_elevated is terminal: if the user is running Chrome/Edge as administrator, nb cannot drive its save dialog (Windows blocks ab\'s input to a higher-integrity window) - do NOT retry, tell the user to restart the browser normally or press Save themselves, or use nbrowser_export_site_session (#54) which needs no dialog'] }, nbrowser_export_site_session: { hint: "Carry ONE site's live session out of the user's REAL signed-in browser so another tool ON THE SAME MACHINE (pup) can act as them there, without a password and without a fresh sign-in (adom/nb-bridge#54). Needs {domain, confirm:true, reason}. The cookie VALUES are never in the reply: they are written owner-only to a machine-local file and you get a handoffPath plus counts, because a secret in a reply is a secret in an AI transcript. Pass the path to the local tool, then nbrowser_drop_handoff.", related: ['nbrowser_login_state', 'nbrowser_profiles', 'nbrowser_drop_handoff'], pitfalls: ['ASK THE USER FIRST, naming the site: this lets another tool act as them there. confirm:true is your attestation that they said yes', 'ONE registrable domain per call - there is deliberately no "export everything"', 'the handoff file lives on the USER\'S machine, not in your container: do not try to read it from an AI thread, hand the path to the local tool', 'if the profile has no cookies for the domain you get no_session - the user may be signed in under a DIFFERENT profile (nbrowser_profiles)', 'it expires in 10 minutes and nbrowser_drop_handoff removes it sooner - do that as soon as the import is done'] }, nbrowser_drop_handoff: { hint: 'Delete a secret-handoff file created by nbrowser_export_site_session, as soon as the local tool has read it. Read-only otherwise; it never returns the contents.', related: ['nbrowser_export_site_session'], pitfalls: ['dropped:false just means it was already consumed or expired, which is the safe outcome', 'this is the cleanup half of the export - call it rather than leaving a session file sitting on the user\'s disk'] },@@ -2018,6 +2019,17 @@ function dispatch(verb, args) { // nbrowser_chrome_status - READ-ONLY: is native Chrome installed / how's an in-flight install going? // nbrowser_prewarm - ab calls this once per installed version (~20s after the first boot after an install // or update; adom/nb-bridge#50). nb has no assets to fetch; what is worth doing early is the tally.+ // nbrowser_export_readiness - READ-ONLY preflight for the password export (nb-bridge#55): can nb drive+ // the browser's save dialog on this machine right now? Opens nothing, exports nothing.+ if (verb === 'nbrowser_export_readiness') {+ const pkey = (targetConn(args) || {}).key || defaultConnKey();+ const browser = browserOfKey(pkey) || 'chrome';+ exportPreflight(browser).catch((e) => ({ ok: true, blockers: [], detail: String(e) })).then((pf) => {+ resolve({ success: true, output: JSON.stringify({ ok: true, browser, canDriveDialog: !pf.elevated, ...pf,+ _hint: pf.elevated ? 'Browser is ELEVATED: the export cannot be driven until it is restarted normally.' : pf.agents && pf.agents.length ? `Another input agent is running (${pf.agents.join(', ')}); SendInput may be refused, UIA should still work.` : 'Clear: nb can drive the save dialog here.' }) });+ });+ return;+ } // nbrowser_export_saved_logins - adom/nb-bridge#55. Hand pup every saved login in ONE pass, through // CHROME'S OWN consented export, so nothing outside Chrome ever cracks the sealed store (ab's // out-of-Chrome decrypt broke on Chrome 156 and will break again).@@ -2047,9 +2059,17 @@ function dispatch(verb, args) { const tgt0 = targetConn(a); const pkey = (tgt0 && tgt0.key) || defaultConnKey(); const browser = browserOfKey(pkey) || 'chrome';- exportSavedLogins({ profileKey: pkey, browser, sites: Array.isArray(a.sites) ? a.sites : null, reason: String(a.reason), timeoutSec: Math.min(Number(a.timeoutSec) || 180, 300) })- .then((r) => resolve(r.ok === false ? { success: false, ...r } : { success: true, output: JSON.stringify(r) }))- .catch((e) => resolve({ success: false, errorCode: 'export_failed', error: String((e && e.message) || e) }));+ // Preflight: stop here (do NOT open anything) when the browser is elevated - that is the only+ // state nb genuinely cannot drive at all. input_contended is surfaced to the caller but not fatal.+ exportPreflight(browser).catch(() => ({ ok: true, blockers: [] })).then((pf) => {+ if (pf.hard) {+ return resolve({ success: false, errorCode: 'browser_elevated', browserElevated: true, preflight: pf,+ error: `${browser === 'edge' ? 'Edge' : 'Chrome'} is running as administrator, so Windows will not let Adom drive its export dialog`,+ _hint: `TERMINAL. Ask the user to close ${browser === 'edge' ? 'Edge' : 'Chrome'} and reopen it NORMALLY (not "Run as administrator"), then retry. Or use nbrowser_export_site_session (#54), which needs no dialog. Nothing was opened.` });+ }+ return exportSavedLogins({ profileKey: pkey, browser, sites: Array.isArray(a.sites) ? a.sites : null, reason: String(a.reason), timeoutSec: Math.min(Number(a.timeoutSec) || 180, 300), preflight: pf })+ .then((r) => resolve(r.ok === false ? { success: false, ...r } : { success: true, output: JSON.stringify(r) }));+ }).catch((e) => resolve({ success: false, errorCode: 'export_failed', error: String((e && e.message) || e) })); return; } // nb-bridge#54: delete a handoff the moment the local tool has read it, instead of waiting for the TTL.@@ -2187,6 +2207,7 @@ function dispatch(verb, args) { d('nbrowser_login_form', 'Detect a login form\'s state (already autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call this before driving a login.', { sessionId: S }, { ok: 'bool', hasForm: 'bool', autofilled: 'bool', sso: 'bool', needs2FA: 'bool', recommendation: 'str' }, 30, { statusVerb: null, example: { sessionId: 's1' } }), d('nbrowser_autologin', 'Fill and submit a saved-credential login. The bridge injects the laptop-decrypted secret directly; the agent NEVER sees it. Gated on confirm:true + host.', { sessionId: S, host: 'required str (e.g. "gusto.com")', confirm: 'required true', username: 'optional str' }, { ok: 'bool', loggedIn: 'bool', needs2FA: 'bool' }, 60, { example: { sessionId: 's1', host: 'gusto.com', confirm: true } }), d('nbrowser_download', 'Download a URL to the user\'s real Downloads folder via chrome.downloads (lands on the LAPTOP, not the container).', { sessionId: S, url: 'required str', filename: 'optional str', saveAs: 'optional bool' }, { ok: 'bool', downloadId: 'int', filename: 'str?' }, 60, { statusVerb: 'nbrowser_download_wait', example: { sessionId: 's1', url: 'https://example.com/datasheet.pdf' } }),+ d('nbrowser_export_readiness', 'READ-ONLY: can nb drive the password-export save dialog here (browser elevated? another input agent running)? Opens nothing.', { profile: P }, { ok: 'bool', browser: 'str', canDriveDialog: 'bool', elevated: 'bool', agents: '[str]', detail: 'str' }, 25, { example: {} }), d('nbrowser_export_saved_logins', "Export EVERY saved login in the profile through the browser's own consented export (one Windows Hello) for a local tool to import. Values are written machine-locally, never returned.", { confirm: 'required true (the user said yes)', reason: 'required str', sites: 'optional [str] (narrow it; rarely right - costs the user another Hello later)', profile: P, timeoutSec: 'optional int (default 180, max 300: how long to wait for the user)' }, { ok: 'bool', loginCount: 'int', siteCount: 'int', sites: '[str]', handoffId: 'str', handoffPath: "str (on the USER's machine)", steps: '[obj]' }, 300, { example: { confirm: true, reason: 'load pup with the user\'s logins so it can sign them in everywhere' } }), d('nbrowser_export_site_session', "Carry ONE site's live session out of the user's real browser for another tool on the SAME machine (pup). Values are written machine-locally, never returned.", { domain: 'required str (e.g. "x.com")', confirm: 'required true (the user said yes)', reason: 'required str (what it is for)', profile: P }, { ok: 'bool', domain: 'str', cookieCount: 'int', authCookieCount: 'int', handoffId: 'str', handoffPath: 'str (on the USER\'s machine)', expiresInSec: 'int' }, 30, { example: { domain: 'x.com', confirm: true, reason: 'carry the signed-in session into pup so it can post as the user' } }), d('nbrowser_drop_handoff', 'Delete a handoff file once the local tool has imported it.', { handoffId: 'required str' }, { ok: 'bool', dropped: 'bool' }, 15, { example: { handoffId: 'session-1790000000000-0a1b2c3d4e5f' } }),@@ -3257,6 +3278,36 @@ function newLoginCsvSince(sinceMs, ownPath) { hits.sort((a, b) => b.mtime - a.mtime); return hits[0] || null; }+// PREFLIGHT (nb-bridge#55, the plan John approved 2026-10-30): refuse a run that cannot possibly finish,+// instead of flailing for minutes. Two things actually break the save-dialog step:+// (1) the browser is ELEVATED - Windows (UIPI) then blocks ALL of ab's input AND its UIA to the+// dialog, so nb can neither type nor click it. This is terminal: the user must relaunch normally.+// (2) another computer-use agent is holding the input queue (seen on John's laptop: OpenAI Codex's+// codex-computer-use-swift). This blocks SendInput; UIA should survive it, so it is a WARNING,+// not a hard stop.+async function exportPreflight(browser) {+ if (process.platform !== 'win32') return { ok: false, hard: true, blockers: ['unsupported_platform'], detail: `platform ${process.platform}` };+ const exeRe = browser === 'edge' ? 'msedge' : 'chrome';+ const ps = [+ '$r = @{ elevated=$false; agents=@() }',+ `foreach ($p in (Get-Process ${exeRe} -ErrorAction SilentlyContinue)) { try { $null = $p.Path } catch { $r.elevated=$true } }`,+ "foreach ($p in (Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'codex-computer-use|cua_|AutoHotkey|nircmd|vnc|anydesk|teamviewer' })) { $r.agents += $p.Name }",+ '$r.agents = $r.agents | Select-Object -Unique',+ '$r | ConvertTo-Json -Compress',+ ].join('; ');+ try {+ const rr = await abCommand(null, 'run_script', { interpreter: 'powershell', scriptB64: Buffer.from(ps, 'utf8').toString('base64'),+ reason: 'nb-bridge#55 preflight: is the browser elevated, or is another computer-use agent holding input?', timeoutSeconds: 20 });+ const out = String((rr && rr.body && (rr.body.stdout || (rr.body.data && rr.body.data.stdout))) || '').trim();+ const j = JSON.parse(out || '{}');+ const agents = [].concat(j.agents || []).filter(Boolean);+ const blockers = [];+ if (j.elevated) blockers.push('browser_elevated');+ if (agents.length) blockers.push('input_contended');+ return { ok: !j.elevated, hard: !!j.elevated, blockers, elevated: !!j.elevated, agents,+ detail: j.elevated ? `${browser} is running as administrator` : agents.length ? `another input agent is running: ${agents.join(', ')}` : 'clear' };+ } catch (e) { return { ok: true, hard: false, blockers: [], agents: [], detail: 'preflight probe failed, proceeding: ' + String((e && e.message) || e) }; }+} async function exportSavedLogins(opts) { const { profileKey, browser, sites, reason, timeoutSec } = opts; const scheme = browser === 'edge' ? 'edge' : 'chrome';
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "nb-bridge",- "version": "0.16.61",+ "version": "0.16.62", "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.",
tools/verb-battery.mjs+4−2@@ -75,6 +75,8 @@ const T = { // An Edge-only box is a PASS: nb drives whatever native browser is there (nb-bridge#46). // nb-bridge#55. The full export needs a HUMAN at the machine (Windows Hello), so the battery exercises // the refusals only: consent and reason. It must never fire a Hello prompt at whoever is sitting there.+ // nb-bridge#55 preflight: read-only, must report whether the dialog is drivable here.+ export_readiness: () => { const r = call('nbrowser_export_readiness'); return A(r.ok === true && typeof r.canDriveDialog === 'boolean' && !!r.browser, `canDriveDialog=${r.canDriveDialog}${(r.agents||[]).length?' (agents: '+r.agents.join(',')+')':''}`, JSON.stringify(r).slice(0,180)); }, export_saved_logins: () => { const noConfirm = call('nbrowser_export_saved_logins', { profile: PROFILE, reason: 'battery: consent gate' }); const noReason = call('nbrowser_export_saved_logins', { profile: PROFILE, confirm: true });@@ -340,7 +342,7 @@ const T = { // ─── coverage map: EVERY VERB_META verb decides its fate here ─────────────── export const COVERAGE = { status: 'live', readiness: 'live', ping: 'live', progress: 'live', scroll: 'live', input_unknown_type: 'guard', hover_raw_xy: 'live', apply_settings: 'skip:internal bridge-to-extension push, exercised by the settings test through nbrowser_settings', whoami: 'live', profiles: 'live',- resolve_profiles: 'live', prewarm: 'live', dead_session_scoping: 'live', export_site_session: 'live', drop_handoff: 'live', export_saved_logins: 'guard', eval_on_browser_page: 'live', guard_profile_required: 'guard', open_minimized: 'live', chrome_status: 'live', describe: 'live', activity: 'live',+ resolve_profiles: 'live', prewarm: 'live', dead_session_scoping: 'live', export_site_session: 'live', drop_handoff: 'live', export_readiness: 'live', export_saved_logins: 'guard', eval_on_browser_page: 'live', guard_profile_required: 'guard', open_minimized: 'live', chrome_status: 'live', describe: 'live', activity: 'live', os_windows: 'live', wake_profile: 'live', rescan: 'live', resync_sessions: 'live', open_window: 'live', pin_session: 'live', use_profile: 'live', list_windows: 'live', window_state: 'live', switch_window: 'live', navigate: 'live', reload: 'live',@@ -434,7 +436,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { 'record_start','record_status','record_stop','record_list', 'download','download_wait','login_state','login_form','credentials','totp','clear_cookies','add_extension_origin', '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','dead_session_scoping','export_site_session','drop_handoff','export_saved_logins','eval_on_browser_page'];+ 'events_and_owned','handle_dialog','cleanup','owned','close_window','dead_session_scoping','export_site_session','drop_handoff','export_readiness','export_saved_logins','eval_on_browser_page']; 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; }