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
|
# 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
|
||||||
|
|||||||
@@ -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. 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
|
## 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-on’s 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
|
||||||
|
|||||||
+20
-40
@@ -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,37 +39,30 @@ 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);
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
adminWait.delete(message.id);
|
if (message && message.type) {
|
||||||
clearTimeout(pending.timer);
|
|
||||||
if (message.ok) {
|
|
||||||
pending.resolve(message.result);
|
|
||||||
} else {
|
|
||||||
pending.reject(new Error(message.error || "admin failed"));
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = message && message.id;
|
const id = message && message.id;
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
"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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+8
-17
@@ -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() {
|
||||||
|
render(await listClients());
|
||||||
|
try {
|
||||||
const info = await browser.runtime.sendMessage({ type: "status" });
|
const info = await browser.runtime.sendMessage({ type: "status" });
|
||||||
statusEl.textContent = info && info.hostUp
|
statusEl.textContent = info && info.hostUp
|
||||||
? `Native host connected · http://127.0.0.1:${info.port}`
|
? "Local script access is on. Agents can call in while this browser is open."
|
||||||
: "Native host is not connected. Install the host, then reload this add-on.";
|
: "Secrets work here. Install the native host if you want scripts and agents to call in.";
|
||||||
if (!info || !info.hostUp) {
|
} catch (err) {
|
||||||
rows.innerHTML = '<tr><td colspan="4" class="muted">Host offline.</td></tr>';
|
statusEl.textContent = "Secrets work here. Local script access status is unavailable.";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
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);
|
||||||
|
|||||||
@@ -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
@@ -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.";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
"""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}")
|
||||||
|
|||||||
@@ -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]
|
|
||||||
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(
|
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,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 0
|
raise SystemExit(2)
|
||||||
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())
|
|
||||||
|
|||||||
Reference in New Issue
Block a user