From 41c83dabac36f64e628f700377c8aeaa8166e67f Mon Sep 17 00:00:00 2001 From: alexveley Date: Tue, 22 Sep 2026 07:42:10 -0400 Subject: [PATCH] Keep client secrets in add-on storage; host only verifies and serves the local API. --- AGENTS.md | 19 ++-- README.md | 16 ++-- extension/background.js | 62 +++++-------- extension/clients.js | 82 +++++++++++++++++ extension/manifest.json | 8 +- extension/options.html | 8 +- extension/options.js | 29 +++--- extension/popup.html | 3 +- extension/popup.js | 4 +- extension/ui.css | 3 + fab_auth.py | 142 ------------------------------ host/firefox_agent_bridge_host.py | 57 +++++++----- tools/register_client.py | 42 ++------- 13 files changed, 185 insertions(+), 290 deletions(-) create mode 100644 extension/clients.js delete mode 100644 fab_auth.py diff --git a/AGENTS.md b/AGENTS.md index e61fa0b..592c81d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,15 +1,16 @@ # Agent handshake — Bookmarks API for Scripting and AI -Use this when working **in this repo**. The shipped product is the add-on under `extension/` only. Do not treat `tools/` as part of the listing. +An API add-on for Firefox to allow agentic and script-based management of user bookmarks. -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. +Use this when working **in this repo**. The shipped product is `extension/` only. + +Do not edit `places.sqlite` while Firefox is open if this API is reachable. ## Preconditions -1. Native host installed: `powershell -NoProfile -File tools/install-native-host.ps1` -2. Add-on loaded (temporary via `about:debugging`, or a signed install) -3. A **client secret** issued from the add-on UI: toolbar icon → **Manage clients** → Generate secret -4. Firefox **running** +1. Add-on loaded. Secrets are issued and stored **in the add-on** (toolbar → Manage clients). The options page does not need the native host. +2. Native host installed only if scripts should call in: `powershell -NoProfile -File tools/install-native-host.ps1` +3. Firefox **running** for those calls If `http://127.0.0.1:17634/health` fails, the host is not up. If `/health` works but calls 401, you do not have a current `FAB_TOKEN`. @@ -31,12 +32,6 @@ python tools/client.py meta.methods The add-on stores **SHA-256(token)** only. Revoke from the same page. A local process that never received the secret cannot call the API. -CLI fallback (same store, prints the token once): - -```text -python tools/register_client.py add --name cursor-agent -``` - ## Preferred caller ```text diff --git a/README.md b/README.md index 061c90b..0f7c635 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@ # Bookmarks API for Scripting and AI -A Firefox add-on that lets **local** scripts and agents call an allowlisted `browser.bookmarks` API — after you issue them a secret from inside the browser. +An API add-on for Firefox to allow agentic and script-based management of user bookmarks. -The published product is the add-on in `extension/`. It does not ship helper scripts. Callers use ordinary HTTP and a Bearer token, stored however they already keep secrets. +The published product is the add-on in `extension/`. Issue a secret in **Manage clients**, store it with your secrets, and keep Firefox open. A separately installed native host is what accepts those local calls. The options page does not need the host. -This is not a bookmark sync engine, and it is not a Mozilla product. Mozilla’s add-on naming rule is “Name for Firefox,” never “Firefox Name”; this listing uses neither form in the title. +This is not a bookmark sync engine, and it is not a Mozilla product. ## Setup -1. Register the native host once (`tools/install-native-host.ps1` on this workstation). -2. Load the add-on (temporary via `about:debugging`, or a signed `.xpi` — [docs/signing.md](docs/signing.md)). -3. Toolbar icon → **Manage clients** → generate a secret → store it with your secrets. +1. Load the add-on (temporary via `about:debugging`, or a signed `.xpi` — [docs/signing.md](docs/signing.md)). +2. Toolbar → **Manage clients** → generate a secret → store it with your secrets. +3. To let scripts call in, register the native host once (`tools/install-native-host.ps1` on this workstation). -While Firefox is open, `http://127.0.0.1:17634/health` answers if the host is up. Authenticated calls use `Authorization: Bearer ` on `/v1/call`. Method list: the repo [AGENTS.md](AGENTS.md) (for people working on this codebase, not for the AMO listing). +Authenticated calls use `Authorization: Bearer ` on `http://127.0.0.1:17634`. Method list for people working in this repo: [AGENTS.md](AGENTS.md). ## Security - Binds **127.0.0.1** only. Browser `Origin` headers are rejected. -- Each caller is an issued token. The add-on stores a hash, not the secret. +- Client hashes live in the add-on’s storage. The host asks the add-on whether a token is valid. - Only `bookmarks.*` (plus `meta.*`). No history, cookies, tabs, or `onMessageExternal`. ## License diff --git a/extension/background.js b/extension/background.js index 803bc33..9f8987a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,7 +4,7 @@ const HOST_NAME = "com.easygoingaming.firefox_agent_bridge"; const RECONNECT_MS = 2000; const ALLOWED = { - "meta.ping": async () => ({ ok: true, extension: "firefox-agent-bridge" }), + "meta.ping": async () => ({ ok: true, extension: "bookmarks-api" }), "meta.methods": async () => Object.keys(ALLOWED).sort(), "bookmarks.getTree": () => browser.bookmarks.getTree(), "bookmarks.getSubTree": (id) => browser.bookmarks.getSubTree(id), @@ -22,7 +22,6 @@ const ALLOWED = { let port = null; let reconnectTimer = null; let hostUp = false; -const adminWait = new Map(); function asArray(value) { if (value === undefined || value === null) { @@ -40,39 +39,32 @@ async function dispatchBookmark(message) { return fn(...asArray(message.args)); } -function adminCall(method, args) { - if (!port) { - return Promise.reject(new Error("native host is not connected")); - } - const id = crypto.randomUUID(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - adminWait.delete(id); - reject(new Error("host did not answer")); - }, 8000); - adminWait.set(id, { resolve, reject, timer }); - port.postMessage({ type: "admin", id, method, args: args || [] }); - }); -} - function attach(nextPort) { port = nextPort; hostUp = true; port.onMessage.addListener(async (message) => { - if (message && message.type === "admin-result") { - const pending = adminWait.get(message.id); - if (!pending) { - return; - } - adminWait.delete(message.id); - clearTimeout(pending.timer); - if (message.ok) { - pending.resolve(message.result); - } else { - pending.reject(new Error(message.error || "admin failed")); + if (message && message.type === "auth") { + try { + const client = await findClientByTokenHash(message.token_hash); + port.postMessage({ + type: "auth-result", + id: message.id, + ok: Boolean(client), + client: client || null, + }); + } catch (err) { + port.postMessage({ + type: "auth-result", + id: message.id, + ok: false, + error: err && err.message ? err.message : String(err), + }); } return; } + if (message && message.type) { + return; + } const id = message && message.id; try { const result = await dispatchBookmark(message); @@ -88,11 +80,6 @@ function attach(nextPort) { port.onDisconnect.addListener(() => { port = null; hostUp = false; - for (const [id, pending] of adminWait.entries()) { - clearTimeout(pending.timer); - pending.reject(new Error("native host disconnected")); - adminWait.delete(id); - } scheduleReconnect(); }); } @@ -118,16 +105,9 @@ function connect() { } browser.runtime.onMessage.addListener((message) => { - const kind = message && message.type; - if (kind === "status") { + if (message && message.type === "status") { return Promise.resolve({ hostUp, port: 17634 }); } - if (kind === "admin") { - return adminCall(message.method, message.args).then( - (result) => ({ ok: true, result }), - (err) => ({ ok: false, error: err.message }) - ); - } return undefined; }); diff --git a/extension/clients.js b/extension/clients.js new file mode 100644 index 0000000..af68b57 --- /dev/null +++ b/extension/clients.js @@ -0,0 +1,82 @@ +"use strict"; + +const CLIENT_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/; +const TOKEN_PREFIX = "fab_"; + +function nowIso() { + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +async function sha256Hex(text) { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function publicClient(row) { + return { + id: row.id, + name: row.name, + created: row.created, + last_used: row.last_used || null, + }; +} + +async function loadClients() { + const data = await browser.storage.local.get("clients"); + return Array.isArray(data.clients) ? data.clients : []; +} + +async function saveClients(clients) { + await browser.storage.local.set({ clients }); +} + +async function listClients() { + return (await loadClients()).map(publicClient); +} + +async function createClient(name) { + name = String(name || "").trim(); + if (!CLIENT_NAME_RE.test(name)) { + throw new Error("Name must be 1–64 characters: A-Za-z0-9._-"); + } + const clients = await loadClients(); + if (clients.some((row) => row.name === name)) { + throw new Error("A client with that name already exists"); + } + const raw = new Uint8Array(32); + crypto.getRandomValues(raw); + const token = TOKEN_PREFIX + Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join(""); + const idBytes = new Uint8Array(16); + crypto.getRandomValues(idBytes); + const id = Array.from(idBytes, (b) => b.toString(16).padStart(2, "0")).join(""); + clients.push({ + id, + name, + token_hash: await sha256Hex(token), + created: nowIso(), + last_used: null, + }); + await saveClients(clients); + return { id, name, token }; +} + +async function revokeClient(id) { + const clients = await loadClients(); + const next = clients.filter((row) => row.id !== id && row.name !== id); + if (next.length === clients.length) { + throw new Error("No such client"); + } + await saveClients(next); + return id; +} + +async function findClientByTokenHash(tokenHash) { + const clients = await loadClients(); + const row = clients.find((item) => item.token_hash === tokenHash); + if (!row) { + return null; + } + row.last_used = nowIso(); + await saveClients(clients); + return publicClient(row); +} diff --git a/extension/manifest.json b/extension/manifest.json index f4ff318..595110e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,17 +2,17 @@ "manifest_version": 3, "name": "Bookmarks API for Scripting and AI", "short_name": "Bookmarks API", - "version": "0.3.0", - "description": "Issue a secret, then let local scripts and agents call an allowlisted bookmarks API.", + "version": "0.4.0", + "description": "An API add-on for Firefox to allow agentic and script-based management of user bookmarks.", "browser_specific_settings": { "gecko": { "id": "firefox-agent-bridge@easygoingaming.com", "strict_min_version": "115.0" } }, - "permissions": ["bookmarks", "nativeMessaging"], + "permissions": ["bookmarks", "nativeMessaging", "storage"], "background": { - "scripts": ["background.js"] + "scripts": ["clients.js", "background.js"] }, "action": { "default_title": "Bookmarks API", diff --git a/extension/options.html b/extension/options.html index 192accd..f6573ac 100644 --- a/extension/options.html +++ b/extension/options.html @@ -8,7 +8,12 @@

