From 33944dbd07493df39769ffe362cbeab0bd736454 Mon Sep 17 00:00:00 2001 From: alexveley Date: Tue, 22 Sep 2026 08:19:12 -0400 Subject: [PATCH] Document who can call the loopback API and let the user pick the listen port. --- AGENTS.md | 4 +- README.md | 14 ++++++- SECURITY.md | 2 +- extension/background.js | 65 ++++++++++++++++++++++++++++++- extension/manifest.json | 2 +- extension/options.html | 16 ++++++++ extension/options.js | 29 ++++++++++++-- extension/popup.js | 12 ++++-- extension/ui.css | 6 +++ host/firefox_agent_bridge_host.py | 52 +++++++++++++++++++++++-- 10 files changed, 184 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 150f3d2..2efe74c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Do not edit `places.sqlite` while Firefox is open if this API is reachable. 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 token. +If `http://127.0.0.1:17634/health` fails, the host is not up or the listen port was changed in Manage clients. If `/health` works but calls 401, you do not have a current token. Use `FAB_URL` when the port is not the default. ## Auth @@ -89,7 +89,7 @@ REST aliases use the same Bearer token. `GET /health` has no token (loopback liv | Symptom | Cause | What to do | |---------|--------|------------| -| Connection refused on `:17634` | Firefox closed or add-on/host missing | Open Firefox; load add-on; install native host | +| Connection refused on the listen port | Firefox closed, add-on/host missing, or port changed | Open Firefox; load add-on; install native host; match `FAB_URL` to Manage clients | | 401 `Bearer token required` | Unsigned request | Set `FAB_TOKEN` | | 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again | | 502 `method not allowed` | Typo or API not in the allowlist | `meta.methods` | diff --git a/README.md b/README.md index aab6503..13a9613 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ An API add-on for Firefox so agents and scripts can manage your bookmarks while The shipped product is the add-on in `extension/`. This is not a bookmark sync engine, and it is not a Mozilla product. +## Who can use this + +The HTTP API listens on **127.0.0.1 only** — the same computer that is running this Firefox. A local script, an editor agent, or a session you have opened on that machine (SSH, remote desktop) can call it, if it presents a secret you issued. + +It does **not** work from chatgpt.com or other sites in a browser tab. Those programs run somewhere else and cannot see this port. Pages open in Firefox are also blocked (requests that carry a browser `Origin` are rejected). + +You can sit at a different computer and still use it *if* your tooling is actually running on the Firefox machine — for example you SSH in and run the client there. Pointing a remote tool at some other host’s loopback will not work. + +If this Firefox profile syncs bookmarks with a Mozilla account, edits made here can appear on your other devices. Sync is Firefox’s feature; the API itself still only accepts connections on this machine. + +The listen port defaults to **17634**. Change it under Manage clients if that port is already taken, and point your tools at the same port (`FAB_URL`). + ## What it does Scripts and local agents call a loopback HTTP API. The add-on performs only `browser.bookmarks` operations (create, search, move, delete, and related reads). It does not read tabs, history, cookies, or the open web. @@ -11,7 +23,7 @@ Scripts and local agents call a loopback HTTP API. The add-on performs only `bro 1. Load the add-on. 2. Toolbar → **Manage clients** → generate a secret → store that secret yourself. 3. Register the native host once if scripts should call in (`tools/install-native-host.ps1` on Windows). -4. Keep Firefox open. Call `http://127.0.0.1:17634` with `Authorization: Bearer `. +4. Keep Firefox open. Call `http://127.0.0.1:` (default `17634`) with `Authorization: Bearer `. ```http POST /v1/call diff --git a/SECURITY.md b/SECURITY.md index d941334..f50f867 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ This add-on is scoped to bookmarks. Treat a leaked `fab_…` secret like a passw - Manifest permissions: `bookmarks`, `storage`, `nativeMessaging` only - No `tabs`, `history`, `cookies`, ``, `webRequest`, or `runtime.onMessageExternal` -- Host bind: `127.0.0.1` +- Host bind: `127.0.0.1` (port defaults to 17634; user-configurable) - Reject HTTP requests that carry a browser `Origin` - Native host `allowed_extensions` pinned to this gecko id - Method allowlist in both `extension/background.js` and `host/firefox_agent_bridge_host.py` diff --git a/extension/background.js b/extension/background.js index 9f8987a..fec3fae 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2,6 +2,7 @@ const HOST_NAME = "com.easygoingaming.firefox_agent_bridge"; const RECONNECT_MS = 2000; +const DEFAULT_PORT = 17634; const ALLOWED = { "meta.ping": async () => ({ ok: true, extension: "bookmarks-api" }), @@ -22,6 +23,47 @@ const ALLOWED = { let port = null; let reconnectTimer = null; let hostUp = false; +let listenPort = DEFAULT_PORT; +let bindError = ""; +let configWaiter = null; + +function normalizePort(value) { + const n = Number(value); + if (!Number.isInteger(n) || n < 1024 || n > 65535) { + throw new Error("Port must be an integer from 1024 to 65535"); + } + return n; +} + +async function loadListenPort() { + const data = await browser.storage.local.get("listenPort"); + try { + return normalizePort(data.listenPort == null ? DEFAULT_PORT : data.listenPort); + } catch (err) { + return DEFAULT_PORT; + } +} + +async function pushConfig() { + if (!port) { + return; + } + listenPort = await loadListenPort(); + await new Promise((resolve) => { + configWaiter = resolve; + port.postMessage({ type: "config", port: listenPort }); + setTimeout(resolve, 2500); + }); +} + +function statusInfo() { + return { + hostUp, + port: listenPort, + listenPort, + bindError, + }; +} function asArray(value) { if (value === undefined || value === null) { @@ -43,6 +85,16 @@ function attach(nextPort) { port = nextPort; hostUp = true; port.onMessage.addListener(async (message) => { + if (message && message.type === "config-result") { + listenPort = message.port || listenPort; + bindError = message.ok ? "" : (message.error || "could not bind port"); + if (configWaiter) { + const done = configWaiter; + configWaiter = null; + done(); + } + return; + } if (message && message.type === "auth") { try { const client = await findClientByTokenHash(message.token_hash); @@ -80,8 +132,10 @@ function attach(nextPort) { port.onDisconnect.addListener(() => { port = null; hostUp = false; + bindError = ""; scheduleReconnect(); }); + pushConfig(); } function scheduleReconnect() { @@ -106,7 +160,16 @@ function connect() { browser.runtime.onMessage.addListener((message) => { if (message && message.type === "status") { - return Promise.resolve({ hostUp, port: 17634 }); + return Promise.resolve(statusInfo()); + } + if (message && message.type === "set-port") { + return (async () => { + const n = normalizePort(message.port); + await browser.storage.local.set({ listenPort: n }); + listenPort = n; + await pushConfig(); + return statusInfo(); + })(); } return undefined; }); diff --git a/extension/manifest.json b/extension/manifest.json index 26d94a9..7eeba38 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Bookmarks API for Scripting and AI", "short_name": "Bookmarks API", - "version": "0.4.1", + "version": "0.4.2", "description": "An API add-on for Firefox to allow agentic and script-based management of user bookmarks.", "browser_specific_settings": { "gecko": { diff --git a/extension/options.html b/extension/options.html index 05902a7..ef3a3ac 100644 --- a/extension/options.html +++ b/extension/options.html @@ -9,6 +9,12 @@

