← Commit history
v0.16.44 (insiders): nb-bridge#55: nbrowser_export_saved_logins hands a local tool every login saved in the user's browser profile in ONE pass, through the browser's own consented export (one Windows Hello, Chrome does the decryption, nothing outside Chrome touches the sealed store); passwords never cross the relay (owner-only machine-local handoff, counts and hostnames in the reply), the notes column carrying TOTP seeds is stripped, and the browser's own plaintext copy is deleted
7 files changed
+191−7
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.43'; // keep in lockstep with package.json (pkg version)+const VERSION = '0.16.44'; // 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.43+0.16.44
bridge/bridge.json+2−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser",- "version": "0.16.43",+ "version": "0.16.44", "expectedExtensionVersion": "0.2.21", "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_saved_logins", "nbrowser_export_site_session", "nbrowser_drop_handoff", "nbrowser_clear_cookies",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.16.43",+ "version": "0.16.44", "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+174@@ -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_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'] }, 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'] }, nbrowser_clear_cookies: { hint: 'Background logout: drop a site\'s cookies (incl. httpOnly) for ONE registrable domain, leaving other sites (e.g. Google SSO) intact.', related: ['nbrowser_login_state'], pitfalls: ['scoped to one domain so it never nukes unrelated sessions', '⚠️ do NOT use this to log OUT of a site that ties device-trust to cookies (e.g. Gusto) - it forces a fresh 2FA; use the site\'s own Sign out'] },@@ -2017,6 +2018,38 @@ 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_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).+ //+ // WHY IT IS ALL-OR-NOTHING BY DEFAULT (John, 2026-10-28): Chrome makes the USER answer Windows Hello+ // for every export. Exporting site-by-site therefore taxes the person with a biometric prompt each+ // time their AI meets a new login, which is exactly the friction pup exists to remove. One Hello,+ // the whole profile, done. `sites` narrows it for a caller that really wants that; no AI should.+ //+ // The password values never cross the relay (same rule as #54): the CSV goes to the owner-only+ // handoff dir for the LOCAL tool to read, and the caller gets counts + hostnames.+ if (verb === 'nbrowser_export_saved_logins') {+ const a = args || {};+ if (a.confirm !== true) {+ return resolve({ success: false, errorCode: 'confirm_required',+ error: 'nbrowser_export_saved_logins needs confirm:true - it exports every password saved in this browser profile for another tool on this machine to import.',+ _hint: 'Ask the user plainly first: "To let pup sign you in everywhere, I can hand it the logins saved in your Chrome. Chrome will ask you to confirm with Windows Hello once. OK?" Then retry with confirm:true and a reason. They answer Hello ONCE and pup has everything.' });+ }+ if (!a.reason) return resolve({ success: false, errorCode: 'reason_required', error: 'reason is required and is recorded with the export.' });+ if (process.platform !== 'win32') {+ return resolve({ success: false, errorCode: 'unsupported_platform',+ error: `Chrome's export is driven through OS UI, which nb only does on Windows today (this box is ${process.platform}).`,+ _hint: 'On macOS the user can export it themselves: chrome://password-manager/settings, Export passwords. Then hand pup the file.' });+ }+ 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) }));+ return;+ } // nb-bridge#54: delete a handoff the moment the local tool has read it, instead of waiting for the TTL. if (verb === 'nbrowser_drop_handoff') { const id = String((args && args.handoffId) || '');@@ -2152,6 +2185,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_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' } }), d('nbrowser_clear_cookies', 'Background logout: drop one registrable domain\'s cookies (incl. httpOnly), leaving other sites (e.g. Google SSO) intact.', { host: 'required str (e.g. "gusto.com"; url/domain also accepted)' }, { ok: 'bool', removed: 'int', hosts: '[str]' }, 30, { example: { host: 'gusto.com' } }),@@ -3097,6 +3131,146 @@ function writeSecretHandoff(kind, meta, secret) { setTimeout(() => { try { fs.unlinkSync(file); } catch {} }, SECRET_TTL_MS).unref?.(); return { handoffId: id, handoffPath: file, expiresInSec: Math.round(SECRET_TTL_MS / 1000) }; }+// THE EXPORT ITSELF (nb-bridge#55). Chrome never exposes saved passwords to an extension, so this drives+// Chrome's own export UI and lets CHROME do the decryption with the user present. Steps:+// 1. tell the user what is about to happen (a Hello prompt out of nowhere is alarming);+// 2. open chrome://password-manager/settings in a window nb owns, in the background;+// 3. invoke "Export passwords" by NAME through UI Automation (never coordinates);+// 4. wait for the user to answer Windows Hello, then for Chrome to write the CSV;+// 5. move the CSV into the owner-only handoff dir, parse it for COUNTS only, strip the note column,+// and delete every other copy (Chrome's own, and the temp).+// Fails loudly with a screenshot path rather than guessing when the page moves under us.+async function exportSavedLogins(opts) {+ const { profileKey, browser, sites, reason, timeoutSec } = opts;+ const scheme = browser === 'edge' ? 'edge' : 'chrome';+ const outDir = path.join(os.tmpdir(), 'adom-nb-export');+ const csvPath = path.join(outDir, `logins-${Date.now()}.csv`);+ try { fs.mkdirSync(outDir, { recursive: true }); } catch {}+ const step = (name, ok, detail) => ({ step: name, ok: !!ok, ...(detail ? { detail } : {}) });+ const steps = [];+ const cleanup = () => { try { fs.unlinkSync(csvPath); } catch {} };++ await _notify({ id: 'nb-export-logins', level: 'warning', scenario: 'reminder',+ title: 'Chrome will ask you to confirm',+ body: `Adom is handing your saved ${scheme === 'edge' ? 'Edge' : 'Chrome'} logins to pup so it can sign you in. Chrome asks for Windows Hello once - approve it and this finishes by itself.` }).catch(() => {});+ steps.push(step('told the user a Hello prompt is coming', true));++ // The page lives in the user's own browser, so open OUR OWN window on it (never reuse theirs).+ const sid = `nb-export-${Date.now()}`;+ const open = await dispatch('nbrowser_open_window', { sessionId: sid, thread: 'nb-bridge', profile: profileKey,+ purpose: 'drive Chrome\'s own password export so pup can import the logins', url: 'about:blank' });+ let hwnd = null;+ try { hwnd = JSON.parse(open.output).hwnd; } catch {}+ if (!hwnd) { cleanup(); return { ok: false, errorCode: 'no_window', error: 'could not open a window to drive the export in', steps }; }+ steps.push(step('opened a background window nb owns', true, `hwnd ${hwnd}`));+ const done = async (res) => { try { await dispatch('nbrowser_close_window', { sessionId: sid }); } catch {} return res; };++ const nav = await abCommand(null, 'desktop_navigate', { hwnd, url: `${scheme}://password-manager/settings`,+ reason: 'nb-bridge#55: open the browser\'s own password export page' }).catch(() => null);+ steps.push(step('opened the password manager', !!nav));+ await _sleep(1500);++ // Invoke the button BY NAME. If the name moves, say so with a screenshot instead of clicking blind.+ let invoked = null;+ for (const name of ['Export passwords', 'Export passwords…', 'Export']) {+ const r = await abCommand(null, 'desktop_ui_click', { hwnd, contains: name,+ reason: 'nb-bridge#55: start the browser\'s own consented password export' }).catch(() => null);+ const okr = r && r.body && (r.body.success !== false);+ if (okr) { invoked = name; break; }+ }+ if (!invoked) {+ const shot = await abCommand(null, 'desktop_screenshot_window', { hwnd, reason: 'nb-bridge#55: the export control was not found' }).catch(() => null);+ const sp = (shot && shot.body && (shot.body.localSafePath || shot.body.safePath || shot.body.fullPath)) || null;+ steps.push(step('found the Export control', false));+ return done({ ok: false, errorCode: 'export_control_not_found', steps,+ error: `could not find an "Export passwords" control on ${scheme}://password-manager/settings - the page has probably changed`,+ screenshot: sp,+ _hint: 'This is UI automation against the browser\'s own page, so it drifts when the browser reorganises it. Look at the screenshot, then file it on adom/nb-bridge; the user can still export by hand from that page and hand pup the file.' });+ }+ steps.push(step('started the export', true, invoked));++ // Chrome now raises Windows Hello, then a Save As dialog. Fill the path, commit, wait for bytes.+ const deadline = Date.now() + timeoutSec * 1000;+ let saved = false;+ while (Date.now() < deadline) {+ await _sleep(1200);+ const wins = await abCommand(null, 'desktop_list_windows', {}).catch(() => null);+ const list = (() => { try { return (wins.body.data || wins.body).windows || []; } catch { return []; } })();+ const dlg = list.find((w) => w.className === '#32770' && /save|export/i.test(String(w.title || '')));+ if (!dlg) continue;+ await abCommand(null, 'desktop_bring_to_front', { hwnd: dlg.hwnd, reason: 'nb-bridge#55: fill the export file name' }).catch(() => {});+ await abCommand(null, 'desktop_press_key', { hwnd: dlg.hwnd, keys: 'ctrl+a', reason: 'nb-bridge#55: clear the file name' }).catch(() => {});+ await abCommand(null, 'desktop_type', { hwnd: dlg.hwnd, text: csvPath, reason: 'nb-bridge#55: write the export where only this user can read it' }).catch(() => {});+ await abCommand(null, 'desktop_press_key', { hwnd: dlg.hwnd, keys: 'enter', reason: 'nb-bridge#55: commit the export' }).catch(() => {});+ saved = true;+ break;+ }+ steps.push(step('answered the save dialog', saved, saved ? null : 'no save dialog appeared'));+ if (!saved) {+ return done({ ok: false, errorCode: 'awaiting_user', steps,+ error: `the export was started but no save dialog appeared within ${timeoutSec}s - the Windows Hello prompt was probably not answered`,+ _hint: 'RELAY TO THE USER: a Windows Hello prompt is (or was) on their PC asking them to confirm the password export; it cannot be answered for them. Once they approve, retry this verb.' });+ }++ // Wait for Chrome to finish writing.+ let bytes = 0;+ const wdl = Date.now() + 45000;+ while (Date.now() < wdl) { await _sleep(800); try { const st = fs.statSync(csvPath); if (st.size > 0) { bytes = st.size; break; } } catch {} }+ if (!bytes) {+ return done({ ok: false, errorCode: 'no_export_file', steps, error: 'the save dialog was answered but no file appeared',+ _hint: 'The user may have cancelled, or Hello failed. Nothing was exported; retry when they are at the machine.' });+ }+ steps.push(step('browser wrote the export', true, `${bytes} bytes`));++ // Parse for COUNTS ONLY, drop the note column, and write the handoff the local tool will read.+ let rows = [];+ try {+ const text = fs.readFileSync(csvPath, 'utf8');+ rows = parseLoginCsv(text);+ } catch (e) {+ cleanup();+ return done({ ok: false, errorCode: 'unreadable_export', steps, error: `could not read the export: ${String((e && e.message) || e)}` });+ }+ const wanted = sites && sites.length ? sites.map((x) => String(x).toLowerCase()) : null;+ const keep = wanted ? rows.filter((r) => wanted.some((w) => String(r.url || '').toLowerCase().includes(w))) : rows;+ const hosts = [...new Set(keep.map((r) => { try { return new URL(r.url).hostname; } catch { return String(r.url || '').replace(/^https?:\/\//, '').split('/')[0]; } }))].filter(Boolean);+ const h = writeSecretHandoff('logins', { verb: 'nbrowser_export_saved_logins', browser, profile: profileKey, reason, count: keep.length },+ { logins: keep.map((r) => ({ url: r.url, username: r.username, password: r.password })) }); // note column deliberately absent+ cleanup(); // the browser's own plaintext copy never survives this verb+ steps.push(step('handed it to the local tool and deleted the browser\'s copy', true));+ logActivity({ verb: 'nbrowser_export_saved_logins', profile: profileKey, ok: true, ops: `${keep.length} login(s) for ${hosts.length} site(s)` });+ return done({ ok: true, browser, profile: profileKey, loginCount: keep.length, siteCount: hosts.length,+ sites: hosts.slice(0, 40), filtered: !!wanted, steps, ...h,+ _hint: `Chrome exported ${keep.length} login(s) across ${hosts.length} site(s), and the values are NOT in this reply: they are in a file on the USER'S machine, readable only by them. ` ++ `Hand handoffPath to pup (it runs on that machine) and call nbrowser_drop_handoff {handoffId} the moment it has imported. The file self-deletes in ${h.expiresInSec}s either way. ` ++ `TOTP/notes were stripped. The user answered Windows Hello once; do NOT run this again per site.` });+}+// Chrome's export CSV: name,url,username,password,note. Quoted fields can contain commas and newlines.+function parseLoginCsv(text) {+ const rows = []; let i = 0, field = '', row = [], inQ = false;+ const pushF = () => { row.push(field); field = ''; };+ const pushR = () => { pushF(); if (row.some((c) => c !== '')) rows.push(row); row = []; };+ while (i < text.length) {+ const c = text[i];+ if (inQ) {+ if (c === '"' && text[i + 1] === '"') { field += '"'; i += 2; continue; }+ if (c === '"') { inQ = false; i++; continue; }+ field += c; i++; continue;+ }+ if (c === '"') { inQ = true; i++; continue; }+ if (c === ',') { pushF(); i++; continue; }+ if (c === '\r') { i++; continue; }+ if (c === '\n') { pushR(); i++; continue; }+ field += c; i++;+ }+ pushR();+ if (!rows.length) return [];+ const head = rows.shift().map((h) => h.trim().toLowerCase());+ const idx = (n) => head.indexOf(n);+ const iu = idx('url'), iun = idx('username'), ip = idx('password'); // `in` is a reserved word+ return rows.map((r) => ({ url: r[iu] || '', username: r[iun] || '', password: r[ip] || '' }))+ .filter((r) => r.url || r.username);+} async function decorateReply(p, frame, payloadIn, setPayload) { let payload = payloadIn; // A verb that carries a secret hands it to the machine, not to the caller (nb-bridge#54).
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "nb-bridge",- "version": "0.16.43",+ "version": "0.16.44", "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+11−2@@ -73,6 +73,15 @@ const T = { profiles: () => { const r = call('nbrowser_profiles'); const ps = r.profiles || []; ctx.profileCount = ps.length; const def = ps.find((p) => p.profile === r.active); const pups = ps.filter((p) => p.origin === 'pup').length; return A(ps.length > 0 && !(def && def.origin === 'pup'), `${ps.length} profiles, default ${r.active} (origin ${def ? def.origin : 'none'}), ${pups} pup connection(s) set aside`, `default is pup's browser or no profiles: ${JSON.stringify(r).slice(0, 160)}`); }, resolve_profiles: () => { const r = call('nbrowser_resolve_profiles'); return A(r.ok !== false, 'resolved', JSON.stringify(r).slice(0, 120)); }, // 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.+ 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 });+ const gated = noConfirm.errorCode === 'confirm_required' && noReason.errorCode === 'reason_required';+ const leak = /password/i.test(JSON.stringify(noConfirm.credentials || '')) ;+ return A(gated && !leak, 'refuses without consent and without a reason (full run needs a human for Windows Hello)', JSON.stringify({ a: noConfirm.errorCode, b: noReason.errorCode }));+ }, // nb-bridge#54. The export must (a) refuse without confirm, (b) keep cookie VALUES out of the reply, // (c) write them owner-only on the user's machine, and (d) be droppable. Uses the harness's own domain. export_site_session: () => {@@ -331,7 +340,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', 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_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',@@ -425,7 +434,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','eval_on_browser_page'];+ 'events_and_owned','handle_dialog','cleanup','owned','close_window','dead_session_scoping','export_site_session','drop_handoff','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; }