Initial Firefox Agent Bridge: allowlisted bookmarks RPC for local scripts.

This commit is contained in:
alexveley
2026-09-22 06:24:31 -04:00
commit 6dcf068365
11 changed files with 690 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.DS_Store
host/com.easygoingaming.firefox_agent_bridge.json
+120
View File
@@ -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
+21
View File
@@ -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.
+61
View File
@@ -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.
+50
View File
@@ -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())
+81
View File
@@ -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();
+16
View File
@@ -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"]
}
}
+4
View File
@@ -0,0 +1,4 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
py -3 -u "%SCRIPT_DIR%firefox_agent_bridge_host.py" %*
+221
View File
@@ -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())
+65
View File
@@ -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())
+47
View File
@@ -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 ...)"