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
Dual-mode login fill, fixing the 2.0.124 regression. Real keystrokes only register when the window is foregrounded (a browser constraint on synthetic key input to a headful window), while a background .value-set works but React-controlled masked fields ignore it. So pup_login now defaults to the background .value-set (works on the vast majority of sites, no foreground) and adds realKeystrokes:true which briefly foregrounds the window and types with genuine key events for React-masked fields like Gustos. When a background fill does not land the password, the response flags reactMaskedFieldSuspected and hints the caller to retry with realKeystrokes. Field detection still matches type=password OR autocomplete=current-password OR name/id password
5 files changed
+68−34
adom-bridge-puppeteer-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "2.0.124",- "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.124/adom-bridge-puppeteer-v2.0.124.zip",- "sha256": "477443e4b9b56ee3c3559c8564db6392382ba04d57a15edba4ce312b275d58e0",- "size": 865740,+ "version": "2.0.125",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.125/adom-bridge-puppeteer-v2.0.125.zip",+ "sha256": "824bc0b28045dc0de3ec412d51179c5c443560bbf7925ca036c002daf1606469",+ "size": 866899, "verbPrefixes": [ "browser_", "desktop_recorder_",
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-2.0.124+2.0.125
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "2.0.124",+ "version": "2.0.125", "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.",
src/credential_vault.js+46−24@@ -275,11 +275,17 @@ async function applyCredentialsToPage(page, url) { // // Runs entirely in the page; returns { ok, filledUsername, filledPassword, // submitted, usernameValue } and NEVER returns the password.-async function fillLoginForm(page, { username, password, submit = false } = {}) {+// v2.0.125: DUAL-MODE fill. Two facts learned the hard way:+// - a programmatic .value-set works in the BACKGROUND (no foreground) but React-controlled+// masked inputs IGNORE it — the value shows in the DOM yet the form submits empty.+// - real keystrokes (page.keyboard) satisfy React BUT only register when the window is+// FOREGROUNDED (a browser/OS constraint on synthetic key input to a headful window).+// So the DEFAULT is .value-set (background, no foreground, works on the vast majority of sites),+// and `keystrokes:true` (which the CALLER must pair with a foregrounded window — pup_login does)+// is the escape hatch for React-masked fields like Gusto's. Field detection (v2.0.123) matches+// type=password OR autocomplete=current-password OR name/id "password" in BOTH modes.+async function fillLoginForm(page, { username, password, submit = false, keystrokes = false } = {}) { if (!password) return { ok: false, reason: 'no_password_supplied' };- // Step 1: locate the fields IN-PAGE and TAG them so the Node side can drive them with real- // keystrokes. (v2.0.123) a password field is not always type=password — Gusto and many- // SSO/Keycloak themes use type=text + autocomplete="current-password" and mask it themselves. const located = await page.evaluate(() => { const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0; const isPwd = (el) => el && el.tagName === 'INPUT' && (el.type === 'password'@@ -300,29 +306,45 @@ async function fillLoginForm(page, { username, password, submit = false } = {}) }); if (!located.ok) return located; - // Step 2: type with REAL keystrokes (page.keyboard), not a .value-set. v2.0.124 — React-- // controlled masked inputs (Gusto's #password is one) IGNORE a programmatic .value: it shows in- // the DOM but submits empty because the framework's own state never updated. Only genuine key- // events register. Clear-then-type, verify the value actually landed, retry once if it didn't.- const typeReal = async (sel, text) => {- try {- await page.focus(sel);- await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');- await page.keyboard.press('Backspace');- await page.type(sel, text, { delay: 12 });- return true;- } catch (e) { return false; }- };+ let filledUsername = false, filledPassword = false; const valLen = async (sel) => { try { return await page.$eval(sel, (el) => (el.value || '').length); } catch (e) { return 0; } }; - let filledUsername = false;- if (located.hasUser && username) filledUsername = await typeReal('[data-pup-uf]', username);+ if (keystrokes) {+ // real keystrokes — caller MUST have foregrounded the window or nothing lands+ const typeReal = async (sel, text) => {+ try {+ await page.focus(sel);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');+ await page.keyboard.press('Backspace');+ await page.type(sel, text, { delay: 12 });+ return true;+ } catch (e) { return false; }+ };+ if (located.hasUser && username) { await typeReal('[data-pup-uf]', username); filledUsername = (await valLen('[data-pup-uf]')) > 0; }+ await typeReal('[data-pup-pf]', password);+ if ((await valLen('[data-pup-pf]')) === 0) await typeReal('[data-pup-pf]', password); // retry once — a React re-render can eat the first pass+ filledPassword = (await valLen('[data-pup-pf]')) > 0;+ } else {+ // .value-set — works in the background; the common case (majority of sites)+ const setDone = await page.evaluate((USERNAME, PASSWORD) => {+ 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 }));+ };+ const u = document.querySelector('[data-pup-uf]'), p = document.querySelector('[data-pup-pf]');+ let fu = false;+ if (u && USERNAME) { setVal(u, USERNAME); fu = true; }+ if (p) setVal(p, PASSWORD);+ return { fu };+ }, username || '', password);+ filledUsername = !!(located.hasUser && username && setDone.fu);+ filledPassword = (await valLen('[data-pup-pf]')) > 0;+ } - await typeReal('[data-pup-pf]', password);- if ((await valLen('[data-pup-pf]')) === 0) await typeReal('[data-pup-pf]', password); // one retry — first type can lose to a React re-render- const filledPassword = (await valLen('[data-pup-pf]')) > 0;-- // Step 3: submit (find the button via the tagged field), clean the tags, click. const submitted = await page.evaluate((SUBMIT) => { const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0; const pwd = document.querySelector('[data-pup-pf]');
src/server.js+16−4@@ -11570,7 +11570,7 @@ const server = http.createServer(async (req, res) => { V('pup_use', 'Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). "cft" fetches Chrome for Testing then pins it. With {browser:"chrome", install:true} it DOWNLOADS & INSTALLS real Google Chrome on an Edge-only box, then pins it — SILENT on a normal PC; on a locked-down box (VM/managed device) it needs a Windows approval, so the bridge pops a native "click YES on the UAC" notification and, if it expires, re-notifies with a one-tap "Approve now" (all autonomous — no AI turn). Poll pup_readiness.chromeStablePhase: installing → awaiting_uac → ready. Persists across restarts; launch still spawn-verifies + falls back.', { browser: '"chrome" | "edge" | "cft" | "auto"', install: 'optional bool — with browser:"chrome", install real Chrome if missing', elevate: 'optional "auto"(default)|true|false — auto: quiet install, prompt only if it needs admin; false: never prompt; true: go straight to the prompt', executablePath: 'optional — pin an exact browser binary' }, 240, {}), V('pup_lower_os_window', 'Send a pup window to the BACKGROUND (behind the user\'s windows) without stealing focus — still fully drivable. The default for open; use this to re-hide a window you foregrounded.', { sessionId: 'optional str' }, 15, {}), V('pup_raise_os_window', 'Bring a pup window to the FOREGROUND (user\'s screen). GATED (v1.8.70): REFUSED with foreground_reason_required unless you pass foregroundReason — a quote/paraphrase of what the user said that justifies it (min 10 chars; logged, recorded in lastAgentUpdate, auditable). Allowed ONLY when the user EXPLICITLY must SEE it (a demo, recording, "show me"). It STEALS focus — to just signal "I updated this window", use pup_alert_window (or the auto-flash), NOT raise. After the show-me: pup_lower_os_window to return it to the background. Alias: pup_focus_window. IMPORTANT: if you are foregrounding in order to CLICK a native dialog or a cross-origin FedCM/Google account chooser, do NOT raise-then-click yourself — use pup_assisted_click, which waits for the user to actually be on the window (alt-tab aware) and narrates via captions, so you never act while they are not ready.', { sessionId: 'optional str', foregroundReason: 'REQUIRED — why the user asked to see this window' }, 15, { foregroundReason: 'user said "show me the wiki page"' }),- 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' }, 20, { sessionId: 'work', submit: true, submitReason: 'confirmed the tab is on the real vendor domain I intended to sign into' }),+ 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_alert_window', 'Nudge the user about a pup window WITHOUT stealing focus — on Windows it flashes the taskbar button; on macOS (no taskbar, and no API to bounce another app\'s Dock tile) it unhides the window at its z-position and posts an AD toast whose click focuses it. Same contract on both, so call it identically; just never tell a Mac user to look for a flashing icon. NOTE (v1.8.23): mutating verbs already AUTO-FLASH (debounced, one per ~5s burst per window) so the user always knows the agent touched a window — use this for an explicit end-of-task "it\'s ready" nudge on top of that. STRONGLY prefer this (or the auto-flash) over pup_raise_os_window, which steals focus and can leak a mid-typed keystroke into the page.', { sessionId: 'optional str', stop: 'optional bool — CLEAR the highlight instead of flashing' }, 15, {}), V('pup_render_html', 'Render a local HTML string in the tab via page.setContent — UTF-8-correct (a data: URL mojibakes accented chars without an explicit charset). Great for previewing generated markup without a temp file. Auto-flashes the window.', { sessionId: 'required str', html: 'required HTML string', waitUntil: 'optional (domcontentloaded default)', updateNote: 'optional note for lastAgentUpdate', silent: 'optional bool — suppress the auto-flash' }, 30, { sessionId: 'work', html: '<h1>Café</h1>' }),@@ -15147,7 +15147,12 @@ const server = http.createServer(async (req, res) => { return; } if (args.submit) { let lh = ''; try { lh = new URL(curUrl).hostname; } catch (e) {} console.log(`[cred] pup_login submit to ${lh} — reason: ${String(args.submitReason).slice(0, 200)}`); }- const r = await credentialVault.fillLoginForm(tab.page, { username, password, submit: !!args.submit });+ // v2.0.125: realKeystrokes forces genuine key events for React-masked fields (Gusto).+ // Those only register when the window is FOREGROUNDED, so raise it first. Default path+ // (.value-set) stays fully background. autoFill-on-load never uses this.+ const useKeystrokes = args.realKeystrokes === true || args.keystrokes === true;+ if (useKeystrokes) { try { await osRaiseSessionWindow(session, resolvedId, 'realKeystrokes login: this site uses a masked field that only accepts real keyboard input, which requires the window on screen for a moment'); await new Promise(r2 => setTimeout(r2, 500)); } catch (e) {} }+ const r = await credentialVault.fillLoginForm(tab.page, { username, password, submit: !!args.submit, keystrokes: useKeystrokes }); if (!r || !r.ok) { sendJSON(res, { success: false, errorCode: r && r.reason === 'no_password_field' ? 'no_login_form' : 'login_fill_failed', output: JSON.stringify({ ok: false, reason: r && r.reason, url: curUrl }),@@ -15156,12 +15161,19 @@ const server = http.createServer(async (req, res) => { : 'Could not fill the login form. Screenshot the page to see its state.' }); return; }+ // v2.0.125: a false filledPassword after the default .value-set almost always means a+ // React-controlled MASKED field (Gusto etc.) silently ignored the set — the classic+ // "submits empty / Password is required" trap. Coach the caller to the fix instead of+ // letting it submit an empty password.+ const reactMaskedMiss = !useKeystrokes && r.filledPassword === false; sendJSON(res, { success: true, output: JSON.stringify({ ok: true, url: curUrl, credentialSource: source, filledUsername: r.filledUsername, filledPassword: r.filledPassword,- submitted: r.submitted, usernameFilled: r.usernameValue, // username only; password is NEVER returned+ submitted: r.submitted, reactMaskedFieldSuspected: reactMaskedMiss || undefined, }),- _hint: `Login form filled in the BACKGROUND from ${source} creds${r.submitted ? ' and SUBMITTED' : ' (not submitted — pass {submit:true} to submit, or drive the submit button yourself)'}. No Chrome autofill dropdown was involved and the window was never foregrounded. Verify with pup_screenshot before assuming you are signed in.` });+ _hint: reactMaskedMiss+ ? `⚠ The password did NOT land — this field looks React-controlled/masked (common on SSO/Keycloak logins like Gusto): a background .value-set is ignored and the form would submit EMPTY. Re-call pup_login {realKeystrokes:true, submit:true, submitReason:"…"}. That types with real key events, which such fields honour, but it briefly foregrounds the window (the only way real keystrokes register). Check login-recipes/<domain>.md on the pup wiki page for the site's flow.`+ : `Login form filled in the BACKGROUND from ${source} creds${r.submitted ? ' and SUBMITTED' : ' (not submitted — pass {submit:true} to submit, or drive the submit button yourself)'}. No Chrome dropdown, window never foregrounded. Verify with pup_screenshot before assuming you are signed in.` }); } catch (e) { sendJSON(res, { success: false, error: e.message }); }