Replace Ed25519 with hashed API tokens and an in-Firefox client manager.
This commit is contained in:
+55
-2
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=<secret></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>
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user