Bookmarks API for Scripting and AI

-

Issue a secret to each client. Store it with your secrets. This add-on keeps only a hash.

+

An API add-on for Firefox to allow agentic and script-based management of user bookmarks.

+
    +
  1. Issue a secret for each agent or script that should be allowed to work with your bookmarks.
  2. +
  3. Store that secret with your secrets. This add-on keeps only a hash.
  4. +
  5. While Firefox is open, those tools call the local bookmarks API. A separately installed native host is what listens for those calls — this page does not need it.
  6. +

@@ -43,6 +48,7 @@ + diff --git a/extension/options.js b/extension/options.js index 8e9121f..a48b189 100644 --- a/extension/options.js +++ b/extension/options.js @@ -15,15 +15,6 @@ function showError(text) { errorEl.textContent = text || ""; } -function admin(method, args) { - return browser.runtime.sendMessage({ type: "admin", method, args }).then((resp) => { - if (!resp || !resp.ok) { - throw new Error((resp && resp.error) || "host error"); - } - return resp.result; - }); -} - function render(clients) { if (!clients.length) { rows.innerHTML = 'No clients yet. Generate a secret to get started.'; @@ -50,21 +41,21 @@ function escapeHtml(value) { } async function refresh() { - const info = await browser.runtime.sendMessage({ type: "status" }); - statusEl.textContent = info && info.hostUp - ? `Native host connected · http://127.0.0.1:${info.port}` - : "Native host is not connected. Install the host, then reload this add-on."; - if (!info || !info.hostUp) { - rows.innerHTML = 'Host offline.'; - return; + render(await listClients()); + try { + const info = await browser.runtime.sendMessage({ type: "status" }); + statusEl.textContent = info && info.hostUp + ? "Local script access is on. Agents can call in while this browser is open." + : "Secrets work here. Install the native host if you want scripts and agents to call in."; + } catch (err) { + statusEl.textContent = "Secrets work here. Local script access status is unavailable."; } - render(await admin("clients.list")); } createBtn.addEventListener("click", async () => { showError(""); try { - const created = await admin("clients.create", [nameInput.value.trim()]); + const created = await createClient(nameInput.value.trim()); nameInput.value = ""; secretEl.value = created.token; modal.showModal(); @@ -81,7 +72,7 @@ rows.addEventListener("click", async (event) => { } showError(""); try { - await admin("clients.revoke", [id]); + await revokeClient(id); await refresh(); } catch (err) { showError(err.message); diff --git a/extension/popup.html b/extension/popup.html index 571b4f6..ba54af3 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -6,7 +6,8 @@

Bookmarks API

-

Checking host…

+

An API add-on for Firefox so agents and scripts can manage your bookmarks.

+

Checking…

diff --git a/extension/popup.js b/extension/popup.js index 10b0bcc..e04c212 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -5,9 +5,9 @@ const manage = document.getElementById("manage"); browser.runtime.sendMessage({ type: "status" }).then((info) => { if (info && info.hostUp) { - statusEl.textContent = `Host connected on 127.0.0.1:${info.port}`; + statusEl.textContent = "Local script access is on."; } else { - statusEl.textContent = "Native host is not connected. Is it installed?"; + statusEl.textContent = "You can still issue secrets. Local script access needs the native host."; } }); diff --git a/extension/ui.css b/extension/ui.css index b0ae5ee..bb43754 100644 --- a/extension/ui.css +++ b/extension/ui.css @@ -17,6 +17,9 @@ body { padding: 24px; } h1, h2 { margin: 0 0 8px; font-size: 1.15rem; } +.lead { margin: 0 0 12px; } +.process { margin: 0 0 16px; padding-left: 1.2rem; } +.process li { margin: 6px 0; } .muted { color: GrayText; } .row { display: flex; gap: 8px; margin: 16px 0; } input[type="text"], textarea, button { diff --git a/fab_auth.py b/fab_auth.py deleted file mode 100644 index 6d9ced6..0000000 --- a/fab_auth.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Hashed API-token clients for Firefox Agent Bridge. - -The host stores SHA-256(token) only. The raw secret is shown once in the -extension UI (or CLI) and then lives wherever the user keeps secrets. -""" -from __future__ import annotations - -import hashlib -import hmac -import json -import os -import re -import secrets -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" -CLIENTS_PATH = STATE_DIR / "clients.json" -NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") -TOKEN_PREFIX = "fab_" - - -def _now() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat() - - -def token_hash(token: str) -> str: - return hashlib.sha256(token.encode("ascii")).hexdigest() - - -def load_clients() -> list[dict[str, Any]]: - if not CLIENTS_PATH.exists(): - return [] - data = json.loads(CLIENTS_PATH.read_text(encoding="utf-8")) - return list(data.get("clients") or []) - - -def save_clients(clients: list[dict[str, Any]]) -> None: - STATE_DIR.mkdir(parents=True, exist_ok=True) - CLIENTS_PATH.write_text( - json.dumps({"clients": clients}, indent=2) + "\n", - encoding="utf-8", - ) - - -def public_client(row: dict[str, Any]) -> dict[str, Any]: - return { - "id": row.get("id"), - "name": row.get("name"), - "created": row.get("created"), - "last_used": row.get("last_used"), - } - - -def list_clients() -> list[dict[str, Any]]: - return [public_client(row) for row in load_clients()] - - -def create_client(name: str) -> dict[str, str]: - name = (name or "").strip() - if not NAME_RE.match(name): - raise ValueError("name must be 1-64 characters: A-Za-z0-9._-") - clients = load_clients() - if any(row.get("name") == name for row in clients): - raise ValueError(f"client already registered: {name}") - token = TOKEN_PREFIX + secrets.token_hex(32) - client_id = secrets.token_hex(16) - clients.append( - { - "id": client_id, - "name": name, - "token_hash": token_hash(token), - "created": _now(), - "last_used": None, - } - ) - save_clients(clients) - return {"id": client_id, "name": name, "token": token} - - -def revoke_client(name_or_id: str) -> str: - clients = load_clients() - kept = [row for row in clients if row.get("id") != name_or_id and row.get("name") != name_or_id] - if len(kept) == len(clients): - raise ValueError(f"no client {name_or_id!r}") - save_clients(kept) - return name_or_id - - -def touch_last_used(client_id: str) -> None: - clients = load_clients() - changed = False - stamp = _now() - for row in clients: - if row.get("id") == client_id: - row["last_used"] = stamp - changed = True - break - if changed: - save_clients(clients) - - -def verify_bearer(headers: dict[str, str]) -> dict[str, Any]: - auth = "" - for key, value in headers.items(): - if key.lower() == "authorization": - auth = (value or "").strip() - break - if not auth.lower().startswith("bearer "): - raise PermissionError("Bearer token required") - token = auth[7:].strip() - if not token.startswith(TOKEN_PREFIX): - raise PermissionError("unrecognized token") - digest = token_hash(token) - matched = None - for row in load_clients(): - stored = row.get("token_hash") or "" - if stored and hmac.compare_digest(stored, digest): - matched = row - break - if not matched: - raise PermissionError("unknown token") - touch_last_used(str(matched["id"])) - return public_client(matched) - - -def handle_admin(method: str, args: list[Any] | None = None) -> Any: - args = args or [] - if method == "clients.list": - return list_clients() - if method == "clients.create": - name = args[0] if args else "" - if isinstance(name, dict): - name = name.get("name") or "" - return create_client(str(name)) - if method == "clients.revoke": - target = args[0] if args else "" - if isinstance(target, dict): - target = target.get("id") or target.get("name") or "" - return {"revoked": revoke_client(str(target))} - raise ValueError(f"unknown admin method: {method}") diff --git a/host/firefox_agent_bridge_host.py b/host/firefox_agent_bridge_host.py index 7cb3d3c..6a08d48 100644 --- a/host/firefox_agent_bridge_host.py +++ b/host/firefox_agent_bridge_host.py @@ -2,6 +2,7 @@ """Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio.""" from __future__ import annotations +import hashlib import json import os import struct @@ -13,10 +14,6 @@ from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlparse -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) -from fab_auth import CLIENTS_PATH, handle_admin, verify_bearer # noqa: E402 - if sys.platform == "win32": import msvcrt @@ -57,6 +54,33 @@ def read_from_extension() -> dict[str, Any] | None: return json.loads(body.decode("utf-8")) +def _header(headers: dict[str, str], name: str) -> str: + want = name.lower() + for key, value in headers.items(): + if key.lower() == want: + return (value or "").strip() + return "" + + +def verify_token_with_extension(token: str, timeout: float = 8.0) -> dict[str, Any]: + if not token.startswith("fab_"): + raise PermissionError("unrecognized token") + digest = hashlib.sha256(token.encode("ascii")).hexdigest() + req_id = str(uuid.uuid4()) + event = threading.Event() + slot: dict[str, Any] = {} + with PENDING_LOCK: + PENDING[req_id] = (event, slot) + send_to_extension({"type": "auth", "id": req_id, "token_hash": digest}) + if not event.wait(timeout): + with PENDING_LOCK: + PENDING.pop(req_id, None) + raise PermissionError("extension did not verify token") + if not slot.get("ok"): + raise PermissionError(slot.get("error") or "unknown token") + return slot.get("client") or {} + + 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() @@ -111,7 +135,10 @@ class Handler(BaseHTTPRequestHandler): if not self._gate_ok(): return False try: - verify_bearer({k: v for k, v in self.headers.items()}) + auth = _header({k: v for k, v in self.headers.items()}, "Authorization") + if not auth.lower().startswith("bearer "): + raise PermissionError("Bearer token required") + verify_token_with_extension(auth[7:].strip()) except PermissionError as exc: self._send(401, {"ok": False, "error": str(exc)}) return False @@ -130,7 +157,7 @@ class Handler(BaseHTTPRequestHandler): if parsed.path == "/health": if not self._gate_ok(): return - self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT}) + self._send(200, {"ok": True, "service": "bookmarks-api", "port": PORT}) return if not self._authorize("GET", parsed.path, b""): return @@ -208,22 +235,6 @@ def stdin_loop() -> None: break if message is None: break - if message.get("type") == "admin": - try: - result = handle_admin(str(message.get("method") or ""), message.get("args") or []) - send_to_extension( - {"type": "admin-result", "id": message.get("id"), "ok": True, "result": result} - ) - except Exception as exc: - send_to_extension( - { - "type": "admin-result", - "id": message.get("id"), - "ok": False, - "error": str(exc), - } - ) - continue req_id = str(message.get("id") or "") with PENDING_LOCK: pending = PENDING.pop(req_id, None) @@ -237,8 +248,6 @@ def stdin_loop() -> None: def main() -> int: STATE_DIR.mkdir(parents=True, exist_ok=True) - if not CLIENTS_PATH.exists(): - log(f"no registered clients at {CLIENTS_PATH}; use the add-on Manage clients page") threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start() server = ThreadingHTTPServer((HOST, PORT), Handler) log(f"listening on http://{HOST}:{PORT}") diff --git a/tools/register_client.py b/tools/register_client.py index 84dbb8b..103bce0 100644 --- a/tools/register_client.py +++ b/tools/register_client.py @@ -1,41 +1,11 @@ #!/usr/bin/env python3 -"""CLI fallback for client secrets. Prefer the add-on Manage clients page.""" +"""Secrets are issued in the add-on UI, not from this script.""" from __future__ import annotations -import argparse -import json import sys -from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) - -from fab_auth import CLIENTS_PATH, create_client, list_clients, revoke_client # noqa: E402 - - -def main() -> int: - parser = argparse.ArgumentParser(description="Manage Firefox Agent Bridge clients (CLI)") - sub = parser.add_subparsers(dest="cmd", required=True) - add = sub.add_parser("add", help="generate a secret (printed once)") - add.add_argument("--name", required=True) - sub.add_parser("list") - drop = sub.add_parser("revoke") - drop.add_argument("name_or_id") - ns = parser.parse_args() - if ns.cmd == "add": - created = create_client(ns.name) - print(created["token"]) - print( - f"id={created['id']} name={created['name']} — store that token in FAB_TOKEN; it will not be shown again.", - file=sys.stderr, - ) - return 0 - if ns.cmd == "list": - print(json.dumps({"store": str(CLIENTS_PATH), "clients": list_clients()}, indent=2)) - return 0 - print(json.dumps({"revoked": revoke_client(ns.name_or_id)})) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +print( + "Generate and revoke clients in Firefox: Bookmarks API toolbar → Manage clients.", + file=sys.stderr, +) +raise SystemExit(2)