← Commit history
nbrowser_autologin (v10 only; v20 app-bound documented); Path B autofill recipe; banner+popup findings; cleaned host; v0.3.7 / manifest 0.0.10
7 files changed
+373−3
bridge/server.js+133@@ -26,6 +26,8 @@ const net = require('net'); const fs = require('fs'); const os = require('os'); const path = require('path');+const crypto = require('crypto');+const { execFileSync } = require('child_process'); const BRIDGE = 'native-browser'; let VERSION = '0.0.1';@@ -173,6 +175,119 @@ function queryCredentials({ browser, profile, match } = {}) { return out; } +// ---- saved-PASSWORD decryption (path A: bridge-local, laptop-only) ----------------------------------+// Decrypts ONE host's saved Chrome/Edge password so nbrowser_autologin can inject it via CDP. SECURITY:+// the plaintext exists only in this process + the frame forwarded DOWNSTREAM to the host/extension+// (loopback, same machine). It is NEVER placed in a verb result, NEVER written to a log, NEVER sent up+// the relay to the AI/container. Callers must keep it that way.+const _aesKeyCache = {}; // browser -> Buffer (DPAPI-decrypted os_crypt AES key)+// Absolute powershell path — AD spawns the bridge with a STRIPPED PATH (no System32), so bare+// `powershell` ENOENTs as "Command failed" even though standalone node finds it (verified 2026-06-23).+const PWSH = (() => { try { const p = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); return fs.existsSync(p) ? p : 'powershell'; } catch { return 'powershell'; } })();+function dpapiUnprotect(buf) { // CurrentUser DPAPI via PowerShell (the bridge runs as the logged-in user)+ const b64 = buf.toString('base64');+ // -EncodedCommand (UTF-16LE base64) avoids ALL shell-quoting issues — passing PS with quotes through+ // cmd mangled the path/args (verified 2026-06-23). The script's own base64 arg is single-quoted inside.+ const ps = "Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String('" + b64 + "'); "+ + "$o=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser); "+ + "[Convert]::ToBase64String($o)";+ const enc = Buffer.from(ps, 'utf16le').toString('base64');+ // AD spawns the bridge with a STRIPPED env; DPAPI CurrentUser needs USERPROFILE/APPDATA to find the+ // user master key (else "The parameter is incorrect", 2026-06-23). Reconstruct them from LOCALAPPDATA.+ const LA = process.env.LOCALAPPDATA || path.join(BROWSER_UD.chrome, '..', '..', '..');+ const UP = process.env.USERPROFILE || path.dirname(path.dirname(LA));+ const SR = process.env.SystemRoot || 'C:\\Windows';+ const childEnv = Object.assign({}, process.env, {+ USERPROFILE: UP, LOCALAPPDATA: LA, APPDATA: process.env.APPDATA || path.join(UP, 'AppData', 'Roaming'),+ SystemRoot: SR, PATH: process.env.PATH || (SR + '\\System32'),+ });+ try { // execFileSync = NO cmd shell (no quoting/PATH issues); explicit env restores the user profile DPAPI needs+ const out = execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: childEnv });+ return Buffer.from(out.trim(), 'base64');+ } catch (e) {+ // Decisive self-test: does DPAPI work AT ALL in the bridge's spawn context? Protect→Unprotect a dummy.+ let st = '?';+ try {+ const t = "Add-Type -AssemblyName System.Security; $s='CurrentUser'; $p=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('rt'),$null,$s); $u=[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($p,$null,$s)); $id=[System.Security.Principal.WindowsIdentity]::GetCurrent(); Write-Output ('rt='+$u+' name='+$id.Name)";+ st = execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(t, 'utf16le').toString('base64')], { encoding: 'utf8', windowsHide: true, env: childEnv }).replace(/\s+/g, ' ').trim();+ } catch (e2) { st = 'roundtrip-threw: ' + String((e2 && (e2.stderr || e2.message)) || e2).replace(/[<>]/g, '').replace(/\s+/g, ' ').slice(0, 160); }+ throw new Error('dpapi-fail selftest{' + st + '}');+ }+}+function chromeAesKey(browser) {+ if (_aesKeyCache[browser]) return _aesKeyCache[browser];+ const ls = JSON.parse(fs.readFileSync(path.join(BROWSER_UD[browser], 'Local State'), 'utf8'));+ let enc = Buffer.from(ls.os_crypt.encrypted_key, 'base64');+ if (enc.slice(0, 5).toString('latin1') === 'DPAPI') enc = enc.slice(5);+ return (_aesKeyCache[browser] = dpapiUnprotect(enc));+}+function decryptBlob(blob, browser) {+ if (!blob || !blob.length) return null;+ const prefix = blob.slice(0, 3).toString('latin1');+ if (prefix === 'v10' || prefix === 'v11') { // AES-256-GCM with the os_crypt key+ const key = chromeAesKey(browser);+ const iv = blob.slice(3, 15), tag = blob.slice(blob.length - 16), ct = blob.slice(15, blob.length - 16);+ const d = crypto.createDecipheriv('aes-256-gcm', key, iv); d.setAuthTag(tag);+ return Buffer.concat([d.update(ct), d.final()]).toString('utf8');+ }+ return dpapiUnprotect(blob).toString('utf8'); // legacy DPAPI-only blob+}+// Read login rows INCLUDING the encrypted password blob (logins col 5) — used ONLY by autologin.+function loginRowsWithBlob(file) {+ const buf = fs.readFileSync(file);+ let pageSize = buf.readUInt16BE(16); if (pageSize === 1) pageSize = 65536;+ const n = Math.floor(buf.length / pageSize), out = [];+ const varint = (off) => { let r = 0n, i = 0; for (; i < 9; i++) { const b = buf[off + i]; if (i === 8) { r = (r << 8n) | BigInt(b); i++; break; } r = (r << 7n) | BigInt(b & 0x7f); if (!(b & 0x80)) { i++; break; } } return [r, i]; };+ for (let p = 1; p <= n; p++) {+ const base = (p - 1) * pageSize, hdr = p === 1 ? base + 100 : base;+ if (buf[hdr] !== 0x0d) continue;+ const cells = buf.readUInt16BE(hdr + 3), ptrArr = hdr + 8;+ for (let c = 0; c < cells; c++) {+ let o = base + buf.readUInt16BE(ptrArr + c * 2);+ let [, l1] = varint(o); o += l1; let [, l2] = varint(o); o += l2;+ const recStart = o; let [hLen, hb] = varint(o); const hEnd = recStart + Number(hLen); o += hb;+ const serials = []; while (o < hEnd) { const [s, sb] = varint(o); o += sb; serials.push(Number(s)); }+ let d = hEnd; const cols = [];+ for (const s of serials) { let v = null, sz = 0; if (s === 0 || s === 8 || s === 9) sz = 0; else if (s >= 1 && s <= 4) sz = s; else if (s === 5) sz = 6; else if (s === 6 || s === 7) sz = 8; else if (s >= 12 && s % 2 === 0) { sz = (s - 12) / 2; v = buf.slice(d, d + sz); } else if (s >= 13) { sz = (s - 13) / 2; v = buf.toString('utf8', d, d + sz); } cols.push(v); d += sz; }+ if (cols[0] && /^https?:\/\//.test(cols[0])) out.push({ host: cols[0].split('/')[2], username: cols[3] || '', blob: cols[5] || null });+ }+ }+ return out;+}+// Decrypt the saved password for a host in a browser profile. Returns {username, password} on success,+// else {error, candidates?} where candidates carries ONLY {host, user, blobLen, pwLen} for debugging —+// NEVER the password. SECURITY: callers MUST NOT return/log/relay the .password field.+function decryptSavedPassword({ browser, profileEmail, host, username }) {+ browser = browser || 'chrome';+ if (!BROWSER_UD[browser]) return { error: 'unknown browser ' + browser };+ const profs = profilesOf(browser);+ const pr = profileEmail ? profs.find((p) => p.email.toLowerCase() === String(profileEmail).toLowerCase()) : (profs.find((p) => p.dir === 'Default') || profs[0]);+ if (!pr) return { error: 'profile not found for ' + profileEmail, profiles: profs.map((p) => p.email || p.dir) };+ const src = path.join(BROWSER_UD[browser], pr.dir, 'Login Data');+ const tmp = path.join(os.tmpdir(), `nb-al-${browser}-${Date.now()}.db`);+ try {+ fs.copyFileSync(src, tmp);+ const rows = loginRowsWithBlob(tmp);+ const h = String(host).toLowerCase();+ const core = h.split('.').slice(-2).join('.'); // e.g. gusto.com — Gusto saves the password under app.* not login.*+ // candidates = any same-site row WITH a non-empty encrypted blob; prefer username + non-"login." host.+ let cand = rows.filter((r) => r.host && r.blob && r.blob.length && (r.host.toLowerCase().includes(h) || r.host.toLowerCase().includes(core)));+ cand.sort((a, b) => {+ const um = (r) => (username && (r.username || '').toLowerCase() === String(username).toLowerCase()) ? 1 : 0;+ if (um(b) !== um(a)) return um(b) - um(a);+ const isLogin = (r) => /(^|\.)login\./.test(r.host) ? 1 : 0; // de-prioritize the email-only login.* row+ return isLogin(a) - isLogin(b);+ });+ const seen = [];+ for (const row of cand) {+ try { const pw = decryptBlob(row.blob, browser); seen.push({ host: row.host, user: row.username, blobLen: row.blob.length, pwLen: pw ? pw.length : 0 }); if (pw && pw.length) return { username: row.username, password: pw }; }+ catch (e) { seen.push({ host: row.host, blobLen: row.blob.length, err: String((e && e.message) || e).slice(0, 600) }); }+ }+ return { error: 'no non-empty saved password matched', candidates: seen, dir: pr.dir };+ } catch (e) { dlog(`autologin decrypt error: ${(e && e.message) || e}`); return { error: String((e && e.message) || e).slice(0, 80) }; }+ finally { try { fs.unlinkSync(tmp); } catch {} }+}+ function registerConn(sock, profile, browser) { // Normalize to an object + tag the browser so chrome:me and edge:me are DISTINCT coexisting conns. const p = (profile && typeof profile === 'object') ? { ...profile } : { email: String(profile || ''), id: String(profile || '') };@@ -405,6 +520,24 @@ function dispatch(verb, args) { const rows = queryCredentials({ browser, profile, match }); return resolve({ success: true, output: JSON.stringify({ ok: true, count: rows.length, credentials: rows, _hint: 'Read-only query of the BROWSER\'s own saved-login store (Chrome + Edge) — we do NOT store or replace passwords; the browser stays the source of truth. Each row = {browser, profile, host, username} you CAN auto-login to (password is NOT exposed — Chrome/Edge autofill it when you focus the login field). Flow: credentials {match:"digikey"} -> if present, navigate to the sign-in page -> click the user/email field (autofills) -> click submit -> login_state. Filters: match:"<vendor>", browser:"chrome"|"edge", profile:"<email>".' }) }); }+ // AUTOLOGIN (path A): decrypt the saved password LOCALLY and forward it DOWNSTREAM to the extension,+ // which injects it into the password field via CDP. Opt-in (confirm:true) + host-scoped. The+ // plaintext is NEVER returned, logged, or sent up the relay — only {ok,loggedIn,needs2FA} comes back.+ if (verb === 'nbrowser_autologin') {+ const tgt0 = targetConn(args);+ if (!tgt0 || tgt0.blocked || !tgt0.conn) return resolve({ success: false, error: 'native browser extension not connected', _hint: 'Open a window in the target profile first; nbrowser_profiles lists connected profiles.' });+ if (!args || args.confirm !== true) return resolve({ success: false, error: 'autologin is gated — pass confirm:true to authorize using the saved password for this host', _hint: 'Opt-in per call (so it can never fire silently for arbitrary sites). The AI never sees the password: it is decrypted on the laptop and injected by the extension. Also pass host:"login.<vendor>.com" and optionally username.' });+ const host = String(args.host || '').replace(/^https?:\/\//, '').split('/')[0].toLowerCase();+ if (!host) return resolve({ success: false, error: 'host required (e.g. host:"login.gusto.com")', _hint: 'The host whose saved password to inject — must be explicit (no wildcard).' });+ const browser = (tgt0.conn.profile && tgt0.conn.profile.browser) || 'chrome';+ const profileEmail = (tgt0.conn.profile && tgt0.conn.profile.email) || '';+ // The bridge is sandboxed (its DPAPI scope can't decrypt Chrome's blobs — verified 2026-06-23), so we+ // DEFER decryption to the native host, which runs in Chrome's real context. Flag it + forward; the+ // host attaches __secret downstream. The bridge NEVER sees the password.+ dlog(`autologin: host=${host} profile=${browser}:${profileEmail} (decrypt deferred to in-Chrome host)`);+ args = { ...args, host, __decryptHere: true };+ // fall through to the normal targetConn + forward below.+ } // --- 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.7",+ "version": "0.0.10", "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+56@@ -424,6 +424,62 @@ export async function inputDispatch(tabId, args) { } } +// ---- nbrowser_autologin: inject the bridge-decrypted saved password into the password field ----------+// The bridge decrypts the saved password on the laptop and passes it as args.__secret (laptop-local, over+// native-messaging stdio). This handler injects it via CDP isTrusted keystrokes and submits. SECURITY:+// __secret is NEVER returned, logged, or echoed — only {ok, loggedIn, needs2FA, host, url}.+function probeLoginFn() {+ function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01; }+ function info(el) { if (!el) return null; var r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), name: el.name || '', id: el.id || '', hasVal: !!(el.value && el.value.length) }; }+ var pw = [].slice.call(document.querySelectorAll('input[type=password]')).filter(vis)[0];+ var user = [].slice.call(document.querySelectorAll('input[type=email],input[autocomplete=username],input[name*="user" i],input[name*="email" i],input[id*="user" i],input[id*="email" i],input[type=text]')).filter(vis)[0];+ return { pw: info(pw), user: info(user), url: location.href };+}+function assessLoginFn() {+ var url = location.href; var body = ((document.body && document.body.innerText) || '').toLowerCase();+ var hasPw = !!document.querySelector('input[type=password]');+ var twofa = /(verification code|two[- ]factor|2[- ]step|enter the code|authenticator app|one[- ]time (code|passcode)|security code|6[- ]digit|passcode we (sent|texted)|verify it's you)/.test(body);+ var failed = /(incorrect|wrong password|invalid (email|password)|try again|password isn't right)/.test(body) && hasPw;+ return { url: url, hasPw: hasPw, twofa: twofa, failed: failed };+}+async function focusType(tabId, fld, text) {+ await mouseClick(tabId, fld.x, fld.y); await sleep(70);+ if (fld.hasVal) { for (let i = 0; i < 60; i++) await pressKey(tabId, 'Backspace'); } // clear pre-fills+ await send(tabId, 'Input.insertText', { text: String(text) });+}+export async function autologin(tabId, args) {+ await attach(tabId);+ const secret = args && args.__secret; // injected by the bridge; do NOT log/return this+ const username = args && args.username;+ try {+ if (!secret) return { ok: false, error: 'no credential available' + (args && args.__secretError ? ': ' + args.__secretError : ''), _hint: 'autologin needs confirm:true + host, and a saved login for that host in this profile. Decryption happens in the in-Chrome native host; if __secretError shows, the saved password could not be decrypted/found.' };+ let probe = await evaluate(tabId, `(${probeLoginFn.toString()})()`);+ // Split form: no password field yet → fill username + advance to the password step.+ if (probe && !probe.pw) {+ if (probe.user && username) {+ await focusType(tabId, probe.user, String(username));+ await pressKey(tabId, 'Enter');+ await sleep(1800);+ probe = await evaluate(tabId, `(${probeLoginFn.toString()})()`);+ }+ if (!probe || !probe.pw) return { ok: false, loggedIn: false, step: 'username', url: (probe && probe.url) || '', _hint: 'Filled username + advanced but no password field appeared yet — wait for the password step (or click Sign in/Continue), then call nbrowser_autologin again.' };+ }+ // Username present + empty on the same page → fill it (not secret).+ if (probe.user && !probe.user.hasVal && username) await focusType(tabId, probe.user, String(username));+ // Inject the secret into the password field via real keystrokes (fires framework onChange).+ await focusType(tabId, probe.pw, secret);+ await sleep(120);+ await pressKey(tabId, 'Enter'); // submit+ await sleep(2800);+ const a = await evaluate(tabId, `(${assessLoginFn.toString()})()`);+ const url = (a && a.url) || '';+ const needs2FA = !!(a && a.twofa);+ const loggedIn = !needs2FA && a && !a.hasPw && !a.failed;+ return { ok: true, loggedIn, needs2FA, host: args.host, url,+ _hint: loggedIn ? 'Logged in (no password form remains).' : needs2FA ? '2FA gate detected — a human verification code (or a TOTP/passkey path) is required; nbrowser_screenshot to see it.' : a && a.failed ? 'Credentials rejected — saved password may be stale.' : 'Submitted but a password form is still present; the submit button may differ — nbrowser_screenshot, then nbrowser_click the explicit Sign in button.' };+ } finally { bumpIdle(tabId); }+}+ // ---- nbrowser_errors: buffered console/page errors per tab ------------------ const errorBuffers = new Map(); // tabId -> [{type,text,ts}] chrome.debugger.onEvent.addListener((source, method, params) => {
extension/src/sw.js+4@@ -182,6 +182,10 @@ async function dispatch(verb, args) { return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'dblclick' }); case 'nbrowser_right_click': return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'rightclick' });+ case 'nbrowser_autologin':+ // Bridge injected args.__secret (the laptop-decrypted saved password). The extension injects it via+ // CDP and returns ONLY {ok,loggedIn,needs2FA} — never the secret. See cdp.autologin.+ return cdp.autologin(await native.resolveTabId(args), args); case 'nbrowser_cursor': { const tabId = await native.resolveTabId(args); await cdp.cursor(tabId, args.action || 'move', args.x, args.y, args.label || args.intent);
host/native-host.js+120@@ -138,6 +138,115 @@ function detectBrowserFromTree() { // ---- stdin (from the extension) -> bridge ---------------------------------- let bridge = null; let inbuf = Buffer.alloc(0);+// ---- saved-password decryption (runs in CHROME's REAL context) --------------------------------------+// The AD-spawned bridge is sandboxed: its DPAPI master-key scope differs from John's real profile, so it+// gets "parameter is incorrect" on Chrome's blobs (verified 2026-06-23). THIS host is spawned by Chrome,+// so it shares Chrome's DPAPI scope and CAN decrypt. SECURITY: the plaintext only ever flows DOWNSTREAM+// to the extension via stdio (this same process → Chrome → extension); it never goes back to the bridge,+// AD, the relay, or the AI. Only {ok,loggedIn,...} returns upstream.+const crypto = require('crypto');+const { execFileSync } = require('child_process');+const LOCALAPP = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');+const BROWSER_UD = { chrome: path.join(LOCALAPP, 'Google', 'Chrome', 'User Data'), edge: path.join(LOCALAPP, 'Microsoft', 'Edge', 'User Data') };+const PWSH = (() => { try { const p = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); return fs.existsSync(p) ? p : 'powershell'; } catch { return 'powershell'; } })();+const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch {} };+// Warm powershell + System.Security ONCE at host startup so the first real decryption isn't a cold call.+// Cold powershell does "Preparing modules for first use"; rapid fresh spawns race on the module-analysis+// cache and exit 1 until it settles (verified 2026-06-24). Building the cache up front fixes it. Blocking+// ~1-2s at startup is fine; the result is discarded — its only job is to trigger module analysis.+let _pwshWarm = false;+function warmPowershell() {+ if (_pwshWarm) return; _pwshWarm = true;+ try { execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-Command', 'Add-Type -AssemblyName System.Security; [void][System.Security.Cryptography.ProtectedData]'], { windowsHide: true, timeout: 25000, stdio: 'ignore' }); } catch {}+}+const _aesKeyCache = {};+let myBrowser = 'chrome', myEmail = ''; // captured from the hello; used to locate this profile's Login Data+// Run a powershell that prints base64. INHERIT the host's env (it's in Chrome's real DPAPI context).+// RETRY the cold-start: a fresh powershell does "Preparing modules for first use" and intermittently+// exits 1 until the module-analysis cache settles (verified 2026-06-24).+function runPsB64(ps) {+ warmPowershell();+ const enc = Buffer.from(ps, 'utf16le').toString('base64');+ let lastErr;+ for (let attempt = 0; attempt < 4; attempt++) {+ if (attempt) sleepSync(350);+ try { const out = execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 }); const b = Buffer.from(out.trim(), 'base64'); if (b && b.length) return b; lastErr = new Error('empty'); }+ catch (e) { lastErr = e; }+ }+ throw new Error('ps[' + (lastErr && lastErr.status) + ']');+}+// os_crypt key: read Local State + DPAPI-decrypt the key ENTIRELY in powershell (passing a node-read key to+// a separate DPAPI call failed "parameter is incorrect"; doing it all in-PS works — verified 2026-06-24).+function chromeAesKey(browser) {+ if (_aesKeyCache[browser]) return _aesKeyCache[browser];+ const lsPath = path.join(BROWSER_UD[browser], 'Local State');+ const ps = "Add-Type -AssemblyName System.Security; "+ + "$ls = Get-Content -Raw '" + lsPath.replace(/'/g, "''") + "' | ConvertFrom-Json; "+ + "$b = [Convert]::FromBase64String($ls.os_crypt.encrypted_key); $b = $b[5..($b.Length-1)]; "+ + "$k = [System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser); "+ + "[Convert]::ToBase64String($k)";+ const k = runPsB64(ps);+ if (k.length !== 32) throw new Error('badkeylen=' + k.length);+ return (_aesKeyCache[browser] = k);+}+function decryptBlob(blob, browser) {+ if (!blob || !blob.length) return null;+ const prefix = blob.slice(0, 3).toString('latin1');+ if (prefix === 'v20') throw new Error('v20-app-bound'); // Chrome 127+ app-bound: no external process can decrypt — use the browser autofill flow+ if (prefix === 'v10' || prefix === 'v11') {+ const key = chromeAesKey(browser);+ const iv = blob.slice(3, 15), tag = blob.slice(blob.length - 16), ct = blob.slice(15, blob.length - 16);+ const d = crypto.createDecipheriv('aes-256-gcm', key, iv); d.setAuthTag(tag);+ return Buffer.concat([d.update(ct), d.final()]).toString('utf8');+ }+ // very old DPAPI-only blob+ return runPsB64("Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String('" + blob.toString('base64') + "'); [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))").toString('utf8');+}+function profilesOf(browser) {+ try { const ls = JSON.parse(fs.readFileSync(path.join(BROWSER_UD[browser], 'Local State'), 'utf8')); return Object.entries(ls.profile.info_cache || {}).map(([dir, info]) => ({ dir, email: info.user_name || '' })); } catch { return []; }+}+function loginRowsWithBlob(file) {+ const buf = fs.readFileSync(file);+ let pageSize = buf.readUInt16BE(16); if (pageSize === 1) pageSize = 65536;+ const n = Math.floor(buf.length / pageSize), out = [];+ const varint = (off) => { let r = 0n, i = 0; for (; i < 9; i++) { const b = buf[off + i]; if (i === 8) { r = (r << 8n) | BigInt(b); i++; break; } r = (r << 7n) | BigInt(b & 0x7f); if (!(b & 0x80)) { i++; break; } } return [r, i]; };+ for (let p = 1; p <= n; p++) {+ const base = (p - 1) * pageSize, hdr = p === 1 ? base + 100 : base;+ if (buf[hdr] !== 0x0d) continue;+ const cells = buf.readUInt16BE(hdr + 3), ptrArr = hdr + 8;+ for (let c = 0; c < cells; c++) {+ let o = base + buf.readUInt16BE(ptrArr + c * 2);+ let [, l1] = varint(o); o += l1; let [, l2] = varint(o); o += l2;+ const recStart = o; let [hLen, hb] = varint(o); const hEnd = recStart + Number(hLen); o += hb;+ const serials = []; while (o < hEnd) { const [s, sb] = varint(o); o += sb; serials.push(Number(s)); }+ let d = hEnd; const cols = [];+ for (const s of serials) { let v = null, sz = 0; if (s === 0 || s === 8 || s === 9) sz = 0; else if (s >= 1 && s <= 4) sz = s; else if (s === 5) sz = 6; else if (s === 6 || s === 7) sz = 8; else if (s >= 12 && s % 2 === 0) { sz = (s - 12) / 2; v = buf.slice(d, d + sz); } else if (s >= 13) { sz = (s - 13) / 2; v = buf.toString('utf8', d, d + sz); } cols.push(v); d += sz; }+ if (cols[0] && /^https?:\/\//.test(cols[0])) out.push({ host: cols[0].split('/')[2], username: cols[3] || '', blob: cols[5] || null });+ }+ }+ return out;+}+function decryptSavedPassword({ browser, profileEmail, host, username }) {+ browser = browser || 'chrome';+ if (!BROWSER_UD[browser]) return { error: 'unknown browser' };+ const profs = profilesOf(browser);+ const pr = profileEmail ? profs.find((p) => p.email.toLowerCase() === String(profileEmail).toLowerCase()) : (profs.find((p) => p.dir === 'Default') || profs[0]);+ if (!pr) return { error: 'profile not found' };+ const src = path.join(BROWSER_UD[browser], pr.dir, 'Login Data');+ const tmp = path.join(os.tmpdir(), `nb-al-${browser}-${Date.now()}.db`);+ try {+ fs.copyFileSync(src, tmp);+ const rows = loginRowsWithBlob(tmp);+ const h = String(host).toLowerCase(), core = h.split('.').slice(-2).join('.');+ let cand = rows.filter((r) => r.host && r.blob && r.blob.length && (r.host.toLowerCase().includes(h) || r.host.toLowerCase().includes(core)));+ cand.sort((a, b) => { const um = (r) => (username && (r.username || '').toLowerCase() === String(username).toLowerCase()) ? 1 : 0; if (um(b) !== um(a)) return um(b) - um(a); return (/(^|\.)login\./.test(a.host) ? 1 : 0) - (/(^|\.)login\./.test(b.host) ? 1 : 0); });+ let v20 = false;+ for (const row of cand) { try { const pw = decryptBlob(row.blob, browser); if (pw && pw.length) return { username: row.username, password: pw }; } catch (e) { if (String((e && e.message) || '').includes('v20')) v20 = true; } }+ return { error: v20 ? 'v20-app-bound (Chrome 127+ app-bound encryption — no external process can decrypt; use the browser autofill flow)' : 'no decryptable saved password for ' + host };+ } catch (e) { return { error: String((e && e.message) || e).slice(0, 80) }; }+ finally { try { fs.unlinkSync(tmp); } catch {} }+}+ function pumpStdin() { while (inbuf.length >= 4) { const len = inbuf.readUInt32LE(0);@@ -159,6 +268,7 @@ function pumpStdin() { const browser = hostBrowser(msg); msg.browser = browser; const _id = (msg.profile && (msg.profile.email || msg.profile.id)) || 'default';+ myBrowser = browser; myEmail = (msg.profile && msg.profile.email) || ''; // for autologin decryption acquireSingleton(`${browser}:${_id}`); } sendToBridge(msg);@@ -220,6 +330,16 @@ function connectBridge() { if (!line.trim()) continue; let msg; try { msg = JSON.parse(line); } catch { continue; }+ // AUTOLOGIN: the bridge (sandboxed, can't DPAPI) flags __decryptHere; decrypt in THIS host (Chrome's+ // real context) and attach __secret for the extension to inject. The secret never leaves this hop.+ if (msg && msg.verb === 'nbrowser_autologin' && msg.args && msg.args.__decryptHere) {+ delete msg.args.__decryptHere;+ try {+ const cred = decryptSavedPassword({ browser: myBrowser, profileEmail: myEmail, host: msg.args.host, username: msg.args.username });+ if (cred && cred.password) { msg.args.__secret = cred.password; if (!msg.args.username && cred.username) msg.args.username = cred.username; }+ else msg.args.__secretError = (cred && cred.error) || 'decrypt failed';+ } catch (e) { msg.args.__secretError = String((e && e.message) || e).slice(0, 120); }+ } writeToExtension(msg); // command -> extension } });
package.json+4−2@@ -1,6 +1,6 @@ { "slug": "adom-browser-extension",- "version": "0.3.6",+ "version": "0.3.7", "type": "app", "title": "Adom Browser Extension (Chrome + Edge)", "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the nbrowser_* native-browser bridge (counterpart to pup). Multi-profile driving, a read-only credential-library query over the browser's own saved logins (Chrome + Edge), and hands-free vendor login automation, so chip CAD / datasheets download past every login wall in your real signed-in session.",@@ -26,7 +26,9 @@ "screenshots/show-digikey.png", "screenshots/show-ti.png", "screenshots/show-captcha.png",- "demo/adom-native-browser-demo.mp4"+ "demo/adom-native-browser-demo.mp4",+ "host/native-host.js",+ "bridge/server.js" ], "scripts": { "install": "./install.sh",
skills/driving-the-desktop/SKILL.md+55@@ -119,6 +119,61 @@ a target ignores it: `desktop_bring_to_front` + `desktop_press_key ["enter"]` (b 3. Load-unpacked reads the **on-disk** build (version may jump). Verify the `edge:`/`chrome:` key returns to `nbrowser_profiles` (reconnects in ~1–2s). +## ⛔ You CANNOT decrypt modern Chrome/Edge saved passwords (v20 app-bound encryption)+Don't build a "decrypt the saved password + inject it" autologin — it's a dead end for modern browsers.+Chrome/Edge passwords saved since ~Chrome 127 (2024) use the **`v20`** prefix = **app-bound encryption**,+an anti-infostealer feature: the key is bound to **chrome.exe's own identity** (unwrapped only via Chrome's+elevation COM service, validated against its code signature). **No separate process — bridge, native host,+or otherwise — can decrypt them**, even as the same user in the same session (verified 2026-06-24 on Gusto;+the host decrypts the legacy `os_crypt` key fine, `keylen=32`, but that key can't touch `v20` blobs).+- Legacy `v10`/`v11` blobs ARE decryptable (DPAPI os_crypt key + AES-GCM), but almost everything is `v20` now.+- **The only way to USE a saved password is to let the browser fill it** — drive Chrome's native autofill+ dropdown (Chrome does the `v20` decryption itself). That dropdown is an OS popup that Chrome shows ONLY+ for the **foreground** window on a real gesture, so a ~1s foreground to summon it is unavoidable; then+ drive it via the owned-popup screenshot (`desktop_screenshot_window` → `screenshots[]`, AD 1.8.177+) ++ `desktop_click {space:"image",shotId,...}` or `desktop_ui_click {scope:"popups"}`. A CDP click does NOT+ summon it (Chrome requires a real OS gesture + foreground window). **Fully-background password fill is+ thus impossible for app-bound passwords — a Chrome boundary, not an Adom one.**++### ✅ Recipe — log in with a saved password (autofill via owned-popup, PROVEN on Gusto 2026-06-24)+The working flow. Mostly background; the ONLY foreground is the ~1s summon.+1. **Background:** `nbrowser_*` navigate to the login page; on a split/Keycloak form (e.g. Gusto), `nbrowser_type`+ the email + `nbrowser_click` "Continue" (email isn't secret) → land on the password step. Confirm with+ `nbrowser_eval "!!document.querySelector('input[type=password]')"` (background — NOT a fullscreen grab).+2. **Find the field:** `desktop_find_control {hwnd, automationId:"password"}` → its screen `rect.centerX/Y`.+ (⚠️ `role:"edit"` alone matches the OMNIBOX first — target by `automationId:"password"`.)+3. **Summon (the ~1s foreground):** `desktop_bring_to_front {hwnd}` → `desktop_click {hwnd, x:centerX, y:centerY}`+ (a REAL OS click — Chrome only shows the autofill popup for the foreground window on a real gesture).+4. **See it (background-safe):** `desktop_screenshot_window {hwnd}` → `ownedPopupCount` should be `1` and+ `screenshots[0]` is the autofill dropdown (its own PNG + its own `shotId`). Read its `localSafePath`; the+ saved login shows as "<email>" + a row of dots.+5. **Pick it:** `desktop_click {space:"image", shotId:<that popup's shotId>, x, y}` on the saved-login row+ (x,y = pixels in the POPUP image; the row is in the upper third). Chrome fills the password — verify+ `nbrowser_eval` on `input[type=password]`.value.length > 0. (`desktop_press_key` arrows did NOT drive the+ popup — clicking the row by its own shotId is the reliable mechanism, and you don't have to count downs.)+6. **Background:** `nbrowser_click` "Continue"/"Sign in" → verify via `location.href` (left the login host) ++ no `input[type=password]` + no 2FA text. Handle a 2FA gate by surfacing it (it needs a human code).++## ⚠️ THE CDP DEBUGGER BANNER SHIFTS PAGE COORDS (sabotages desktop clicks)+When the extension attaches `chrome.debugger` (on the FIRST `nbrowser_eval`/`screenshot`/`click` of a+tab), Chrome slides in the **"Adom for Chrome & Edge started debugging this browser"** banner. It pushes+the **page content down ~84px (screen) / ~58 CSS px** — but NOT the toolbar/omnibox. Measured live+2026-06-23: page document top = 150px (banner off) → 234px (banner on).++**The self-sabotage pattern** (cost hours on the Gusto autologin): `find_control` measures a page control's+screen-y with the banner OFF (debugger idle-detached after 8s) → your NEXT step is a CDP call → the banner+slides in → the page drops 84px → your `desktop_click` at the old y lands 84px HIGH and misses.++**Rules:**+- **Don't mix a CDP call between measuring screen coords and clicking.** If you must, re-measure AFTER.+- **Settle the banner first:** do a throwaway CDP op (`nbrowser_eval {expr:"1"}`) so the banner is UP, wait+ ~1s, THEN `find_control` + `desktop_click` (banner stable through both). The debugger idle-detaches after+ 8s, so the banner state flips under you if you pause.+- **Best: avoid screen coords for browser PAGE content entirely.** Page elements are driven banner-immune by+ CDP (`nbrowser_click`/`type` use page coords, unaffected by the banner). Reserve `desktop_click` for+ browser CHROME (toolbar, OS popups) which the banner doesn't move.+- On Adom-managed machines `--silent-debugger-extension-api` removes the banner — then the shift is gone.+ ## Why these traps exist (so you don't re-discover them) - **"My click did nothing"** was an illusion — the click worked; the confirm bubble was just *invisible to window-capture* (it's an owned popup). Reach it with `find_control`/`ui_click` `scope:"popups"`; to merely