Present the repo as a scoped bookmarks API product and drop homelab-only notes.
This commit is contained in:
@@ -12,15 +12,15 @@ Do not edit `places.sqlite` while Firefox is open if this API is reachable.
|
|||||||
2. Native host installed only if scripts should call in: `powershell -NoProfile -File tools/install-native-host.ps1`
|
2. Native host installed only if scripts should call in: `powershell -NoProfile -File tools/install-native-host.ps1`
|
||||||
3. Firefox **running** for those calls
|
3. Firefox **running** for those calls
|
||||||
|
|
||||||
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`.
|
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 token.
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
This is a normal API token, not an SSH key.
|
This is a normal API token, not an SSH key.
|
||||||
|
|
||||||
1. In Firefox, open the Bookmarks API toolbar icon → **Manage clients**.
|
1. In Firefox, open the Bookmarks API toolbar icon → **Manage clients**.
|
||||||
2. Name the client (e.g. `cursor-agent`) and click **Generate secret**.
|
2. Name the client 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.
|
3. Copy the `fab_…` value **once**. Store it with your other secrets (environment variable, password manager, `.env`, or CI secret).
|
||||||
4. Give tooling only that string:
|
4. Give tooling only that string:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -41,7 +41,7 @@ python tools/client.py METHOD [ARGS_JSON]
|
|||||||
`ARGS_JSON` is a JSON **array** matching the WebExtension function arguments.
|
`ARGS_JSON` is a JSON **array** matching the WebExtension function arguments.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]"
|
python tools/client.py bookmarks.search "[{\"title\":\"Research\"}]"
|
||||||
```
|
```
|
||||||
|
|
||||||
HTTP:
|
HTTP:
|
||||||
@@ -51,10 +51,10 @@ POST /v1/call
|
|||||||
Authorization: Bearer fab_…
|
Authorization: Bearer fab_…
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]}
|
{"method": "bookmarks.search", "args": [{"title": "Research"}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Allowlisted methods (v0.2)
|
## Allowlisted methods
|
||||||
|
|
||||||
| Method | Args | Notes |
|
| Method | Args | Notes |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
@@ -72,7 +72,7 @@ Content-Type: application/json
|
|||||||
| `bookmarks.removeTree` | `id` | Folder and descendants |
|
| `bookmarks.removeTree` | `id` | Folder and descendants |
|
||||||
| `bookmarks.getRecent` | `numberOfItems` | |
|
| `bookmarks.getRecent` | `numberOfItems` | |
|
||||||
|
|
||||||
Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension.
|
Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` **and** `ALLOWED_METHODS` in `host/firefox_agent_bridge_host.py`, then reloading the extension.
|
||||||
|
|
||||||
REST aliases use the same Bearer token. `GET /health` has no token (loopback liveness only).
|
REST aliases use the same Bearer token. `GET /health` has no token (loopback liveness only).
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ REST aliases use the same Bearer token. `GET /health` has no token (loopback liv
|
|||||||
| Connection refused on `:17634` | Firefox closed or add-on/host missing | Open Firefox; load add-on; install native host |
|
| 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 `Bearer token required` | Unsigned request | Set `FAB_TOKEN` |
|
||||||
| 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again |
|
| 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again |
|
||||||
| 502 `method not allowed` | Typo or API not in `ALLOWED` | `meta.methods` |
|
| 502 `method not allowed` | Typo or API not in the allowlist | `meta.methods` |
|
||||||
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO |
|
| Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO |
|
||||||
|
|
||||||
## Security boundaries
|
## Security boundaries
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 EasyGoin / EGG
|
Copyright (c) 2026 EasyGoin
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Privacy
|
||||||
|
|
||||||
|
Bookmarks API for Scripting and AI runs on your computer. It does not send your bookmarks, secrets, or browsing data to the authors or to any remote service.
|
||||||
|
|
||||||
|
## What it can see
|
||||||
|
|
||||||
|
With the `bookmarks` permission, the add-on can read and change bookmarks in the Firefox profile where it is installed. That is the product. Local scripts you authorize can request the same operations through the loopback API.
|
||||||
|
|
||||||
|
## What it stores
|
||||||
|
|
||||||
|
Manage clients stores **SHA-256 hashes** of issued secrets, plus the client name and timestamps, in `browser.storage.local`. The plaintext secret is shown once and is not kept.
|
||||||
|
|
||||||
|
The add-on does not use `storage.sync`, so those hashes are not uploaded with a Firefox Account.
|
||||||
|
|
||||||
|
## What it does not collect
|
||||||
|
|
||||||
|
No telemetry, crash reports, or analytics. No account. No network calls to the authors.
|
||||||
|
|
||||||
|
The optional native host listens on `127.0.0.1` only so *your* local tools can reach the add-on. It does not accept browser-origin requests.
|
||||||
|
|
||||||
|
## Revoking access
|
||||||
|
|
||||||
|
Toolbar → **Manage clients** → **Revoke**. After that, the old secret is rejected.
|
||||||
@@ -1,25 +1,59 @@
|
|||||||
# Bookmarks API for Scripting and AI
|
# Bookmarks API for Scripting and AI
|
||||||
|
|
||||||
An API add-on for Firefox to allow agentic and script-based management of user bookmarks.
|
An API add-on for Firefox so agents and scripts can manage your bookmarks while the browser is open.
|
||||||
|
|
||||||
The published product is the add-on in `extension/`. Issue a secret in **Manage clients**, store it with your secrets, and keep Firefox open so those tools can talk to this add-on.
|
The shipped product is the add-on in `extension/`. This is not a bookmark sync engine, and it is not a Mozilla product.
|
||||||
|
|
||||||
This is not a bookmark sync engine, and it is not a Mozilla product.
|
## What it does
|
||||||
|
|
||||||
## Setup
|
Scripts and local agents call a loopback HTTP API. The add-on performs only `browser.bookmarks` operations (create, search, move, delete, and related reads). It does not read tabs, history, cookies, or the open web.
|
||||||
|
|
||||||
1. Load the add-on (temporary via `about:debugging`, or a signed `.xpi` — [docs/signing.md](docs/signing.md)).
|
1. Load the add-on.
|
||||||
2. Toolbar → **Manage clients** → generate a secret → store it with your secrets.
|
2. Toolbar → **Manage clients** → generate a secret → store that secret yourself.
|
||||||
3. To let scripts call in, register the native host once (`tools/install-native-host.ps1` on this workstation).
|
3. Register the native host once if scripts should call in (`tools/install-native-host.ps1` on Windows).
|
||||||
|
4. Keep Firefox open. Call `http://127.0.0.1:17634` with `Authorization: Bearer <secret>`.
|
||||||
|
|
||||||
Authenticated calls use `Authorization: Bearer <secret>` on `http://127.0.0.1:17634`. Method list for people working in this repo: [AGENTS.md](AGENTS.md).
|
```http
|
||||||
|
POST /v1/call
|
||||||
|
Authorization: Bearer fab_…
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"method": "bookmarks.search", "args": [{"title": "Research"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
set FAB_TOKEN=fab_…
|
||||||
|
python tools/client.py meta.methods
|
||||||
|
python tools/client.py bookmarks.search "[{\"title\":\"Research\"}]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Method list and failure modes: [AGENTS.md](AGENTS.md). Signing for Firefox Release: [docs/signing.md](docs/signing.md).
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Why |
|
||||||
|
|------------|-----|
|
||||||
|
| `bookmarks` | The only WebExtension API this add-on calls |
|
||||||
|
| `storage` | SHA-256 hashes of issued client secrets |
|
||||||
|
| `nativeMessaging` | The only inbound path from local scripts (Mozilla does not allow a raw listening socket in the extension) |
|
||||||
|
|
||||||
|
It does **not** request `tabs`, `history`, `cookies`, `<all_urls>`, `webRequest`, or `runtime.onMessageExternal`.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Binds **127.0.0.1** only. Browser `Origin` headers are rejected.
|
- HTTP binds **127.0.0.1** only. Requests with a browser `Origin` header are rejected.
|
||||||
- Client hashes live in the add-on’s storage. The host asks the add-on whether a token is valid.
|
- The native host may talk only to this add-on’s gecko id.
|
||||||
- Only `bookmarks.*` (plus `meta.*`). No history, cookies, tabs, or `onMessageExternal`.
|
- The add-on stores **hashes**, not secrets. Revoke from Manage clients.
|
||||||
|
- Both the host and the add-on allowlist the same `meta.*` / `bookmarks.*` methods.
|
||||||
|
|
||||||
|
See [PRIVACY.md](PRIVACY.md) and [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
- Not bidirectional bookmark sync
|
||||||
|
- Not a general Firefox remote-control surface
|
||||||
|
- Not a reason to edit `places.sqlite` while Firefox is running
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT.
|
MIT. Copyright EasyGoin.
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
This add-on is scoped to bookmarks. Treat a leaked `fab_…` secret like a password for your bookmark tree: revoke it, then issue a new one.
|
||||||
|
|
||||||
|
## Boundaries that must stay
|
||||||
|
|
||||||
|
- Manifest permissions: `bookmarks`, `storage`, `nativeMessaging` only
|
||||||
|
- No `tabs`, `history`, `cookies`, `<all_urls>`, `webRequest`, or `runtime.onMessageExternal`
|
||||||
|
- Host bind: `127.0.0.1`
|
||||||
|
- Reject HTTP requests that carry a browser `Origin`
|
||||||
|
- Native host `allowed_extensions` pinned to this gecko id
|
||||||
|
- Method allowlist in both `extension/background.js` and `host/firefox_agent_bridge_host.py`
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
Please open an issue on this repository. Do not attach live secrets or a copy of `places.sqlite`.
|
||||||
+7
-7
@@ -1,6 +1,6 @@
|
|||||||
# Signing for Firefox Release
|
# Signing for Firefox Release
|
||||||
|
|
||||||
Firefox Release and Beta will only keep an add-on that Mozilla has signed. Temporary add-ons from `about:debugging` die on restart. Developer Edition / Nightly can load unsigned builds if `xpinstall.signatures.required` is false — do not rely on that for this workstation.
|
Firefox Release and Beta will only keep an add-on that Mozilla has signed. Temporary add-ons from `about:debugging` are removed on restart. Developer Edition / Nightly can load unsigned builds if `xpinstall.signatures.required` is false — do not rely on that for daily use.
|
||||||
|
|
||||||
Mozilla signs through [addons.mozilla.org](https://addons.mozilla.org/) even when the add-on is **not** listed in the store.
|
Mozilla signs through [addons.mozilla.org](https://addons.mozilla.org/) even when the add-on is **not** listed in the store.
|
||||||
|
|
||||||
@@ -9,14 +9,14 @@ Mozilla signs through [addons.mozilla.org](https://addons.mozilla.org/) even whe
|
|||||||
1. Register at [addons.mozilla.org](https://addons.mozilla.org/) (Mozilla account).
|
1. Register at [addons.mozilla.org](https://addons.mozilla.org/) (Mozilla account).
|
||||||
2. Open [API credentials](https://addons.mozilla.org/developers/addon/api/key/).
|
2. Open [API credentials](https://addons.mozilla.org/developers/addon/api/key/).
|
||||||
3. Generate a JWT **issuer** (`user:…`) and **secret**.
|
3. Generate a JWT **issuer** (`user:…`) and **secret**.
|
||||||
4. Store both in Vaultwarden. Never commit them. Env names `WEB_EXT_API_KEY` / `WEB_EXT_API_SECRET` (or `AMO_JWT_ISSUER` / `AMO_JWT_SECRET` if you prefer wrappers).
|
4. Store both in your password manager. Never commit them. Env names `WEB_EXT_API_KEY` / `WEB_EXT_API_SECRET`.
|
||||||
|
|
||||||
## 2. Choose a channel
|
## 2. Choose a channel
|
||||||
|
|
||||||
| Channel | What you get |
|
| Channel | What you get |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| **unlisted** | Signed `.xpi` for self-install. Not on the AMO store. Enough to survive Firefox restarts. **Start here.** |
|
| **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. |
|
| **listed** | Public AMO listing, automatic updates via Firefox. Needs listing metadata, review, and Add-on Policy compliance. |
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
@@ -30,16 +30,16 @@ cd extension
|
|||||||
web-ext sign --channel=unlisted --api-key $env:WEB_EXT_API_KEY --api-secret $env:WEB_EXT_API_SECRET
|
web-ext sign --channel=unlisted --api-key $env:WEB_EXT_API_KEY --api-secret $env:WEB_EXT_API_SECRET
|
||||||
```
|
```
|
||||||
|
|
||||||
The gecko id in `manifest.json` (`firefox-agent-bridge@easygoingaming.com`) must stay stable. Bump `version` for every new sign.
|
The gecko id in `manifest.json` must stay stable. Bump `version` for every new sign.
|
||||||
|
|
||||||
`web-ext` writes a signed `.xpi` under `web-ext-artifacts/`. Install it in Firefox: the `.xpi` file, or `about:addons` → gear → Install Add-on From File.
|
`web-ext` writes a signed `.xpi` under `web-ext-artifacts/`. Install it in Firefox from that file, or `about:addons` → gear → Install Add-on From File.
|
||||||
|
|
||||||
Keep the native host registered (`tools/install-native-host.ps1`). Signing replaces only the extension; the host is unchanged.
|
Keep the native host registered (`tools/install-native-host.ps1`). Signing replaces only the extension; the host is unchanged.
|
||||||
|
|
||||||
## 4. Updates
|
## 4. Updates
|
||||||
|
|
||||||
Bump `version`, sign again on the same channel and id. For unlisted self-distribution, [updates](https://extensionworkshop.com/documentation/manage/updating-your-extension/) need an `update_url` in `browser_specific_settings.gecko` if you want Firefox to auto-fetch. Until that exists, drop in a new `.xpi` by hand.
|
Bump `version`, sign again on the same channel and id. For unlisted self-distribution, [updates](https://extensionworkshop.com/documentation/manage/updating-your-extension/) need an `update_url` in `browser_specific_settings.gecko` if you want Firefox to auto-fetch. Until that exists, install a new `.xpi` by hand.
|
||||||
|
|
||||||
## 5. Listed (later)
|
## 5. Listed (later)
|
||||||
|
|
||||||
`web-ext sign --channel=listed` plus an AMO metadata JSON (name **Bookmarks API for Scripting and AI**, license MIT). Listing copy: local scripts only, loopback HTTP, bearer token, bookmarks allowlist. Do not title it “Firefox …”. Mozilla only allows “Name for Firefox”; this add-on uses neither form.
|
`web-ext sign --channel=listed` plus AMO metadata (name **Bookmarks API for Scripting and AI**, license MIT, privacy policy from [PRIVACY.md](../PRIVACY.md)). Listing copy: local scripts only, loopback HTTP, bearer token, bookmarks allowlist. Do not title it “Firefox …”. Mozilla only allows “Name for Firefox”; this add-on uses neither form.
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
@@ -24,7 +23,23 @@ HOST = "127.0.0.1"
|
|||||||
PORT = int(os.environ.get("FAB_PORT", "17634"))
|
PORT = int(os.environ.get("FAB_PORT", "17634"))
|
||||||
MAX_BODY = 256 * 1024
|
MAX_BODY = 256 * 1024
|
||||||
ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"}
|
ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"}
|
||||||
STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge"
|
ALLOWED_METHODS = frozenset(
|
||||||
|
{
|
||||||
|
"meta.ping",
|
||||||
|
"meta.methods",
|
||||||
|
"bookmarks.getTree",
|
||||||
|
"bookmarks.getSubTree",
|
||||||
|
"bookmarks.getChildren",
|
||||||
|
"bookmarks.get",
|
||||||
|
"bookmarks.search",
|
||||||
|
"bookmarks.create",
|
||||||
|
"bookmarks.update",
|
||||||
|
"bookmarks.move",
|
||||||
|
"bookmarks.remove",
|
||||||
|
"bookmarks.removeTree",
|
||||||
|
"bookmarks.getRecent",
|
||||||
|
}
|
||||||
|
)
|
||||||
STDIN_LOCK = threading.Lock()
|
STDIN_LOCK = threading.Lock()
|
||||||
PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {}
|
PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {}
|
||||||
PENDING_LOCK = threading.Lock()
|
PENDING_LOCK = threading.Lock()
|
||||||
@@ -82,6 +97,8 @@ def verify_token_with_extension(token: str, timeout: float = 8.0) -> dict[str, A
|
|||||||
|
|
||||||
|
|
||||||
def call_extension(method: str, args: list[Any] | None = None, timeout: float = 15.0) -> dict[str, Any]:
|
def call_extension(method: str, args: list[Any] | None = None, timeout: float = 15.0) -> dict[str, Any]:
|
||||||
|
if method not in ALLOWED_METHODS:
|
||||||
|
raise RuntimeError(f"method not allowed: {method}")
|
||||||
req_id = str(uuid.uuid4())
|
req_id = str(uuid.uuid4())
|
||||||
event = threading.Event()
|
event = threading.Event()
|
||||||
slot: dict[str, Any] = {}
|
slot: dict[str, Any] = {}
|
||||||
@@ -247,7 +264,6 @@ def stdin_loop() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start()
|
threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start()
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
log(f"listening on http://{HOST}:{PORT}")
|
log(f"listening on http://{HOST}:{PORT}")
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Call Firefox Agent Bridge with a Bearer token from FAB_TOKEN or a file."""
|
"""Call Bookmarks API with a Bearer token from FAB_TOKEN or a file."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -62,7 +62,7 @@ def call(
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Call Firefox Agent Bridge")
|
parser = argparse.ArgumentParser(description="Call Bookmarks API")
|
||||||
parser.add_argument("method", help="e.g. bookmarks.search or meta.methods")
|
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("args_json", nargs="?", default="[]", help="JSON array of arguments")
|
||||||
parser.add_argument("--url", default=DEFAULT_BASE)
|
parser.add_argument("--url", default=DEFAULT_BASE)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Register the native messaging host for the current Windows user.
|
# Register the native messaging host for the current Windows user.
|
||||||
# Does not load the Firefox extension — see README / AGENTS.md.
|
# Does not load the add-on — see README / AGENTS.md.
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$Root = Split-Path -Parent $PSScriptRoot
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Secrets are issued in the add-on UI, not from this script."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
print(
|
|
||||||
"Generate and revoke clients in Firefox: Bookmarks API toolbar → Manage clients.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise SystemExit(2)
|
|
||||||
Reference in New Issue
Block a user