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.368: pup_credential_reveal: hand ONE saved login to a LOCAL desktop assistant, gated by the user clicking Reveal on a sticky toast naming the caller, the host and the reason. One host per call, never bulk, returned once, never logged; a relayed cloud caller is refused with relay_forbidden and pointed at pup_login instead.
6 files changed
+82−10
SKILL.md+7@@ -31,6 +31,13 @@ dropdown. Backed by the OS keychain (encrypted at rest); passwords are never log domain is caught before the secret is sent. - **Manage**: `pup_credentials {}` lists saved logins (host + username only); `credential_delete {host}` removes one; `credential_set {host, username, password}` stores one explicitly.+- **Reveal one to a LOCAL assistant** (v2.0.368): `pup_credential_reveal {host, reason}` hands the+ saved username + password for ONE host to a caller running on the desktop (a desktop assistant+ such as Grok Bot calling `adom-bridge` natively), after the user clicks **Reveal** on a sticky toast+ that names the caller, the host and your reason. No click, no secret. One host per call, never+ 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. ## 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.367",- "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.367/adom-bridge-puppeteer-v2.0.367.zip",- "sha256": "57a9a0d09b708c6cddda49bd182e62112328e80dfe5af0aedfb3fbcd0c7e4b9a",- "size": 1534302,+ "version": "2.0.368",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.368/adom-bridge-puppeteer-v2.0.368.zip",+ "sha256": "cb5b6e103b7c63f29b8d82fcb0d5634b46a2170e8fc454a2c1622d9fef81b449",+ "size": 1536501, "verbPrefixes": [ "browser_", "desktop_recorder_",@@ -12,5 +12,5 @@ ], "healthEndpoint": "/status", "statusVerb": "browser_readiness",- "released_at": "2026-08-28T21:49:13.730322Z"+ "released_at": "2026-09-02T11:09:22.422896Z" }
package.json+3−2@@ -1,6 +1,6 @@ { "slug": "pup-bridge",- "version": "2.0.367",+ "version": "2.0.368", "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": [@@ -60,7 +60,8 @@ "pup-bridge pup_login with submit:true sends a stored password to a live site. It is reason-gated so the live domain is named and a lookalike is caught first.", "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_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." ], "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.367+2.0.368
src/bridge.json+2−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "2.0.367",+ "version": "2.0.368", "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.",@@ -33,6 +33,7 @@ "pup_forward", "pup_reload", "pup_dialog",+ "pup_credential_reveal", "pup_click", "pup_type", "pup_press_key",
src/server.js+64−1@@ -58,7 +58,7 @@ const _logRing = []; const _loopRing = []; { const _orig = console.log.bind(console);- const EVENT_RE = /^\[(hwnd|birth|focus|foreground|caption|screenshot|settings|annotate|launch|visibility|health|reclaim|recovery|migrate|park|dialog|tab)\]/;+ const EVENT_RE = /^\[(hwnd|birth|focus|foreground|caption|screenshot|settings|annotate|launch|visibility|health|reclaim|recovery|migrate|park|dialog|tab|credential)\]/; console.log = (...a) => { try { const t = a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ');@@ -14758,6 +14758,7 @@ const server = http.createServer(async (req, res) => { 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', 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_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, {}),@@ -19343,6 +19344,68 @@ const server = http.createServer(async (req, res) => { } return; }+ // v2.0.368 (John: "all of us humans are now using ai as our personal assistant so it has to+ // be able to do everything we can do as a human. but yes, security needs to be taken into+ // account."). REVEAL one stored secret to a LOCAL caller, with the user's click as the gate.+ //+ // Why this exists: Grok Bot runs ON the desktop with a shell and calls adom-bridge natively.+ // The adom-you vault is the list that grows with every login pup makes on John's behalf, and+ // nothing could read a secret back out of it: pup_credentials lists host+username only, and+ // pup_login types the secret into a pup window itself. Right for an AI driving pup; wrong for+ // an assistant that needs the credential in ITS OWN tool. ab's own precedent for handing+ // plaintext to a caller is desktop_decrypt_browser_credentials: LOCAL callers only, a consent+ // dialog naming the requester, plaintext returned ONCE over loopback, never logged. This copies+ // that shape and narrows it: ONE host per request, never bulk.+ //+ // THE GATE IS THE CLICK, NOT THE TRANSPORT CHECK. pup cannot verify where a call came from:+ // caller headers are self-reported attribution (ab knows the verified relay connection; pup+ // does not). So the container-header refusal below is a best-effort tripwire for the common+ // cloud-relay case, and the thing that actually protects the vault is the sticky toast on the+ // user's own screen, naming the caller and the host, that THEY have to click. No click, no secret.+ case 'pup_credential_reveal': {+ const host = String((args && args.host) || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');+ 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.' });+ return;+ }+ let cred = null;+ try { cred = await credentialVault.getCredentialForUrl('https://' + host); } catch (e) { cred = null; }+ if (!cred) { sendJSON(res, { success: false, errorCode: 'no_credential', error: `no saved login for ${host}`, _hint: 'pup_credentials {} lists the hosts the vault knows (host + username only).' }); return; }+ const who = _callerThread || 'an unnamed local caller';+ const toastId = `credreveal-${Date.now().toString(36)}`;+ console.log(`[credential] reveal REQUESTED for ${host} (user ${cred.username}) by "${who}" — asking the user on the desktop`);+ let decision = null;+ try {+ const r = await chrome.abCommand('notify_user', {+ id: toastId, level: 'warning', scenario: 'reminder',+ title: `Reveal your ${host} login to ${who}?`,+ body: `${who} is asking pup for the saved login for ${host} (username ${cred.username}). Reason given: "${reason}". Reveal sends the password to that caller ONCE. Deny sends nothing.`,+ buttons: [{ label: 'Reveal' }, { label: 'Deny' }],+ wait: true, timeoutSec: 120,+ }, { timeoutMs: 130000, callerThread: _callerThread || undefined, callerReason: reason });+ const p = abPayload(r) || {};+ decision = p.action || (r && r.action) || null;+ } catch (e) { decision = null; }+ if (decision !== 'Reveal') {+ const code = decision ? 'consent_declined' : 'consent_timeout';+ console.log(`[credential] reveal ${code.toUpperCase()} for ${host} by "${who}"`);+ sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined on the desktop; nothing was revealed' : 'the user did not answer the consent toast within 120s; nothing was revealed', _hint: 'Do not retry in a loop. If the user wants this, they will click Reveal on the next ask.' });+ return;+ }+ // Plaintext ONCE. The audit line below is the only record and it carries no secret.+ console.log(`[credential] reveal GRANTED for ${host} (user ${cred.username}) to "${who}" — user clicked Reveal`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, host: cred.host, username: cred.username, password: cred.password, sourceAccount: cred.sourceAccount || null, revealedTo: who,+ _hint: 'This secret was released ONCE because the user clicked Reveal on their desktop. Use it, then drop it: never log it, never echo it into another verb\'s args or a response, never write it to disk.',+ }) });+ return;+ } case 'credential_delete': { try { const r = await credentialVault.deleteCredential(args.host);