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.
← Commit history
v2.0.374: Two backlog items. Issue 84: a payment-card vault so pup can FINISH a checkout - pup_card_set/pup_cards/pup_card_delete (OS keychain, list shows nickname/brand/last4/expiry only, no verb ever returns the number or CVV) and pup_fill_card, which types the card into the checkout (walking Stripe-style iframes) after the user approves EACH fill on a desktop toast naming merchant, amount and last4; the digits never reach the caller. Issue 82: pup_credential_reveal now gates on ab's forge-proof X-Adom-Verified-Origin header - only a verified local transport passes; relay/bridge/missing origins are refused.
6 files changed
+132−16
SKILL.md+18@@ -38,6 +38,24 @@ dropdown. Backed by the OS keychain (encrypted at rest); passwords are never log bulk, returned ONCE, and it must never be logged or echoed into another verb. A relayed (cloud container) call is refused with `relay_forbidden`: from a cloud thread you do not need the secret, call `pup_login {submit:true, submitReason}` and pup signs the site in inside its own window.+ (v2.0.374: the gate rides ab's forge-proof `X-Adom-Verified-Origin` header — only a VERIFIED+ local transport passes; a missing/bridge origin is refused with `origin_unverified`.)++## Payment cards: pup can finish a checkout (v2.0.374)++pup keeps a **card vault** beside the credential vault, so the AI can complete a purchase+end-to-end instead of stalling at the payment step:++- `pup_card_set {nickname, number, expiry, cvv, name}` stores a card in the OS keychain — ask the+ USER for the details, never harvest them from a page. `pup_cards {}` lists nickname, brand,+ last4 and expiry only; `pup_card_delete {nickname}` removes one. **No verb ever returns the+ number or CVV.**+- `pup_fill_card {nickname, merchant, amount}` fills the card into the checkout in your window+ WITHOUT the digits ever reaching you: pup types them into the card fields itself (walking+ payment-processor iframes like Stripe) and returns booleans. **Every fill is approved by the+ user in the moment** on a desktop toast naming the merchant, amount and last4 — pass+ `merchant` + `amount` so that toast describes the real charge. Then screenshot, verify, and+ submit. Works from cloud threads: the human click is the authorization. ## Addressing: your thread name IS the handle
adom-bridge-puppeteer-manifest.json+5−5@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "2.0.373",- "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.373/adom-bridge-puppeteer-v2.0.373.zip",- "sha256": "eefd1ce4f6a3dab59f1b77527edcc96b737975e0cfa6e143c04dde1d3d9b1230",- "size": 1539589,+ "version": "2.0.374",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.374/adom-bridge-puppeteer-v2.0.374.zip",+ "sha256": "86122e30ee548adbba0b83049482b5ae1f8d624c97f1c4d43749d3912f604ff2",+ "size": 1545543, "verbPrefixes": [ "browser_", "desktop_recorder_",@@ -12,5 +12,5 @@ ], "healthEndpoint": "/status", "statusVerb": "browser_readiness",- "released_at": "2026-09-02T12:59:39.349014Z"+ "released_at": "2026-09-02T15:35:01.932633Z" }
package.json+3−2@@ -1,6 +1,6 @@ { "slug": "pup-bridge",- "version": "2.0.373",+ "version": "2.0.374", "type": "app", "description": "The pup SKILL PACK \u2014 the AI skills that teach a cloud/container AI how to drive the Adom Desktop Puppeteer (pup) bridge (browser_* verbs): the everyday `pup` skill plus focused sub-skills for windows/sessions/tabs, screenshots+recording, browsers+Chrome install, and driving the Adom wiki. Installs into ~/.claude/skills + ~/.codex/skills. DOCS ONLY \u2014 this is NOT the bridge runtime. The bridge itself (the Node program that drives the browser) runs on the DESKTOP: Adom Desktop bundles it and auto-installs/updates it, and it's published separately on the Releases tab as adom-bridge-puppeteer-v<ver>.zip. The pkg version is kept in lockstep with the Release version so the page's max version row always carries BOTH a pkg tarball (skills) and a runtime zip (bridge) \u2014 a skills-only or runtime-only bump on the top row breaks constraint installs. TL;DR: pkg install = the skills for your AI; the bridge = the Release zip, which Adom Desktop manages for you.", "keywords": [@@ -61,7 +61,8 @@ "pup-bridge credential_set and credential_delete write to and remove from the user's OS keychain.", "pup-bridge pup_raise_os_window and pup_assisted_click take over the user's screen and keyboard focus. Native input lands wherever focus is at that instant.", "pup-bridge pup_close_window and pup_quit_idle_browsers destroy windows the user may be looking at, which commonly hold state a person is part-way through.",- "pup-bridge pup_credential_reveal returns one saved password in plaintext to a local caller. The user must click Reveal on a desktop toast first, so it should never be called silently or in a loop."+ "pup-bridge pup_credential_reveal returns one saved password in plaintext to a local caller. The user must click Reveal on a desktop toast first, so it should never be called silently or in a loop.",+ "pup-bridge pup_fill_card types a stored payment card into a checkout page. The user must approve EACH fill on a desktop toast naming merchant, amount and last4; the digits never reach the caller." ], "environment": [ "pup-bridge drives Chrome for Testing, installed and owned by pup, in a pup-managed profile, on the user's desktop but in the background.",
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-2.0.373+2.0.374
src/bridge.json+5−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "2.0.373",+ "version": "2.0.374", "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. pup_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. pup_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, pup_alert_window posts a native toast whose click focuses the window, pup_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.",@@ -34,6 +34,10 @@ "pup_reload", "pup_dialog", "pup_credential_reveal",+ "pup_card_set",+ "pup_cards",+ "pup_card_delete",+ "pup_fill_card", "pup_click", "pup_type", "pup_press_key",
src/server.js+100−7@@ -204,7 +204,7 @@ function ensureDepsInstalled() { // sees it listening and never reaps, and the ~8s of native loading happens in ensureHeavyInit() AFTER // the bind (first line of the listen callback, plus lazily on the first non-/health request). /health // needs no heavy module (its chrome.readiness() is guarded for the pre-init window).-let _depState = null, puppeteer = null, credentialVault = null, chrome = null;+let _depState = null, puppeteer = null, credentialVault = null, cardVault = null, chrome = null; let _heavyInitDone = false; function ensureHeavyInit() { if (_heavyInitDone) return;@@ -212,6 +212,7 @@ function ensureHeavyInit() { try { _depState = ensureDepsInstalled(); } catch (e) { _depState = { ok: false, error: e.message }; } puppeteer = require('puppeteer'); credentialVault = require('./credential_vault');+ cardVault = require('./card_vault'); chrome = require('./chrome'); // Chrome-for-Testing self-heal (also pulls puppeteer internally) try { sharp = require('sharp'); } catch (e) { console.warn('[pup] sharp unavailable — screenshots served at capture size, not downscaled:', e.message); sharp = null; }@@ -10088,7 +10089,7 @@ async function enumAllPupChromeHwnds() { // one session. With sessionId omitted these resolve to the CALLER THREAD's own window (the same // ai-<thread-slug> keying pup_open_window uses) — never to activeSessionId, which is just whichever // window was touched last, usually another thread's.-const SESSION_TARGETING_VERBS = new Set(['pup_progress', 'pup_navigate', 'pup_reload', 'pup_back', 'pup_forward', 'pup_open_tab', 'pup_switch_tab', 'pup_close_tab', 'pup_close_window', 'pup_eval', 'pup_evaluate', 'pup_click', 'pup_type', 'pup_press_key', 'pup_input_dispatch', 'pup_screenshot', 'pup_screenshot_full_res', 'pup_errors', 'pup_list_tabs', 'pup_wait', 'pup_alert_window', 'pup_focus_window', 'pup_raise_os_window', 'pup_lower_os_window', 'pup_window_info', 'pup_record_start', 'pup_record_stop', 'pup_wiki_set_view', 'pup_switch_window', 'pup_annotate', 'pup_floaty_click', 'pup_move_to_workspace']);+const SESSION_TARGETING_VERBS = new Set(['pup_progress', 'pup_navigate', 'pup_reload', 'pup_back', 'pup_forward', 'pup_open_tab', 'pup_switch_tab', 'pup_close_tab', 'pup_close_window', 'pup_eval', 'pup_evaluate', 'pup_click', 'pup_type', 'pup_press_key', 'pup_input_dispatch', 'pup_screenshot', 'pup_screenshot_full_res', 'pup_errors', 'pup_list_tabs', 'pup_wait', 'pup_alert_window', 'pup_focus_window', 'pup_raise_os_window', 'pup_lower_os_window', 'pup_window_info', 'pup_record_start', 'pup_record_stop', 'pup_wiki_set_view', 'pup_switch_window', 'pup_annotate', 'pup_floaty_click', 'pup_move_to_workspace', 'pup_fill_card']); function threadDefaultSessionId(callerThread) { return 'ai-' + String(callerThread).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); }@@ -14841,6 +14842,10 @@ const server = http.createServer(async (req, res) => { V('pup_login', 'Fill (and optionally submit) an HTML login form in the CURRENT tab, in the BACKGROUND — no Chrome autofill dropdown, no foregrounding, React/Vue-safe. Creds from the keychain vault (matched on the tab URL; captured automatically as you log in, or stored via credential_set / save:true) or passed explicitly. Passwords are NEVER echoed back. Vault-matched pages AUTO-FILL on load (_autoLogin on the response). SUBMIT IS REASON-GATED: submit:true requires submitReason AND makes you confirm the live domain (the refusal regurgitates the exact host so a lookalike/phishing site is caught before the secret is sent). pup_configure {autoLogin:"off"} disables auto-fill; {captureLogins:"off"} stops auto-capture.', { sessionId: 'optional str — omit to target the window your own thread owns', username: 'optional str', password: 'optional str — with username, fills these instead of the vault', submit: 'optional bool — submit after filling; REQUIRES submitReason', submitReason: 'required WITH submit:true — why you are confident the CURRENT domain is the correct, intended site to send these credentials to (min 10 chars; the refusal shows you the live host to confirm; logged, auditable)', save: 'optional bool — with explicit creds, store them (OS keychain) so next time is automatic', host: 'optional str — override the vault host key when saving', realKeystrokes: 'optional bool — type with REAL key events instead of a background .value-set. Needed for React-controlled MASKED fields (Gusto/Keycloak SSO) that ignore .value and would submit an empty password. It briefly FOREGROUNDS the window (real keystrokes only register on-screen). Use when a normal fill returned filledPassword:false / reactMaskedFieldSuspected, or when the login-recipe for the site says so.' }, 20, { sessionId: 'work', submit: true, submitReason: 'confirmed the tab is on the real vendor domain I intended to sign into' }), V('pup_credentials', 'List saved logins (host + username only; passwords are in the OS keychain and NEVER returned). pup captures these automatically as you sign into sites and auto-fills them in the background on return visits.', {}, 10, {}), V('pup_credential_reveal', 'Reveal ONE saved login (username + password) to a LOCAL caller on the desktop, gated by a sticky consent toast the user must click. One host per call, never bulk. A relayed (cloud container) call is refused with relay_forbidden: from a cloud thread use pup_login instead, which signs the site in inside a pup window without the secret ever leaving the vault. The secret is returned ONCE and must never be logged or echoed.', { host: 'required str — the site, e.g. "github.com"', reason: 'required str (min 10 chars) — shown to the user on the consent toast; say what you will do with it' }, 130, { host: 'github.com', reason: 'signing into GitHub in my own browser to open a PR for the user' }),+ V('pup_card_set', 'Store a payment card in the OS-keychain card vault under a nickname. Ask the USER for the details — never harvest them from a page. Validates Luhn + expiry + cvv. No verb ever returns the number or CVV afterward.', { nickname: 'required str, e.g. "adom-amex"', number: 'required str', expiry: 'required MM/YY', cvv: 'required 3-4 digits', name: 'optional cardholder name' }, 15, {}),+ V('pup_cards', 'List saved payment cards: nickname, brand, last4, expiry, cardholder only. The number and CVV never leave the keychain.', {}, 10, {}),+ V('pup_card_delete', 'Remove a saved card from the keychain + index by nickname.', { nickname: 'required str' }, 10, {}),+ V('pup_fill_card', 'Fill a saved card into the checkout page of a pup window WITHOUT the digits ever reaching you: pup types them into the card fields (walking payment-processor iframes like Stripe) and returns booleans. EVERY fill is gated by a desktop approval toast naming merchant, amount and last4 — the user authorizes the specific charge in the moment. Pass merchant+amount so the toast is honest. Allowed from cloud threads (the human click is the gate). After ok:true, screenshot to verify, then submit.', { nickname: 'required str', merchant: 'recommended str — shown on the approval toast', amount: 'recommended str, e.g. "$350"', window: 'optional (defaults to your thread\'s window)' }, 130, { nickname: 'adom-amex', merchant: 'venturedallas.com', amount: '$350' }), V('pup_import_browser_logins', 'Seed pup\'s vault from the user\'s OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY — the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. ["snapeda","digikey"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).', { browsers: 'optional str[] subset of ["chrome","edge","brave"] (default: all found)', hostFilter: 'optional str or str[] — only import logins whose host contains one of these (surgical import)' }, 190, { hostFilter: ['snapeda', 'ultralibrarian', 'digikey'] }), V('pup_reap_profiles', 'Delete LEAKED isolated profile directories — the iso-* throwaway jars minted by isolated:true opens (and by wikiView:public before 2.0.132, when the public view moved to the server-side ?view=public param) whose window is long gone (they used to pile up, 100s per machine). Only removes an iso-* dir that no live session or session file owns, that no Chrome is holding, and that is >15 min old; durable jars (adom-you, named profiles) are NEVER touched. Runs automatically every 15 min too. dryRun:true previews the list without deleting.', { dryRun: 'optional bool — preview which iso-* profiles would be removed, delete nothing' }, 20, { dryRun: true }), V('pup_move_to_workspace', 'MOVE this pup window\'s page into the user\'s Hydrogen workspace: the active tab\'s URL is handed to Hydrogen (ah) on this machine via its Browser Picker (webview by default, honoring the user\'s per-domain choice), then the pup window closes — its owner initiated the move. keepWindow:true keeps the pup window open too. Needs Hydrogen running locally (hydrogen_unreachable otherwise; the window is left untouched on failure). Cloud Hydrogen (hc) is not yet supported.', { sessionId: 'optional str', keepWindow: 'optional bool — hand the URL over but keep the pup window open' }, 25, {}),@@ -19455,11 +19460,26 @@ const server = http.createServer(async (req, res) => { const reason = String((args && args.reason) || '').trim(); if (!host) { sendJSON(res, { success: false, errorCode: 'host_required', error: 'pup_credential_reveal needs {host}: the site whose login you need, one per call', _hint: 'Example: {"host":"github.com","reason":"signing into GitHub in my own browser to open a PR for the user"}. Bulk export does not exist on purpose.' }); return; } if (reason.length < 10) { sendJSON(res, { success: false, errorCode: 'reason_required', error: 'pup_credential_reveal needs a reason (min 10 chars) — it is shown to the user on the consent toast', _hint: 'Say what you will do with it, in words the user will read on their screen before deciding.' }); return; }- // Best-effort transport tripwire (see above). A relayed cloud caller arrives stamped with a- // container; a process on the desktop calling the CLI does not.- if (_callerContainer) {- console.log(`[credential] reveal REFUSED for ${host}: relayed caller "${_callerThread || '?'}" (container ${_callerContainer})`);- sendJSON(res, { success: false, errorCode: 'relay_forbidden', error: 'pup_credential_reveal is for LOCAL callers on the desktop only; a relayed (cloud container) call is refused, matching ab\'s rule for desktop_decrypt_browser_credentials', _hint: 'From a cloud thread, do not ask for the secret: call pup_login {submit:true, submitReason} and pup signs the site in inside a pup window without the credential ever leaving the vault.' });+ // v2.0.374 (#82): ab 2.0.96+ stamps X-Adom-Verified-Origin from the transport it+ // OBSERVED (relay ws vs loopback vs bridge callback) — the one origin signal a caller+ // cannot forge. This verb hands out plaintext, so the gate is strict allow-list: only a+ // VERIFIED local caller passes. 'relay' and 'bridge' are refused, and a MISSING header+ // (pre-2.0.96 ab, or an unidentified process POSTing at pup's port directly) is refused+ // rather than assumed safe. The self-reported container header stays as a tripwire.+ const _vo = req.headers['x-adom-verified-origin'] || null;+ if (_vo !== 'local' || _callerContainer) {+ console.log(`[credential] reveal REFUSED for ${host}: verified-origin=${_vo || 'MISSING'} caller "${_callerThread || '?'}"${_callerContainer ? ` (container ${_callerContainer})` : ''}`);+ const isRelay = _vo === 'relay' || !!_callerContainer;+ sendJSON(res, {+ success: false,+ errorCode: isRelay ? 'relay_forbidden' : 'origin_unverified',+ error: isRelay+ ? 'pup_credential_reveal is for LOCAL callers on the desktop only; a relayed (cloud container) call is refused, matching ab\'s rule for desktop_decrypt_browser_credentials'+ : `pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin "${_vo || 'MISSING'}"`,+ _hint: isRelay+ ? 'From a cloud thread, do not ask for the secret: call pup_login {submit:true, submitReason} and pup signs the site in inside a pup window without the credential ever leaving the vault.'+ : 'Call through the adom-bridge CLI on this desktop (ab 2.0.96+). A bridge callback or a direct loopback POST is not accepted for plaintext reveals.',+ }); return; } let cred = null;@@ -19494,6 +19514,79 @@ const server = http.createServer(async (req, res) => { }) }); return; }+ // ── Payment-card vault (issue #84: pup could drive a whole checkout, then stall at the+ // card step). Storage mirrors the credential vault; the CONSENT model is stricter: a card+ // is not a password, so EVERY fill prompts the human once, in the moment, with merchant ++ // amount + last4 on the toast. No verb ever returns a PAN or CVV — pup_fill_card types+ // the digits into the page itself and returns booleans, so the AI never handles them.+ case 'pup_card_set': {+ try {+ const r = await cardVault.setCard({ nickname: args.nickname, number: args.number, expiry: args.expiry, cvv: args.cvv, name: args.name });+ console.log(`[card] SET ${r.nickname} (${r.brand} …${r.last4}) by "${_callerThread || '?'}"`);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r, _hint: 'Stored in the OS keychain. Fill it into a checkout with pup_fill_card {nickname, merchant, amount} — the user approves each fill on a desktop toast. Never echo the number/cvv anywhere again; drop them from your context.' }) });+ } catch (e) { sendJSON(res, { success: false, errorCode: 'card_invalid', error: e.message }); }+ return;+ }+ case 'pup_cards': {+ const cards = cardVault.listCards();+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cards, _hint: cards.length ? 'Nickname/brand/last4/expiry only — the number and CVV never leave the keychain. Fill with pup_fill_card.' : 'No cards saved. Store one with pup_card_set {nickname, number, expiry, cvv, name} — ask the USER to provide it, never harvest it from a page.' }) });+ return;+ }+ case 'pup_card_delete': {+ const r = await cardVault.deleteCard(args.nickname);+ console.log(`[card] DELETE ${args.nickname} (removed=${r.removed}) by "${_callerThread || '?'}"`);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r }) });+ return;+ }+ case 'pup_fill_card': {+ const nickname = String((args && args.nickname) || '').trim().toLowerCase();+ if (!nickname) { sendJSON(res, { success: false, errorCode: 'nickname_required', error: 'pup_fill_card needs {nickname} — pup_cards {} lists what the vault holds', _hint: 'Also pass {merchant, amount} so the user\'s approval toast names the specific charge.' }); return; }+ const _lv = requireLiveSession(res, args);+ if (!_lv) return;+ const session = _lv.session;+ const sessionId = _lv.sessionId || args.sessionId;+ const meta = (cardVault.listCards() || []).find(c => c.nickname === nickname);+ if (!meta) { sendJSON(res, { success: false, errorCode: 'no_card', error: `no saved card named "${nickname}"`, _hint: 'pup_cards {} lists saved cards; pup_card_set stores one.' }); return; }+ const tab0 = (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];+ const pg = tab0 && tab0.page;+ if (!pg) { sendJSON(res, { success: false, errorCode: 'no_page', error: 'the target window has no live page' }); return; }+ let merchant = String((args && args.merchant) || '').trim();+ if (!merchant) { try { merchant = new URL(pg.url()).hostname; } catch (e) { merchant = 'this page'; } }+ const amount = String((args && args.amount) || '').trim();+ const who = _callerThread || 'an unnamed caller';+ // THE GATE: per-fill human approval, in the moment, naming the specific charge. This is+ // deliberately allowed for RELAYED callers (unlike pup_credential_reveal): the digits+ // never reach the caller, and the human click is the authorization for this one fill.+ console.log(`[card] fill REQUESTED ${nickname} (…${meta.last4}) on ${merchant}${amount ? ' for ' + amount : ''} by "${who}" — asking the user`);+ let decision = null;+ try {+ const r = await chrome.abCommand('notify_user', {+ id: `cardfill-${Date.now().toString(36)}`, level: 'warning', scenario: 'reminder',+ title: `Pay with ${meta.brand} …${meta.last4} on ${merchant}?`,+ body: `${who} wants to fill your card "${nickname}" (${meta.brand} ending ${meta.last4}) into the checkout on ${merchant}${amount ? ` for ${amount}` : ''}. Approve fills the card fields in the pup window so you (or the AI) can submit. Deny fills nothing.`,+ buttons: [{ label: 'Approve' }, { label: 'Deny' }],+ wait: true, timeoutSec: 120,+ }, { timeoutMs: 130000, callerThread: _callerThread || undefined, callerReason: `fill card ${nickname} on ${merchant}` });+ const p = abPayload(r) || {};+ decision = p.action || (r && r.action) || null;+ } catch (e) { decision = null; }+ if (decision !== 'Approve') {+ const code = decision ? 'consent_declined' : 'consent_timeout';+ console.log(`[card] fill ${code.toUpperCase()} ${nickname} on ${merchant} by "${who}"`);+ sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined the card fill on the desktop; nothing was filled' : 'the user did not answer the approval toast within 120s; nothing was filled', _hint: 'Do not retry in a loop. Tell the user what you were trying to buy and let them re-initiate.' });+ return;+ }+ let card = null;+ try { card = await cardVault.getCard(nickname); } catch (e) { card = null; }+ if (!card) { sendJSON(res, { success: false, errorCode: 'no_card', error: 'the card vanished from the keychain between listing and filling' }); return; }+ try {+ const r = await cardVault.fillCardIntoPage(pg, card);+ console.log(`[card] fill GRANTED+DONE ${nickname} (…${meta.last4}) on ${merchant} by "${who}": ${JSON.stringify(r.filled)} (${r.framesScanned} frames)`);+ const missing = Object.entries(r.filled).filter(([k, v]) => !v && (k !== 'name' || card.name)).map(([k]) => k);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, filled: r.filled, framesScanned: r.framesScanned, last4: meta.last4, brand: meta.brand, _hint: missing.length ? `Fields not found on the page: ${missing.join(', ')} — screenshot the checkout and check whether they use an unusual widget; fill those by driving the page directly WITHOUT ever asking for the digits.` : 'All card fields filled. Screenshot to verify, then submit the checkout. The digits never left the desktop; do not try to read them back.' }) });+ } catch (e) { sendJSON(res, { success: false, errorCode: 'fill_failed', error: `card fill failed: ${e.message}` }); }+ return;+ } case 'credential_delete': { try { const r = await credentialVault.deleteCredential(args.host);