Keep client secrets in add-on storage; host only verifies and serves the local API.

This commit is contained in:
alexveley
2026-09-22 07:42:10 -04:00
parent de0814f3ed
commit 41c83dabac
13 changed files with 185 additions and 290 deletions
+7 -12
View File
@@ -1,15 +1,16 @@
# Agent handshake — Bookmarks API for Scripting and AI # 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 ## Preconditions
1. Native host installed: `powershell -NoProfile -File tools/install-native-host.ps1` 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. Add-on loaded (temporary via `about:debugging`, or a signed install) 2. Native host installed only if scripts should call in: `powershell -NoProfile -File tools/install-native-host.ps1`
3. A **client secret** issued from the add-on UI: toolbar icon → **Manage clients** → Generate secret 3. Firefox **running** for those calls
4. Firefox **running**
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`. 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. 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 ## Preferred caller
```text ```text
+8 -8
View File
@@ -1,23 +1,23 @@
# Bookmarks API for Scripting and AI # 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. Mozillas 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 ## Setup
1. Register the native host once (`tools/install-native-host.ps1` on this workstation). 1. Load the add-on (temporary via `about:debugging`, or a signed `.xpi` — [docs/signing.md](docs/signing.md)).
2. 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. Toolbar icon → **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 <secret>` 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 <secret>` on `http://127.0.0.1:17634`. Method list for people working in this repo: [AGENTS.md](AGENTS.md).
## Security ## Security
- Binds **127.0.0.1** only. Browser `Origin` headers are rejected. - 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-ons storage. The host asks the add-on whether a token is valid.
- Only `bookmarks.*` (plus `meta.*`). No history, cookies, tabs, or `onMessageExternal`. - Only `bookmarks.*` (plus `meta.*`). No history, cookies, tabs, or `onMessageExternal`.
## License ## License
+21 -41
View File
@@ -4,7 +4,7 @@ const HOST_NAME = "com.easygoingaming.firefox_agent_bridge";
const RECONNECT_MS = 2000; const RECONNECT_MS = 2000;
const ALLOWED = { 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(), "meta.methods": async () => Object.keys(ALLOWED).sort(),
"bookmarks.getTree": () => browser.bookmarks.getTree(), "bookmarks.getTree": () => browser.bookmarks.getTree(),
"bookmarks.getSubTree": (id) => browser.bookmarks.getSubTree(id), "bookmarks.getSubTree": (id) => browser.bookmarks.getSubTree(id),
@@ -22,7 +22,6 @@ const ALLOWED = {
let port = null; let port = null;
let reconnectTimer = null; let reconnectTimer = null;
let hostUp = false; let hostUp = false;
const adminWait = new Map();
function asArray(value) { function asArray(value) {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -40,39 +39,32 @@ async function dispatchBookmark(message) {
return fn(...asArray(message.args)); 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) { 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 === "admin-result") { if (message && message.type === "auth") {
const pending = adminWait.get(message.id); try {
if (!pending) { const client = await findClientByTokenHash(message.token_hash);
return; port.postMessage({
} type: "auth-result",
adminWait.delete(message.id); id: message.id,
clearTimeout(pending.timer); ok: Boolean(client),
if (message.ok) { client: client || null,
pending.resolve(message.result); });
} else { } catch (err) {
pending.reject(new Error(message.error || "admin failed")); port.postMessage({
type: "auth-result",
id: message.id,
ok: false,
error: err && err.message ? err.message : String(err),
});
} }
return; return;
} }
if (message && message.type) {
return;
}
const id = message && message.id; const id = message && message.id;
try { try {
const result = await dispatchBookmark(message); const result = await dispatchBookmark(message);
@@ -88,11 +80,6 @@ function attach(nextPort) {
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
port = null; port = null;
hostUp = false; hostUp = false;
for (const [id, pending] of adminWait.entries()) {
clearTimeout(pending.timer);
pending.reject(new Error("native host disconnected"));
adminWait.delete(id);
}
scheduleReconnect(); scheduleReconnect();
}); });
} }
@@ -118,16 +105,9 @@ function connect() {
} }
browser.runtime.onMessage.addListener((message) => { browser.runtime.onMessage.addListener((message) => {
const kind = message && message.type; if (message && message.type === "status") {
if (kind === "status") {
return Promise.resolve({ hostUp, port: 17634 }); 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; return undefined;
}); });
+82
View File
@@ -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 164 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);
}
+4 -4
View File
@@ -2,17 +2,17 @@
"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.3.0", "version": "0.4.0",
"description": "Issue a secret, then let local scripts and agents call an allowlisted bookmarks API.", "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": {
"id": "firefox-agent-bridge@easygoingaming.com", "id": "firefox-agent-bridge@easygoingaming.com",
"strict_min_version": "115.0" "strict_min_version": "115.0"
} }
}, },
"permissions": ["bookmarks", "nativeMessaging"], "permissions": ["bookmarks", "nativeMessaging", "storage"],
"background": { "background": {
"scripts": ["background.js"] "scripts": ["clients.js", "background.js"]
}, },
"action": { "action": {
"default_title": "Bookmarks API", "default_title": "Bookmarks API",
+7 -1
View File
@@ -8,7 +8,12 @@
<body class="page"> <body class="page">
<header> <header>
<h1>Bookmarks API for Scripting and AI</h1> <h1>Bookmarks API for Scripting and AI</h1>
<p class="muted">Issue a secret to each client. Store it with your secrets. This add-on keeps only a hash.</p> <p class="lead">An API add-on for Firefox to allow agentic and script-based management of user bookmarks.</p>
<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>
<li>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.</li>
</ol>
<p id="status" class="muted"></p> <p id="status" class="muted"></p>
</header> </header>
@@ -43,6 +48,7 @@
</div> </div>
</dialog> </dialog>
<script src="clients.js"></script>
<script src="options.js"></script> <script src="options.js"></script>
</body> </body>
</html> </html>
+10 -19
View File
@@ -15,15 +15,6 @@ function showError(text) {
errorEl.textContent = 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) { function render(clients) {
if (!clients.length) { if (!clients.length) {
rows.innerHTML = '<tr><td colspan="4" class="muted">No clients yet. Generate a secret to get started.</td></tr>'; rows.innerHTML = '<tr><td colspan="4" class="muted">No clients yet. Generate a secret to get started.</td></tr>';
@@ -50,21 +41,21 @@ function escapeHtml(value) {
} }
async function refresh() { async function refresh() {
const info = await browser.runtime.sendMessage({ type: "status" }); render(await listClients());
statusEl.textContent = info && info.hostUp try {
? `Native host connected · http://127.0.0.1:${info.port}` const info = await browser.runtime.sendMessage({ type: "status" });
: "Native host is not connected. Install the host, then reload this add-on."; statusEl.textContent = info && info.hostUp
if (!info || !info.hostUp) { ? "Local script access is on. Agents can call in while this browser is open."
rows.innerHTML = '<tr><td colspan="4" class="muted">Host offline.</td></tr>'; : "Secrets work here. Install the native host if you want scripts and agents to call in.";
return; } catch (err) {
statusEl.textContent = "Secrets work here. Local script access status is unavailable.";
} }
render(await admin("clients.list"));
} }
createBtn.addEventListener("click", async () => { createBtn.addEventListener("click", async () => {
showError(""); showError("");
try { try {
const created = await admin("clients.create", [nameInput.value.trim()]); const created = await createClient(nameInput.value.trim());
nameInput.value = ""; nameInput.value = "";
secretEl.value = created.token; secretEl.value = created.token;
modal.showModal(); modal.showModal();
@@ -81,7 +72,7 @@ rows.addEventListener("click", async (event) => {
} }
showError(""); showError("");
try { try {
await admin("clients.revoke", [id]); await revokeClient(id);
await refresh(); await refresh();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
+2 -1
View File
@@ -6,7 +6,8 @@
</head> </head>
<body class="popup"> <body class="popup">
<h1>Bookmarks API</h1> <h1>Bookmarks API</h1>
<p id="status" class="muted">Checking host…</p> <p class="muted">An API add-on for Firefox so agents and scripts can manage your bookmarks.</p>
<p id="status" class="muted">Checking…</p>
<button id="manage" type="button">Manage clients</button> <button id="manage" type="button">Manage clients</button>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
+2 -2
View File
@@ -5,9 +5,9 @@ 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.hostUp) {
statusEl.textContent = `Host connected on 127.0.0.1:${info.port}`; statusEl.textContent = "Local script access is on.";
} else { } 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.";
} }
}); });
+3
View File
@@ -17,6 +17,9 @@ body {
padding: 24px; padding: 24px;
} }
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; }
.process { margin: 0 0 16px; padding-left: 1.2rem; }
.process li { margin: 6px 0; }
.muted { color: GrayText; } .muted { color: GrayText; }
.row { display: flex; gap: 8px; margin: 16px 0; } .row { display: flex; gap: 8px; margin: 16px 0; }
input[type="text"], textarea, button { input[type="text"], textarea, button {
-142
View File
@@ -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}")
+33 -24
View File
@@ -2,6 +2,7 @@
"""Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio.""" """Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio."""
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os import os
import struct import struct
@@ -13,10 +14,6 @@ from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlparse 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": if sys.platform == "win32":
import msvcrt import msvcrt
@@ -57,6 +54,33 @@ def read_from_extension() -> dict[str, Any] | None:
return json.loads(body.decode("utf-8")) 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]: def call_extension(method: str, args: list[Any] | None = None, timeout: float = 15.0) -> dict[str, Any]:
req_id = str(uuid.uuid4()) req_id = str(uuid.uuid4())
event = threading.Event() event = threading.Event()
@@ -111,7 +135,10 @@ class Handler(BaseHTTPRequestHandler):
if not self._gate_ok(): if not self._gate_ok():
return False return False
try: 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: except PermissionError as exc:
self._send(401, {"ok": False, "error": str(exc)}) self._send(401, {"ok": False, "error": str(exc)})
return False return False
@@ -130,7 +157,7 @@ class Handler(BaseHTTPRequestHandler):
if parsed.path == "/health": if parsed.path == "/health":
if not self._gate_ok(): if not self._gate_ok():
return return
self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT}) self._send(200, {"ok": True, "service": "bookmarks-api", "port": PORT})
return return
if not self._authorize("GET", parsed.path, b""): if not self._authorize("GET", parsed.path, b""):
return return
@@ -208,22 +235,6 @@ def stdin_loop() -> None:
break break
if message is None: if message is None:
break 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 "") 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)
@@ -237,8 +248,6 @@ def stdin_loop() -> None:
def main() -> int: def main() -> int:
STATE_DIR.mkdir(parents=True, exist_ok=True) 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() threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start()
server = ThreadingHTTPServer((HOST, PORT), Handler) server = ThreadingHTTPServer((HOST, PORT), Handler)
log(f"listening on http://{HOST}:{PORT}") log(f"listening on http://{HOST}:{PORT}")
+6 -36
View File
@@ -1,41 +1,11 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
import argparse
import json
import sys import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] print(
sys.path.insert(0, str(ROOT)) "Generate and revoke clients in Firefox: Bookmarks API toolbar → Manage clients.",
file=sys.stderr,
from fab_auth import CLIENTS_PATH, create_client, list_clients, revoke_client # noqa: E402 )
raise SystemExit(2)
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())