Replace Ed25519 with hashed API tokens and an in-Firefox client manager.
This commit is contained in:
@@ -5,75 +5,59 @@ Use this when a script or coding agent needs to **create, update, move, or delet
|
||||
## Preconditions
|
||||
|
||||
1. Native host installed: `powershell -NoProfile -File tools/install-native-host.ps1`
|
||||
2. A **registered client key** for this tool (see Auth). The host has only the public half.
|
||||
3. Extension loaded in the target Firefox profile (`about:debugging` temporary add-on, or a signed install)
|
||||
4. Firefox **running** (the host process is started by the extension)
|
||||
2. Add-on loaded (temporary via `about:debugging`, or a signed install)
|
||||
3. A **client secret** issued from the add-on UI: toolbar icon → **Manage clients** → Generate secret
|
||||
4. Firefox **running**
|
||||
|
||||
If `http://127.0.0.1:17634/health` fails, the host is not up — open Firefox and confirm the extension is loaded. If `/health` works but `/v1/ready` fails, the stdio pipe is down; reload the extension.
|
||||
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`.
|
||||
|
||||
## Auth
|
||||
|
||||
The loopback port is **not** an open local API. Each caller is a named Ed25519 client.
|
||||
This is a normal API token, not an SSH key.
|
||||
|
||||
Register once per tool (human or agent operator does this; do not write the private key under `%LOCALAPPDATA%\firefox-agent-bridge`):
|
||||
1. In Firefox, open the Agent Bridge toolbar icon → **Manage clients**.
|
||||
2. Name the client (e.g. `cursor-agent`) and click **Generate secret**.
|
||||
3. Copy the `fab_…` value **once**. Store it however you already store secrets: env var, Vaultwarden, `.env`, CI secret, Cursor env.
|
||||
4. Give tooling only that string:
|
||||
|
||||
```text
|
||||
python tools/register_client.py add --name cursor-agent --write-key %USERPROFILE%\.fab\cursor-agent.json
|
||||
set FAB_TOKEN=fab_…
|
||||
python tools/client.py meta.methods
|
||||
```
|
||||
|
||||
Give tooling **only** that key file:
|
||||
`FAB_TOKEN_FILE` pointing at a file that contains only the token is also fine. `--token` on the CLI overrides both.
|
||||
|
||||
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
|
||||
set FAB_KEY_FILE=%USERPROFILE%\.fab\cursor-agent.json
|
||||
python tools/register_client.py add --name cursor-agent
|
||||
```
|
||||
|
||||
or `python tools/client.py --key PATH …`.
|
||||
|
||||
The host stores public keys in `%LOCALAPPDATA%\firefox-agent-bridge\clients.json`. Revoke with `python tools/register_client.py revoke NAME_OR_ID`. List with `… list`.
|
||||
|
||||
Every request except `/health` must be signed:
|
||||
|
||||
```
|
||||
Authorization: FAB-ED25519 id=<client_id>
|
||||
X-FAB-Timestamp: <unix seconds>
|
||||
X-FAB-Nonce: <unique hex>
|
||||
X-FAB-Signature: <base64 Ed25519 of canonical message>
|
||||
```
|
||||
|
||||
Canonical message (UTF-8, newline-separated):
|
||||
|
||||
```text
|
||||
v1
|
||||
<client_id>
|
||||
<timestamp>
|
||||
<nonce>
|
||||
<HTTP_METHOD>
|
||||
<path>
|
||||
<sha256 hex of raw body>
|
||||
```
|
||||
|
||||
Skew allowance is 90 seconds. Nonces cannot be reused. `tools/client.py` builds this for you.
|
||||
|
||||
Do not use a shared bearer token. Do not commit private key bundles. Do not paste PEM material into chat.
|
||||
|
||||
## Preferred caller
|
||||
|
||||
```text
|
||||
python tools/client.py --key KEYFILE METHOD [ARGS_JSON]
|
||||
python tools/client.py METHOD [ARGS_JSON]
|
||||
```
|
||||
|
||||
`ARGS_JSON` is a JSON **array** matching the WebExtension function arguments.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
python tools/client.py --key %USERPROFILE%\.fab\cursor-agent.json meta.methods
|
||||
python tools/client.py --key %USERPROFILE%\.fab\cursor-agent.json bookmarks.search "[{\"title\":\"10.132.x.x\"}]"
|
||||
python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]"
|
||||
```
|
||||
|
||||
HTTP equivalent: `POST /v1/call` with the signature headers above and body `{"method":"bookmarks.search","args":[{"title":"10.132.x.x"}]}`.
|
||||
HTTP:
|
||||
|
||||
## Allowlisted methods (v0.1)
|
||||
```http
|
||||
POST /v1/call
|
||||
Authorization: Bearer fab_…
|
||||
Content-Type: application/json
|
||||
|
||||
{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]}
|
||||
```
|
||||
|
||||
## Allowlisted methods (v0.2)
|
||||
|
||||
| Method | Args | Notes |
|
||||
|--------|------|--------|
|
||||
@@ -93,58 +77,38 @@ HTTP equivalent: `POST /v1/call` with the signature headers above and body `{"me
|
||||
|
||||
Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension.
|
||||
|
||||
REST aliases (same signature):
|
||||
|
||||
| HTTP | Maps to |
|
||||
|------|---------|
|
||||
| `GET /health` | host process only (no key) |
|
||||
| `GET /v1/ready` | `meta.ping` |
|
||||
| `GET /v1/methods` | `meta.methods` |
|
||||
| `GET /v1/bookmarks/tree` | `bookmarks.getTree` |
|
||||
| `GET /v1/bookmarks/{id}` | `bookmarks.get` |
|
||||
| `POST /v1/bookmarks/search` | body is the query object |
|
||||
| `POST /v1/bookmarks/create` | body is `CreateDetails` |
|
||||
| `POST /v1/bookmarks/update` | `{id, changes}` |
|
||||
| `POST /v1/bookmarks/move` | `{id, destination}` |
|
||||
| `POST /v1/bookmarks/remove` | `{id}` |
|
||||
| `POST /v1/bookmarks/remove-tree` | `{id}` |
|
||||
| `POST /v1/call` | `{method, args}` |
|
||||
REST aliases use the same Bearer token. `GET /health` has no token (loopback liveness only).
|
||||
|
||||
## Typical folder replace
|
||||
|
||||
1. `bookmarks.search` `{title: "…"}` — take the hit **without** a `url` (that is the folder). Fail if zero or many.
|
||||
1. `bookmarks.search` `{title: "…"}` — take the hit **without** a `url`. Fail if zero or many.
|
||||
2. `bookmarks.getChildren` on that id.
|
||||
3. `bookmarks.remove` each bookmark; `bookmarks.removeTree` each child folder.
|
||||
4. `bookmarks.create` each new item with `parentId` set.
|
||||
|
||||
`examples/replace_named_folder.py` does exactly that (`FAB_KEY_FILE` must be set). It is an example, not a sync service.
|
||||
|
||||
You cannot modify Firefox's bookmark root (`The bookmark root cannot be modified`). Operate on a named subfolder (toolbar / menu / a folder the user already created).
|
||||
`examples/replace_named_folder.py` does that (`FAB_TOKEN` must be set).
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Symptom | Cause | What to do |
|
||||
|---------|--------|------------|
|
||||
| Connection refused on `:17634` | Firefox closed or extension not loaded | Open Firefox; load/reload the add-on |
|
||||
| 401 `signed FAB-ED25519 client required` | Old bearer token or unsigned curl | Use `tools/client.py` and a registered key |
|
||||
| 401 `unknown client id` | Key revoked or host has no `clients.json` | `register_client.py list` / `add` |
|
||||
| 401 `bad signature` / `replayed nonce` / skew | Wrong key, reused request, or clock drift | New request; check `FAB_KEY_FILE` |
|
||||
| 502 `method not allowed` | Typo or API not in `ALLOWED` | Use `meta.methods` |
|
||||
| 502 timeout | Extension died mid-call | Reload the add-on |
|
||||
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO unlisted |
|
||||
| Native host not found | Installer not run | `tools/install-native-host.ps1` |
|
||||
| Connection refused on `:17634` | Firefox closed or add-on/host missing | Open Firefox; load add-on; install native host |
|
||||
| 401 `Bearer token required` | Unsigned request | Set `FAB_TOKEN` |
|
||||
| 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again |
|
||||
| 502 `method not allowed` | Typo or API not in `ALLOWED` | `meta.methods` |
|
||||
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO |
|
||||
|
||||
## Security boundaries
|
||||
|
||||
- The extension does **not** listen on `runtime.onMessageExternal`. Other add-ons cannot call the dispatcher.
|
||||
- The native host manifest `allowed_extensions` is pinned to `firefox-agent-bridge@easygoingaming.com`. A different add-on cannot `connectNative` to this host.
|
||||
- HTTP is `127.0.0.1` only. Requests that carry a browser `Origin` header are rejected. `Host` must be loopback.
|
||||
- Local processes **without** a registered private key cannot edit bookmarks through this port. The public store is useless for impersonation.
|
||||
- Another add-on that already has the `bookmarks` permission does not need this bridge.
|
||||
- No `runtime.onMessageExternal`. Other add-ons cannot call the dispatcher.
|
||||
- Native host `allowed_extensions` is pinned to this add-on's gecko id.
|
||||
- HTTP is `127.0.0.1` only; browser `Origin` headers are rejected.
|
||||
- Secrets are ordinary Bearer tokens. Do not write them under `%LOCALAPPDATA%\firefox-agent-bridge`.
|
||||
- Another add-on with the `bookmarks` permission already has Places.
|
||||
- Do not add `tabs`, `history`, `<all_urls>`, or `onMessageExternal` without a new threat review.
|
||||
|
||||
## What this project is not
|
||||
|
||||
- Not a bidirectional sync implementation
|
||||
- Not a general Firefox remote-control surface (no tabs, history, cookies, native file access)
|
||||
- Not a general Firefox remote-control surface
|
||||
- Not a reason to keep editing `places.sqlite` on a live profile
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Firefox Agent Bridge
|
||||
|
||||
A small Firefox extension that exposes an **allowlisted** WebExtension API to local scripts and agents.
|
||||
A small Firefox add-on that exposes an **allowlisted** WebExtension API to local scripts and agents — after you issue them a secret from inside Firefox.
|
||||
|
||||
This is **not** a bookmark sync engine. Firefox already has `browser.bookmarks`. The extension is a bridge: while Firefox is open, a script on the same machine can call those functions over `http://127.0.0.1:17634`.
|
||||
This is **not** a bookmark sync engine. Firefox already has `browser.bookmarks`. The add-on is a bridge: while Firefox is open, a script on the same machine can call those functions over `http://127.0.0.1:17634` with a Bearer token.
|
||||
|
||||
Agents: start at [AGENTS.md](AGENTS.md).
|
||||
|
||||
@@ -10,16 +10,7 @@ Agents: start at [AGENTS.md](AGENTS.md).
|
||||
|
||||
Firefox will not let an outside process talk to Places. Editing `places.sqlite` while the browser is running loses deletes. The supported path is `browser.bookmarks` inside an extension, plus [native messaging](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging) so a localhost HTTP host can reach that extension.
|
||||
|
||||
## Pieces
|
||||
|
||||
| Piece | Role |
|
||||
|-------|------|
|
||||
| `extension/` | WebExtension (`bookmarks` + `nativeMessaging`) |
|
||||
| `host/` | Native host: stdio to Firefox, HTTP to scripts |
|
||||
| `tools/client.py` | Signed Python caller (`FAB_KEY_FILE`) |
|
||||
| `examples/replace_named_folder.py` | Sample "replace this folder" script |
|
||||
|
||||
## Install (this workstation)
|
||||
## Setup
|
||||
|
||||
1. Register the native host (once):
|
||||
|
||||
@@ -27,42 +18,36 @@ Firefox will not let an outside process talk to Places. Editing `places.sqlite`
|
||||
powershell -NoProfile -File tools\install-native-host.ps1
|
||||
```
|
||||
|
||||
That registers
|
||||
`HKCU\Software\Mozilla\NativeMessagingHosts\com.easygoingaming.firefox_agent_bridge`.
|
||||
2. Load the add-on (`about:debugging` temporary, or a signed `.xpi` — [docs/signing.md](docs/signing.md)).
|
||||
|
||||
Then create a client key **outside** that state directory and give the file to tooling:
|
||||
3. Click the toolbar icon → **Manage clients** → name a client → **Generate secret**. Copy the `fab_…` token into whatever secret store you already use.
|
||||
|
||||
```powershell
|
||||
python tools\register_client.py add --name cursor-agent --write-key $HOME\.fab\cursor-agent.json
|
||||
$env:FAB_KEY_FILE = "$HOME\.fab\cursor-agent.json"
|
||||
```
|
||||
|
||||
2. Load the extension. Firefox Release will not keep an unsigned add-on across restarts:
|
||||
|
||||
- `about:debugging#/runtime/this-firefox`
|
||||
- **Load Temporary Add-on**
|
||||
- pick `extension\manifest.json`
|
||||
|
||||
Permanent install: Mozilla-signed `.xpi` — [docs/signing.md](docs/signing.md). Start with `--channel=unlisted`.
|
||||
|
||||
3. Leave Firefox open. The extension starts the host; `GET http://127.0.0.1:17634/health` should return JSON.
|
||||
4. Leave Firefox open. `GET http://127.0.0.1:17634/health` should return JSON.
|
||||
|
||||
## Call it
|
||||
|
||||
```powershell
|
||||
$env:FAB_KEY_FILE = "$HOME\.fab\cursor-agent.json"
|
||||
$env:FAB_TOKEN = "fab_…" # the value from the Manage clients modal
|
||||
python tools/client.py meta.methods
|
||||
python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]"
|
||||
python tools/client.py bookmarks.search '[{"title":"10.132.x.x"}]'
|
||||
```
|
||||
|
||||
See [AGENTS.md](AGENTS.md) for the method list, HTTP surface, and failure modes.
|
||||
`curl` works the same: `Authorization: Bearer fab_…`.
|
||||
|
||||
## Security
|
||||
|
||||
- Binds **127.0.0.1 only**. Browser `Origin` headers are rejected; `Host` must be loopback.
|
||||
- Calls (except `/health`) must be signed by a **registered Ed25519 client**. The host keeps public keys only.
|
||||
- Only the methods in `extension/background.js` `ALLOWED` run. No history, cookies, tabs, or `onMessageExternal` in v0.1.
|
||||
- Another add-on with `bookmarks` already has Places; this bridge does not give it a new path in.
|
||||
- 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.
|
||||
- Only `bookmarks.*` (plus `meta.*`) in `extension/background.js`. No history, cookies, tabs, or `onMessageExternal`.
|
||||
|
||||
## Pieces
|
||||
|
||||
| Piece | Role |
|
||||
|-------|------|
|
||||
| `extension/` | Add-on, popup, Manage clients page |
|
||||
| `host/` | Native host: stdio to Firefox, HTTP to scripts |
|
||||
| `tools/client.py` | Bearer caller (`FAB_TOKEN`) |
|
||||
| `examples/replace_named_folder.py` | Sample folder replace |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Mozilla signs through [addons.mozilla.org](https://addons.mozilla.org/) even whe
|
||||
| **unlisted** | Signed `.xpi` for self-install. Not on the AMO store. Enough to survive Firefox restarts. **Start here.** |
|
||||
| **listed** | Public AMO listing, automatic updates via Firefox. Needs listing metadata, review, and Add-on Policy compliance. Do this when you want strangers to find it. |
|
||||
|
||||
Unlisted submissions can still be pulled for manual review. Native messaging is allowed; be ready to explain that the HTTP listener is the **native host**, bind is loopback-only, and the extension does not accept `onMessageExternal`.
|
||||
Unlisted submissions can still be pulled for manual review. Native messaging is allowed; be ready to explain that the HTTP listener is the **native host**, bind is loopback-only, tokens are hashed at rest, and the extension does not accept `onMessageExternal`.
|
||||
|
||||
## 3. Sign (unlisted)
|
||||
|
||||
|
||||
+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; }
|
||||
+76
-162
@@ -1,42 +1,34 @@
|
||||
"""Ed25519 client registration and request signing.
|
||||
"""Hashed API-token clients for Firefox Agent Bridge.
|
||||
|
||||
The host stores public keys only. Private keys are written where the user
|
||||
says and passed into tooling — never next to the native host state.
|
||||
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 base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
)
|
||||
|
||||
STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge"
|
||||
CLIENTS_PATH = STATE_DIR / "clients.json"
|
||||
SKEW_SECONDS = 90
|
||||
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 []
|
||||
@@ -52,177 +44,99 @@ def save_clients(clients: list[dict[str, Any]]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
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 _unb64(text: str) -> bytes:
|
||||
return base64.b64decode(text.encode("ascii"))
|
||||
def list_clients() -> list[dict[str, Any]]:
|
||||
return [public_client(row) for row in load_clients()]
|
||||
|
||||
|
||||
def body_hash(raw: bytes) -> str:
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def canonical(
|
||||
client_id: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
http_method: str,
|
||||
path: str,
|
||||
raw_body: bytes,
|
||||
) -> bytes:
|
||||
return "\n".join(
|
||||
[
|
||||
"v1",
|
||||
client_id,
|
||||
timestamp,
|
||||
nonce,
|
||||
http_method.upper(),
|
||||
path,
|
||||
body_hash(raw_body),
|
||||
]
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def register_client(name: str, key_path: Path) -> dict[str, str]:
|
||||
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 chars of A-Za-z0-9._-")
|
||||
key_path = key_path.expanduser().resolve()
|
||||
if STATE_DIR in key_path.parents or key_path.parent == STATE_DIR:
|
||||
raise ValueError(
|
||||
f"refusing to write a private key under {STATE_DIR} — pick a path you will give to tooling"
|
||||
)
|
||||
raise ValueError("name must be 1-64 characters: A-Za-z0-9._-")
|
||||
clients = load_clients()
|
||||
if any(c.get("name") == name for c in clients):
|
||||
if any(row.get("name") == name for row in clients):
|
||||
raise ValueError(f"client already registered: {name}")
|
||||
private = Ed25519PrivateKey.generate()
|
||||
public = private.public_key()
|
||||
client_id = uuid.uuid4().hex
|
||||
pub_pem = public.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode("ascii")
|
||||
priv_pem = private.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode("ascii")
|
||||
token = TOKEN_PREFIX + secrets.token_hex(32)
|
||||
client_id = secrets.token_hex(16)
|
||||
clients.append(
|
||||
{
|
||||
"id": client_id,
|
||||
"name": name,
|
||||
"public_key_pem": pub_pem,
|
||||
"token_hash": token_hash(token),
|
||||
"created": _now(),
|
||||
"last_used": None,
|
||||
}
|
||||
)
|
||||
save_clients(clients)
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if key_path.exists():
|
||||
raise ValueError(f"key file already exists: {key_path}")
|
||||
bundle = {
|
||||
"id": client_id,
|
||||
"name": name,
|
||||
"private_key_pem": priv_pem,
|
||||
"public_key_pem": pub_pem,
|
||||
}
|
||||
key_path.write_text(json.dumps(bundle, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
os.chmod(key_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return {"id": client_id, "name": name, "key_file": str(key_path)}
|
||||
return {"id": client_id, "name": name, "token": token}
|
||||
|
||||
|
||||
def revoke_client(name_or_id: str) -> str:
|
||||
clients = load_clients()
|
||||
kept = [c for c in clients if c.get("id") != name_or_id and c.get("name") != name_or_id]
|
||||
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 load_key_bundle(path: Path) -> dict[str, str]:
|
||||
data = json.loads(path.expanduser().read_text(encoding="utf-8"))
|
||||
if not data.get("id") or not data.get("private_key_pem"):
|
||||
raise ValueError("key file must contain id and private_key_pem")
|
||||
return data
|
||||
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 sign_headers(
|
||||
bundle: dict[str, str],
|
||||
http_method: str,
|
||||
path: str,
|
||||
raw_body: bytes,
|
||||
) -> dict[str, str]:
|
||||
private = load_pem_private_key(bundle["private_key_pem"].encode("ascii"), password=None)
|
||||
if not isinstance(private, Ed25519PrivateKey):
|
||||
raise ValueError("key file is not an Ed25519 private key")
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
message = canonical(bundle["id"], timestamp, nonce, http_method, path, raw_body)
|
||||
signature = _b64(private.sign(message))
|
||||
return {
|
||||
"Authorization": f"FAB-ED25519 id={bundle['id']}",
|
||||
"X-FAB-Timestamp": timestamp,
|
||||
"X-FAB-Nonce": nonce,
|
||||
"X-FAB-Signature": signature,
|
||||
}
|
||||
|
||||
|
||||
class ReplayCache:
|
||||
def __init__(self, limit: int = 2048) -> None:
|
||||
self.limit = limit
|
||||
self._seen: dict[str, float] = {}
|
||||
|
||||
def accept(self, nonce: str, now: float) -> bool:
|
||||
cutoff = now - SKEW_SECONDS
|
||||
stale = [key for key, ts in self._seen.items() if ts < cutoff]
|
||||
for key in stale:
|
||||
del self._seen[key]
|
||||
if nonce in self._seen:
|
||||
return False
|
||||
self._seen[nonce] = now
|
||||
if len(self._seen) > self.limit:
|
||||
oldest = sorted(self._seen, key=self._seen.get)[: len(self._seen) - self.limit]
|
||||
for key in oldest:
|
||||
del self._seen[key]
|
||||
return True
|
||||
|
||||
|
||||
def _hdr(headers: dict[str, str], name: str) -> str:
|
||||
want = name.lower()
|
||||
def verify_bearer(headers: dict[str, str]) -> dict[str, Any]:
|
||||
auth = ""
|
||||
for key, value in headers.items():
|
||||
if key.lower() == want:
|
||||
return (value or "").strip()
|
||||
return ""
|
||||
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 verify_request(
|
||||
headers: dict[str, str],
|
||||
http_method: str,
|
||||
path: str,
|
||||
raw_body: bytes,
|
||||
replay: ReplayCache,
|
||||
) -> dict[str, Any]:
|
||||
auth = _hdr(headers, "Authorization")
|
||||
match = re.fullmatch(r"FAB-ED25519 id=([0-9a-f]{32})", auth)
|
||||
if not match:
|
||||
raise PermissionError("signed FAB-ED25519 client required")
|
||||
client_id = match.group(1)
|
||||
timestamp = _hdr(headers, "X-FAB-Timestamp")
|
||||
nonce = _hdr(headers, "X-FAB-Nonce")
|
||||
signature = _hdr(headers, "X-FAB-Signature")
|
||||
if not timestamp.isdigit() or not nonce or not signature:
|
||||
raise PermissionError("missing signature headers")
|
||||
now = time.time()
|
||||
if abs(now - int(timestamp)) > SKEW_SECONDS:
|
||||
raise PermissionError("timestamp outside allowed skew")
|
||||
if not replay.accept(nonce, now):
|
||||
raise PermissionError("replayed nonce")
|
||||
client = next((c for c in load_clients() if c.get("id") == client_id), None)
|
||||
if not client:
|
||||
raise PermissionError("unknown client id")
|
||||
public = load_pem_public_key(client["public_key_pem"].encode("ascii"))
|
||||
if not isinstance(public, Ed25519PublicKey):
|
||||
raise PermissionError("stored key is not Ed25519")
|
||||
message = canonical(client_id, timestamp, nonce, http_method, path, raw_body)
|
||||
try:
|
||||
public.verify(_unb64(signature), message)
|
||||
except InvalidSignature as exc:
|
||||
raise PermissionError("bad signature") from exc
|
||||
return client
|
||||
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}")
|
||||
|
||||
@@ -15,7 +15,7 @@ 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, ReplayCache, verify_request # noqa: E402
|
||||
from fab_auth import CLIENTS_PATH, handle_admin, verify_bearer # noqa: E402
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
@@ -31,7 +31,6 @@ STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-ag
|
||||
STDIN_LOCK = threading.Lock()
|
||||
PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {}
|
||||
PENDING_LOCK = threading.Lock()
|
||||
REPLAY = ReplayCache()
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
@@ -112,7 +111,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not self._gate_ok():
|
||||
return False
|
||||
try:
|
||||
verify_request({k: v for k, v in self.headers.items()}, http_method, path, raw_body, REPLAY)
|
||||
verify_bearer({k: v for k, v in self.headers.items()})
|
||||
except PermissionError as exc:
|
||||
self._send(401, {"ok": False, "error": str(exc)})
|
||||
return False
|
||||
@@ -209,6 +208,22 @@ def stdin_loop() -> None:
|
||||
break
|
||||
if message is None:
|
||||
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 "")
|
||||
with PENDING_LOCK:
|
||||
pending = PENDING.pop(req_id, None)
|
||||
@@ -223,7 +238,7 @@ def stdin_loop() -> None:
|
||||
def main() -> int:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not CLIENTS_PATH.exists():
|
||||
log(f"no registered clients at {CLIENTS_PATH}; run tools/register_client.py add")
|
||||
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()
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
log(f"listening on http://{HOST}:{PORT}")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
cryptography>=42
|
||||
+19
-22
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Call Firefox Agent Bridge with a registered Ed25519 client key."""
|
||||
"""Call Firefox Agent Bridge with a Bearer token from FAB_TOKEN or a file."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -11,21 +11,21 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from fab_auth import load_key_bundle, sign_headers # noqa: E402
|
||||
|
||||
DEFAULT_BASE = os.environ.get("FAB_URL", "http://127.0.0.1:17634")
|
||||
|
||||
|
||||
def key_path() -> Path:
|
||||
env = os.environ.get("FAB_KEY_FILE")
|
||||
def token_value(explicit: str | None = None) -> str:
|
||||
if explicit:
|
||||
return explicit.strip()
|
||||
env = os.environ.get("FAB_TOKEN")
|
||||
if env:
|
||||
return Path(env)
|
||||
return env.strip()
|
||||
path = os.environ.get("FAB_TOKEN_FILE")
|
||||
if path:
|
||||
return Path(path).expanduser().read_text(encoding="utf-8").strip()
|
||||
raise SystemExit(
|
||||
"no client key: set FAB_KEY_FILE or pass --key "
|
||||
"(python tools/register_client.py add --name NAME --write-key PATH)"
|
||||
"no token: set FAB_TOKEN, or FAB_TOKEN_FILE, or pass --token. "
|
||||
"Generate one from the add-on toolbar → Manage clients."
|
||||
)
|
||||
|
||||
|
||||
@@ -33,20 +33,17 @@ def call(
|
||||
method: str,
|
||||
args: list[Any] | None = None,
|
||||
base: str = DEFAULT_BASE,
|
||||
key_file: Path | None = None,
|
||||
token: str | None = None,
|
||||
) -> Any:
|
||||
payload = json.dumps({"method": method, "args": args or []}).encode("utf-8")
|
||||
path = "/v1/call"
|
||||
bundle = load_key_bundle(key_file or key_path())
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
**sign_headers(bundle, "POST", path, payload),
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
base.rstrip("/") + path,
|
||||
base.rstrip("/") + "/v1/call",
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers=headers,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token_value(token)}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
@@ -69,12 +66,12 @@ def main() -> int:
|
||||
parser.add_argument("method", help="e.g. bookmarks.search or meta.methods")
|
||||
parser.add_argument("args_json", nargs="?", default="[]", help="JSON array of arguments")
|
||||
parser.add_argument("--url", default=DEFAULT_BASE)
|
||||
parser.add_argument("--key", type=Path, help="client key bundle (or FAB_KEY_FILE)")
|
||||
parser.add_argument("--token", help="override FAB_TOKEN")
|
||||
ns = parser.parse_args()
|
||||
args = json.loads(ns.args_json)
|
||||
if not isinstance(args, list):
|
||||
raise SystemExit("args_json must be a JSON array")
|
||||
print(json.dumps(call(ns.method, args, base=ns.url, key_file=ns.key), indent=2))
|
||||
print(json.dumps(call(ns.method, args, base=ns.url, token=ns.token), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,5 @@ Set-ItemProperty -Path $regPath -Name "(default)" -Value $ManifestPath
|
||||
Write-Host "registered $regPath"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Register a client key for tooling (private key stays out of $StateDir):"
|
||||
Write-Host " python tools\register_client.py add --name cursor-agent --write-key `$HOME\.fab\cursor-agent.json"
|
||||
Write-Host "Then load the extension and set FAB_KEY_FILE to that path."
|
||||
Write-Host "Load the add-on, then use the toolbar icon → Manage clients to generate a secret."
|
||||
Write-Host "Put that fab_… token in FAB_TOKEN for scripts. Do not store it under $StateDir."
|
||||
|
||||
+15
-27
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Register, list, or revoke Ed25519 clients for Firefox Agent Bridge."""
|
||||
"""CLI fallback for client secrets. Prefer the add-on Manage clients page."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -10,42 +10,30 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from fab_auth import CLIENTS_PATH, load_clients, register_client, revoke_client # noqa: E402
|
||||
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")
|
||||
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 keypair and register the public half")
|
||||
add.add_argument("--name", required=True, help="label, e.g. cursor-agent")
|
||||
add.add_argument(
|
||||
"--write-key",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="private key bundle path for tooling (must not be under LocalAppData\\firefox-agent-bridge)",
|
||||
)
|
||||
|
||||
sub.add_parser("list", help="show registered public clients")
|
||||
|
||||
drop = sub.add_parser("revoke", help="drop a client by name or id")
|
||||
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":
|
||||
info = register_client(ns.name, ns.write_key)
|
||||
print(json.dumps(info, indent=2))
|
||||
print(f"give {info['key_file']} to tooling via FAB_KEY_FILE or --key", file=sys.stderr)
|
||||
created = create_client(ns.name)
|
||||
print(created["token"])
|
||||
print(
|
||||
f"id={created['id']} name={created['name']} — store that token in FAB_TOKEN; it will not be shown again.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
if ns.cmd == "list":
|
||||
rows = [
|
||||
{"id": c.get("id"), "name": c.get("name"), "created": c.get("created")}
|
||||
for c in load_clients()
|
||||
]
|
||||
print(json.dumps({"store": str(CLIENTS_PATH), "clients": rows}, indent=2))
|
||||
print(json.dumps({"store": str(CLIENTS_PATH), "clients": list_clients()}, indent=2))
|
||||
return 0
|
||||
revoke_client(ns.name_or_id)
|
||||
print(json.dumps({"revoked": ns.name_or_id}))
|
||||
print(json.dumps({"revoked": revoke_client(ns.name_or_id)}))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user