Replace Ed25519 with hashed API tokens and an in-Firefox client manager.

This commit is contained in:
alexveley
2026-09-22 07:24:35 -04:00
parent 55f7e863dc
commit 9242c01015
16 changed files with 487 additions and 339 deletions
+55 -2
View File
@@ -21,6 +21,8 @@ const ALLOWED = {
let port = null;
let reconnectTimer = null;
let hostUp = false;
const adminWait = new Map();
function asArray(value) {
if (value === undefined || value === null) {
@@ -29,7 +31,7 @@ function asArray(value) {
return Array.isArray(value) ? value : [value];
}
async function dispatch(message) {
async function dispatchBookmark(message) {
const method = message && message.method;
const fn = ALLOWED[method];
if (!fn) {
@@ -38,12 +40,42 @@ async function dispatch(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"));
}
return;
}
const id = message && message.id;
try {
const result = await dispatch(message);
const result = await dispatchBookmark(message);
port.postMessage({ id, ok: true, result });
} catch (err) {
port.postMessage({
@@ -55,6 +87,12 @@ 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();
});
}
@@ -73,9 +111,24 @@ function connect() {
try {
attach(browser.runtime.connectNative(HOST_NAME));
} catch (err) {
hostUp = false;
console.error("native host connect failed", err);
scheduleReconnect();
}
}
browser.runtime.onMessage.addListener((message) => {
const kind = message && message.type;
if (kind === "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;
});
connect();
+10 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "Firefox Agent Bridge",
"version": "0.1.0",
"description": "Expose an allowlisted WebExtension API to local scripts and agents over native messaging.",
"version": "0.2.0",
"description": "Let local scripts and agents call an allowlisted WebExtension API after you issue them a secret.",
"browser_specific_settings": {
"gecko": {
"id": "firefox-agent-bridge@easygoingaming.com",
@@ -12,5 +12,13 @@
"permissions": ["bookmarks", "nativeMessaging"],
"background": {
"scripts": ["background.js"]
},
"action": {
"default_title": "Firefox Agent Bridge",
"default_popup": "popup.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
}
}
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Firefox Agent Bridge</title>
<link rel="stylesheet" href="ui.css">
</head>
<body class="page">
<header>
<h1>Firefox Agent Bridge</h1>
<p class="muted">Issue a secret to each script or agent. The add-on keeps only a hash. Put the secret in an env var, vault, or <code>.env</code> — whatever you already use.</p>
<p id="status" class="muted"></p>
</header>
<section class="row">
<input id="name" type="text" maxlength="64" placeholder="Client name, e.g. cursor-agent" autocomplete="off">
<button id="create" type="button">Generate secret</button>
</section>
<p id="error" class="error" hidden></p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last used</th>
<th></th>
</tr>
</thead>
<tbody id="rows">
<tr><td colspan="4" class="muted">Loading…</td></tr>
</tbody>
</table>
<dialog id="secret-modal">
<h2>Copy this secret now</h2>
<p class="muted">It will not be shown again. Store it the same way you store any other API token.</p>
<label class="sr">Secret</label>
<textarea id="secret" readonly rows="3"></textarea>
<p class="hint">Example: <code>set FAB_TOKEN=&lt;secret&gt;</code> then <code>python tools/client.py meta.methods</code></p>
<div class="row">
<button id="copy" type="button">Copy</button>
<button id="close" type="button">I saved it</button>
</div>
</dialog>
<script src="options.js"></script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
"use strict";
const rows = document.getElementById("rows");
const nameInput = document.getElementById("name");
const createBtn = document.getElementById("create");
const errorEl = document.getElementById("error");
const statusEl = document.getElementById("status");
const modal = document.getElementById("secret-modal");
const secretEl = document.getElementById("secret");
const copyBtn = document.getElementById("copy");
const closeBtn = document.getElementById("close");
function showError(text) {
errorEl.hidden = !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>';
return;
}
rows.innerHTML = "";
for (const client of clients) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(client.name)}</td>
<td>${escapeHtml(client.created || "")}</td>
<td>${escapeHtml(client.last_used || "never")}</td>
<td><button type="button" data-revoke="${escapeHtml(client.id)}">Revoke</button></td>`;
rows.appendChild(tr);
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 admin("clients.list"));
}
createBtn.addEventListener("click", async () => {
showError("");
try {
const created = await admin("clients.create", [nameInput.value.trim()]);
nameInput.value = "";
secretEl.value = created.token;
modal.showModal();
await refresh();
} catch (err) {
showError(err.message);
}
});
rows.addEventListener("click", async (event) => {
const id = event.target && event.target.getAttribute("data-revoke");
if (!id) {
return;
}
showError("");
try {
await admin("clients.revoke", [id]);
await refresh();
} catch (err) {
showError(err.message);
}
});
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(secretEl.value);
copyBtn.textContent = "Copied";
} catch (err) {
showError(err.message);
}
});
closeBtn.addEventListener("click", () => {
secretEl.value = "";
copyBtn.textContent = "Copy";
modal.close();
});
refresh().catch((err) => showError(err.message));
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="ui.css">
</head>
<body class="popup">
<h1>Agent Bridge</h1>
<p id="status" class="muted">Checking host…</p>
<button id="manage" type="button">Manage clients</button>
<script src="popup.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
"use strict";
const statusEl = document.getElementById("status");
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}`;
} else {
statusEl.textContent = "Native host is not connected. Is it installed?";
}
});
manage.addEventListener("click", () => {
browser.runtime.openOptionsPage();
});
+42
View File
@@ -0,0 +1,42 @@
:root {
color-scheme: light dark;
font: 14px/1.45 system-ui, sans-serif;
}
body {
margin: 0;
color: CanvasText;
background: Canvas;
}
.popup {
width: 280px;
padding: 12px;
}
.page {
max-width: 720px;
margin: 0 auto;
padding: 24px;
}
h1, h2 { margin: 0 0 8px; font-size: 1.15rem; }
.muted { color: GrayText; }
.row { display: flex; gap: 8px; margin: 16px 0; }
input[type="text"], textarea, button {
font: inherit;
}
input[type="text"], textarea {
flex: 1;
padding: 6px 8px;
}
textarea { width: 100%; box-sizing: border-box; }
button { padding: 6px 10px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, Canvas); }
.error { color: #b00020; }
.hint { font-size: 0.9rem; }
.sr { position: absolute; left: -9999px; }
dialog {
border: 1px solid color-mix(in srgb, CanvasText 20%, Canvas);
border-radius: 8px;
padding: 16px;
max-width: 36rem;
}
code { font-size: 0.9em; }