← Commit history

ffpup bridge: manifest + source + docs

barrett-land ·126907ef3f ·4mo ago ·parent e4704cf
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-}\ No newline at end of file+  "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); });