Keep client secrets in add-on storage; host only verifies and serves the local API.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
- 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
|
||||
|
||||
+20
-40
@@ -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,37 +39,30 @@ 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) {
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
const id = message && message.id;
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
<body class="page">
|
||||
<header>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -43,6 +48,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="clients.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+8
-17
@@ -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 = '<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() {
|
||||
render(await listClients());
|
||||
try {
|
||||
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 = '<tr><td colspan="4" class="muted">Host offline.</td></tr>';
|
||||
return;
|
||||
? "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);
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
</head>
|
||||
<body class="popup">
|
||||
<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>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
|
||||
+2
-2
@@ -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.";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
-142
@@ -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}")
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.",
|
||||
"Generate and revoke clients in Firefox: Bookmarks API toolbar → Manage clients.",
|
||||
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())
|
||||
raise SystemExit(2)
|
||||
|
||||
Reference in New Issue
Block a user