skill
ffpup — Firefox (BiDi) bridge
Public Unreviewedby barrett-land
Firefox automation bridge for Adom Desktop — the WebDriver-BiDi sibling of the pup (Chrome) bridge.
← Commit history
ffpup bridge: manifest + source + docs
6 files changed
+471−26
README.md+24−2@@ -1,3 +1,25 @@-# ffpup — Firefox (BiDi) bridge+# ffpup — Firefox (WebDriver BiDi) bridge for Adom Desktop -Firefox automation bridge for Adom Desktop — the WebDriver-BiDi sibling of the pup (Chrome) bridge. Drives the real Firefox with trusted input. Install via adom-desktop bridge_install.+The BiDi sibling of the `pup` (Chrome) bridge. Drives the user's REAL Firefox over+WebDriver BiDi — **trusted input** (`isTrusted=true` on clicks AND keystrokes),+persistent logged-in profiles, and **no "controlled by automation" infobar**.+Uses the system Firefox — no browser download.++## Install+```+adom-desktop bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/adom-desktop-ffpup-bridge/files/ffpup-manifest.json"}'+```++## Verbs (`ffbrowser_` prefix, mirroring pup's `browser_`)+`ffbrowser_open_window`, `open_tab`, `close_tab`, `switch_tab`, `list_tabs`,+`close_window`, `navigate`, `reload`, `evaluate`, `screenshot`,+`input_dispatch` (click|move|type|key), `errors`, `status`++## Example+```+adom-desktop ffbrowser_open_window '{"sessionId":"x","profile":"x","url":"https://example.com"}'+adom-desktop ffbrowser_input_dispatch '{"sessionId":"x","type":"type","selector":"input[name=q]","text":"hello"}'+adom-desktop ffbrowser_screenshot '{"sessionId":"x"}'+```++Requires **Node >= 18** and a **system Firefox (>= 121)**. Vendors `puppeteer-core` (CommonJS).
bridge.jsonadded+41@@ -0,0 +1,41 @@+{+ "manifest_version": 1,+ "name": "ffpup",+ "displayName": "ffpup (Firefox / WebDriver BiDi)",+ "version": "0.1.0",+ "description": "Firefox browser automation, the BiDi sibling of the puppeteer (Chrome) bridge. Drives the user's REAL Firefox over WebDriver BiDi — trusted input (isTrusted=true), persistent logged-in profiles, and NO 'controlled by automation' infobar. Mirrors the pup verb surface under the ffbrowser_ prefix. Uses the system Firefox (no browser download).",+ "homepage": "https://github.com/adom-inc/adom-desktop",+ "author": "Adom Inc.",+ "license": "MIT",+ "spawn": {+ "kind": "node",+ "entrypoint": "server.js",+ "port": 0,+ "healthEndpoint": "/status",+ "stopMethod": "kill",+ "killImageName": "node.exe"+ },+ "verbPrefixes": ["ffbrowser_"],+ "verbs": [+ "ffbrowser_open_window",+ "ffbrowser_open_tab",+ "ffbrowser_close_tab",+ "ffbrowser_switch_tab",+ "ffbrowser_list_tabs",+ "ffbrowser_close_window",+ "ffbrowser_navigate",+ "ffbrowser_reload",+ "ffbrowser_evaluate",+ "ffbrowser_screenshot",+ "ffbrowser_input_dispatch",+ "ffbrowser_errors",+ "ffbrowser_status"+ ],+ "dependencies": {+ "node": ">=20",+ "firefox": ">=121"+ },+ "category": "Browser Automation",+ "tags": ["firefox", "puppeteer", "webdriver-bidi", "headful", "ffpup"],+ "docs": "https://wiki.adom.inc/adom/adom-desktop-ffpup-bridge"+}
ffpup-manifest.jsonadded+8@@ -0,0 +1,8 @@+{+ "manifest_version": 1,+ "version": "0.1.0",+ "url": "ffpup-bridge-v0.1.0.zip",+ "sha256": "43c6584e5584e13ea36581fe47ed4d998795cea020d3748d4bbdc8b0535d9059",+ "size": 9547472,+ "released_at": "2026-06-29T16:53:57Z"+}
package.jsonadded+12@@ -0,0 +1,12 @@+{+ "name": "adom-ffpup-bridge",+ "version": "0.1.0",+ "description": "ffpup — Firefox (WebDriver BiDi) bridge for Adom Desktop. Sibling of the puppeteer (Chrome) bridge.",+ "main": "server.js",+ "scripts": {+ "start": "node server.js"+ },+ "dependencies": {+ "puppeteer-core": "^24.43.1"+ }+}
page.json+3−24@@ -1,28 +1,7 @@ {- "schema_version": 1, "type": "skill", "slug": "adom-desktop-ffpup-bridge", "title": "ffpup — Firefox (BiDi) bridge",- "brief": "Firefox automation bridge for Adom Desktop — the WebDriver-BiDi sibling of the pup (Chrome) bridge. Drives the real Firefox with trusted input. Install via adom-desktop bridge_install.",- "version": "0.1.0",- "author": {- "id": "682236db0247d17bdaa47ecb",- "name": "barrett-land",- "email": "[email protected]"- },- "tags": [],- "license": "proprietary",- "visibility": {- "public": true- },- "hero": null,- "sample_prompts": [],- "discovery_triggers": [],- "discovery_pitch": null,- "metadata": {},- "created_at": "2026-06-29T16:54:39.771Z",- "updated_at": "2026-06-29T16:54:39.771Z",- "source_path": "SKILL.md",- "sub_skills": [],- "parent_app": null-}+ "brief": "Firefox automation bridge for Adom Desktop — the WebDriver-BiDi sibling of the pup (Chrome) bridge.",+ "tags": ["adom-desktop", "bridge", "firefox", "webdriver-bidi", "ffpup", "puppeteer"]+}
server.jsadded+383@@ -0,0 +1,383 @@+// ffpup — Firefox (WebDriver BiDi) bridge for Adom Desktop.+//+// The BiDi sibling of the puppeteer (Chrome) bridge. Drives the user's REAL+// Firefox over WebDriver BiDi: trusted input (isTrusted=true on clicks AND+// keystrokes — the thing a WebExtension content script can't fake), persistent+// logged-in profiles, and NO "controlled by automation" infobar (unlike Chrome's+// --remote-debugging-port). Uses the system Firefox — no browser download.+//+// Protocol (matches the pup bridge so AD routes it identically):+// GET /status → health JSON+// POST /command {command, args} → dispatch a verb, returns {success, output|error}+// Verbs are the ffbrowser_* surface mirroring pup's browser_*.+//+// Launched by Adom Desktop as: node server.js --port <os-assigned>++const http = require('http');+const fs = require('fs');+const path = require('path');++// puppeteer-core (no bundled-browser download); fall back to full puppeteer in dev.+let puppeteer;+try { puppeteer = require('puppeteer-core'); }+catch { puppeteer = require('puppeteer'); }++const PORT = (() => {+ const i = process.argv.indexOf('--port');+ return i !== -1 && process.argv[i + 1] ? parseInt(process.argv[i + 1], 10) : 8861;+})();++// Headful by default (this drives the user's real browser). FFPUP_HEADLESS=1 for+// container/CI testing where there's no display.+const HEADLESS = process.env.FFPUP_HEADLESS === '1';++const STATE_DIR = path.join(__dirname, '.ffpup');+const PROFILES_DIR = path.join(STATE_DIR, 'profiles');+fs.mkdirSync(PROFILES_DIR, { recursive: true });++// Resolve a Firefox executable: env override → system install → puppeteer-managed.+function resolveFirefox() {+ if (process.env.FFPUP_FIREFOX_PATH && fs.existsSync(process.env.FFPUP_FIREFOX_PATH)) {+ return process.env.FFPUP_FIREFOX_PATH;+ }+ const candidates = process.platform === 'win32'+ ? ['C:/Program Files/Mozilla Firefox/firefox.exe', 'C:/Program Files (x86)/Mozilla Firefox/firefox.exe']+ : ['/usr/bin/firefox', '/usr/lib/firefox/firefox', '/snap/bin/firefox',+ '/Applications/Firefox.app/Contents/MacOS/firefox'];+ for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch { /* ignore */ } }+ return null; // null → puppeteer uses its own downloaded Firefox (dev only)+}++// Firefox prefs mirroring the Chrome launch-flag suppressions: no first-run, no+// default-browser nag, no telemetry, no session-restore (this surface is+// AI-driven — it knows what it wants open).+const FIREFOX_PREFS = {+ 'browser.shell.checkDefaultBrowser': false,+ 'browser.startup.page': 0,+ 'browser.startup.homepage_override.mstone': 'ignore',+ 'datareporting.policy.dataSubmissionEnabled': false,+ 'toolkit.telemetry.enabled': false,+ 'app.update.auto': false,+ 'app.update.enabled': false,+ 'browser.tabs.warnOnClose': false,+ 'browser.sessionstore.resume_from_crash': false,+ 'browser.aboutwelcome.enabled': false,+};++// ── State ────────────────────────────────────────────────────────────+// browsers: profileName → { browser, refCount } (one Firefox per profile)+// sessions: sessionId → { tabs:[{tabId,page,errors}], activeTabId, profileName, tabCounter }+const browsers = new Map();+const sessions = new Map();+let activeSessionId = null;++function createSession(profileName) {+ return { tabs: [], activeTabId: null, profileName, tabCounter: 0 };+}+function nextTabId(s) { return `tab-${++s.tabCounter}`; }+function getTab(session, tabId) {+ if (tabId) return session.tabs.find(t => t.tabId === tabId) || null;+ return session.tabs.find(t => t.tabId === session.activeTabId) || session.tabs[session.tabs.length - 1] || null;+}++async function getOrLaunchBrowser(profileName, freshProfile) {+ const existing = browsers.get(profileName);+ if (existing && existing.browser.isConnected()) {+ try {+ await Promise.race([+ existing.browser.pages(),+ new Promise((_, rej) => setTimeout(() => rej(new Error('health check timeout')), 2500)),+ ]);+ return existing.browser;+ } catch {+ try { await existing.browser.close(); } catch { /* ignore */ }+ browsers.delete(profileName);+ }+ }+ const launchOpts = {+ browser: 'firefox', // WebDriver BiDi+ headless: HEADLESS,+ defaultViewport: null,+ extraPrefsFirefox: FIREFOX_PREFS,+ handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,+ };+ if (!freshProfile) {+ const userDataDir = path.join(PROFILES_DIR, profileName);+ fs.mkdirSync(userDataDir, { recursive: true });+ launchOpts.userDataDir = userDataDir;+ }+ const exe = resolveFirefox();+ if (exe) launchOpts.executablePath = exe;++ const browser = await puppeteer.launch(launchOpts);+ browser.on('disconnected', () => { browsers.delete(profileName); });+ browsers.set(profileName, { browser, refCount: 0 });+ return browser;+}++function attachTab(session, page) {+ const tabId = nextTabId(session);+ const errors = [];+ page.on('pageerror', e => errors.push({ type: 'page', text: String(e && e.message || e), ts: Date.now() }));+ page.on('console', msg => { if (msg.type() === 'error') errors.push({ type: 'console', text: msg.text(), ts: Date.now() }); });+ page.on('close', () => {+ const i = session.tabs.findIndex(t => t.tabId === tabId);+ if (i !== -1) {+ session.tabs.splice(i, 1);+ if (session.activeTabId === tabId) {+ session.activeTabId = session.tabs.length ? session.tabs[session.tabs.length - 1].tabId : null;+ }+ }+ });+ session.tabs.push({ tabId, page, errors });+ session.activeTabId = tabId;+ return tabId;+}++async function sessionInfo(sessionId) {+ const s = sessions.get(sessionId);+ if (!s) return null;+ const tabs = [];+ for (const t of s.tabs) {+ let url = null, title = null;+ try { url = t.page.url(); } catch { /* closed */ }+ try { title = await t.page.title(); } catch { /* ignore */ }+ tabs.push({ tabId: t.tabId, url, title, active: t.tabId === s.activeTabId, errorCount: t.errors.length });+ }+ return { sessionId, profile: s.profileName, engine: 'firefox', activeTabId: s.activeTabId, tabs, tabCount: tabs.length };+}++// ── Verb handlers ────────────────────────────────────────────────────+const handlers = {+ async ffbrowser_open_window(args) {+ if (!args.sessionId) throw err('sessionId is required. Example: ffbrowser_open_window {"sessionId":"x","profile":"x","url":"https://…"}');+ if (!args.profile) throw err('profile is required (use the same value as sessionId for a dedicated profile, or share another session\'s profile to share cookies/logins).');+ const sessionId = args.sessionId;+ const profileName = args.profile;++ // Reuse a live session: just navigate.+ const existing = sessions.get(sessionId);+ if (existing) {+ const tab = getTab(existing);+ if (tab) {+ try {+ await tab.page.title();+ if (args.url) { await tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 }); tab.errors.length = 0; }+ activeSessionId = sessionId;+ return ok(await sessionInfo(sessionId));+ } catch { sessions.delete(sessionId); }+ } else { sessions.delete(sessionId); }+ }++ const browser = await getOrLaunchBrowser(profileName, !!args.freshProfile);+ // Reuse a blank startup tab if one is free, else open a new page.+ const claimed = new Set();+ for (const s of sessions.values()) for (const t of s.tabs) claimed.add(t.page);+ const pages = await browser.pages();+ let page = pages.find(p => { if (claimed.has(p)) return false; try { const u = p.url(); return !u || u === 'about:blank'; } catch { return false; } });+ if (!page) page = await browser.newPage();++ if (args.url) await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });++ const session = createSession(profileName);+ attachTab(session, page);+ sessions.set(sessionId, session);+ activeSessionId = sessionId;+ return ok(await sessionInfo(sessionId));+ },++ async ffbrowser_open_tab(args) {+ const s = requireSession(args.sessionId);+ const browser = browsers.get(s.profileName).browser;+ const page = await browser.newPage();+ if (args.url) await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });+ const tabId = attachTab(s, page);+ return ok({ ...(await sessionInfo(args.sessionId)), openedTabId: tabId });+ },++ async ffbrowser_close_tab(args) {+ const s = requireSession(args.sessionId);+ const tab = getTab(s, args.tabId);+ if (!tab) throw err(`tab not found: ${args.tabId || '(active)'}`);+ try { await tab.page.close(); } catch { /* ignore */ }+ return ok(await sessionInfo(args.sessionId));+ },++ async ffbrowser_switch_tab(args) {+ const s = requireSession(args.sessionId);+ const tab = getTab(s, args.tabId);+ if (!tab) throw err(`tab not found: ${args.tabId}`);+ s.activeTabId = tab.tabId;+ try { await tab.page.bringToFront(); } catch { /* ignore */ }+ return ok(await sessionInfo(args.sessionId));+ },++ async ffbrowser_list_tabs(args) {+ requireSession(args.sessionId);+ return ok(await sessionInfo(args.sessionId));+ },++ async ffbrowser_close_window(args) {+ const s = requireSession(args.sessionId);+ sessions.delete(args.sessionId);+ if (activeSessionId === args.sessionId) activeSessionId = sessions.keys().next().value || null;+ const entry = browsers.get(s.profileName);+ // Close the browser only if no other session shares this profile.+ const stillUsed = [...sessions.values()].some(o => o.profileName === s.profileName);+ if (entry && !stillUsed) { try { await entry.browser.close(); } catch { /* ignore */ } browsers.delete(s.profileName); }+ return ok({ closed: args.sessionId });+ },++ async ffbrowser_navigate(args) {+ const tab = activeTabOf(args);+ if (!args.url) throw err('url is required');+ await tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });+ tab.errors.length = 0;+ return ok({ url: tab.page.url(), title: await tab.page.title().catch(() => null) });+ },++ async ffbrowser_reload(args) {+ const tab = activeTabOf(args);+ await tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });+ tab.errors.length = 0;+ return ok({ url: tab.page.url() });+ },++ async ffbrowser_evaluate(args) {+ const tab = activeTabOf(args);+ if (!args.expr) throw err('expr is required. Usage: ffbrowser_evaluate {"sessionId":"…","expr":"document.title"}');+ const value = await tab.page.evaluate(expr => {+ // eslint-disable-next-line no-eval+ const r = eval(expr);+ return r;+ }, args.expr);+ return ok({ value });+ },++ async ffbrowser_screenshot(args) {+ const tab = activeTabOf(args);+ const buf = await tab.page.screenshot({ fullPage: !!args.fullPage, type: 'png' });+ const shotPath = path.join(STATE_DIR, `shot-${args.sessionId}-${Date.now()}.png`);+ try { fs.writeFileSync(shotPath, buf); } catch { /* ignore */ }+ return ok({ path: shotPath, base64: buf.toString('base64'), bytes: buf.length });+ },++ // Trusted input via puppeteer's BiDi-backed page.mouse / page.keyboard.+ // type: click | move | type | key+ async ffbrowser_input_dispatch(args) {+ const tab = activeTabOf(args);+ const page = tab.page;+ const t = (args.type || '').toLowerCase();+ if (t === 'click') {+ if (args.selector) {+ const elt = await page.$(args.selector);+ if (!elt) throw err(`selector not found: ${args.selector}`);+ await elt.click();+ return ok({ clicked: args.selector, isTrusted: true });+ }+ if (typeof args.x === 'number' && typeof args.y === 'number') {+ await page.mouse.click(args.x, args.y);+ return ok({ clicked: { x: args.x, y: args.y }, isTrusted: true });+ }+ throw err('click: provide {selector} OR {x,y}');+ }+ if (t === 'move') {+ if (typeof args.x !== 'number' || typeof args.y !== 'number') throw err('move: {x,y} required');+ await page.mouse.move(args.x, args.y);+ return ok({ moved: { x: args.x, y: args.y } });+ }+ if (t === 'type') {+ if (args.selector) { const e = await page.$(args.selector); if (e) await e.focus(); }+ if (typeof args.text !== 'string') throw err('type: {text} required');+ await page.keyboard.type(args.text, { delay: args.delay || 0 });+ return ok({ typed: args.text.length, isTrusted: true });+ }+ if (t === 'key') {+ if (!args.key) throw err('key: {key} required (e.g. Enter, Escape, Tab, ArrowDown)');+ await page.keyboard.press(args.key);+ return ok({ pressed: args.key, isTrusted: true });+ }+ throw err(`unknown input type "${args.type}". Supported: click | move | type | key`);+ },++ async ffbrowser_errors(args) {+ const tab = activeTabOf(args);+ return ok({ errors: tab.errors, count: tab.errors.length });+ },++ async ffbrowser_status() {+ const out = [];+ for (const id of sessions.keys()) out.push(await sessionInfo(id));+ return ok({ bridge: 'ffpup', engine: 'firefox', sessionCount: sessions.size, activeSession: activeSessionId, sessions: out });+ },+};++// ── helpers for handlers ─────────────────────────────────────────────+function err(message) { const e = new Error(message); e._ffpup = true; return e; }+function ok(obj) { return { success: true, output: JSON.stringify({ ok: true, ...obj }) }; }+function requireSession(sessionId) {+ if (!sessionId) throw err('sessionId is required');+ const s = sessions.get(sessionId);+ if (!s) throw err(`session "${sessionId}" not found. Open one first with ffbrowser_open_window. Available: [${[...sessions.keys()].join(', ')}]`);+ return s;+}+function activeTabOf(args) {+ const s = requireSession(args.sessionId);+ const tab = getTab(s, args.tabId);+ if (!tab) throw err(`no open tab in session "${args.sessionId}"`);+ return tab;+}++// ── HTTP server ──────────────────────────────────────────────────────+function readBody(req) {+ return new Promise((resolve, reject) => {+ let data = '';+ req.on('data', c => { data += c; });+ req.on('end', () => resolve(data));+ req.on('error', reject);+ });+}+function sendJSON(res, obj, status = 200) {+ const body = JSON.stringify(obj);+ res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });+ res.end(body);+}++const server = http.createServer(async (req, res) => {+ const url = new URL(req.url, `http://127.0.0.1:${PORT}`);+ const pathname = url.pathname;+ if (req.method === 'OPTIONS') {+ res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' });+ return res.end();+ }+ // Health — accept both /status (manifest) and /health (pup-compat).+ if ((pathname === '/status' || pathname === '/health') && req.method === 'GET') {+ return sendJSON(res, {+ status: 'ok', bridge: 'ffpup', engine: 'firefox', port: PORT,+ browserCount: browsers.size, sessionCount: sessions.size, activeSession: activeSessionId,+ firefox: resolveFirefox() || '(puppeteer-managed)', headless: HEADLESS,+ });+ }+ if (pathname === '/command' && req.method === 'POST') {+ let command;+ try {+ const body = JSON.parse(await readBody(req) || '{}');+ command = body.command;+ const args = body.args || {};+ const handler = handlers[command];+ if (!handler) return sendJSON(res, { success: false, error: `unknown command "${command}". Known: ${Object.keys(handlers).join(', ')}` });+ const result = await handler(args);+ return sendJSON(res, result);+ } catch (e) {+ return sendJSON(res, { success: false, error: e && e.message ? e.message : String(e), command });+ }+ }+ sendJSON(res, { success: false, error: `not found: ${req.method} ${pathname}` }, 404);+});++server.listen(PORT, '127.0.0.1', () => {+ console.log(`[ffpup] listening on 127.0.0.1:${PORT} — firefox=${resolveFirefox() || '(puppeteer-managed)'} headless=${HEADLESS}`);+});++process.on('SIGTERM', () => { server.close(); process.exit(0); });+process.on('SIGINT', () => { server.close(); process.exit(0); });