Document who can call the loopback API and let the user pick the listen port.

This commit is contained in:
alexveley
2026-09-22 08:19:12 -04:00
parent cfda629418
commit 33944dbd07
10 changed files with 184 additions and 18 deletions
+64 -1
View File
@@ -2,6 +2,7 @@
const HOST_NAME = "com.easygoingaming.firefox_agent_bridge";
const RECONNECT_MS = 2000;
const DEFAULT_PORT = 17634;
const ALLOWED = {
"meta.ping": async () => ({ ok: true, extension: "bookmarks-api" }),
@@ -22,6 +23,47 @@ const ALLOWED = {
let port = null;
let reconnectTimer = null;
let hostUp = false;
let listenPort = DEFAULT_PORT;
let bindError = "";
let configWaiter = null;
function normalizePort(value) {
const n = Number(value);
if (!Number.isInteger(n) || n < 1024 || n > 65535) {
throw new Error("Port must be an integer from 1024 to 65535");
}
return n;
}
async function loadListenPort() {
const data = await browser.storage.local.get("listenPort");
try {
return normalizePort(data.listenPort == null ? DEFAULT_PORT : data.listenPort);
} catch (err) {
return DEFAULT_PORT;
}
}
async function pushConfig() {
if (!port) {
return;
}
listenPort = await loadListenPort();
await new Promise((resolve) => {
configWaiter = resolve;
port.postMessage({ type: "config", port: listenPort });
setTimeout(resolve, 2500);
});
}
function statusInfo() {
return {
hostUp,
port: listenPort,
listenPort,
bindError,
};
}
function asArray(value) {
if (value === undefined || value === null) {
@@ -43,6 +85,16 @@ function attach(nextPort) {
port = nextPort;
hostUp = true;
port.onMessage.addListener(async (message) => {
if (message && message.type === "config-result") {
listenPort = message.port || listenPort;
bindError = message.ok ? "" : (message.error || "could not bind port");
if (configWaiter) {
const done = configWaiter;
configWaiter = null;
done();
}
return;
}
if (message && message.type === "auth") {
try {
const client = await findClientByTokenHash(message.token_hash);
@@ -80,8 +132,10 @@ function attach(nextPort) {
port.onDisconnect.addListener(() => {
port = null;
hostUp = false;
bindError = "";
scheduleReconnect();
});
pushConfig();
}
function scheduleReconnect() {
@@ -106,7 +160,16 @@ function connect() {
browser.runtime.onMessage.addListener((message) => {
if (message && message.type === "status") {
return Promise.resolve({ hostUp, port: 17634 });
return Promise.resolve(statusInfo());
}
if (message && message.type === "set-port") {
return (async () => {
const n = normalizePort(message.port);
await browser.storage.local.set({ listenPort: n });
listenPort = n;
await pushConfig();
return statusInfo();
})();
}
return undefined;
});
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Bookmarks API for Scripting and AI",
"short_name": "Bookmarks API",
"version": "0.4.1",
"version": "0.4.2",
"description": "An API add-on for Firefox to allow agentic and script-based management of user bookmarks.",
"browser_specific_settings": {
"gecko": {
+16
View File
@@ -9,6 +9,12 @@
<header>
<h1>Bookmarks API for Scripting and AI</h1>
<p class="lead">An API add-on for Firefox to allow agentic and script-based management of user bookmarks.</p>
<section class="usage">
<h2>Who can use this</h2>
<p>Tools on <strong>this computer</strong> can call the API — a local script, an editor agent, or a session you opened here (SSH, remote desktop). They need a secret you issue below.</p>
<p>It does not work from chatgpt.com or other sites in a browser tab. Those run elsewhere and cannot see this machines loopback port. Pages inside Firefox are blocked too.</p>
<p class="muted">If this Firefox profile syncs bookmarks with a Mozilla account, changes made here can show up on your other devices. The API itself still only accepts connections on this machine.</p>
</section>
<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>
@@ -17,6 +23,16 @@
<p id="status" class="muted"></p>
</header>
<section class="settings">
<h2>Listen port</h2>
<p class="muted">Loopback only (<code>127.0.0.1</code>). Change this if the default is already in use. Your tools must use the same port.</p>
<div class="row">
<label class="sr" for="port">Listen port</label>
<input id="port" type="number" min="1024" max="65535" step="1" value="17634">
<button id="save-port" type="button">Save port</button>
</div>
</section>
<section class="row">
<input id="name" type="text" maxlength="64" placeholder="Client name" autocomplete="off">
<button id="create" type="button">Generate secret</button>
+25 -4
View File
@@ -5,11 +5,26 @@ const nameInput = document.getElementById("name");
const createBtn = document.getElementById("create");
const errorEl = document.getElementById("error");
const statusEl = document.getElementById("status");
const portInput = document.getElementById("port");
const savePortBtn = document.getElementById("save-port");
const modal = document.getElementById("secret-modal");
const secretEl = document.getElementById("secret");
const copyBtn = document.getElementById("copy");
const closeBtn = document.getElementById("close");
function applyStatus(info) {
if (info && info.listenPort) {
portInput.value = String(info.listenPort);
}
if (info && info.bindError) {
statusEl.textContent = `Could not listen on ${info.listenPort}: ${info.bindError}`;
return;
}
statusEl.textContent = info && info.hostUp
? `This add-on is ready on 127.0.0.1:${info.listenPort} while Firefox is open.`
: "You can issue and revoke secrets here anytime.";
}
function showError(text) {
errorEl.hidden = !text;
errorEl.textContent = text || "";
@@ -43,15 +58,21 @@ function escapeHtml(value) {
async function refresh() {
render(await listClients());
try {
const info = await browser.runtime.sendMessage({ type: "status" });
statusEl.textContent = info && info.hostUp
? "This add-on is ready for agents and scripts while Firefox is open."
: "You can issue and revoke secrets here anytime.";
applyStatus(await browser.runtime.sendMessage({ type: "status" }));
} catch (err) {
statusEl.textContent = "You can issue and revoke secrets here anytime.";
}
}
savePortBtn.addEventListener("click", async () => {
showError("");
try {
applyStatus(await browser.runtime.sendMessage({ type: "set-port", port: portInput.value }));
} catch (err) {
showError(err.message);
}
});
createBtn.addEventListener("click", async () => {
showError("");
try {
+8 -4
View File
@@ -4,11 +4,15 @@ const statusEl = document.getElementById("status");
const manage = document.getElementById("manage");
browser.runtime.sendMessage({ type: "status" }).then((info) => {
if (info && info.hostUp) {
statusEl.textContent = "Ready for agents and scripts while Firefox is open.";
} else {
statusEl.textContent = "You can issue and revoke secrets here.";
if (info && info.bindError) {
statusEl.textContent = `Port ${info.listenPort} is not available.`;
return;
}
if (info && info.hostUp) {
statusEl.textContent = `Ready on 127.0.0.1:${info.listenPort} while Firefox is open.`;
return;
}
statusEl.textContent = "You can issue and revoke secrets here.";
});
manage.addEventListener("click", () => {
+6
View File
@@ -18,6 +18,12 @@ body {
}
h1, h2 { margin: 0 0 8px; font-size: 1.15rem; }
.lead { margin: 0 0 12px; }
.usage, .settings { margin: 0 0 16px; }
.usage p { margin: 0 0 8px; }
input[type="number"] {
width: 8rem;
padding: 6px 8px;
}
.process { margin: 0 0 16px; padding-left: 1.2rem; }
.process li { margin: 6px 0; }
.muted { color: GrayText; }