Initial Firefox Agent Bridge: allowlisted bookmarks RPC for local scripts.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
host/com.easygoingaming.firefox_agent_bridge.json
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Agent handshake — Firefox Agent Bridge
|
||||||
|
|
||||||
|
Use this when a script or coding agent needs to **create, update, move, or delete Firefox bookmarks while Firefox is open**. Do not edit `places.sqlite` if this bridge is reachable.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
1. Native host installed: `powershell -NoProfile -File tools/install-native-host.ps1`
|
||||||
|
2. Extension loaded in the target Firefox profile (`about:debugging` temporary add-on, or a signed install)
|
||||||
|
3. Firefox **running** (the host process is started by the extension)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Token file (created by the installer):
|
||||||
|
|
||||||
|
`%LOCALAPPDATA%\firefox-agent-bridge\token`
|
||||||
|
|
||||||
|
Send it on every request except `/health`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Override with env `FAB_TOKEN` or `FAB_URL` (default `http://127.0.0.1:17634`).
|
||||||
|
|
||||||
|
## Preferred caller
|
||||||
|
|
||||||
|
From this repo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python tools/client.py METHOD [ARGS_JSON]
|
||||||
|
```
|
||||||
|
|
||||||
|
`ARGS_JSON` is a JSON **array** matching the WebExtension function arguments.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python tools/client.py meta.methods
|
||||||
|
python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]"
|
||||||
|
python tools/client.py bookmarks.getChildren "[\"FOLDER_ID\"]"
|
||||||
|
python tools/client.py bookmarks.create "[{\"parentId\":\"FOLDER_ID\",\"title\":\"NPM\",\"url\":\"http://10.132.99.80:81/\"}]"
|
||||||
|
python tools/client.py bookmarks.remove "[\"BOOKMARK_ID\"]"
|
||||||
|
python tools/client.py bookmarks.removeTree "[\"FOLDER_ID\"]"
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP equivalent:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/call
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Allowlisted methods (v0.1)
|
||||||
|
|
||||||
|
| Method | Args | Notes |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `meta.ping` | (none) | Bridge alive |
|
||||||
|
| `meta.methods` | (none) | This table |
|
||||||
|
| `bookmarks.getTree` | (none) | Full tree |
|
||||||
|
| `bookmarks.getSubTree` | `id` | |
|
||||||
|
| `bookmarks.getChildren` | `id` | |
|
||||||
|
| `bookmarks.get` | `id` or `[id, …]` | |
|
||||||
|
| `bookmarks.search` | `{title? url? query?}` or string | |
|
||||||
|
| `bookmarks.create` | `{parentId?, title, url?}` | Omit `url` to create a folder |
|
||||||
|
| `bookmarks.update` | `id`, `{title? url?}` | |
|
||||||
|
| `bookmarks.move` | `id`, `{parentId? index?}` | |
|
||||||
|
| `bookmarks.remove` | `id` | Bookmark or empty folder |
|
||||||
|
| `bookmarks.removeTree` | `id` | Folder and descendants |
|
||||||
|
| `bookmarks.getRecent` | `numberOfItems` | |
|
||||||
|
|
||||||
|
Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension.
|
||||||
|
|
||||||
|
REST aliases (same auth):
|
||||||
|
|
||||||
|
| HTTP | Maps to |
|
||||||
|
|------|---------|
|
||||||
|
| `GET /health` | host process only (no token) |
|
||||||
|
| `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}` |
|
||||||
|
|
||||||
|
## Typical folder replace
|
||||||
|
|
||||||
|
1. `bookmarks.search` `{title: "…"}` — take the hit **without** a `url` (that is the folder). 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. 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).
|
||||||
|
|
||||||
|
## 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 | Missing/wrong bearer | Read the token 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` |
|
||||||
|
|
||||||
|
## 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 reason to keep editing `places.sqlite` on a live profile
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 EasyGoin / EGG
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Firefox Agent Bridge
|
||||||
|
|
||||||
|
A small Firefox extension that exposes an **allowlisted** WebExtension API to local scripts and agents.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
Agents: start at [AGENTS.md](AGENTS.md).
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
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` | Stdlib Python caller |
|
||||||
|
| `examples/replace_named_folder.py` | Sample "replace this folder" script |
|
||||||
|
|
||||||
|
## Install (this workstation)
|
||||||
|
|
||||||
|
1. Register the native host (once):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -File tools\install-native-host.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
That writes `%LOCALAPPDATA%\firefox-agent-bridge\token` and
|
||||||
|
`HKCU\Software\Mozilla\NativeMessagingHosts\com.easygoingaming.firefox_agent_bridge`.
|
||||||
|
|
||||||
|
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 later = AMO-signed (unlisted is enough).
|
||||||
|
|
||||||
|
3. Leave Firefox open. The extension starts the host; `GET http://127.0.0.1:17634/health` should return JSON.
|
||||||
|
|
||||||
|
## Call it
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$token = Get-Content $env:LOCALAPPDATA\firefox-agent-bridge\token -Raw
|
||||||
|
curl.exe -s -H "Authorization: Bearer $token" http://127.0.0.1:17634/v1/methods
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Binds **127.0.0.1 only**.
|
||||||
|
- Every mutating call (and most reads) needs `Authorization: Bearer <token>`.
|
||||||
|
- Only the methods in `extension/background.js` `ALLOWED` run. No history, cookies, or tabs in v0.1.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Example: replace children of a named bookmark folder from a small JSON list.
|
||||||
|
|
||||||
|
This is not a sync engine. It finds one folder by title, deletes its children,
|
||||||
|
and creates the supplied bookmarks. See AGENTS.md.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
from client import call # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def find_folder(title: str) -> dict:
|
||||||
|
hits = call("bookmarks.search", [{"title": title}])
|
||||||
|
folders = [n for n in hits if not n.get("url")]
|
||||||
|
if not folders:
|
||||||
|
raise SystemExit(f"folder not found: {title}")
|
||||||
|
if len(folders) > 1:
|
||||||
|
raise SystemExit(f"multiple folders titled {title!r}; pass a unique name")
|
||||||
|
return folders[0]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("usage: replace_named_folder.py FOLDER_TITLE items.json", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
title = sys.argv[1]
|
||||||
|
items = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
|
||||||
|
folder = find_folder(title)
|
||||||
|
for child in call("bookmarks.getChildren", [folder["id"]]):
|
||||||
|
if child.get("url"):
|
||||||
|
call("bookmarks.remove", [child["id"]])
|
||||||
|
else:
|
||||||
|
call("bookmarks.removeTree", [child["id"]])
|
||||||
|
for item in items:
|
||||||
|
call(
|
||||||
|
"bookmarks.create",
|
||||||
|
[{"parentId": folder["id"], "title": item["title"], "url": item["url"]}],
|
||||||
|
)
|
||||||
|
print(f"replaced {len(items)} bookmarks in {title!r} ({folder['id']})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const HOST_NAME = "com.easygoingaming.firefox_agent_bridge";
|
||||||
|
const RECONNECT_MS = 2000;
|
||||||
|
|
||||||
|
const ALLOWED = {
|
||||||
|
"meta.ping": async () => ({ ok: true, extension: "firefox-agent-bridge" }),
|
||||||
|
"meta.methods": async () => Object.keys(ALLOWED).sort(),
|
||||||
|
"bookmarks.getTree": () => browser.bookmarks.getTree(),
|
||||||
|
"bookmarks.getSubTree": (id) => browser.bookmarks.getSubTree(id),
|
||||||
|
"bookmarks.getChildren": (id) => browser.bookmarks.getChildren(id),
|
||||||
|
"bookmarks.get": (idOrIds) => browser.bookmarks.get(idOrIds),
|
||||||
|
"bookmarks.search": (query) => browser.bookmarks.search(query),
|
||||||
|
"bookmarks.create": (details) => browser.bookmarks.create(details),
|
||||||
|
"bookmarks.update": (id, changes) => browser.bookmarks.update(id, changes),
|
||||||
|
"bookmarks.move": (id, destination) => browser.bookmarks.move(id, destination),
|
||||||
|
"bookmarks.remove": (id) => browser.bookmarks.remove(id),
|
||||||
|
"bookmarks.removeTree": (id) => browser.bookmarks.removeTree(id),
|
||||||
|
"bookmarks.getRecent": (numberOfItems) => browser.bookmarks.getRecent(numberOfItems),
|
||||||
|
};
|
||||||
|
|
||||||
|
let port = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
|
function asArray(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(value) ? value : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatch(message) {
|
||||||
|
const method = message && message.method;
|
||||||
|
const fn = ALLOWED[method];
|
||||||
|
if (!fn) {
|
||||||
|
throw new Error(`method not allowed: ${method || "(missing)"}`);
|
||||||
|
}
|
||||||
|
return fn(...asArray(message.args));
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(nextPort) {
|
||||||
|
port = nextPort;
|
||||||
|
port.onMessage.addListener(async (message) => {
|
||||||
|
const id = message && message.id;
|
||||||
|
try {
|
||||||
|
const result = await dispatch(message);
|
||||||
|
port.postMessage({ id, ok: true, result });
|
||||||
|
} catch (err) {
|
||||||
|
port.postMessage({
|
||||||
|
id,
|
||||||
|
ok: false,
|
||||||
|
error: err && err.message ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port.onDisconnect.addListener(() => {
|
||||||
|
port = null;
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, RECONNECT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
try {
|
||||||
|
attach(browser.runtime.connectNative(HOST_NAME));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("native host connect failed", err);
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"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.",
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "firefox-agent-bridge@easygoingaming.com",
|
||||||
|
"strict_min_version": "115.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissions": ["bookmarks", "nativeMessaging"],
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
py -3 -u "%SCRIPT_DIR%firefox_agent_bridge_host.py" %*
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Native messaging host: 127.0.0.1 HTTP <-> Firefox extension stdio."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
|
||||||
|
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
|
||||||
|
|
||||||
|
HOST = "127.0.0.1"
|
||||||
|
PORT = int(os.environ.get("FAB_PORT", "17634"))
|
||||||
|
STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge"
|
||||||
|
TOKEN_PATH = STATE_DIR / "token"
|
||||||
|
STDIN_LOCK = threading.Lock()
|
||||||
|
PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {}
|
||||||
|
PENDING_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
sys.stderr.write(msg + "\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def read_token() -> str:
|
||||||
|
env = os.environ.get("FAB_TOKEN")
|
||||||
|
if env:
|
||||||
|
return env.strip()
|
||||||
|
if TOKEN_PATH.exists():
|
||||||
|
return TOKEN_PATH.read_text(encoding="utf-8").strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def send_to_extension(payload: dict[str, Any]) -> None:
|
||||||
|
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
|
with STDIN_LOCK:
|
||||||
|
sys.stdout.buffer.write(struct.pack("@I", len(raw)))
|
||||||
|
sys.stdout.buffer.write(raw)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def read_from_extension() -> dict[str, Any] | None:
|
||||||
|
header = sys.stdin.buffer.read(4)
|
||||||
|
if len(header) < 4:
|
||||||
|
return None
|
||||||
|
(length,) = struct.unpack("@I", header)
|
||||||
|
body = sys.stdin.buffer.read(length)
|
||||||
|
if len(body) < length:
|
||||||
|
return None
|
||||||
|
return json.loads(body.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def call_extension(method: str, args: list[Any] | None = None, timeout: float = 15.0) -> dict[str, Any]:
|
||||||
|
req_id = str(uuid.uuid4())
|
||||||
|
event = threading.Event()
|
||||||
|
slot: dict[str, Any] = {}
|
||||||
|
with PENDING_LOCK:
|
||||||
|
PENDING[req_id] = (event, slot)
|
||||||
|
send_to_extension({"id": req_id, "method": method, "args": args or []})
|
||||||
|
if not event.wait(timeout):
|
||||||
|
with PENDING_LOCK:
|
||||||
|
PENDING.pop(req_id, None)
|
||||||
|
raise TimeoutError(f"extension did not answer {method}")
|
||||||
|
if not slot.get("ok"):
|
||||||
|
raise RuntimeError(slot.get("error") or "extension error")
|
||||||
|
return slot.get("result")
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: Any) -> None:
|
||||||
|
log("%s - %s" % (self.address_string(), fmt % args))
|
||||||
|
|
||||||
|
def _send(self, code: int, payload: Any) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _auth_ok(self) -> bool:
|
||||||
|
token = read_token()
|
||||||
|
if not token:
|
||||||
|
self._send(500, {"ok": False, "error": "bridge token missing; run install-native-host.ps1"})
|
||||||
|
return False
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
got = header[7:].strip() if header.lower().startswith("bearer ") else ""
|
||||||
|
if got != token:
|
||||||
|
self._send(401, {"ok": False, "error": "missing or invalid bearer token"})
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _json_body(self) -> dict[str, Any]:
|
||||||
|
length = int(self.headers.get("Content-Length") or "0")
|
||||||
|
if length <= 0:
|
||||||
|
return {}
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("JSON object required")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == "/health":
|
||||||
|
self._send(200, {"ok": True, "service": "firefox-agent-bridge", "port": PORT})
|
||||||
|
return
|
||||||
|
if not self._auth_ok():
|
||||||
|
return
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
try:
|
||||||
|
if parsed.path in ("/v1/ready", "/v1/methods"):
|
||||||
|
method = "meta.ping" if parsed.path == "/v1/ready" else "meta.methods"
|
||||||
|
result = call_extension(method)
|
||||||
|
self._send(200, {"ok": True, "result": result})
|
||||||
|
return
|
||||||
|
if parsed.path == "/v1/bookmarks/tree":
|
||||||
|
result = call_extension("bookmarks.getTree")
|
||||||
|
self._send(200, {"ok": True, "result": result})
|
||||||
|
return
|
||||||
|
if parsed.path.startswith("/v1/bookmarks/") and parsed.path != "/v1/bookmarks/":
|
||||||
|
node_id = parsed.path[len("/v1/bookmarks/") :].strip("/")
|
||||||
|
if "children" in qs:
|
||||||
|
result = call_extension("bookmarks.getChildren", [node_id])
|
||||||
|
else:
|
||||||
|
result = call_extension("bookmarks.get", [node_id])
|
||||||
|
self._send(200, {"ok": True, "result": result})
|
||||||
|
return
|
||||||
|
self._send(404, {"ok": False, "error": "not found"})
|
||||||
|
except Exception as exc:
|
||||||
|
self._send(502, {"ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if not self._auth_ok():
|
||||||
|
return
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
try:
|
||||||
|
body = self._json_body()
|
||||||
|
except ValueError as exc:
|
||||||
|
self._send(400, {"ok": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if parsed.path == "/v1/call":
|
||||||
|
method = body.get("method")
|
||||||
|
args = body.get("args") or []
|
||||||
|
if not method:
|
||||||
|
self._send(400, {"ok": False, "error": "method required"})
|
||||||
|
return
|
||||||
|
result = call_extension(str(method), list(args) if not isinstance(args, list) else args)
|
||||||
|
self._send(200, {"ok": True, "result": result})
|
||||||
|
return
|
||||||
|
rest = {
|
||||||
|
"/v1/bookmarks/search": ("bookmarks.search", [body.get("query", body)]),
|
||||||
|
"/v1/bookmarks/create": ("bookmarks.create", [body]),
|
||||||
|
"/v1/bookmarks/update": ("bookmarks.update", [body.get("id"), body.get("changes") or {}]),
|
||||||
|
"/v1/bookmarks/move": ("bookmarks.move", [body.get("id"), body.get("destination") or {}]),
|
||||||
|
"/v1/bookmarks/remove": ("bookmarks.remove", [body.get("id")]),
|
||||||
|
"/v1/bookmarks/remove-tree": ("bookmarks.removeTree", [body.get("id")]),
|
||||||
|
}
|
||||||
|
if parsed.path in rest:
|
||||||
|
method, args = rest[parsed.path]
|
||||||
|
result = call_extension(method, args)
|
||||||
|
self._send(200, {"ok": True, "result": result})
|
||||||
|
return
|
||||||
|
self._send(404, {"ok": False, "error": "not found"})
|
||||||
|
except Exception as exc:
|
||||||
|
self._send(502, {"ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
def stdin_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = read_from_extension()
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"stdin read failed: {exc}")
|
||||||
|
break
|
||||||
|
if message is None:
|
||||||
|
break
|
||||||
|
req_id = str(message.get("id") or "")
|
||||||
|
with PENDING_LOCK:
|
||||||
|
pending = PENDING.pop(req_id, None)
|
||||||
|
if not pending:
|
||||||
|
continue
|
||||||
|
event, slot = pending
|
||||||
|
slot.update(message)
|
||||||
|
event.set()
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
token = read_token()
|
||||||
|
if not token:
|
||||||
|
log(f"no token at {TOKEN_PATH}; run tools/install-native-host.ps1")
|
||||||
|
threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start()
|
||||||
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
|
log(f"listening on http://{HOST}:{PORT}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal client for Firefox Agent Bridge. Stdlib only."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DEFAULT_BASE = os.environ.get("FAB_URL", "http://127.0.0.1:17634")
|
||||||
|
TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" / "token"
|
||||||
|
|
||||||
|
|
||||||
|
def token() -> str:
|
||||||
|
env = os.environ.get("FAB_TOKEN")
|
||||||
|
if env:
|
||||||
|
return env.strip()
|
||||||
|
if TOKEN_PATH.exists():
|
||||||
|
return TOKEN_PATH.read_text(encoding="utf-8").strip()
|
||||||
|
raise SystemExit(f"no token: set FAB_TOKEN or create {TOKEN_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
def call(method: str, args: list[Any] | None = None, base: str = DEFAULT_BASE) -> Any:
|
||||||
|
payload = json.dumps({"method": method, "args": args or []}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
base.rstrip("/") + "/v1/call",
|
||||||
|
data=payload,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
body = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SystemExit(
|
||||||
|
f"bridge unreachable at {base} ({exc}). "
|
||||||
|
"Firefox must be open with the extension loaded."
|
||||||
|
) from exc
|
||||||
|
if not body.get("ok"):
|
||||||
|
raise SystemExit(body.get("error") or body)
|
||||||
|
return body.get("result")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Call Firefox Agent Bridge")
|
||||||
|
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)
|
||||||
|
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), indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Register the native messaging host for the current Windows user.
|
||||||
|
# Does not load the Firefox extension — see README / AGENTS.md.
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$HostDir = Join-Path $Root "host"
|
||||||
|
$CmdPath = Join-Path $HostDir "firefox-agent-bridge-host.cmd"
|
||||||
|
$ManifestPath = Join-Path $HostDir "com.easygoingaming.firefox_agent_bridge.json"
|
||||||
|
$StateDir = Join-Path $env:LOCALAPPDATA "firefox-agent-bridge"
|
||||||
|
$TokenPath = Join-Path $StateDir "token"
|
||||||
|
$HostName = "com.easygoingaming.firefox_agent_bridge"
|
||||||
|
$ExtensionId = "firefox-agent-bridge@easygoingaming.com"
|
||||||
|
|
||||||
|
if (-not (Test-Path $CmdPath)) {
|
||||||
|
throw "host launcher missing: $CmdPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
|
||||||
|
if (-not (Test-Path $TokenPath) -or -not (Get-Content -Raw $TokenPath).Trim()) {
|
||||||
|
$bytes = New-Object byte[] 32
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
|
||||||
|
$token = -join ($bytes | ForEach-Object { $_.ToString("x2") })
|
||||||
|
[System.IO.File]::WriteAllText($TokenPath, $token)
|
||||||
|
Write-Host "wrote $TokenPath"
|
||||||
|
} else {
|
||||||
|
Write-Host "kept existing token at $TokenPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = @{
|
||||||
|
name = $HostName
|
||||||
|
description = "Firefox Agent Bridge native host"
|
||||||
|
path = $CmdPath
|
||||||
|
type = "stdio"
|
||||||
|
allowed_extensions = @($ExtensionId)
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
[System.IO.File]::WriteAllText($ManifestPath, $manifest)
|
||||||
|
Write-Host "wrote $ManifestPath"
|
||||||
|
|
||||||
|
$regPath = "HKCU:\Software\Mozilla\NativeMessagingHosts\$HostName"
|
||||||
|
New-Item -Path $regPath -Force | Out-Null
|
||||||
|
Set-ItemProperty -Path $regPath -Name "(default)" -Value $ManifestPath
|
||||||
|
Write-Host "registered $regPath"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Next: in Firefox open about:debugging#/runtime/this-firefox"
|
||||||
|
Write-Host "Load Temporary Add-on and pick extension\manifest.json"
|
||||||
|
Write-Host "Token is read from $TokenPath (Authorization: Bearer ...)"
|
||||||
Reference in New Issue
Block a user