Keep client secrets in add-on storage; host only verifies and serves the local API.
This commit is contained in:
+21
-41
@@ -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,39 +39,32 @@ 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) {
|
||||
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 === "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;
|
||||
}
|
||||
if (message && message.type) {
|
||||
return;
|
||||
}
|
||||
const id = message && message.id;
|
||||
try {
|
||||
const result = await dispatchBookmark(message);
|
||||
@@ -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>
|
||||
|
||||
+10
-19
@@ -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() {
|
||||
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;
|
||||
render(await listClients());
|
||||
try {
|
||||
const info = await browser.runtime.sendMessage({ type: "status" });
|
||||
statusEl.textContent = info && info.hostUp
|
||||
? "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 {
|
||||
|
||||
Reference in New Issue
Block a user