Document who can call the loopback API and let the user pick the listen port.

This commit is contained in:
alexveley
2026-09-22 08:19:12 -04:00
parent cfda629418
commit 33944dbd07
10 changed files with 184 additions and 18 deletions
+2 -2
View File
@@ -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` 2. Native host installed only if scripts should call in: `powershell -NoProfile -File tools/install-native-host.ps1`
3. Firefox **running** for those calls 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 ## Auth
@@ -89,7 +89,7 @@ REST aliases use the same Bearer token. `GET /health` has no token (loopback liv
| Symptom | Cause | What to do | | 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 `Bearer token required` | Unsigned request | Set `FAB_TOKEN` |
| 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again | | 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` | | 502 `method not allowed` | Typo or API not in the allowlist | `meta.methods` |
+13 -1
View File
@@ -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. 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 hosts 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 Firefoxs 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 ## 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. 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. 1. Load the add-on.
2. Toolbar → **Manage clients** → generate a secret → store that secret yourself. 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). 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 <secret>`. 4. Keep Firefox open. Call `http://127.0.0.1:<port>` (default `17634`) with `Authorization: Bearer <secret>`.
```http ```http
POST /v1/call POST /v1/call
+1 -1
View File
@@ -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 - Manifest permissions: `bookmarks`, `storage`, `nativeMessaging` only
- No `tabs`, `history`, `cookies`, `<all_urls>`, `webRequest`, or `runtime.onMessageExternal` - No `tabs`, `history`, `cookies`, `<all_urls>`, `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` - Reject HTTP requests that carry a browser `Origin`
- Native host `allowed_extensions` pinned to this gecko id - Native host `allowed_extensions` pinned to this gecko id
- Method allowlist in both `extension/background.js` and `host/firefox_agent_bridge_host.py` - Method allowlist in both `extension/background.js` and `host/firefox_agent_bridge_host.py`
+64 -1
View File
@@ -2,6 +2,7 @@
const HOST_NAME = "com.easygoingaming.firefox_agent_bridge"; const HOST_NAME = "com.easygoingaming.firefox_agent_bridge";
const RECONNECT_MS = 2000; const RECONNECT_MS = 2000;
const DEFAULT_PORT = 17634;
const ALLOWED = { const ALLOWED = {
"meta.ping": async () => ({ ok: true, extension: "bookmarks-api" }), "meta.ping": async () => ({ ok: true, extension: "bookmarks-api" }),
@@ -22,6 +23,47 @@ const ALLOWED = {
let port = null; let port = null;
let reconnectTimer = null; let reconnectTimer = null;
let hostUp = false; 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) { function asArray(value) {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -43,6 +85,16 @@ function attach(nextPort) {
port = nextPort; port = nextPort;
hostUp = true; hostUp = true;
port.onMessage.addListener(async (message) => { 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") { if (message && message.type === "auth") {
try { try {
const client = await findClientByTokenHash(message.token_hash); const client = await findClientByTokenHash(message.token_hash);
@@ -80,8 +132,10 @@ function attach(nextPort) {
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
port = null; port = null;
hostUp = false; hostUp = false;
bindError = "";
scheduleReconnect(); scheduleReconnect();
}); });
pushConfig();
} }
function scheduleReconnect() { function scheduleReconnect() {
@@ -106,7 +160,16 @@ function connect() {
browser.runtime.onMessage.addListener((message) => { browser.runtime.onMessage.addListener((message) => {
if (message && message.type === "status") { 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; return undefined;
}); });
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Bookmarks API for Scripting and AI", "name": "Bookmarks API for Scripting and AI",
"short_name": "Bookmarks API", "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.", "description": "An API add-on for Firefox to allow agentic and script-based management of user bookmarks.",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+16
View File
@@ -9,6 +9,12 @@
<header> <header>
<h1>Bookmarks API for Scripting and AI</h1> <h1>Bookmarks API for Scripting and AI</h1>
<p class="lead">An API add-on for Firefox to allow agentic and script-based management of user bookmarks.</p> <p class="lead">An API add-on for Firefox to allow agentic and script-based management of user bookmarks.</p>
<section class="usage">
<h2>Who can use this</h2>
<p>Tools on <strong>this computer</strong> 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.</p>
<p>It does not work from chatgpt.com or other sites in a browser tab. Those run elsewhere and cannot see this machines loopback port. Pages inside Firefox are blocked too.</p>
<p class="muted">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.</p>
</section>
<ol class="process"> <ol class="process">
<li>Issue a secret for each agent or script that should be allowed to work with your bookmarks.</li> <li>Issue a secret for each agent or script that should be allowed to work with your bookmarks.</li>
<li>Store that secret with your secrets. This add-on keeps only a hash.</li> <li>Store that secret with your secrets. This add-on keeps only a hash.</li>
@@ -17,6 +23,16 @@
<p id="status" class="muted"></p> <p id="status" class="muted"></p>
</header> </header>
<section class="settings">
<h2>Listen port</h2>
<p class="muted">Loopback only (<code>127.0.0.1</code>). Change this if the default is already in use. Your tools must use the same port.</p>
<div class="row">
<label class="sr" for="port">Listen port</label>
<input id="port" type="number" min="1024" max="65535" step="1" value="17634">
<button id="save-port" type="button">Save port</button>
</div>
</section>
<section class="row"> <section class="row">
<input id="name" type="text" maxlength="64" placeholder="Client name" autocomplete="off"> <input id="name" type="text" maxlength="64" placeholder="Client name" autocomplete="off">
<button id="create" type="button">Generate secret</button> <button id="create" type="button">Generate secret</button>
+25 -4
View File
@@ -5,11 +5,26 @@ const nameInput = document.getElementById("name");
const createBtn = document.getElementById("create"); const createBtn = document.getElementById("create");
const errorEl = document.getElementById("error"); const errorEl = document.getElementById("error");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const portInput = document.getElementById("port");
const savePortBtn = document.getElementById("save-port");
const modal = document.getElementById("secret-modal"); const modal = document.getElementById("secret-modal");
const secretEl = document.getElementById("secret"); const secretEl = document.getElementById("secret");
const copyBtn = document.getElementById("copy"); const copyBtn = document.getElementById("copy");
const closeBtn = document.getElementById("close"); 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) { function showError(text) {
errorEl.hidden = !text; errorEl.hidden = !text;
errorEl.textContent = text || ""; errorEl.textContent = text || "";
@@ -43,15 +58,21 @@ function escapeHtml(value) {
async function refresh() { async function refresh() {
render(await listClients()); render(await listClients());
try { try {
const info = await browser.runtime.sendMessage({ type: "status" }); applyStatus(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.";
} catch (err) { } catch (err) {
statusEl.textContent = "You can issue and revoke secrets here anytime."; 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 () => { createBtn.addEventListener("click", async () => {
showError(""); showError("");
try { try {
+8 -4
View File
@@ -4,11 +4,15 @@ const statusEl = document.getElementById("status");
const manage = document.getElementById("manage"); const manage = document.getElementById("manage");
browser.runtime.sendMessage({ type: "status" }).then((info) => { browser.runtime.sendMessage({ type: "status" }).then((info) => {
if (info && info.hostUp) { if (info && info.bindError) {
statusEl.textContent = "Ready for agents and scripts while Firefox is open."; statusEl.textContent = `Port ${info.listenPort} is not available.`;
} else { return;
statusEl.textContent = "You can issue and revoke secrets here.";
} }
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", () => { manage.addEventListener("click", () => {
+6
View File
@@ -18,6 +18,12 @@ body {
} }
h1, h2 { margin: 0 0 8px; font-size: 1.15rem; } h1, h2 { margin: 0 0 8px; font-size: 1.15rem; }
.lead { margin: 0 0 12px; } .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 { margin: 0 0 16px; padding-left: 1.2rem; }
.process li { margin: 6px 0; } .process li { margin: 6px 0; }
.muted { color: GrayText; } .muted { color: GrayText; }
+48 -4
View File
@@ -20,9 +20,12 @@ if sys.platform == "win32":
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
HOST = "127.0.0.1" 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 MAX_BODY = 256 * 1024
ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"} ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"}
HTTP_SERVER: ThreadingHTTPServer | None = None
SERVER_LOCK = threading.Lock()
ALLOWED_METHODS = frozenset( ALLOWED_METHODS = frozenset(
{ {
"meta.ping", "meta.ping",
@@ -45,6 +48,45 @@ PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {}
PENDING_LOCK = threading.Lock() 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 102465535",
}
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: def log(msg: str) -> None:
sys.stderr.write(msg + "\n") sys.stderr.write(msg + "\n")
sys.stderr.flush() sys.stderr.flush()
@@ -252,6 +294,9 @@ def stdin_loop() -> None:
break break
if message is None: if message is None:
break break
if message.get("type") == "config":
send_to_extension(apply_listen_port(message.get("port")))
continue
req_id = str(message.get("id") or "") req_id = str(message.get("id") or "")
with PENDING_LOCK: with PENDING_LOCK:
pending = PENDING.pop(req_id, None) pending = PENDING.pop(req_id, None)
@@ -265,10 +310,9 @@ def stdin_loop() -> None:
def main() -> int: def main() -> int:
threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start() threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start()
server = ThreadingHTTPServer((HOST, PORT), Handler) apply_listen_port(DEFAULT_PORT)
log(f"listening on http://{HOST}:{PORT}")
try: try:
server.serve_forever() threading.Event().wait()
except KeyboardInterrupt: except KeyboardInterrupt:
return 0 return 0
return 0 return 0