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 with its AI thread's name in your taskbar or dock.
← Commit history
v1.8.16: session ownership — refuse declared cross-thread window steals, reprimand anonymous grabs, expose owner in status; skill teaches owner + task-prefixed sessionIds; pkg 1.8.18
6 files changed
+150−18
SKILL.md+26@@ -134,6 +134,32 @@ adom-desktop browser_reload '{"sessionId":"myapp"}' **These are Bash commands, not MCP tools.** Always pass `sessionId` — `'{}'` targets the *active* session, which may not be the one you mean. +## Window ownership — do NOT steal another AI thread's window++**Pup sessions are shared across every AI thread on this desktop.** If you `browser_open_window` with a+sessionId that already exists, pup NAVIGATES that existing window — and if another thread was using it,+you just destroyed their work. Rules:++1. **Task-prefix your sessionId** — `"<your-task>-web"`, never generic ids like `wiki` / `test` / `main`+ (those collide across threads).+2. **Always pass `owner:"<your-task-name>"`** on `browser_open_window`. Pup then REFUSES other threads'+ attempts to navigate/close your window (`errorCode: session_owned_by_another_thread`) and tells them+ to open their own.+3. **If YOU get `session_owned_by_another_thread`** — that window is not yours. Open your own with a new+ task-prefixed sessionId. Pass `takeover:true` ONLY if the user explicitly told you to repurpose that+ window.+4. **Unsure what exists?** `browser_status` lists every window with its `owner`, current `url`, and+ `ageMinutes` — check before reusing any id.+5. **Reusing your OWN window is good** (same sessionId + same owner = smooth refresh — e.g. republishing+ a wiki page into the same window). Ownership only blocks *cross-thread* grabs.++```bash+# right: owned, task-prefixed+adom-desktop browser_open_window '{"sessionId":"wikifix-preview","owner":"wikifix","url":"<url>"}'+# wrong: generic id, anonymous — may hijack another thread's window (pup will reprimand you in _hint)+adom-desktop browser_open_window '{"sessionId":"wiki","url":"<url>"}'+```+ ## Background by default — pup NEVER pops onto the user's screen unless asked **Pup is the AI's workspace, not the user's.** `browser_open_window` opens **in the background by default**
adom-bridge-puppeteer-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "1.8.15",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.8.15/adom-bridge-puppeteer-v1.8.15.zip",- "sha256": "0e66662d847d6052cd945395f50056eaef2d78ac8504160b503e2379af8ff3e6",- "size": 139369,+ "version": "1.8.16",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.8.16/adom-bridge-puppeteer-v1.8.16.zip",+ "sha256": "8cd38ead0e20b054fad2893b731e43431b92e9cd2aec7ad0d3df4764a7c2d076",+ "size": 142437, "verbPrefixes": [ "browser_", "desktop_recorder_",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-desktop-puppeteer-bridge",- "version": "1.8.17",+ "version": "1.8.18", "type": "app", "description": "CONTAINER-SIDE install \u2014 the pup skill pack in the wiki skillpack layout: the everyday `pup` user skill PLUS two bundled developer sub-skills (pup-bridge-dev, pup-bridge-publish; user-invocable:false, DEVELOPER-only descriptions so they never trigger for everyday users), all dropped into ~/.claude/skills/ AND ~/.codex/skills/ by an explicit install.sh list. It does NOT contain the bridge runtime: the bridge (Node + browser driving) lives on the DESKTOP \u2014 bundled inside Adom Desktop and published on the Releases tab as adom-bridge-puppeteer-v<ver>.zip, which Adom Desktop streams into its bridges-cache. TL;DR: pkg install = the skill pack; the bridge = the Release zip + bundled in Adom Desktop.", "keywords": [
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-1.8.15+1.8.16
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "1.8.15",+ "version": "1.8.16", "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. browser_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. browser_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.", "homepage": "https://wiki.adom.inc/adom/adom-desktop", "author": "Adom Inc.",
src/server.js+117−11@@ -96,6 +96,14 @@ function createSession(profileName) { latestShot: null, profileName, tabCounter: 0,+ // v1.8.16: advisory ownership. Multiple AI threads share this bridge and+ // sessions are keyed only by sessionId — so one thread could silently+ // navigate away ("steal") a window another thread was using. `owner` is a+ // caller-declared label (browser_open_window {owner:"my-task"}); it can't+ // be authenticated, but it lets us REFUSE declared cross-owner takeovers+ // and reprimand anonymous ones with a loud hint.+ owner: null,+ createdAt: Date.now(), // Ring buffer of recently-closed tabs (capped at 20). Used by // resolveTabOrError to enrich tab_not_found errors with `lastKnownUrl` // when the bogus tabId matches a tab that closed in the last few seconds.@@ -1784,7 +1792,7 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer // Launch a session as a tab in a Chrome browser // profile: which Chrome profile to use (defaults to sessionId) // If another session already uses that profile, this creates a NEW TAB in the same Chrome-async function launchSession(sessionId, url, { freshProfile = false, profile, strictPermissions = false, downloadPath = null, nativeSite = false } = {}) {+async function launchSession(sessionId, url, { freshProfile = false, profile, strictPermissions = false, downloadPath = null, nativeSite = false, owner = null, takeover = false } = {}) { const profileName = profile || sessionId; // If session already exists and page is still alive, just navigate@@ -1795,6 +1803,35 @@ async function launchSession(sessionId, url, { freshProfile = false, profile, st try { // Check if the page is still valid await existing.page.title();+ // ── v1.8.16 ownership gate ────────────────────────────────────────+ // Reusing an existing sessionId with a NEW url = navigating away a+ // window some thread was using. If both sides declared owners and+ // they differ, REFUSE (unless takeover:true) — the caller is about+ // to steal another thread's window. Same-owner / no-url reuse is the+ // legitimate "refresh my own window" pattern and passes untouched.+ let prevUrl = null; try { prevUrl = existing.page.url() || null; } catch {}+ const navigatingAway = !!url && !!prevUrl && url !== prevUrl;+ const declaredMismatch = navigatingAway && existing.owner && owner && owner !== existing.owner;+ if (declaredMismatch && !takeover) {+ const age = Math.round((Date.now() - (existing.createdAt || Date.now())) / 60000);+ const err = new Error(`Session "${sessionId}" belongs to another thread (owner "${existing.owner}", opened ${age} min ago, currently showing ${prevUrl}).`);+ err.code = 'session_owned_by_another_thread';+ err.details = { sessionId, owner: existing.owner, requestedBy: owner, currentUrl: prevUrl, ageMinutes: age };+ throw err;+ }+ // One-shot reuse metadata for the open_window handler's hint:+ // was this a benign refresh, a sanctioned takeover, or an anonymous grab?+ existing._reuseMeta = {+ reused: true,+ previousUrl: prevUrl,+ navigatedAway: navigatingAway,+ previousOwner: existing.owner || null,+ sameOwner: !!(existing.owner && owner && owner === existing.owner),+ anonymousGrab: navigatingAway && !!existing.owner && !owner,+ takeover: !!(declaredMismatch && takeover),+ };+ // Ownership follows a sanctioned takeover or fills in when unset.+ if ((takeover || !existing.owner) && owner) existing.owner = owner; if (url) { await existing.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); existing.errors.length = 0;@@ -1802,6 +1839,7 @@ async function launchSession(sessionId, url, { freshProfile = false, profile, st activeSessionId = sessionId; return existing; } catch (e) {+ if (e && e.code === 'session_owned_by_another_thread') throw e; // Page was closed — fall through to create new tab console.log(`Session "${sessionId}" page was closed — recreating tab`); }@@ -1920,6 +1958,8 @@ async function launchSession(sessionId, url, { freshProfile = false, profile, st // Create the session with this page as its first tab. const session = createSession(profileName);+ // v1.8.16: stamp the declared owner (advisory thread/task label) at create.+ if (owner) session.owner = owner; // v1.6.3+: stash the per-session downloadPath BEFORE attachTab — attachTab // reads it to apply Page.setDownloadBehavior on the new page. Default // resolves in attachTab itself if unset.@@ -2337,7 +2377,7 @@ setInterval(async () => { // asked to adopt: a calling AI should never have to guess and never have to // read docs first. { verb: { hint, related[], pitfalls[] } }. const VERB_META = {- browser_open_window: { hint: 'Opens a generic, fully-controllable pup window (a fresh isolated Chrome/Edge process — never your real signed-in browser) that you then drive by sessionId. To drive the user\'s REAL Chrome/Edge, use the Adom browser extension\'s nbrowser_* verbs instead.', related: ['browser_navigate', 'browser_list_windows', 'browser_close_window'], pitfalls: ['sessionId is required and is your handle for every later call', 'profile defaults to sessionId — omit it for a plain "open in pup"'] },+ browser_open_window: { hint: 'Opens a generic, fully-controllable pup window (a fresh isolated Chrome/Edge process — never your real signed-in browser) that you then drive by sessionId. To drive the user\'s REAL Chrome/Edge, use the Adom browser extension\'s nbrowser_* verbs instead.', related: ['browser_navigate', 'browser_list_windows', 'browser_close_window'], pitfalls: ['sessionId is required and is your handle for every later call', 'profile defaults to sessionId — omit it for a plain "open in pup"', 'sessionIds are SHARED across all AI threads on this desktop — a generic id ("wiki", "test") may collide with another thread\'s window and navigating it away steals their work. Use a task-prefixed sessionId + pass owner:"<your-task>" so pup can protect it', 'reusing an existing sessionId with a new url NAVIGATES that window — check browser_status (shows owner per window) first if unsure'] }, browser_open_tab: { hint: 'Adds a tab to an existing session; returns the new tabId.', related: ['browser_switch_tab', 'browser_list_tabs', 'browser_close_tab'], pitfalls: ['the session must already exist (browser_open_window first)'] }, browser_close_tab: { hint: 'Closes one tab; the session stays open.', related: ['browser_close_window', 'browser_list_tabs'], pitfalls: ['omit tabId to close the active tab — closing the last tab may close the window'] }, browser_close_window: { hint: 'Closes the whole session and all its tabs. Destructive.', related: ['browser_list_windows'], pitfalls: ['this ends the session — its sessionId is no longer valid afterward'] },@@ -2408,6 +2448,9 @@ async function getSessionInfo(sessionId, session) { profile: session.profileName, tabCount: session.tabs.length, activeTabId: session.activeTabId,+ // v1.8.16: advisory ownership — which AI thread/task opened this window.+ owner: session.owner || null,+ ageMinutes: session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null, }; // One-shot: surface launch-time metadata (restored-tab cleanup count) // so callers can verify session-restore prevention worked. Drop after read.@@ -2418,6 +2461,24 @@ async function getSessionInfo(sessionId, session) { return info; } +// v1.8.16: shared ownership gate for mutating verbs (navigate / close). Returns a+// refusal payload when the caller DECLARED an owner that mismatches the session's+// (send it as-is), else null. Anonymous callers pass (advisory model — we can't+// authenticate threads), but open_window's hints teach them to declare.+function ownershipRefusal(session, sessionId, args, verb) {+ const caller = (args && typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null;+ if (!session || !session.owner || !caller || caller === session.owner || (args && args.takeover)) return null;+ let curUrl = null; try { curUrl = session.page?.url() || null; } catch {}+ const age = session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null;+ return {+ success: false,+ errorCode: 'session_owned_by_another_thread',+ error: `Session "${sessionId}" belongs to another thread (owner "${session.owner}", opened ${age} min ago, currently showing ${curUrl}).`,+ sessionId, owner: session.owner, requestedBy: caller, currentUrl: curUrl, ageMinutes: age,+ _hint: `STOP — this window belongs to AI thread "${session.owner}", not you ("${caller}"). ${verb} on it would break that thread's work. Open your OWN window instead: browser_open_window with a UNIQUE task-prefixed sessionId (e.g. "${caller}-web") and owner:"${caller}". Only pass takeover:true if the user EXPLICITLY told you to repurpose this window. browser_status lists every window + its owner.`,+ };+}+ const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); const pathname = url.pathname;@@ -2478,7 +2539,7 @@ const server = http.createServer(async (req, res) => { hint: m.hint || summary, related: m.related || [], pitfalls: m.pitfalls || [] }; }; sendJSON(res, { success: true, bridge: 'puppeteer', verbs: [- V('browser_open_window', 'Open a generic pup window — a fresh isolated Chrome/Edge process (never your real signed-in browser), fully drivable via CDP. Opens in the BACKGROUND by default (does not steal focus; still fully screenshottable/drivable) — pass foreground:true ONLY if the user wants to watch. Pass width+height (+ optional x,y, screen px) to open at a specific size/position (auto-foregrounds; default is maximized). profile defaults to sessionId. (For the user\'s REAL Chrome/Edge, use the Adom extension\'s nbrowser_* verbs.)', { sessionId: 'required str', url: 'optional str', profile: 'optional str — defaults to sessionId', foreground: 'optional bool — show it (default false = background)', width: 'optional int px', height: 'optional int px', x: 'optional int px', y: 'optional int px' }, 60, { sessionId: 'work', url: 'https://example.com', width: 960, height: 540 }),+ V('browser_open_window', 'Open a generic pup window — a fresh isolated Chrome/Edge process (never your real signed-in browser), fully drivable via CDP. Opens in the BACKGROUND by default (does not steal focus; still fully screenshottable/drivable) — pass foreground:true ONLY if the user wants to watch. Pass width+height (+ optional x,y, screen px) to open at a specific size/position (auto-foregrounds; default is maximized). profile defaults to sessionId. OWNERSHIP: sessionIds are shared across ALL AI threads — use a task-prefixed sessionId + owner:"<your-task>" so pup can refuse other threads stealing your window (reusing an existing id with a new url navigates it away; declared cross-owner reuse errors with session_owned_by_another_thread unless takeover:true). (For the user\'s REAL Chrome/Edge, use the Adom extension\'s nbrowser_* verbs.)', { sessionId: 'required str — task-prefix it (e.g. "mytask-web"); generic ids collide across threads', url: 'optional str', profile: 'optional str — defaults to sessionId', owner: 'optional str — your thread/task label; lets pup protect this window from other threads', takeover: 'optional bool — claim a window owned by another thread (only when the user explicitly asks)', foreground: 'optional bool — show it (default false = background)', width: 'optional int px', height: 'optional int px', x: 'optional int px', y: 'optional int px' }, 60, { sessionId: 'mytask-web', owner: 'mytask', url: 'https://example.com', width: 960, height: 540 }), V('browser_open_tab', 'Open a new tab in a session.', { sessionId: 'required str', url: 'optional str' }, 30, { sessionId: 'work', url: 'https://example.com' }), V('browser_close_tab', 'Close a tab.', { sessionId: 'required str', tabId: 'optional str (default active)' }, 15, { sessionId: 'work' }), V('browser_close_window', 'Close a window/session (its tabs go too).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),@@ -2592,13 +2653,34 @@ const server = http.createServer(async (req, res) => { const downloadPath = (args.downloadPath && typeof args.downloadPath === 'string') ? args.downloadPath : path.join(os.homedir(), 'Downloads');- const session = await launchSession(sessionId, args.url, {- freshProfile: !!args.freshProfile,- profile: args.profile,- strictPermissions: !!args.strictPermissions,- downloadPath,- nativeSite: !!args.nativeSite,- });+ let session;+ try {+ session = await launchSession(sessionId, args.url, {+ freshProfile: !!args.freshProfile,+ profile: args.profile,+ strictPermissions: !!args.strictPermissions,+ downloadPath,+ nativeSite: !!args.nativeSite,+ owner: (typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null,+ takeover: !!args.takeover,+ });+ } catch (e) {+ if (e && e.code === 'session_owned_by_another_thread') {+ const d = e.details || {};+ sendJSON(res, {+ success: false,+ errorCode: 'session_owned_by_another_thread',+ error: e.message,+ ...d,+ _hint: `STOP — do NOT take this window. Session "${d.sessionId}" was opened by another AI thread (owner "${d.owner}") and is currently showing ${d.currentUrl}. Navigating it away would destroy that thread's work. Do ONE of: (1) open your OWN window with a UNIQUE sessionId — prefix it with your task, e.g. "${d.requestedBy}-web" (RECOMMENDED); (2) if the user EXPLICITLY told you to repurpose this window, re-send with takeover:true to claim it; (3) browser_status first to see all windows + owners before picking a sessionId. Generic ids like "wiki"/"test"/"main" collide across threads — always task-prefix yours.`,+ });+ return;+ }+ throw e;+ }+ // One-shot reuse/steal report from launchSession (existing-session path).+ const reuse = session && session._reuseMeta ? session._reuseMeta : null;+ if (session) delete session._reuseMeta; // Stash on the session so attachTab uses it for any subsequent // tab open or popup auto-attach in this session. if (session) session._downloadPath = downloadPath;@@ -2621,6 +2703,21 @@ const server = http.createServer(async (req, res) => { const bgHint = foreground ? 'Opened in the FOREGROUND as requested. Return it to the background with browser_lower_os_window once the user is done watching.' : 'Opened in the BACKGROUND (behind the user\'s windows, did NOT steal focus) — this is the default so pup never disrupts the user. It is FULLY drivable there: browser_screenshot / browser_eval / browser_navigate all work while it stays hidden. ONLY bring it to the user\'s screen if they explicitly want to WATCH (a demo, a recording, a "show me") — then call browser_raise_os_window (or pass foreground:true on open). Otherwise keep it in the background.';+ // v1.8.16: ownership/reuse verdict — reprimand grabs, bless refreshes.+ let ownHint = '';+ if (reuse && reuse.navigatedAway) {+ if (reuse.takeover) {+ ownHint = ` TAKEOVER: you claimed session "${sessionId}" from thread "${reuse.previousOwner}" (was showing ${reuse.previousUrl}) via takeover:true — you now own it. Only do this when the user explicitly asked.`;+ } else if (reuse.anonymousGrab) {+ ownHint = ` ⚠ YOU JUST TOOK OVER AN EXISTING WINDOW: session "${sessionId}" belonged to thread "${reuse.previousOwner}" and was showing ${reuse.previousUrl} — you navigated it away WITHOUT declaring an owner. If that window wasn't yours, you broke another AI thread's work: re-open ITS url (${reuse.previousUrl}) on this sessionId, then use your OWN unique task-prefixed sessionId. ALWAYS pass owner:"<your-task-name>" on browser_open_window so pup can protect windows across threads.`;+ } else if (reuse.sameOwner) {+ ownHint = ` Reused your own window (owner match) — navigated ${reuse.previousUrl} → new url.`;+ } else {+ ownHint = ` NOTE: reused EXISTING session "${sessionId}" (was showing ${reuse.previousUrl}, no owner declared by either side). If that window belonged to another AI thread, you just stole it — prefer a unique task-prefixed sessionId for new work, and pass owner:"<your-task-name>" so pup can enforce ownership.`;+ }+ } else if (reuse && reuse.reused) {+ ownHint = ' Reused the existing window (same/no url — benign refresh).';+ } sendJSON(res, { success: true, output: JSON.stringify({@@ -2637,7 +2734,10 @@ const server = http.createServer(async (req, res) => { placement: foreground ? 'foreground' : 'background', // v1.8.2: window size (if width/height were given). null = default maximized. size: sized ? { width: Math.round(w), height: Math.round(h), x: Number.isFinite(wx) ? Math.round(wx) : null, y: Number.isFinite(wy) ? Math.round(wy) : null } : null,- _hint: `${bgHint} ${brep._hint}`,+ // v1.8.16: reuse/ownership verdict (null = fresh window).+ reused: reuse ? { navigatedAway: reuse.navigatedAway, previousUrl: reuse.previousUrl, previousOwner: reuse.previousOwner, takeover: reuse.takeover } : null,+ owner: session.owner || null,+ _hint: `${bgHint}${ownHint} ${brep._hint}`, ...info, }), });@@ -2725,6 +2825,8 @@ const server = http.createServer(async (req, res) => { return; } const session = sessions.get(sessionId);+ // v1.8.16: refuse a declared cross-owner close (destroying another thread's window).+ { const refusal = ownershipRefusal(session, sessionId, args, 'browser_close_window'); if (refusal) { sendJSON(res, refusal); return; } } const profileName = session.profileName; // Close ALL tabs in this session (a window can now hold many tabs)@@ -3675,6 +3777,8 @@ const server = http.createServer(async (req, res) => { // signals. See recoverOrRelaunchSession. try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); } catch (e) { sendSessionResolveError(res, e); return; }+ // v1.8.16: refuse a declared cross-owner navigate (window steal).+ { const refusal = ownershipRefusal(session, resolvedId, args, 'browser_navigate'); if (refusal) { sendJSON(res, refusal); return; } } let tab, tabId; try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); } catch (e) { sendTabResolveError(res, e); return; }@@ -3834,6 +3938,8 @@ const server = http.createServer(async (req, res) => { sendJSON(res, { success: false, error: `Session "${args.sessionId}" not found. Active sessions: ${[...sessions.keys()].join(', ') || '(none)'}.` }); return; }+ // v1.8.16: refuse a declared cross-owner close.+ { const refusal = ownershipRefusal(target, args.sessionId, args, 'browser_close'); if (refusal) { sendJSON(res, refusal); return; } } const profileName = target.profileName; // Are there other sessions on this profile? If yes, only close // this session's tab (Chrome stays alive for the others). If no,