From 6dcf0683650b948fd20bd7726fbf72df1aa765d1 Mon Sep 17 00:00:00 2001 From: alexveley Date: Tue, 22 Sep 2026 06:24:31 -0400 Subject: [PATCH] Initial Firefox Agent Bridge: allowlisted bookmarks RPC for local scripts. --- .gitignore | 4 + AGENTS.md | 120 ++++++++++++++++ LICENSE | 21 +++ README.md | 61 ++++++++ examples/replace_named_folder.py | 50 +++++++ extension/background.js | 81 +++++++++++ extension/manifest.json | 16 +++ host/firefox-agent-bridge-host.cmd | 4 + host/firefox_agent_bridge_host.py | 221 +++++++++++++++++++++++++++++ tools/client.py | 65 +++++++++ tools/install-native-host.ps1 | 47 ++++++ 11 files changed, 690 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/replace_named_folder.py create mode 100644 extension/background.js create mode 100644 extension/manifest.json create mode 100644 host/firefox-agent-bridge-host.cmd create mode 100644 host/firefox_agent_bridge_host.py create mode 100644 tools/client.py create mode 100644 tools/install-native-host.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c7aaa2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.DS_Store +host/com.easygoingaming.firefox_agent_bridge.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d201c9b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,120 @@ +# Agent handshake — Firefox Agent Bridge + +Use this when a script or coding agent needs to **create, update, move, or delete Firefox bookmarks while Firefox is open**. Do not edit `places.sqlite` if this bridge is reachable. + +## Preconditions + +1. Native host installed: `powershell -NoProfile -File tools/install-native-host.ps1` +2. Extension loaded in the target Firefox profile (`about:debugging` temporary add-on, or a signed install) +3. Firefox **running** (the host process is started by the extension) + +If `http://127.0.0.1:17634/health` fails, the host is not up — open Firefox and confirm the extension is loaded. If `/health` works but `/v1/ready` fails, the stdio pipe is down; reload the extension. + +## Auth + +Token file (created by the installer): + +`%LOCALAPPDATA%\firefox-agent-bridge\token` + +Send it on every request except `/health`: + +``` +Authorization: Bearer +``` + +Override with env `FAB_TOKEN` or `FAB_URL` (default `http://127.0.0.1:17634`). + +## Preferred caller + +From this repo: + +```text +python tools/client.py METHOD [ARGS_JSON] +``` + +`ARGS_JSON` is a JSON **array** matching the WebExtension function arguments. + +Examples: + +```text +python tools/client.py meta.methods +python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]" +python tools/client.py bookmarks.getChildren "[\"FOLDER_ID\"]" +python tools/client.py bookmarks.create "[{\"parentId\":\"FOLDER_ID\",\"title\":\"NPM\",\"url\":\"http://10.132.99.80:81/\"}]" +python tools/client.py bookmarks.remove "[\"BOOKMARK_ID\"]" +python tools/client.py bookmarks.removeTree "[\"FOLDER_ID\"]" +``` + +HTTP equivalent: + +```http +POST /v1/call +Content-Type: application/json + +{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]} +``` + +## Allowlisted methods (v0.1) + +| Method | Args | Notes | +|--------|------|--------| +| `meta.ping` | (none) | Bridge alive | +| `meta.methods` | (none) | This table | +| `bookmarks.getTree` | (none) | Full tree | +| `bookmarks.getSubTree` | `id` | | +| `bookmarks.getChildren` | `id` | | +| `bookmarks.get` | `id` or `[id, …]` | | +| `bookmarks.search` | `{title? url? query?}` or string | | +| `bookmarks.create` | `{parentId?, title, url?}` | Omit `url` to create a folder | +| `bookmarks.update` | `id`, `{title? url?}` | | +| `bookmarks.move` | `id`, `{parentId? index?}` | | +| `bookmarks.remove` | `id` | Bookmark or empty folder | +| `bookmarks.removeTree` | `id` | Folder and descendants | +| `bookmarks.getRecent` | `numberOfItems` | | + +Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension. + +REST aliases (same auth): + +| HTTP | Maps to | +|------|---------| +| `GET /health` | host process only (no token) | +| `GET /v1/ready` | `meta.ping` | +| `GET /v1/methods` | `meta.methods` | +| `GET /v1/bookmarks/tree` | `bookmarks.getTree` | +| `GET /v1/bookmarks/{id}` | `bookmarks.get` | +| `POST /v1/bookmarks/search` | body is the query object | +| `POST /v1/bookmarks/create` | body is `CreateDetails` | +| `POST /v1/bookmarks/update` | `{id, changes}` | +| `POST /v1/bookmarks/move` | `{id, destination}` | +| `POST /v1/bookmarks/remove` | `{id}` | +| `POST /v1/bookmarks/remove-tree` | `{id}` | +| `POST /v1/call` | `{method, args}` | + +## Typical folder replace + +1. `bookmarks.search` `{title: "…"}` — take the hit **without** a `url` (that is the folder). Fail if zero or many. +2. `bookmarks.getChildren` on that id. +3. `bookmarks.remove` each bookmark; `bookmarks.removeTree` each child folder. +4. `bookmarks.create` each new item with `parentId` set. + +`examples/replace_named_folder.py` does exactly that. It is an example, not a sync service. + +You cannot modify Firefox's bookmark root (`The bookmark root cannot be modified`). Operate on a named subfolder (toolbar / menu / a folder the user already created). + +## Failure modes + +| Symptom | Cause | What to do | +|---------|--------|------------| +| Connection refused on `:17634` | Firefox closed or extension not loaded | Open Firefox; load/reload the add-on | +| 401 | Missing/wrong bearer | Read the token file | +| 502 `method not allowed` | Typo or API not in `ALLOWED` | Use `meta.methods` | +| 502 timeout | Extension died mid-call | Reload the add-on | +| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO unlisted | +| Native host not found | Installer not run | `tools/install-native-host.ps1` | + +## What this project is not + +- Not a bidirectional sync implementation +- Not a general Firefox remote-control surface (no tabs, history, cookies, native file access) +- Not a reason to keep editing `places.sqlite` on a live profile diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6dbc02 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 EasyGoin / EGG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f16b18b --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Firefox Agent Bridge + +A small Firefox extension that exposes an **allowlisted** WebExtension API to local scripts and agents. + +This is **not** a bookmark sync engine. Firefox already has `browser.bookmarks`. The extension is a bridge: while Firefox is open, a script on the same machine can call those functions over `http://127.0.0.1:17634`. + +Agents: start at [AGENTS.md](AGENTS.md). + +## Why this exists + +Firefox will not let an outside process talk to Places. Editing `places.sqlite` while the browser is running loses deletes. The supported path is `browser.bookmarks` inside an extension, plus [native messaging](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging) so a localhost HTTP host can reach that extension. + +## Pieces + +| Piece | Role | +|-------|------| +| `extension/` | WebExtension (`bookmarks` + `nativeMessaging`) | +| `host/` | Native host: stdio to Firefox, HTTP to scripts | +| `tools/client.py` | Stdlib Python caller | +| `examples/replace_named_folder.py` | Sample "replace this folder" script | + +## Install (this workstation) + +1. Register the native host (once): + + ```powershell + powershell -NoProfile -File tools\install-native-host.ps1 + ``` + + That writes `%LOCALAPPDATA%\firefox-agent-bridge\token` and + `HKCU\Software\Mozilla\NativeMessagingHosts\com.easygoingaming.firefox_agent_bridge`. + +2. Load the extension. Firefox Release will not keep an unsigned add-on across restarts: + + - `about:debugging#/runtime/this-firefox` + - **Load Temporary Add-on** + - pick `extension\manifest.json` + + Permanent install later = AMO-signed (unlisted is enough). + +3. Leave Firefox open. The extension starts the host; `GET http://127.0.0.1:17634/health` should return JSON. + +## Call it + +```powershell +$token = Get-Content $env:LOCALAPPDATA\firefox-agent-bridge\token -Raw +curl.exe -s -H "Authorization: Bearer $token" http://127.0.0.1:17634/v1/methods +python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]" +``` + +See [AGENTS.md](AGENTS.md) for the method list, HTTP surface, and failure modes. + +## Security + +- Binds **127.0.0.1 only**. +- Every mutating call (and most reads) needs `Authorization: Bearer `. +- Only the methods in `extension/background.js` `ALLOWED` run. No history, cookies, or tabs in v0.1. + +## License + +MIT. diff --git a/examples/replace_named_folder.py b/examples/replace_named_folder.py new file mode 100644 index 0000000..7937ef7 --- /dev/null +++ b/examples/replace_named_folder.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Example: replace children of a named bookmark folder from a small JSON list. + +This is not a sync engine. It finds one folder by title, deletes its children, +and creates the supplied bookmarks. See AGENTS.md. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +from client import call # noqa: E402 + + +def find_folder(title: str) -> dict: + hits = call("bookmarks.search", [{"title": title}]) + folders = [n for n in hits if not n.get("url")] + if not folders: + raise SystemExit(f"folder not found: {title}") + if len(folders) > 1: + raise SystemExit(f"multiple folders titled {title!r}; pass a unique name") + return folders[0] + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: replace_named_folder.py FOLDER_TITLE items.json", file=sys.stderr) + return 2 + title = sys.argv[1] + items = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) + folder = find_folder(title) + for child in call("bookmarks.getChildren", [folder["id"]]): + if child.get("url"): + call("bookmarks.remove", [child["id"]]) + else: + call("bookmarks.removeTree", [child["id"]]) + for item in items: + call( + "bookmarks.create", + [{"parentId": folder["id"], "title": item["title"], "url": item["url"]}], + ) + print(f"replaced {len(items)} bookmarks in {title!r} ({folder['id']})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..162bae9 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,81 @@ +"use strict"; + +const HOST_NAME = "com.easygoingaming.firefox_agent_bridge"; +const RECONNECT_MS = 2000; + +const ALLOWED = { + "meta.ping": async () => ({ ok: true, extension: "firefox-agent-bridge" }), + "meta.methods": async () => Object.keys(ALLOWED).sort(), + "bookmarks.getTree": () => browser.bookmarks.getTree(), + "bookmarks.getSubTree": (id) => browser.bookmarks.getSubTree(id), + "bookmarks.getChildren": (id) => browser.bookmarks.getChildren(id), + "bookmarks.get": (idOrIds) => browser.bookmarks.get(idOrIds), + "bookmarks.search": (query) => browser.bookmarks.search(query), + "bookmarks.create": (details) => browser.bookmarks.create(details), + "bookmarks.update": (id, changes) => browser.bookmarks.update(id, changes), + "bookmarks.move": (id, destination) => browser.bookmarks.move(id, destination), + "bookmarks.remove": (id) => browser.bookmarks.remove(id), + "bookmarks.removeTree": (id) => browser.bookmarks.removeTree(id), + "bookmarks.getRecent": (numberOfItems) => browser.bookmarks.getRecent(numberOfItems), +}; + +let port = null; +let reconnectTimer = null; + +function asArray(value) { + if (value === undefined || value === null) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +async function dispatch(message) { + const method = message && message.method; + const fn = ALLOWED[method]; + if (!fn) { + throw new Error(`method not allowed: ${method || "(missing)"}`); + } + return fn(...asArray(message.args)); +} + +function attach(nextPort) { + port = nextPort; + port.onMessage.addListener(async (message) => { + const id = message && message.id; + try { + const result = await dispatch(message); + port.postMessage({ id, ok: true, result }); + } catch (err) { + port.postMessage({ + id, + ok: false, + error: err && err.message ? err.message : String(err), + }); + } + }); + port.onDisconnect.addListener(() => { + port = null; + scheduleReconnect(); + }); +} + +function scheduleReconnect() { + if (reconnectTimer) { + return; + } + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, RECONNECT_MS); +} + +function connect() { + try { + attach(browser.runtime.connectNative(HOST_NAME)); + } catch (err) { + console.error("native host connect failed", err); + scheduleReconnect(); + } +} + +connect(); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..1bb61cf --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,16 @@ +{ + "manifest_version": 3, + "name": "Firefox Agent Bridge", + "version": "0.1.0", + "description": "Expose an allowlisted WebExtension API to local scripts and agents over native messaging.", + "browser_specific_settings": { + "gecko": { + "id": "firefox-agent-bridge@easygoingaming.com", + "strict_min_version": "115.0" + } + }, + "permissions": ["bookmarks", "nativeMessaging"], + "background": { + "scripts": ["background.js"] + } +} diff --git a/host/firefox-agent-bridge-host.cmd b/host/firefox-agent-bridge-host.cmd new file mode 100644 index 0000000..a1f73ea --- /dev/null +++ b/host/firefox-agent-bridge-host.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +py -3 -u "%SCRIPT_DIR%firefox_agent_bridge_host.py" %* diff --git a/host/firefox_agent_bridge_host.py b/host/firefox_agent_bridge_host.py new file mode 100644 index 0000000..4b10828 --- /dev/null +++ b/host/firefox_agent_bridge_host.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio.""" +from __future__ import annotations + +import json +import os +import struct +import sys +import threading +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlparse + +if sys.platform == "win32": + import msvcrt + + msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) + msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) + +HOST = "127.0.0.1" +PORT = int(os.environ.get("FAB_PORT", "17634")) +STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" +TOKEN_PATH = STATE_DIR / "token" +STDIN_LOCK = threading.Lock() +PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {} +PENDING_LOCK = threading.Lock() + + +def log(msg: str) -> None: + sys.stderr.write(msg + "\n") + sys.stderr.flush() + + +def read_token() -> str: + env = os.environ.get("FAB_TOKEN") + if env: + return env.strip() + if TOKEN_PATH.exists(): + return TOKEN_PATH.read_text(encoding="utf-8").strip() + return "" + + +def send_to_extension(payload: dict[str, Any]) -> None: + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + with STDIN_LOCK: + sys.stdout.buffer.write(struct.pack("@I", len(raw))) + sys.stdout.buffer.write(raw) + sys.stdout.buffer.flush() + + +def read_from_extension() -> dict[str, Any] | None: + header = sys.stdin.buffer.read(4) + if len(header) < 4: + return None + (length,) = struct.unpack("@I", header) + body = sys.stdin.buffer.read(length) + if len(body) < length: + return None + return json.loads(body.decode("utf-8")) + + +def call_extension(method: str, args: list[Any] | None = None, timeout: float = 15.0) -> dict[str, Any]: + req_id = str(uuid.uuid4()) + event = threading.Event() + slot: dict[str, Any] = {} + with PENDING_LOCK: + PENDING[req_id] = (event, slot) + send_to_extension({"id": req_id, "method": method, "args": args or []}) + if not event.wait(timeout): + with PENDING_LOCK: + PENDING.pop(req_id, None) + raise TimeoutError(f"extension did not answer {method}") + if not slot.get("ok"): + raise RuntimeError(slot.get("error") or "extension error") + return slot.get("result") + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt: str, *args: Any) -> None: + log("%s - %s" % (self.address_string(), fmt % args)) + + def _send(self, code: int, payload: Any) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _auth_ok(self) -> bool: + token = read_token() + if not token: + self._send(500, {"ok": False, "error": "bridge token missing; run install-native-host.ps1"}) + return False + header = self.headers.get("Authorization", "") + got = header[7:].strip() if header.lower().startswith("bearer ") else "" + if got != token: + self._send(401, {"ok": False, "error": "missing or invalid bearer token"}) + return False + return True + + def _json_body(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length") or "0") + if length <= 0: + return {} + raw = self.rfile.read(length) + if not raw: + return {} + data = json.loads(raw.decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("JSON object required") + return data + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/health": + self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT}) + return + if not self._auth_ok(): + return + qs = parse_qs(parsed.query) + try: + if parsed.path in ("/v1/ready", "/v1/methods"): + method = "meta.ping" if parsed.path == "/v1/ready" else "meta.methods" + result = call_extension(method) + self._send(200, {"ok": True, "result": result}) + return + if parsed.path == "/v1/bookmarks/tree": + result = call_extension("bookmarks.getTree") + self._send(200, {"ok": True, "result": result}) + return + if parsed.path.startswith("/v1/bookmarks/") and parsed.path != "/v1/bookmarks/": + node_id = parsed.path[len("/v1/bookmarks/") :].strip("/") + if "children" in qs: + result = call_extension("bookmarks.getChildren", [node_id]) + else: + result = call_extension("bookmarks.get", [node_id]) + self._send(200, {"ok": True, "result": result}) + return + self._send(404, {"ok": False, "error": "not found"}) + except Exception as exc: + self._send(502, {"ok": False, "error": str(exc)}) + + def do_POST(self) -> None: + if not self._auth_ok(): + return + parsed = urlparse(self.path) + try: + body = self._json_body() + except ValueError as exc: + self._send(400, {"ok": False, "error": str(exc)}) + return + try: + if parsed.path == "/v1/call": + method = body.get("method") + args = body.get("args") or [] + if not method: + self._send(400, {"ok": False, "error": "method required"}) + return + result = call_extension(str(method), list(args) if not isinstance(args, list) else args) + self._send(200, {"ok": True, "result": result}) + return + rest = { + "/v1/bookmarks/search": ("bookmarks.search", [body.get("query", body)]), + "/v1/bookmarks/create": ("bookmarks.create", [body]), + "/v1/bookmarks/update": ("bookmarks.update", [body.get("id"), body.get("changes") or {}]), + "/v1/bookmarks/move": ("bookmarks.move", [body.get("id"), body.get("destination") or {}]), + "/v1/bookmarks/remove": ("bookmarks.remove", [body.get("id")]), + "/v1/bookmarks/remove-tree": ("bookmarks.removeTree", [body.get("id")]), + } + if parsed.path in rest: + method, args = rest[parsed.path] + result = call_extension(method, args) + self._send(200, {"ok": True, "result": result}) + return + self._send(404, {"ok": False, "error": "not found"}) + except Exception as exc: + self._send(502, {"ok": False, "error": str(exc)}) + + +def stdin_loop() -> None: + while True: + try: + message = read_from_extension() + except Exception as exc: + log(f"stdin read failed: {exc}") + break + if message is None: + break + req_id = str(message.get("id") or "") + with PENDING_LOCK: + pending = PENDING.pop(req_id, None) + if not pending: + continue + event, slot = pending + slot.update(message) + event.set() + os._exit(0) + + +def main() -> int: + STATE_DIR.mkdir(parents=True, exist_ok=True) + token = read_token() + if not token: + log(f"no token at {TOKEN_PATH}; run tools/install-native-host.ps1") + threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start() + server = ThreadingHTTPServer((HOST, PORT), Handler) + log(f"listening on http://{HOST}:{PORT}") + try: + server.serve_forever() + except KeyboardInterrupt: + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/client.py b/tools/client.py new file mode 100644 index 0000000..63b51ef --- /dev/null +++ b/tools/client.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Minimal client for Firefox Agent Bridge. Stdlib only.""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +DEFAULT_BASE = os.environ.get("FAB_URL", "http://127.0.0.1:17634") +TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" / "token" + + +def token() -> str: + env = os.environ.get("FAB_TOKEN") + if env: + return env.strip() + if TOKEN_PATH.exists(): + return TOKEN_PATH.read_text(encoding="utf-8").strip() + raise SystemExit(f"no token: set FAB_TOKEN or create {TOKEN_PATH}") + + +def call(method: str, args: list[Any] | None = None, base: str = DEFAULT_BASE) -> Any: + payload = json.dumps({"method": method, "args": args or []}).encode("utf-8") + req = urllib.request.Request( + base.rstrip("/") + "/v1/call", + data=payload, + method="POST", + headers={ + "Authorization": f"Bearer {token()}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + body = json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as exc: + raise SystemExit( + f"bridge unreachable at {base} ({exc}). " + "Firefox must be open with the extension loaded." + ) from exc + if not body.get("ok"): + raise SystemExit(body.get("error") or body) + return body.get("result") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Call Firefox Agent Bridge") + parser.add_argument("method", help="e.g. bookmarks.search or meta.methods") + parser.add_argument("args_json", nargs="?", default="[]", help="JSON array of arguments") + parser.add_argument("--url", default=DEFAULT_BASE) + ns = parser.parse_args() + args = json.loads(ns.args_json) + if not isinstance(args, list): + raise SystemExit("args_json must be a JSON array") + print(json.dumps(call(ns.method, args, base=ns.url), indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/install-native-host.ps1 b/tools/install-native-host.ps1 new file mode 100644 index 0000000..01cbc23 --- /dev/null +++ b/tools/install-native-host.ps1 @@ -0,0 +1,47 @@ +# Register the native messaging host for the current Windows user. +# Does not load the Firefox extension — see README / AGENTS.md. + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$HostDir = Join-Path $Root "host" +$CmdPath = Join-Path $HostDir "firefox-agent-bridge-host.cmd" +$ManifestPath = Join-Path $HostDir "com.easygoingaming.firefox_agent_bridge.json" +$StateDir = Join-Path $env:LOCALAPPDATA "firefox-agent-bridge" +$TokenPath = Join-Path $StateDir "token" +$HostName = "com.easygoingaming.firefox_agent_bridge" +$ExtensionId = "firefox-agent-bridge@easygoingaming.com" + +if (-not (Test-Path $CmdPath)) { + throw "host launcher missing: $CmdPath" +} + +New-Item -ItemType Directory -Force -Path $StateDir | Out-Null +if (-not (Test-Path $TokenPath) -or -not (Get-Content -Raw $TokenPath).Trim()) { + $bytes = New-Object byte[] 32 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + $token = -join ($bytes | ForEach-Object { $_.ToString("x2") }) + [System.IO.File]::WriteAllText($TokenPath, $token) + Write-Host "wrote $TokenPath" +} else { + Write-Host "kept existing token at $TokenPath" +} + +$manifest = @{ + name = $HostName + description = "Firefox Agent Bridge native host" + path = $CmdPath + type = "stdio" + allowed_extensions = @($ExtensionId) +} | ConvertTo-Json -Compress +[System.IO.File]::WriteAllText($ManifestPath, $manifest) +Write-Host "wrote $ManifestPath" + +$regPath = "HKCU:\Software\Mozilla\NativeMessagingHosts\$HostName" +New-Item -Path $regPath -Force | Out-Null +Set-ItemProperty -Path $regPath -Name "(default)" -Value $ManifestPath +Write-Host "registered $regPath" + +Write-Host "" +Write-Host "Next: in Firefox open about:debugging#/runtime/this-firefox" +Write-Host "Load Temporary Add-on and pick extension\manifest.json" +Write-Host "Token is read from $TokenPath (Authorization: Bearer ...)"