app
Pup - Puppeteer Bridge
Public Made by Adomby adom
pup is the AI's own browser: a real, full Chrome on the user's desktop that the AI fully controls (a sandbox, not the user's signed-in browser). Rides Bridge; pup_* verbs open windows and tabs, navigate, screenshot, and eval JS.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
// credential_vault.js — HTTP Basic Auth credential vault for pup sessions.
//
// Why this exists
// ───────────────
// When a pup tab navigates to a host that issues an HTTP Basic Auth challenge
// (nxp.componentsearchengine.com, mouser.componentsearchengine.com, several
// vendor design portals), Chrome shows a NATIVE auth dialog. That dialog
// lives outside the page's DOM and CANNOT be driven via browser_eval / DOM
// manipulation. Without this vault, the user has to manually type the same
// email + password every single time. Users have called this "heinous."
//
// Puppeteer exposes `page.authenticate({username, password})`, which
// intercepts basic-auth challenges BEFORE the dialog shows and supplies
// stored creds programmatically. This module owns the storage + lookup
// half of that flow.
//
// Storage
// ───────
// Two parts:
// 1. The PASSWORD lives in the OS keychain via `keytar` (Windows DPAPI /
// macOS Keychain / Linux libsecret). Service name `adom-desktop-pup`.
// Account name is the host pattern. The OS encrypts at rest using the
// logged-in-user's session key, so even disk theft can't extract creds.
// 2. The INDEX (host pattern + username + timestamps, NEVER passwords)
// lives at `plugins/puppeteer/credentials.index.json` so we can list
// entries fast without poking keytar 50 times.
//
// Host matching
// ─────────────
// Patterns support a simple glob:
// "*.componentsearchengine.com" → matches any subdomain (suffix match)
// "componentsearchengine.com" → exact match only
// "secure.example.com" → exact match only
// Best-match wins (longest non-glob suffix beats shorter glob).
// For a navigation to https://nxp.componentsearchengine.com/preview.php,
// host = "nxp.componentsearchengine.com" — would match "*.componentsearchengine.com".
//
// What this module does NOT do
// ────────────────────────────
// - Ship its own crypto. Never. OS keychain only.
// - Echo passwords back via list/get APIs. Index returns username + host only.
// - Persist anything to the wiki / cloud. All local, all per-machine.
const fs = require('fs');
const path = require('path');
const KEYTAR_SERVICE = 'adom-desktop-pup';
const INDEX_PATH = path.join(__dirname, 'credentials.index.json');
let keytar = null;
let keytarLoadError = null;
try {
// keytar is a native module; load lazily so the bridge starts even if it's
// missing. The first credential_set call will surface a clear error.
keytar = require('keytar');
} catch (e) {
keytarLoadError = e.message;
console.log(`[credential_vault] keytar load failed (will be required for credential_set): ${e.message}`);
}
// ── Index helpers ──────────────────────────────────────────────────────
function readIndex() {
try {
const raw = fs.readFileSync(INDEX_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (Array.isArray(parsed.entries)) return parsed.entries;
} catch {}
return [];
}
function writeIndex(entries) {
const data = { version: 1, entries };
fs.writeFileSync(INDEX_PATH, JSON.stringify(data, null, 2));
}
// ── Glob matching ──────────────────────────────────────────────────────
function hostMatchesPattern(host, pattern) {
if (!host || !pattern) return false;
const h = host.toLowerCase();
const p = pattern.toLowerCase();
if (p === h) return true;
if (p.startsWith('*.')) {
const suffix = p.slice(1); // ".componentsearchengine.com"
if (h === suffix.slice(1)) return true; // bare apex is a valid match
if (h.endsWith(suffix)) return true;
}
return false;
}
// "Best match" = longest non-glob suffix wins. Exact match beats glob match
// of the same length. So "nxp.componentsearchengine.com" entry would win
// over "*.componentsearchengine.com" entry for that exact host.
function scorePattern(pattern, host) {
if (!hostMatchesPattern(host, pattern)) return -1;
const exact = !pattern.startsWith('*.');
return (exact ? 1000 : 0) + pattern.length;
}
// ── Public API ─────────────────────────────────────────────────────────
async function setCredential({ host, username, password }) {
if (!host || !username || !password) {
throw new Error('host, username, and password are all required');
}
if (!keytar) {
throw new Error(
`keytar is not installed in plugins/puppeteer/. Install: cd plugins/puppeteer && npm install keytar. Original load error: ${keytarLoadError}`
);
}
await keytar.setPassword(KEYTAR_SERVICE, host, password);
const entries = readIndex();
const idx = entries.findIndex(e => e.host === host);
const now = new Date().toISOString();
if (idx >= 0) {
entries[idx] = { ...entries[idx], username, updatedAt: now };
} else {
entries.push({ host, username, addedAt: now, updatedAt: now });
}
writeIndex(entries);
return { ok: true, host, username };
}
// The keytar account key that holds THIS entry's password. Imported (identity-tagged)
// entries use a per-entry composite so BOTH sides of a host+user conflict from different
// source accounts can coexist; legacy single-host entries fall back to the host key.
function keytarAccountFor(entry) { return entry.keytarAccount || entry.host; }
// v1.9.325: a host can now have MULTIPLE tagged credentials (personal + work + media, from
// the browser import). Pick the best: best host-pattern match first, then the preferred
// identity if the caller gave one (opts.preferAccount — e.g. the pup window's owner), then
// most-recently-used. Returns the chosen entry's sourceAccount so the caller knows the persona.
async function getCredentialForUrl(url, opts = {}) {
if (!keytar) return null;
let host;
try {
host = new URL(url).hostname;
} catch {
return null;
}
const entries = readIndex();
const matches = [];
for (const e of entries) {
const s = scorePattern(e.host, host);
if (s >= 0) matches.push({ e, s });
}
if (!matches.length) return null;
const prefer = opts.preferAccount ? String(opts.preferAccount).toLowerCase() : null;
matches.sort((a, b) => {
if (b.s !== a.s) return b.s - a.s; // best host pattern
if (prefer) {
const ap = (a.e.sourceAccount || '').toLowerCase() === prefer ? 1 : 0;
const bp = (b.e.sourceAccount || '').toLowerCase() === prefer ? 1 : 0;
if (bp !== ap) return bp - ap; // preferred identity
}
const at = a.e.lastUsed || a.e.updatedAt || '';
const bt = b.e.lastUsed || b.e.updatedAt || '';
return String(bt).localeCompare(String(at)); // most-recently-used
});
const best = matches[0].e;
const password = await keytar.getPassword(KEYTAR_SERVICE, keytarAccountFor(best));
if (!password) return null;
return { host: best.host, username: best.username, password, sourceAccount: best.sourceAccount || null };
}
// ── Bulk import from the user's REAL browsers (the adom-you seed) ────────────
// Rows come from AD's desktop_decrypt_browser_credentials (the SYSTEM-elevated ABE decrypt,
// AD >= 1.9.220). Each row: {origin, username, password, sourceBrowser, sourceProfile,
// sourceAccount, lastUsed}. DECISION (locked with John): keep BOTH sides of a (host,username)
// conflict when they come from DIFFERENT source accounts, so the agent can act as the RIGHT
// persona per site; same (host,username,sourceAccount) is an update (newest wins). Passwords go
// to keytar under a per-entry composite account; the index NEVER holds a password. The caller
// hands us plaintext ONCE and must drop it after — we return COUNTS ONLY, never plaintext.
function hostFromOrigin(origin) {
if (!origin) return null;
try { return new URL(origin).hostname || String(origin); } catch { return String(origin); }
}
function importAccountKey(host, username, sourceAccount) {
return `imp ${host} ${username || ''} ${sourceAccount || ''}`;
}
async function mergeImportedCredentials(rows) {
if (!keytar) throw new Error(`keytar is not installed in the bridge dir. Original load error: ${keytarLoadError}`);
if (!Array.isArray(rows)) return { added: 0, updated: 0, skipped: 0, total: 0 };
const entries = readIndex();
const now = new Date().toISOString();
let added = 0, updated = 0, skipped = 0;
for (const r of rows) {
try {
const host = hostFromOrigin(r && (r.origin || r.host));
const username = (r && r.username) || '';
const password = r && r.password;
if (!host || !password) { skipped++; continue; }
const acct = importAccountKey(host, username, r.sourceAccount);
await keytar.setPassword(KEYTAR_SERVICE, acct, password);
const idx = entries.findIndex(e => keytarAccountFor(e) === acct);
const meta = {
host, username, keytarAccount: acct,
sourceAccount: r.sourceAccount || null,
sourceBrowser: r.sourceBrowser || null,
sourceProfile: r.sourceProfile || null,
imported: true,
lastUsed: r.lastUsed || null,
};
if (idx >= 0) { entries[idx] = { ...entries[idx], ...meta, updatedAt: now }; updated++; }
else { entries.push({ ...meta, addedAt: now, updatedAt: now }); added++; }
} catch (e) { skipped++; }
}
writeIndex(entries);
return { added, updated, skipped, total: rows.length };
}
function listCredentials() {
// Index already excludes passwords by construction. Now also surfaces the identity tags
// from an import so the dashboard can show WHICH persona each saved login belongs to.
return readIndex().map(e => ({
host: e.host,
username: e.username,
sourceAccount: e.sourceAccount || null,
sourceBrowser: e.sourceBrowser || null,
sourceProfile: e.sourceProfile || null,
imported: !!e.imported,
addedAt: e.addedAt,
updatedAt: e.updatedAt,
}));
}
async function deleteCredential(host) {
if (!host) throw new Error('host required');
const entries = readIndex();
const toRemove = entries.filter(e => e.host === host);
let removed = false;
if (keytar) {
try {
if (toRemove.length) {
for (const e of toRemove) {
const r = await keytar.deletePassword(KEYTAR_SERVICE, keytarAccountFor(e));
removed = removed || r;
}
} else {
removed = await keytar.deletePassword(KEYTAR_SERVICE, host); // legacy host-keyed
}
} catch (e) {
console.log(`[credential_vault] keytar deletePassword error: ${e.message}`);
}
}
const next = entries.filter(e => e.host !== host);
const removedFromIndex = next.length !== entries.length;
if (removedFromIndex) writeIndex(next);
return { ok: true, host, removedFromKeychain: removed, removedFromIndex };
}
// Apply stored creds to a page given the target URL it's about to load.
// Idempotent: if no match, no-op. Returns the matched host pattern (or null).
async function applyCredentialsToPage(page, url) {
const creds = await getCredentialForUrl(url);
if (!creds) return null;
try {
await page.authenticate({ username: creds.username, password: creds.password });
return creds.host;
} catch (e) {
console.log(`[credential_vault] page.authenticate failed for ${creds.host}: ${e.message}`);
return null;
}
}
// ── HTML login FORM autofill (v1.9.124) ─────────────────────────────────
// applyCredentialsToPage above handles the NATIVE Basic-Auth dialog. This
// handles the other, more common case John asked about: an HTML <form> login,
// where Chrome's own password manager would pop the floaty autofill dropdown —
// a NATIVE popup pup can't click without foregrounding the window. Because pup
// owns the creds, we skip Chrome's UI entirely: find the fields and fill them
// over CDP, in the background, no dropdown, no focus steal. React/Vue-safe
// (native value setter + bubbled input/change events, the standard technique).
//
// Runs entirely in the page; returns { ok, filledUsername, filledPassword,
// submitted, usernameValue } and NEVER returns the password.
async function fillLoginForm(page, { username, password, submit = false } = {}) {
if (!password) return { ok: false, reason: 'no_password_supplied' };
const result = await page.evaluate((USERNAME, PASSWORD, SUBMIT) => {
const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0;
const pwd = [...document.querySelectorAll('input[type="password"]')].find(isVisible);
if (!pwd) return { ok: false, reason: 'no_password_field' };
// Username: prefer autocomplete=username, then email, else the last visible
// text-ish input that appears BEFORE the password field in DOM order.
const allInputs = [...document.querySelectorAll('input')];
const pi = allInputs.indexOf(pwd);
let user = [...document.querySelectorAll('input[autocomplete="username"], input[type="email"]')].find(isVisible);
if (!user) {
const cands = [...document.querySelectorAll('input[type="text"], input[type="tel"], input:not([type])')].filter(isVisible);
user = [...cands].reverse().find((c) => allInputs.indexOf(c) < pi) || cands[0] || null;
}
const setVal = (el, val) => {
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
try { el.focus(); } catch (e) {}
setter.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
};
if (user && USERNAME) setVal(user, USERNAME);
setVal(pwd, PASSWORD);
let submitted = false;
if (SUBMIT) {
const form = pwd.form;
const btn = form && [...form.querySelectorAll('button[type="submit"], input[type="submit"], button')].find(isVisible);
if (btn) { btn.click(); submitted = true; }
else if (form) { (form.requestSubmit ? form.requestSubmit() : form.submit()); submitted = true; }
}
return { ok: true, filledUsername: !!(user && USERNAME), filledPassword: true, submitted, usernameValue: user ? user.value : null };
}, username || '', password, !!submit);
return result;
}
// Pull a stored cred for the page's URL and fill its login form in the
// background. No-op (returns null) if no vault match or no login form.
async function autofillFormFromVault(page, url, { submit = false } = {}) {
const creds = await getCredentialForUrl(url);
if (!creds) return null;
try {
const r = await fillLoginForm(page, { username: creds.username, password: creds.password, submit });
if (r && r.ok) return { host: creds.host, username: creds.username, submitted: !!r.submitted };
return null;
} catch (e) {
console.log(`[credential_vault] autofillFormFromVault failed for ${creds.host}: ${e.message}`);
return null;
}
}
module.exports = {
setCredential,
mergeImportedCredentials,
getCredentialForUrl,
listCredentials,
deleteCredential,
applyCredentialsToPage,
fillLoginForm,
autofillFormFromVault,
// Exposed for tests + diagnostics
hostMatchesPattern,
scorePattern,
};