app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
master
John Lauer
v2.0.382 (insiders): Insiders tier, corrected to the fleet convention (John: a separate private page is wiki-bloat): th…
e7b1214
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
// card_vault.js — payment-card vault for pup (issue #84).
//
// Why this exists
// ───────────────
// pup's credential vault stores LOGINS; there was no equivalent for payment cards, so an AI
// could drive an entire checkout and then stall at the Stripe step (the Venture Dallas case,
// 2026-09-02: pass selected, form filled, no card). A normal Chrome profile keeps cards in its
// autofill store; pup's durable profile had nothing. This module is the storage half; the verbs
// in server.js own the CONSENT gate (a per-fill desktop toast naming merchant + amount + last4).
//
// Storage (mirrors credential_vault.js)
// ─────────────────────────────────────
// 1. PAN + CVV live in the OS keychain via keytar. Service `adom-pup-cards`,
// account = the card's nickname. OS-encrypted at rest; never our own crypto.
// 2. The INDEX (nickname, brand, last4, expiry, cardholder name, timestamps — NEVER the PAN
// or CVV) lives at ~/.adom/pup-cards.index.json for fast listing.
//
// Hard rules
// - list/verb surfaces return nickname/brand/last4/expiry only. No verb returns a PAN or CVV.
// - The full card is read back ONLY by the in-process fill path (getCard), which types it
// into a page pup is driving and returns booleans.
// - Every audit line carries the last4 at most.
const fs = require('fs');
const path = require('path');
const os = require('os');
const KEYTAR_SERVICE = 'adom-pup-cards';
const INDEX_PATH = path.join(os.homedir(), '.adom', 'pup-cards.index.json');
let keytar = null;
let keytarLoadError = null;
try { keytar = require('keytar'); } catch (e) { keytarLoadError = e.message; }
function readIndex() {
try { return JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8')); } catch (e) { return { cards: [] }; }
}
function writeIndex(ix) {
try { fs.mkdirSync(path.dirname(INDEX_PATH), { recursive: true }); fs.writeFileSync(INDEX_PATH, JSON.stringify(ix, null, 2)); } catch (e) {}
}
function normalizePan(s) {
const pan = String(s || '').replace(/[\s-]/g, '');
if (!/^\d{12,19}$/.test(pan)) return null;
// Luhn
let sum = 0, dbl = false;
for (let i = pan.length - 1; i >= 0; i--) {
let d = pan.charCodeAt(i) - 48;
if (dbl) { d *= 2; if (d > 9) d -= 9; }
sum += d; dbl = !dbl;
}
return (sum % 10 === 0) ? pan : null;
}
function brandOf(pan) {
if (/^4/.test(pan)) return 'Visa';
if (/^(5[1-5]|22[2-9]|2[3-6]|27[01]|2720)/.test(pan)) return 'Mastercard';
if (/^3[47]/.test(pan)) return 'Amex';
if (/^(6011|65|64[4-9])/.test(pan)) return 'Discover';
return 'Card';
}
function normalizeExpiry(s) {
const m = String(s || '').trim().match(/^(\d{1,2})\s*[\/\-]?\s*(\d{2}|\d{4})$/);
if (!m) return null;
const mo = Number(m[1]);
if (mo < 1 || mo > 12) return null;
const yr = m[2].length === 4 ? m[2].slice(2) : m[2];
return String(mo).padStart(2, '0') + '/' + yr;
}
async function setCard({ nickname, number, expiry, cvv, name }) {
if (!keytar) throw new Error('keytar unavailable: ' + (keytarLoadError || 'not loaded'));
const nick = String(nickname || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-');
if (!nick) throw new Error('nickname required (e.g. "adom-amex")');
const pan = normalizePan(number);
if (!pan) throw new Error('card number failed validation (digits + Luhn); check for typos');
const exp = normalizeExpiry(expiry);
if (!exp) throw new Error('expiry must be MM/YY or MM/YYYY');
const csc = String(cvv || '').trim();
if (!/^\d{3,4}$/.test(csc)) throw new Error('cvv must be 3 or 4 digits');
await keytar.setPassword(KEYTAR_SERVICE, nick, JSON.stringify({ pan, cvv: csc }));
const ix = readIndex();
ix.cards = (ix.cards || []).filter(c => c.nickname !== nick);
ix.cards.push({ nickname: nick, brand: brandOf(pan), last4: pan.slice(-4), expiry: exp, name: String(name || '').trim() || null, updatedAt: new Date().toISOString() });
writeIndex(ix);
return { nickname: nick, brand: brandOf(pan), last4: pan.slice(-4), expiry: exp };
}
function listCards() {
return (readIndex().cards || []).map(c => ({ nickname: c.nickname, brand: c.brand, last4: c.last4, expiry: c.expiry, name: c.name || null }));
}
async function deleteCard(nickname) {
const nick = String(nickname || '').trim().toLowerCase();
const ix = readIndex();
const had = (ix.cards || []).some(c => c.nickname === nick);
ix.cards = (ix.cards || []).filter(c => c.nickname !== nick);
writeIndex(ix);
if (keytar) { try { await keytar.deletePassword(KEYTAR_SERVICE, nick); } catch (e) {} }
return { removed: had };
}
// INTERNAL ONLY — feeds the fill path; no verb exposes this.
async function getCard(nickname) {
if (!keytar) throw new Error('keytar unavailable: ' + (keytarLoadError || 'not loaded'));
const nick = String(nickname || '').trim().toLowerCase();
const meta = (readIndex().cards || []).find(c => c.nickname === nick);
if (!meta) return null;
const raw = await keytar.getPassword(KEYTAR_SERVICE, nick);
if (!raw) return null;
const sec = JSON.parse(raw);
return { ...meta, pan: sec.pan, cvv: sec.cvv };
}
// ── The fill ───────────────────────────────────────────────────────────
// Walks EVERY frame (checkout card fields usually live in payment-processor iframes — Stripe
// mounts one iframe per field) and types via puppeteer's CDP keyboard, so the events are
// trusted and land inside cross-origin frames where DOM injection cannot.
const FIELD_SELECTORS = {
number: ['input[autocomplete="cc-number"]', 'input[name="cardnumber"]', 'input[name="cardNumber"]', 'input[id*="cardnumber" i]', 'input[name*="card-number" i]', 'input[id*="card-number" i]', 'input[placeholder*="card number" i]', 'input[name="number"][inputmode="numeric"]'],
expiry: ['input[autocomplete="cc-exp"]', 'input[name="exp-date"]', 'input[name="expiry"]', 'input[id*="expir" i]', 'input[placeholder*="MM / YY" i]', 'input[placeholder*="MM/YY" i]'],
cvv: ['input[autocomplete="cc-csc"]', 'input[name="cvc"]', 'input[name="cvv"]', 'input[id*="cvc" i]', 'input[id*="cvv" i]', 'input[name*="security" i]', 'input[placeholder*="CVC" i]', 'input[placeholder*="CVV" i]'],
name: ['input[autocomplete="cc-name"]', 'input[name="ccname"]', 'input[id*="cardholder" i]', 'input[name*="cardholder" i]', 'input[placeholder*="name on card" i]'],
};
async function fillCardIntoPage(page, card) {
const filled = { number: false, expiry: false, cvv: false, name: false };
const values = { number: card.pan, expiry: card.expiry, cvv: card.cvv, name: card.name || '' };
const frames = page.frames();
for (const [field, selectors] of Object.entries(FIELD_SELECTORS)) {
if (!values[field]) continue;
for (const fr of frames) {
if (filled[field]) break;
for (const sel of selectors) {
if (filled[field]) break;
let el = null;
try { el = await fr.$(sel); } catch (e) { el = null; }
if (!el) continue;
try {
const visible = await el.evaluate(n => { const r = n.getBoundingClientRect(); return r.width > 2 && r.height > 2 && !n.disabled && !n.readOnly; }).catch(() => false);
if (!visible) continue;
await el.click({ clickCount: 3 }); // focus + select any existing value
await el.type(String(values[field]), { delay: 25 }); // trusted CDP keystrokes (processor JS needs them)
filled[field] = true;
} catch (e) { /* next selector/frame */ }
try { await el.dispose(); } catch (e) {}
}
}
}
// Split expiry fields (cc-exp-month / cc-exp-year) when the combined field wasn't found.
if (!filled.expiry) {
const [mo, yr] = card.expiry.split('/');
for (const fr of frames) {
if (filled.expiry) break;
try {
const m = await fr.$('input[autocomplete="cc-exp-month"], select[autocomplete="cc-exp-month"], select[name*="exp" i][name*="month" i]');
const y = await fr.$('input[autocomplete="cc-exp-year"], select[autocomplete="cc-exp-year"], select[name*="exp" i][name*="year" i]');
if (m && y) {
const setOne = async (el, val, longVal) => {
const tag = await el.evaluate(n => n.tagName).catch(() => 'INPUT');
if (tag === 'SELECT') { await el.select(val).catch(() => el.select(longVal)); }
else { await el.click({ clickCount: 3 }); await el.type(val, { delay: 25 }); }
};
await setOne(m, mo, mo);
await setOne(y, yr, '20' + yr);
filled.expiry = true;
}
} catch (e) {}
}
}
return { filled, framesScanned: frames.length };
}
module.exports = { setCard, listCards, deleteCard, getCard, fillCardIntoPage, normalizePan, brandOf, normalizeExpiry };