Document who can call the loopback API and let the user pick the listen port.
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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 <secret>`.
|
||||
4. Keep Firefox open. Call `http://127.0.0.1:<port>` (default `17634`) with `Authorization: Bearer <secret>`.
|
||||
|
||||
```http
|
||||
POST /v1/call
|
||||
|
||||
+1
-1
@@ -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`, `<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`
|
||||
- Native host `allowed_extensions` pinned to this gecko id
|
||||
- Method allowlist in both `extension/background.js` and `host/firefox_agent_bridge_host.py`
|
||||
|
||||
+64
-1
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<header>
|
||||
<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>
|
||||
<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 machine’s 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">
|
||||
<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>
|
||||
@@ -17,6 +23,16 @@
|
||||
<p id="status" class="muted"></p>
|
||||
</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">
|
||||
<input id="name" type="text" maxlength="64" placeholder="Client name" autocomplete="off">
|
||||
<button id="create" type="button">Generate secret</button>
|
||||
|
||||
+25
-4
@@ -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 {
|
||||
|
||||
+8
-4
@@ -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", () => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user