Bookmarks API for Scripting and AI

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

+
+

Who can use this

+

Tools on this computer can call the API — a local script, an editor agent, or a session you opened here (SSH, remote desktop). They need a secret you issue below.

+

It does not work from chatgpt.com or other sites in a browser tab. Those run elsewhere and cannot see this machine’s loopback port. Pages inside Firefox are blocked too.

+

If this Firefox profile syncs bookmarks with a Mozilla account, changes made here can show up on your other devices. The API itself still only accepts connections on this machine.

+
  1. Issue a secret for each agent or script that should be allowed to work with your bookmarks.
  2. Store that secret with your secrets. This add-on keeps only a hash.
  3. @@ -17,6 +23,16 @@

+
+

Listen port

+

Loopback only (127.0.0.1). Change this if the default is already in use. Your tools must use the same port.

+
+ + + +
+
+
diff --git a/extension/options.js b/extension/options.js index 542eb42..86a0b1b 100644 --- a/extension/options.js +++ b/extension/options.js @@ -5,11 +5,26 @@ const nameInput = document.getElementById("name"); const createBtn = document.getElementById("create"); const errorEl = document.getElementById("error"); const statusEl = document.getElementById("status"); +const portInput = document.getElementById("port"); +const savePortBtn = document.getElementById("save-port"); const modal = document.getElementById("secret-modal"); const secretEl = document.getElementById("secret"); const copyBtn = document.getElementById("copy"); const closeBtn = document.getElementById("close"); +function applyStatus(info) { + if (info && info.listenPort) { + portInput.value = String(info.listenPort); + } + if (info && info.bindError) { + statusEl.textContent = `Could not listen on ${info.listenPort}: ${info.bindError}`; + return; + } + statusEl.textContent = info && info.hostUp + ? `This add-on is ready on 127.0.0.1:${info.listenPort} while Firefox is open.` + : "You can issue and revoke secrets here anytime."; +} + function showError(text) { errorEl.hidden = !text; errorEl.textContent = text || ""; @@ -43,15 +58,21 @@ function escapeHtml(value) { async function refresh() { render(await listClients()); try { - const info = await browser.runtime.sendMessage({ type: "status" }); - statusEl.textContent = info && info.hostUp - ? "This add-on is ready for agents and scripts while Firefox is open." - : "You can issue and revoke secrets here anytime."; + applyStatus(await browser.runtime.sendMessage({ type: "status" })); } catch (err) { statusEl.textContent = "You can issue and revoke secrets here anytime."; } } +savePortBtn.addEventListener("click", async () => { + showError(""); + try { + applyStatus(await browser.runtime.sendMessage({ type: "set-port", port: portInput.value })); + } catch (err) { + showError(err.message); + } +}); + createBtn.addEventListener("click", async () => { showError(""); try { diff --git a/extension/popup.js b/extension/popup.js index ca8cec8..ea80285 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -4,11 +4,15 @@ const statusEl = document.getElementById("status"); const manage = document.getElementById("manage"); browser.runtime.sendMessage({ type: "status" }).then((info) => { - if (info && info.hostUp) { - statusEl.textContent = "Ready for agents and scripts while Firefox is open."; - } else { - statusEl.textContent = "You can issue and revoke secrets here."; + if (info && info.bindError) { + statusEl.textContent = `Port ${info.listenPort} is not available.`; + return; } + if (info && info.hostUp) { + statusEl.textContent = `Ready on 127.0.0.1:${info.listenPort} while Firefox is open.`; + return; + } + statusEl.textContent = "You can issue and revoke secrets here."; }); manage.addEventListener("click", () => { diff --git a/extension/ui.css b/extension/ui.css index bb43754..79e6481 100644 --- a/extension/ui.css +++ b/extension/ui.css @@ -18,6 +18,12 @@ body { } h1, h2 { margin: 0 0 8px; font-size: 1.15rem; } .lead { margin: 0 0 12px; } +.usage, .settings { margin: 0 0 16px; } +.usage p { margin: 0 0 8px; } +input[type="number"] { + width: 8rem; + padding: 6px 8px; +} .process { margin: 0 0 16px; padding-left: 1.2rem; } .process li { margin: 6px 0; } .muted { color: GrayText; } diff --git a/host/firefox_agent_bridge_host.py b/host/firefox_agent_bridge_host.py index 22609e3..8ff9705 100644 --- a/host/firefox_agent_bridge_host.py +++ b/host/firefox_agent_bridge_host.py @@ -20,9 +20,12 @@ if sys.platform == "win32": msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) HOST = "127.0.0.1" -PORT = int(os.environ.get("FAB_PORT", "17634")) +DEFAULT_PORT = int(os.environ.get("FAB_PORT", "17634")) +PORT = DEFAULT_PORT MAX_BODY = 256 * 1024 ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"} +HTTP_SERVER: ThreadingHTTPServer | None = None +SERVER_LOCK = threading.Lock() ALLOWED_METHODS = frozenset( { "meta.ping", @@ -45,6 +48,45 @@ PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {} PENDING_LOCK = threading.Lock() +def set_port_globals(port: int) -> None: + global PORT, ALLOWED_HOSTS + PORT = port + ALLOWED_HOSTS = {f"127.0.0.1:{port}", f"localhost:{port}"} + + +def apply_listen_port(requested: Any) -> dict[str, Any]: + global HTTP_SERVER + try: + port = int(requested) + except (TypeError, ValueError): + return {"type": "config-result", "ok": False, "error": "invalid port"} + if port < 1024 or port > 65535: + return { + "type": "config-result", + "ok": False, + "port": port, + "error": "port must be 1024–65535", + } + with SERVER_LOCK: + if HTTP_SERVER is not None and PORT == port: + return {"type": "config-result", "ok": True, "port": port} + old = HTTP_SERVER + HTTP_SERVER = None + if old is not None: + old.shutdown() + old.server_close() + try: + set_port_globals(port) + HTTP_SERVER = ThreadingHTTPServer((HOST, port), Handler) + except OSError as exc: + return {"type": "config-result", "ok": False, "port": port, "error": str(exc)} + threading.Thread( + target=HTTP_SERVER.serve_forever, name="fab-http", daemon=True + ).start() + log(f"listening on http://{HOST}:{port}") + return {"type": "config-result", "ok": True, "port": port} + + def log(msg: str) -> None: sys.stderr.write(msg + "\n") sys.stderr.flush() @@ -252,6 +294,9 @@ def stdin_loop() -> None: break if message is None: break + if message.get("type") == "config": + send_to_extension(apply_listen_port(message.get("port"))) + continue req_id = str(message.get("id") or "") with PENDING_LOCK: pending = PENDING.pop(req_id, None) @@ -265,10 +310,9 @@ def stdin_loop() -> None: def main() -> int: threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start() - server = ThreadingHTTPServer((HOST, PORT), Handler) - log(f"listening on http://{HOST}:{PORT}") + apply_listen_port(DEFAULT_PORT) try: - server.serve_forever() + threading.Event().wait() except KeyboardInterrupt: return 0 return 0