Compare commits
4 Commits
v0.15.6
...
2c38cc8874
| Author | SHA1 | Date | |
|---|---|---|---|
|
2c38cc8874
|
|||
|
cea8a7e994
|
|||
|
7fe0e27fec
|
|||
|
6fa931aa36
|
@@ -37,7 +37,6 @@ terminal / python script / remote client
|
||||
No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
|
||||
|
||||
**Message format**
|
||||
|
||||
Every command is a JSON object:
|
||||
```json
|
||||
{ "id": "uuid", "command": "tabs.list", "args": {} }
|
||||
@@ -50,11 +49,12 @@ Every response:
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
|
||||
|
||||
browser-cli has two parts: the **CLI / native host** (a Python package) and the **browser extension** (published on the public stores).
|
||||
|
||||
### Install with uv
|
||||
Install the CLI from PyPI as a uv tool:
|
||||
Install the CLI from PyPI as a uv tool, then register the native host:
|
||||
|
||||
```sh
|
||||
uv tool install real-browser-cli
|
||||
@@ -76,28 +76,37 @@ To upgrade later:
|
||||
uv tool upgrade real-browser-cli
|
||||
```
|
||||
|
||||
### Add the browser extension
|
||||
Install the extension from its public store listing (the `install` command prints the right link for you):
|
||||
|
||||
- Chrome / Chromium / Brave / Edge / Vivaldi — [Chrome Web Store](https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp)
|
||||
- Firefox — [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/browser-cli/)
|
||||
|
||||
The native host manifest trusts both the published store ID and the unpacked development ID, so the store extension works out of the box. If you are hacking on the extension yourself, run `browser-cli install <browser> --dev` for the unpacked / temporary-add-on load steps instead.
|
||||
|
||||
### Install from source
|
||||
```sh
|
||||
git clone <repo>
|
||||
cd browser-cli
|
||||
uv sync
|
||||
uv run browser-cli install brave # or: chrome, chromium, edge, vivaldi, firefox
|
||||
npm ci && npm run build:extension # build the unpacked extension bundles
|
||||
uv run browser-cli install brave --dev # --dev prints unpacked-load steps; or: chrome, chromium, edge, vivaldi, firefox
|
||||
```
|
||||
|
||||
The `install` command will:
|
||||
1. Ask you to load the browser-specific extension package
|
||||
2. Show the stable extension ID used by that browser family
|
||||
3. Write the native messaging manifest to your OS so the browser can find the host
|
||||
4. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
|
||||
Omit `--dev` to be pointed at the public store listing instead of loading the unpacked build.
|
||||
|
||||
After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
|
||||
The `install` command will:
|
||||
1. Write the native messaging manifest to your OS so the browser can find the host
|
||||
2. Copy the native host into an internal `libexec` directory and create a small wrapper outside your `PATH`
|
||||
3. Print the public store link for installing the extension (or, with `--dev`, the unpacked / temporary-add-on load steps)
|
||||
|
||||
After install, add the extension from the store link above and **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
|
||||
|
||||
Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper imports the installed `browser_cli.native.host` entry point. On Windows the install command also registers the host in the current user's Registry for the selected browser.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```text
|
||||
browser-cli/
|
||||
├── browser_cli/
|
||||
@@ -133,7 +142,6 @@ browser-cli/
|
||||
---
|
||||
|
||||
## CLI reference
|
||||
|
||||
During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
|
||||
|
||||
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
|
||||
@@ -141,7 +149,6 @@ If exactly one browser instance is connected, commands auto-target it. Use `--br
|
||||
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
|
||||
|
||||
### Navigation (`nav`)
|
||||
|
||||
```sh
|
||||
# Open a URL (no focus stealing by default)
|
||||
browser-cli nav open https://example.com
|
||||
@@ -163,7 +170,6 @@ browser-cli nav focus github # focuses first tab whose URL contains "
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
Each search command opens the search results in your browser using the same flags as `nav open`.
|
||||
|
||||
```sh
|
||||
@@ -187,7 +193,6 @@ browser-cli search so click choices
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```sh
|
||||
browser-cli tabs list # list all open tabs (all windows)
|
||||
browser-cli tabs count # count all tabs
|
||||
@@ -215,7 +220,6 @@ browser-cli tabs merge-windows # pull all tabs into the current wi
|
||||
```
|
||||
|
||||
### Tab groups
|
||||
|
||||
```sh
|
||||
browser-cli groups list # list all tab groups
|
||||
browser-cli groups count # count groups
|
||||
@@ -237,7 +241,6 @@ browser-cli groups move 42 -l # short left alias
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```sh
|
||||
browser-cli windows list # list all windows
|
||||
browser-cli windows open # open a new window
|
||||
@@ -247,7 +250,6 @@ browser-cli windows close 1 # close a window
|
||||
```
|
||||
|
||||
### DOM
|
||||
|
||||
These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`.
|
||||
|
||||
```sh
|
||||
@@ -260,7 +262,6 @@ browser-cli dom type "#search" "hello" # type text into an input
|
||||
```
|
||||
|
||||
### Extract
|
||||
|
||||
```sh
|
||||
browser-cli extract links # all <a href> links on the page
|
||||
browser-cli extract images # all <img> tags (src + alt)
|
||||
@@ -272,7 +273,6 @@ browser-cli extract markdown --selector "article" # specific DOM subtree as Ma
|
||||
```
|
||||
|
||||
### Sessions
|
||||
|
||||
A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared.
|
||||
|
||||
```sh
|
||||
@@ -286,7 +286,6 @@ browser-cli session auto-save off
|
||||
```
|
||||
|
||||
### Misc
|
||||
|
||||
```sh
|
||||
browser-cli clients # show connected browser info from the registry
|
||||
browser-cli clients rename --browser abcd1234 work # rename one connected browser instance
|
||||
@@ -297,7 +296,6 @@ browser-cli completion zsh --script # output raw completion script
|
||||
```
|
||||
|
||||
### Remote control, auth, and gateways
|
||||
|
||||
```sh
|
||||
# On the machine with the browser
|
||||
browser-cli auth keygen --output ~/.config/browser-cli/client.key
|
||||
@@ -305,6 +303,13 @@ PUBKEY=$(browser-cli auth show --key ~/.config/browser-cli/client.key | tail -n1
|
||||
browser-cli auth trust "$PUBKEY"
|
||||
browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.config/browser-cli/authorized_keys
|
||||
|
||||
# Allow remote browser control (navigation, clicks); safe-only otherwise
|
||||
browser-cli serve --authorized-keys ~/.config/browser-cli/authorized_keys --allow-control
|
||||
|
||||
# Per-key authorization (inline in authorized_keys) + a tighter rate limit
|
||||
browser-cli auth trust "$PUBKEY" --name ci-bot --allow-read-page --allow-control
|
||||
browser-cli serve --authorized-keys ~/.config/browser-cli/authorized_keys --rate-limit 20
|
||||
|
||||
# From another machine
|
||||
browser-cli --remote browser-host.example:8765 --key ~/.config/browser-cli/client.key tabs list
|
||||
browser-cli remote trust browser-host.example:8765 ~/.config/browser-cli/client.key
|
||||
@@ -315,12 +320,24 @@ browser-cli serve-http --port 8766
|
||||
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
|
||||
```
|
||||
|
||||
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
|
||||
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Use `browser-cli remote trust ENDPOINT KEY` to remember a key for later calls. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
|
||||
|
||||
#### n8n integration
|
||||
The n8n community node is published as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli) on npm. It talks directly to a remote `browser-cli serve` endpoint over the same Ed25519-authenticated, ML-KEM-encrypted TCP protocol as the CLI remote client. Install it from n8n's Community Nodes UI, run `browser-cli serve` on the browser machine, paste the client key into the node credential, and drive tabs/DOM/extraction/raw commands from a workflow. See [`n8n-nodes-browser-cli/README.md`](n8n-nodes-browser-cli/README.md).
|
||||
|
||||
#### Security model
|
||||
- **`serve` (TCP)** authenticates every connection with an Ed25519 signature over a fresh server nonce and, for modern clients, wraps the transport in an ML-KEM-768 (post-quantum) AEAD channel. Commands are gated by a **safe-only policy by default** — even a trusted key can only run read-only status/listing commands until you open more with `--allow-read-page`, `--allow-control`, `--allow-dangerous`, `--allow-keys`, or `--allow-all` (full control, including `dom.eval`/`storage.*`). `--no-auth` is rejected on non-loopback hosts.
|
||||
- **Per-key authorization:** a key in `authorized_keys` can carry an optional `allow:` token (`<pubkey> <name> allow:read-page,control`) listing its categories (`all`, `safe`, `read-page`, `control`, `dangerous`, `keys`). That key uses its own policy, overriding the server-wide `--allow-*` default; keys without a token fall back to the default. Set it with `auth trust <pubkey> --allow-control …` when adding a key, or change it later with `auth policy <pubkey|name> …` (interactive picker when run with no args; `--safe`/`--server-default`/`--allow-*` for scripting). Both work locally and over `--remote`; `auth keys` shows each key's policy.
|
||||
- **Key-management is its own category:** listing/trusting/repolicing keys (`auth keys`/`auth trust`/`auth policy` over `--remote`) requires the `keys` category. A key trusted only for browsing — even with full `control`+`dangerous` — cannot manage the trust store unless granted `allow:keys` (or `allow:all`). This prevents a compromised browser key from escalating by trusting its own.
|
||||
- **Rate limiting:** `--rate-limit N` caps commands/second per client key (token bucket, default `100`, `0` disables) so a compromised key can't hammer the browser.
|
||||
- **Audit logging:** request logs include the acting key (its name from `authorized_keys` plus a short pubkey), not just the client address.
|
||||
- **`serve-http`** is a convenience gateway with the inverse trade-off: commands are gated by the same `--allow-*` policy (safe-only by default) and requests are throttled per client address (`--rate-limit`, default `100`/s) with an 8 MB body cap, but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there, and binding beyond loopback prints a loud cleartext warning. If you must expose it, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted, and prefer `serve` (encrypted) for real remote use.
|
||||
|
||||
For low latency, an authenticated encrypted remote connection is kept open and reused for further commands in the same process — so SDK scripts and multi-browser fan-out avoid repeating the TCP/TLS/challenge handshake on every command. Aggregate commands also fan out to remote targets concurrently. Both degrade gracefully against older servers that handle one command per connection.
|
||||
|
||||
---
|
||||
|
||||
## Python SDK
|
||||
|
||||
```python
|
||||
from browser_cli import AsyncBrowserCLI, BrowserCLI
|
||||
|
||||
@@ -456,7 +473,6 @@ raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw com
|
||||
```
|
||||
|
||||
**Error handling**
|
||||
|
||||
```python
|
||||
from browser_cli import BrowserCLI, BrowserNotConnected
|
||||
|
||||
@@ -488,7 +504,6 @@ if isinstance(counts, BrowserCounts):
|
||||
---
|
||||
|
||||
## Example scripts
|
||||
|
||||
See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management.
|
||||
|
||||
```sh
|
||||
@@ -499,7 +514,6 @@ bash examples/demo.sh
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run check:extension # type-check, build extension bundles, syntax-check bundle
|
||||
@@ -513,7 +527,7 @@ nix-shell # automatically runs npm ci when node_modules is missing/outdated
|
||||
npm run check:extension
|
||||
```
|
||||
|
||||
The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. On NixOS, use `nix-shell` first if npm is not installed globally.
|
||||
The extension source lives in `extension/src/`. `extension/background.js` and `extension/content-dispatch.js` are generated and ignored by git. Run `npm run build:extension` before loading the unpacked `extension/` directory; `browser-cli install <browser> --dev` prints the per-browser load steps. On NixOS, use `nix-shell` first if npm is not installed globally.
|
||||
|
||||
Packaging:
|
||||
|
||||
@@ -540,7 +554,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
|
||||
- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli clients rename --browser <current-alias> <new-alias>`.
|
||||
- **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, Vivaldi, and Firefox. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely.
|
||||
@@ -549,7 +562,6 @@ For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
PolyForm Noncommercial License 1.0.0. See [LICENSE](LICENSE).
|
||||
|
||||
Commercial use is not permitted under this license. For commercial licensing, contact the project maintainer.
|
||||
|
||||
@@ -17,11 +17,14 @@ from browser_cli.auth.agent import (
|
||||
)
|
||||
from browser_cli.auth.keys import (
|
||||
add_authorized_key,
|
||||
format_authorized_line,
|
||||
generate_keypair,
|
||||
load_authorized_keys,
|
||||
load_authorized_keys_with_names,
|
||||
load_authorized_keys_with_policies,
|
||||
load_private_key,
|
||||
public_key_hex,
|
||||
set_authorized_key_policy,
|
||||
)
|
||||
from browser_cli.auth.pq import (
|
||||
new_nonce,
|
||||
@@ -51,9 +54,11 @@ __all__ = [
|
||||
"agent_list_keys",
|
||||
"agent_sign_raw",
|
||||
"canonical_payload",
|
||||
"format_authorized_line",
|
||||
"generate_keypair",
|
||||
"load_authorized_keys",
|
||||
"load_authorized_keys_with_names",
|
||||
"load_authorized_keys_with_policies",
|
||||
"load_private_key",
|
||||
"new_nonce",
|
||||
"pq_decrypt",
|
||||
@@ -62,6 +67,7 @@ __all__ = [
|
||||
"pq_kex_server_decapsulate",
|
||||
"pq_kex_server_keypair",
|
||||
"public_key_hex",
|
||||
"set_authorized_key_policy",
|
||||
"sign",
|
||||
"verify",
|
||||
]
|
||||
|
||||
@@ -29,31 +29,97 @@ def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
|
||||
return key.pubkey_bytes.hex()
|
||||
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||
|
||||
def _parse_authorized_line(line: str) -> tuple[str, str, list[str] | None] | None:
|
||||
"""Parse one authorized_keys line into (pubkey, name, categories).
|
||||
|
||||
Line format: ``<pubkey> [name words...] [allow:cat,cat,...]``. The optional
|
||||
``allow:`` token may appear anywhere after the pubkey (conventionally last);
|
||||
the remaining words form the name. ``categories`` is None when no ``allow:``
|
||||
token is present (the key falls back to the server-wide policy), or a list of
|
||||
category strings (possibly empty) otherwise.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
tokens = line.split()
|
||||
pubkey = tokens[0]
|
||||
categories: list[str] | None = None
|
||||
name_tokens: list[str] = []
|
||||
for tok in tokens[1:]:
|
||||
if tok.startswith("allow:"):
|
||||
categories = [c for c in tok[len("allow:"):].split(",") if c]
|
||||
else:
|
||||
name_tokens.append(tok)
|
||||
return pubkey, " ".join(name_tokens), categories
|
||||
|
||||
def format_authorized_line(pub_hex: str, name: str = "", categories: list[str] | None = None) -> str:
|
||||
"""Render an authorized_keys line. Inverse of :func:`_parse_authorized_line`."""
|
||||
parts = [pub_hex]
|
||||
if name:
|
||||
parts.append(name)
|
||||
if categories is not None:
|
||||
parts.append("allow:" + ",".join(categories))
|
||||
return " ".join(parts)
|
||||
|
||||
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
|
||||
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
|
||||
return [(pubkey, name) for pubkey, name, _cats in load_authorized_keys_with_policies(path)]
|
||||
|
||||
def load_authorized_keys_with_policies(path: Path) -> list[tuple[str, str, list[str] | None]]:
|
||||
"""Return list of (pubkey_hex, name, categories) triples. categories is None when unset."""
|
||||
if not path.exists():
|
||||
return []
|
||||
result = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
pubkey = parts[0]
|
||||
name = parts[1].strip() if len(parts) > 1 else ""
|
||||
result.append((pubkey, name))
|
||||
parsed = _parse_authorized_line(line)
|
||||
if parsed is not None:
|
||||
result.append(parsed)
|
||||
return result
|
||||
|
||||
def load_authorized_keys(path: Path) -> list[str]:
|
||||
return [pubkey for pubkey, _name in load_authorized_keys_with_names(path)]
|
||||
|
||||
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
|
||||
def add_authorized_key(path: Path, pub_hex: str, name: str = "", categories: list[str] | None = None) -> bool:
|
||||
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)}
|
||||
if pub_hex in existing:
|
||||
return False
|
||||
line = (f"{pub_hex} {name}".rstrip()) + "\n"
|
||||
line = format_authorized_line(pub_hex, name, categories) + "\n"
|
||||
with open(path, "a", encoding="utf-8") as file:
|
||||
file.write(line)
|
||||
return True
|
||||
|
||||
def set_authorized_key_policy(path: Path, identifier: str, categories: list[str] | None) -> tuple[str, str] | None:
|
||||
"""Update the per-key policy for a trusted key.
|
||||
|
||||
``identifier`` may be the full public key or an exact key name. ``categories``
|
||||
is written as the ``allow:`` token; ``None`` removes the token so the key uses
|
||||
the server default. Returns ``(pubkey, name)`` for the updated key, ``None`` if
|
||||
no key matched, and raises ``ValueError`` for ambiguous names.
|
||||
"""
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
wanted = identifier.strip()
|
||||
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
matches: list[tuple[int, str, str, str]] = []
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
parsed = _parse_authorized_line(line)
|
||||
if parsed is None:
|
||||
continue
|
||||
pubkey, name, _cats = parsed
|
||||
if pubkey.lower() == wanted.lower() or (name and name == wanted):
|
||||
newline = "\n" if line.endswith("\n") else ""
|
||||
matches.append((index, pubkey, name, newline))
|
||||
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) > 1:
|
||||
raise ValueError(f"ambiguous key name: {identifier!r} matches {len(matches)} keys")
|
||||
|
||||
index, pubkey, name, newline = matches[0]
|
||||
lines[index] = format_authorized_line(pubkey, name, categories) + newline
|
||||
path.write_text("".join(lines), encoding="utf-8")
|
||||
return pubkey, name
|
||||
|
||||
+1
-19
@@ -5,9 +5,6 @@ browser-cli — Control your running browser from the terminal.
|
||||
import click
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.commands.navigate import nav_group
|
||||
@@ -35,7 +32,7 @@ from browser_cli.commands.serve_http import cmd_serve_http
|
||||
from browser_cli.commands.watch import watch_group
|
||||
from browser_cli.commands.workspace import workspace_group
|
||||
from browser_cli.commands.raw import cmd_command
|
||||
from browser_cli.constants import PYPI_PACKAGE_NAME
|
||||
from browser_cli.version_manager import project_version as _project_version
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -53,21 +50,6 @@ def _patched_group_shell_complete(self, ctx, incomplete):
|
||||
|
||||
click.Group.shell_complete = _patched_group_shell_complete
|
||||
|
||||
def _project_version() -> str:
|
||||
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
try:
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return package_version(PYPI_PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
def _print_version(ctx, param, value):
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
@@ -7,6 +7,7 @@ from browser_cli.client.core import (
|
||||
_send_remote,
|
||||
_send_remote_async,
|
||||
active_browser_targets,
|
||||
collect_browser_clients,
|
||||
remote_browser_targets,
|
||||
remote_browser_targets_async,
|
||||
remote_target_for_alias,
|
||||
@@ -39,6 +40,7 @@ __all__ = [
|
||||
"_send_remote",
|
||||
"_send_remote_async",
|
||||
"active_browser_targets",
|
||||
"collect_browser_clients",
|
||||
"display_browser_name",
|
||||
"remote_browser_targets",
|
||||
"remote_browser_targets_async",
|
||||
|
||||
@@ -35,8 +35,6 @@ def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | Non
|
||||
msg["accept_encoding"] = transport.client_accept_encoding()
|
||||
key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
|
||||
private_key = load_private_key(key_spec)
|
||||
if key is not None:
|
||||
remote_registry.save_remote_key(remote_endpoint, str(key))
|
||||
|
||||
route_profile = requested_profile
|
||||
if not route_profile and command not in NO_ROUTE_COMMANDS:
|
||||
@@ -52,8 +50,6 @@ async def add_remote_auth_fields_async(msg: dict, command: str, requested_profil
|
||||
msg["accept_encoding"] = transport.client_accept_encoding()
|
||||
key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint)
|
||||
private_key = await asyncio.to_thread(load_private_key, key_spec)
|
||||
if key is not None:
|
||||
await asyncio.to_thread(remote_registry.save_remote_key, remote_endpoint, str(key))
|
||||
|
||||
route_profile = requested_profile
|
||||
if not route_profile and command not in NO_ROUTE_COMMANDS:
|
||||
|
||||
+203
-8
@@ -15,11 +15,39 @@ from browser_cli import local_transport
|
||||
from browser_cli.client import auth, messages, targets as target_discovery
|
||||
from browser_cli.client.targets import BrowserTarget
|
||||
from browser_cli.remote import registry as remote_registry
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.endpoints import _remote_display_name
|
||||
from browser_cli.endpoints import _remote_display_name, display_browser_name
|
||||
from browser_cli.registry import load_registry
|
||||
from browser_cli.remote.transport import _send_remote, _send_remote_async
|
||||
|
||||
def _run_concurrent(factories: list) -> list:
|
||||
"""Run async thunks concurrently, returning results in order.
|
||||
|
||||
Each item in *factories* is a zero-arg callable returning a coroutine. The
|
||||
return list mirrors the input order; a thunk that raises yields its exception
|
||||
object in that slot (callers filter as they would in a sequential loop). Falls
|
||||
back to sequential execution if an event loop is already running on this
|
||||
thread (e.g. inside the async serve handler), where ``asyncio.run`` is illegal.
|
||||
"""
|
||||
if not factories:
|
||||
return []
|
||||
|
||||
async def _gather():
|
||||
return await asyncio.gather(*(factory() for factory in factories), return_exceptions=True)
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_gather())
|
||||
|
||||
# An event loop is already running on this thread (e.g. the async serve
|
||||
# handler), where asyncio.run is illegal. Run the gather on a worker thread
|
||||
# that has no loop of its own, preserving concurrency and result order.
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
return executor.submit(lambda: asyncio.run(_gather())).result()
|
||||
|
||||
def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for item in items or []:
|
||||
@@ -27,6 +55,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
||||
display = str(item.get("displayName") or profile)
|
||||
display_name = _remote_display_name(endpoint, profile, display)
|
||||
browser_name = item.get("browserName") or item.get("name")
|
||||
version = item.get("version")
|
||||
extension_version = item.get("extensionVersion")
|
||||
targets.append(
|
||||
BrowserTarget(
|
||||
profile=profile,
|
||||
@@ -35,6 +65,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
||||
remote=endpoint,
|
||||
browser_name=str(browser_name) if browser_name else None,
|
||||
display_group=display_name.rsplit(":", 1)[0],
|
||||
version=str(version) if version else None,
|
||||
extension_version=str(extension_version) if extension_version else None,
|
||||
)
|
||||
)
|
||||
return targets
|
||||
@@ -48,12 +80,20 @@ def remote_browser_targets(endpoint: str, key=None, *, suppress_pq_warning: bool
|
||||
)
|
||||
|
||||
def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
endpoints = list(remote_registry.load_remotes())
|
||||
if not endpoints:
|
||||
return []
|
||||
results = _run_concurrent([
|
||||
(lambda ep=ep: asyncio.to_thread(remote_browser_targets, ep, key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
for ep in endpoints
|
||||
])
|
||||
targets: list[BrowserTarget] = []
|
||||
for endpoint in remote_registry.load_remotes():
|
||||
try:
|
||||
targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
for result in results:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
targets.extend(result)
|
||||
return targets
|
||||
|
||||
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
|
||||
@@ -111,6 +151,160 @@ def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_p
|
||||
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
return targets
|
||||
|
||||
def _cached_client_row(target: BrowserTarget) -> dict | None:
|
||||
"""Build a clients row from a target's discovery data, skipping a roundtrip.
|
||||
|
||||
Returns None when the remote didn't advertise its version (older serve), so
|
||||
callers fall back to an explicit ``clients.list`` query.
|
||||
"""
|
||||
if target.version is None and target.extension_version is None:
|
||||
return None
|
||||
return {
|
||||
"profile": target.display_name,
|
||||
"profileGroup": target.display_group,
|
||||
"name": target.browser_name or "",
|
||||
"version": target.version or "",
|
||||
"extensionVersion": target.extension_version or "",
|
||||
}
|
||||
|
||||
def _rows_from_result(result, label: str, profile_group: str | None) -> list[dict]:
|
||||
rows = []
|
||||
for item in result or []:
|
||||
row = dict(item)
|
||||
row["profile"] = label
|
||||
if profile_group:
|
||||
row["profileGroup"] = profile_group
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
async def _client_rows_async(
|
||||
label: str,
|
||||
*,
|
||||
profile: str | None = None,
|
||||
remote: str | None = None,
|
||||
key=None,
|
||||
suppress_pq_warning: bool = False,
|
||||
profile_group: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return display-ready clients.list rows for one browser target."""
|
||||
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
|
||||
result = await asyncio.to_thread(
|
||||
send_command,
|
||||
"clients.list",
|
||||
profile=profile,
|
||||
remote=remote,
|
||||
key=key,
|
||||
**kwargs,
|
||||
)
|
||||
return _rows_from_result(result, label, profile_group)
|
||||
|
||||
def collect_browser_clients(
|
||||
*,
|
||||
browser_alias: str | None = None,
|
||||
remote: str | None = None,
|
||||
key=None,
|
||||
registry_path=None,
|
||||
) -> list[dict]:
|
||||
"""Return display-ready browser client rows for CLI/SDK consumers.
|
||||
|
||||
Rows preserve the CLI-facing shape: ``profile``, optional ``profileGroup``,
|
||||
``name``, ``version``, and ``extensionVersion``.
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
|
||||
if not remote and browser_alias:
|
||||
resolved = remote_target_for_alias(browser_alias)
|
||||
if not resolved:
|
||||
return rows
|
||||
targets = remote_browser_targets(resolved.remote)
|
||||
uncached = []
|
||||
for target in targets:
|
||||
cached = _cached_client_row(target)
|
||||
if cached is not None:
|
||||
rows.append(cached)
|
||||
else:
|
||||
uncached.append(target)
|
||||
results = _run_concurrent([
|
||||
(lambda t=t: _client_rows_async(
|
||||
t.display_name,
|
||||
profile=t.profile,
|
||||
remote=resolved.remote,
|
||||
key=key,
|
||||
profile_group=t.display_group,
|
||||
))
|
||||
for t in uncached
|
||||
])
|
||||
for result in results:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
rows.extend(result)
|
||||
return rows
|
||||
|
||||
if remote:
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||
for item in result or []:
|
||||
row = dict(item)
|
||||
row["profile"] = row.get("profile") or browser_alias or "remote"
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
path = registry_path or target_discovery.REGISTRY_PATH
|
||||
profiles: dict[str, str] = load_registry(path) if path.exists() else {}
|
||||
local_items = list(profiles.items())
|
||||
|
||||
remote_targets = []
|
||||
cached_remote_rows = [] # deferred so local profiles still render first
|
||||
for target in active_browser_targets(suppress_pq_warning=True):
|
||||
if target.remote is None:
|
||||
continue
|
||||
cached = _cached_client_row(target)
|
||||
if cached is not None:
|
||||
cached_remote_rows.append(cached) # discovery already carried version/extVersion — no extra roundtrip
|
||||
else:
|
||||
remote_targets.append(target)
|
||||
|
||||
factories = [
|
||||
(lambda name=name, sock=sock: _client_rows_async(
|
||||
display_browser_name(name, sock), profile=name, profile_group="local",
|
||||
))
|
||||
for name, sock in local_items
|
||||
] + [
|
||||
(lambda t=t: _client_rows_async(
|
||||
t.display_name,
|
||||
profile=t.profile,
|
||||
remote=t.remote,
|
||||
suppress_pq_warning=True,
|
||||
profile_group=t.display_group,
|
||||
))
|
||||
for t in remote_targets
|
||||
]
|
||||
results = _run_concurrent(factories)
|
||||
|
||||
for (name, sock), result in zip(local_items, results[:len(local_items)]):
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
rows.append({
|
||||
"profile": display_browser_name(name, sock),
|
||||
"profileGroup": "local",
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
})
|
||||
elif isinstance(result, BaseException):
|
||||
raise result
|
||||
else:
|
||||
rows.extend(result)
|
||||
|
||||
for result in results[len(local_items):]:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
rows.extend(result)
|
||||
rows.extend(cached_remote_rows)
|
||||
return rows
|
||||
|
||||
def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, key=key)
|
||||
if len(targets) == 1:
|
||||
@@ -159,11 +353,12 @@ def send_command(
|
||||
|
||||
return messages.decode_response(response)
|
||||
|
||||
async def remote_browser_targets_async(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
async def remote_browser_targets_async(endpoint: str, key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
"""Async variant of :func:`remote_browser_targets`."""
|
||||
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
|
||||
return _remote_target_items(
|
||||
endpoint,
|
||||
await send_command_async("browser-cli.targets", remote=endpoint, key=key),
|
||||
await send_command_async("browser-cli.targets", remote=endpoint, key=key, **kwargs),
|
||||
)
|
||||
|
||||
async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
|
||||
|
||||
@@ -19,6 +19,11 @@ class BrowserTarget:
|
||||
remote: str | None = None
|
||||
browser_name: str | None = None
|
||||
display_group: str | None = None
|
||||
# Populated from a remote ``browser-cli.targets`` response when the remote is
|
||||
# new enough to advertise them, letting ``clients`` skip a redundant
|
||||
# ``clients.list`` roundtrip. None means "unknown — fall back to a query".
|
||||
version: str | None = None
|
||||
extension_version: str | None = None
|
||||
|
||||
def is_reachable_unix_endpoint(endpoint: str) -> bool:
|
||||
"""Return True when a Unix socket path exists and accepts connections."""
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
SAFE_COMMANDS = {
|
||||
"browser-cli.targets",
|
||||
"clients.list",
|
||||
"extension.capabilities",
|
||||
"extension.info",
|
||||
@@ -74,15 +75,24 @@ DANGEROUS_PREFIXES = (
|
||||
"storage.",
|
||||
)
|
||||
|
||||
# Server-side key-management control commands. Gated separately so a key can be
|
||||
# trusted for browser use without also being able to list or add trusted keys.
|
||||
KEY_COMMANDS = {
|
||||
"browser-cli.auth.keys",
|
||||
"browser-cli.auth.trust",
|
||||
"browser-cli.auth.policy",
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandPolicy:
|
||||
allow_read_page: bool = False
|
||||
allow_control: bool = False
|
||||
allow_dangerous: bool = False
|
||||
allow_keys: bool = False
|
||||
|
||||
@classmethod
|
||||
def unrestricted(cls) -> "CommandPolicy":
|
||||
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True)
|
||||
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True, allow_keys=True)
|
||||
|
||||
def _is_control(command: str) -> bool:
|
||||
if command in CONTROL_COMMANDS:
|
||||
@@ -93,6 +103,8 @@ def _is_control(command: str) -> bool:
|
||||
|
||||
def command_category(command: str) -> str:
|
||||
name = str(command or "")
|
||||
if name in KEY_COMMANDS:
|
||||
return "keys"
|
||||
if name in DANGEROUS_COMMANDS or any(name.startswith(prefix) for prefix in DANGEROUS_PREFIXES):
|
||||
return "dangerous"
|
||||
if name in READ_PAGE_COMMANDS:
|
||||
@@ -113,7 +125,9 @@ def assert_command_allowed(command: str, policy: CommandPolicy) -> None:
|
||||
return
|
||||
if category == "dangerous" and policy.allow_dangerous:
|
||||
return
|
||||
if category == "keys" and policy.allow_keys:
|
||||
return
|
||||
raise PermissionError(
|
||||
f"Raw command '{command}' is {category} and blocked by default; "
|
||||
"use --allow-read-page, --allow-control, or --allow-dangerous explicitly"
|
||||
"use --allow-read-page, --allow-control, --allow-dangerous, or --allow-keys explicitly"
|
||||
)
|
||||
|
||||
@@ -31,6 +31,66 @@ def gentle_mode_option(help_text: str):
|
||||
help=help_text,
|
||||
)
|
||||
|
||||
def command_policy_options(fn):
|
||||
"""Reusable raw-command safety flags for /command-like entry points."""
|
||||
fn = click.option(
|
||||
"--allow-all",
|
||||
is_flag=True,
|
||||
help="Allow every command (equivalent to --allow-read-page --allow-control --allow-dangerous --allow-keys)",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--allow-keys",
|
||||
is_flag=True,
|
||||
help="Allow key-management commands (list/trust authorized keys over --remote)",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--allow-dangerous",
|
||||
is_flag=True,
|
||||
help="Allow high-risk commands such as dom.eval, storage.*, screenshots",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--allow-control",
|
||||
is_flag=True,
|
||||
help="Allow browser-control commands such as nav.*, tabs.close, dom.click",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--allow-read-page",
|
||||
is_flag=True,
|
||||
help="Allow page-content read commands such as extract.* and dom.text",
|
||||
)(fn)
|
||||
return fn
|
||||
|
||||
def command_policy_from_options(*, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool = False, allow_all: bool = False):
|
||||
"""Build a CommandPolicy from shared raw-command safety flags."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
|
||||
if allow_all:
|
||||
return CommandPolicy.unrestricted()
|
||||
return CommandPolicy(
|
||||
allow_read_page=allow_read_page,
|
||||
allow_control=allow_control,
|
||||
allow_dangerous=allow_dangerous,
|
||||
allow_keys=allow_keys,
|
||||
)
|
||||
|
||||
def command_categories_from_options(*, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool = False, allow_all: bool = False):
|
||||
"""Convert the shared --allow-* flags into a category list, or None if none were set.
|
||||
|
||||
None means "no explicit policy" — the key falls back to the server-wide default.
|
||||
"""
|
||||
if allow_all:
|
||||
return ["all"]
|
||||
cats = []
|
||||
if allow_read_page:
|
||||
cats.append("read-page")
|
||||
if allow_control:
|
||||
cats.append("control")
|
||||
if allow_dangerous:
|
||||
cats.append("dangerous")
|
||||
if allow_keys:
|
||||
cats.append("keys")
|
||||
return cats or None
|
||||
|
||||
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
|
||||
"""Render a count result.
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.commands import command_categories_from_options, command_policy_options, handle_errors
|
||||
|
||||
console = Console()
|
||||
|
||||
@click.group("auth")
|
||||
@@ -39,9 +41,15 @@ def cmd_auth_keygen(output, force):
|
||||
@click.argument("pubkey")
|
||||
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@command_policy_options
|
||||
@click.pass_context
|
||||
def cmd_auth_trust(ctx, pubkey, name, keys_file):
|
||||
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
|
||||
@handle_errors
|
||||
def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Add a public key to the authorized keys file (locally or on a remote serve host).
|
||||
|
||||
Pass --allow-read-page/--allow-control/--allow-dangerous/--allow-all to record a
|
||||
per-key policy (an ``allow:`` token); without any, the key uses the server default.
|
||||
"""
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
|
||||
|
||||
if len(pubkey) != 64:
|
||||
@@ -53,34 +61,125 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file):
|
||||
console.print("[red]Invalid public key:[/red] not valid hex")
|
||||
sys.exit(1)
|
||||
|
||||
categories = command_categories_from_options(
|
||||
allow_read_page=allow_read_page, allow_control=allow_control,
|
||||
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
|
||||
)
|
||||
policy_label = f" [dim]allow:{','.join(categories)}[/dim]" if categories else ""
|
||||
|
||||
remote = (ctx.obj or {}).get("remote")
|
||||
if remote:
|
||||
from browser_cli.client import send_command
|
||||
args = {"pubkey": pubkey, "name": name}
|
||||
if categories is not None:
|
||||
args["allow"] = categories
|
||||
result = send_command(
|
||||
"browser-cli.auth.trust",
|
||||
args={"pubkey": pubkey, "name": name},
|
||||
args=args,
|
||||
remote=remote,
|
||||
key=(ctx.obj or {}).get("key"),
|
||||
)
|
||||
added = (result or {}).get("added", False)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]{policy_label}")
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
|
||||
return
|
||||
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
added = add_authorized_key(path, pubkey, name)
|
||||
added = add_authorized_key(path, pubkey, name, categories)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]{policy_label}")
|
||||
console.print(f" File: {path}")
|
||||
console.print("\nStart the server with:")
|
||||
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
|
||||
|
||||
@auth_group.command("policy")
|
||||
@click.argument("identifier", required=False)
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@click.option("--server-default", is_flag=True, help="Remove the per-key allow: token so this key uses the server default policy.")
|
||||
@click.option("--safe", "safe_only", is_flag=True, help="Set an explicit safe-only policy (writes allow: with no categories).")
|
||||
@command_policy_options
|
||||
@click.pass_context
|
||||
@handle_errors
|
||||
def cmd_auth_policy(ctx, identifier, keys_file, server_default, safe_only, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Change a trusted key's per-key policy.
|
||||
|
||||
IDENTIFIER may be the full public key or an exact key name. Omit IDENTIFIER in
|
||||
an interactive terminal to pick a key first, then edit the policy with real
|
||||
checkbox prompts. Use --safe for an explicit safe-only override,
|
||||
--server-default to remove the override, or one or more --allow-* flags for
|
||||
scriptable/non-interactive usage.
|
||||
"""
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, set_authorized_key_policy
|
||||
|
||||
explicit_allow = any([allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all])
|
||||
modes = sum(1 for enabled in [server_default, safe_only, explicit_allow] if enabled)
|
||||
if modes > 1:
|
||||
console.print("[red]Choose exactly one policy mode:[/red] --server-default, --safe, or one/more --allow-* flags")
|
||||
sys.exit(1)
|
||||
|
||||
is_interactive = click.get_text_stream("stdin").isatty()
|
||||
current_categories = None
|
||||
if not identifier:
|
||||
if not is_interactive:
|
||||
console.print("[red]Missing key identifier:[/red] pass a public key/name, or run interactively to pick one")
|
||||
sys.exit(1)
|
||||
entry = _prompt_key_entry(_load_policy_entries(ctx, keys_file))
|
||||
identifier = entry.get("pubkey") or entry.get("name") or ""
|
||||
current_categories = entry.get("allow")
|
||||
elif modes == 0 and is_interactive:
|
||||
entry = _find_policy_entry(ctx, keys_file, identifier)
|
||||
current_categories = entry.get("allow") if entry else None
|
||||
|
||||
if server_default:
|
||||
categories = None
|
||||
elif safe_only:
|
||||
categories = []
|
||||
elif explicit_allow:
|
||||
categories = command_categories_from_options(
|
||||
allow_read_page=allow_read_page, allow_control=allow_control,
|
||||
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
|
||||
)
|
||||
else:
|
||||
if not is_interactive:
|
||||
console.print("[red]Choose a policy mode:[/red] --server-default, --safe, one/more --allow-* flags, or run interactively")
|
||||
sys.exit(1)
|
||||
categories = _prompt_policy_categories(identifier, current_categories)
|
||||
|
||||
remote = (ctx.obj or {}).get("remote")
|
||||
if remote:
|
||||
from browser_cli.client import send_command
|
||||
result = send_command(
|
||||
"browser-cli.auth.policy",
|
||||
args={"identifier": identifier, "allow": categories},
|
||||
remote=remote,
|
||||
key=(ctx.obj or {}).get("key"),
|
||||
)
|
||||
name = (result or {}).get("name") or ""
|
||||
pubkey = (result or {}).get("pubkey") or identifier
|
||||
label = f" ({name})" if name else ""
|
||||
console.print(f"[green]✓[/green] Updated policy on {remote}{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
|
||||
return
|
||||
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
try:
|
||||
updated = set_authorized_key_policy(path, identifier, categories)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
sys.exit(1)
|
||||
if updated is None:
|
||||
console.print(f"[red]Trusted key not found:[/red] {identifier}")
|
||||
sys.exit(1)
|
||||
pubkey, name = updated
|
||||
label = f" ({name})" if name else ""
|
||||
console.print(f"[green]✓[/green] Updated policy{label}: [cyan]{pubkey}[/cyan] → {_policy_label(categories)}")
|
||||
console.print(f" File: {path}")
|
||||
|
||||
@auth_group.command("show")
|
||||
@click.option(
|
||||
"--key",
|
||||
@@ -123,6 +222,7 @@ def cmd_auth_show(key_src):
|
||||
@auth_group.command("keys")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@click.pass_context
|
||||
@handle_errors
|
||||
def cmd_auth_keys(ctx, keys_file):
|
||||
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
|
||||
from rich.table import Table
|
||||
@@ -138,9 +238,9 @@ def cmd_auth_keys(ctx, keys_file):
|
||||
entries = result or []
|
||||
source_label = remote
|
||||
else:
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
|
||||
entries = [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
|
||||
source_label = str(path)
|
||||
|
||||
if not entries:
|
||||
@@ -151,7 +251,186 @@ def cmd_auth_keys(ctx, keys_file):
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Public Key")
|
||||
table.add_column("Policy")
|
||||
table.add_column("Description")
|
||||
for entry in entries:
|
||||
name = entry.get("name") or "[dim]—[/dim]"
|
||||
table.add_row(name, entry.get("pubkey", ""))
|
||||
allow = entry.get("allow")
|
||||
table.add_row(name, entry.get("pubkey", ""), _policy_label(allow), _policy_description(allow))
|
||||
console.print(table)
|
||||
|
||||
def _load_policy_entries(ctx, keys_file):
|
||||
"""Load trusted-key entries for interactive selection."""
|
||||
remote = (ctx.obj or {}).get("remote")
|
||||
if remote:
|
||||
from browser_cli.client import send_command
|
||||
return send_command(
|
||||
"browser-cli.auth.keys",
|
||||
remote=remote,
|
||||
key=(ctx.obj or {}).get("key"),
|
||||
) or []
|
||||
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
return [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
|
||||
|
||||
def _find_policy_entry(ctx, keys_file, identifier: str):
|
||||
"""Find the current key entry so the checkbox prompt can preselect values."""
|
||||
wanted = identifier.strip()
|
||||
for entry in _load_policy_entries(ctx, keys_file):
|
||||
pubkey = str(entry.get("pubkey") or "")
|
||||
name = str(entry.get("name") or "")
|
||||
if pubkey.lower() == wanted.lower() or (name and name == wanted):
|
||||
return entry
|
||||
return None
|
||||
|
||||
def _prompt_key_entry(entries):
|
||||
"""Interactive checkbox flow step 1: choose which key to edit."""
|
||||
if not entries:
|
||||
raise click.ClickException("no trusted keys found")
|
||||
|
||||
import questionary
|
||||
|
||||
choices = []
|
||||
for entry in entries:
|
||||
name = entry.get("name") or "unnamed key"
|
||||
pubkey = entry.get("pubkey") or ""
|
||||
policy = _plain_policy_label(entry.get("allow"))
|
||||
choices.append(questionary.Choice(
|
||||
title=f"{name} [{policy}] {pubkey[:12]}…{pubkey[-8:]}",
|
||||
value=entry,
|
||||
))
|
||||
selected = questionary.select("Which trusted key do you want to edit?", choices=choices).ask()
|
||||
if selected is None:
|
||||
raise click.ClickException("cancelled")
|
||||
return selected
|
||||
|
||||
def _prompt_policy_categories(identifier: str, current_categories=None):
|
||||
"""Interactive policy picker for ``auth policy`` using real checkboxes."""
|
||||
import questionary
|
||||
|
||||
checked = set(current_categories or [])
|
||||
special_checked = {
|
||||
"__server_default__": current_categories is None,
|
||||
"__safe__": current_categories == [],
|
||||
"__all__": isinstance(current_categories, list) and "all" in current_categories,
|
||||
}
|
||||
choices = [
|
||||
questionary.Choice(
|
||||
title="read-page — read page content: extract text/html/links/images, dom.text/query/exists",
|
||||
value="read-page",
|
||||
checked="read-page" in checked,
|
||||
),
|
||||
questionary.Choice(
|
||||
title="control — control browser: open URLs, close tabs, click/type/scroll, sessions/groups",
|
||||
value="control",
|
||||
checked="control" in checked,
|
||||
),
|
||||
questionary.Choice(
|
||||
title="dangerous — high risk: dom.eval JavaScript, storage access, screenshots",
|
||||
value="dangerous",
|
||||
checked="dangerous" in checked,
|
||||
),
|
||||
questionary.Choice(
|
||||
title="keys — admin access to key management over --remote: auth keys/trust/policy",
|
||||
value="keys",
|
||||
checked="keys" in checked,
|
||||
),
|
||||
questionary.Separator(),
|
||||
questionary.Choice(
|
||||
title="all — allow everything",
|
||||
value="__all__",
|
||||
checked=special_checked["__all__"],
|
||||
),
|
||||
questionary.Choice(
|
||||
title="safe — explicit safe-only override",
|
||||
value="__safe__",
|
||||
checked=special_checked["__safe__"],
|
||||
),
|
||||
questionary.Choice(
|
||||
title="server default — remove per-key override and inherit server policy",
|
||||
value="__server_default__",
|
||||
checked=special_checked["__server_default__"],
|
||||
),
|
||||
]
|
||||
selected = questionary.checkbox(
|
||||
f"Policy for {identifier}",
|
||||
choices=choices,
|
||||
instruction="(space to toggle, enter to save)",
|
||||
).ask()
|
||||
if selected is None:
|
||||
raise click.ClickException("cancelled")
|
||||
return _parse_checkbox_policy_selection(selected)
|
||||
|
||||
def _parse_checkbox_policy_selection(selected):
|
||||
special = [value for value in selected if value in {"__all__", "__safe__", "__server_default__"}]
|
||||
normal = [value for value in selected if value not in {"__all__", "__safe__", "__server_default__"}]
|
||||
if len(special) > 1 or (special and normal):
|
||||
raise click.ClickException("select either categories, all, safe, or server default — not a mix")
|
||||
if special == ["__server_default__"]:
|
||||
return None
|
||||
if special == ["__safe__"]:
|
||||
return []
|
||||
if special == ["__all__"]:
|
||||
return ["all"]
|
||||
return normal
|
||||
|
||||
def _parse_policy_selection(raw: str):
|
||||
value = raw.strip().lower()
|
||||
if value in {"default", "server-default", "server default", "inherit", "none"}:
|
||||
return None
|
||||
if value in {"safe", "safe-only", ""}:
|
||||
return []
|
||||
if value == "all":
|
||||
return ["all"]
|
||||
|
||||
number_map = {
|
||||
"1": "read-page",
|
||||
"2": "control",
|
||||
"3": "dangerous",
|
||||
"4": "keys",
|
||||
}
|
||||
valid = {"read-page", "control", "dangerous", "keys"}
|
||||
categories = []
|
||||
for token in [part.strip() for part in value.replace(" ", ",").split(",") if part.strip()]:
|
||||
category = number_map.get(token, token)
|
||||
if category == "all":
|
||||
return ["all"]
|
||||
if category not in valid:
|
||||
raise click.ClickException(f"unknown policy choice: {token}")
|
||||
if category not in categories:
|
||||
categories.append(category)
|
||||
return categories
|
||||
|
||||
def _plain_policy_label(categories) -> str:
|
||||
"""Plain-text policy label for interactive prompt titles."""
|
||||
if categories is None:
|
||||
return "server default"
|
||||
if "all" in categories:
|
||||
return "all"
|
||||
return ", ".join(categories) if categories else "safe"
|
||||
|
||||
def _policy_label(categories) -> str:
|
||||
"""Render an authorized_keys ``allow:`` token for display."""
|
||||
if categories is None:
|
||||
return "[dim]server default[/dim]"
|
||||
if "all" in categories:
|
||||
return "[yellow]all[/yellow]"
|
||||
return ", ".join(categories) if categories else "safe"
|
||||
|
||||
def _policy_description(categories) -> str:
|
||||
"""Human-readable explanation for a policy category list."""
|
||||
if categories is None:
|
||||
return "Inherits the policy from browser-cli serve"
|
||||
if "all" in categories:
|
||||
return "Full access: page reads, browser control, dangerous commands, key admin"
|
||||
if not categories:
|
||||
return "Safe status/list commands only"
|
||||
|
||||
descriptions = {
|
||||
"read-page": "read page content",
|
||||
"control": "control browser/tabs/page input",
|
||||
"dangerous": "run high-risk commands",
|
||||
"keys": "manage trusted keys remotely",
|
||||
}
|
||||
return "; ".join(descriptions.get(category, category) for category in categories)
|
||||
|
||||
+25
-115
@@ -11,11 +11,10 @@ from browser_cli.client import (
|
||||
BrowserNotConnected,
|
||||
REGISTRY_PATH,
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
remote_browser_targets,
|
||||
remote_target_for_alias,
|
||||
collect_browser_clients,
|
||||
send_command,
|
||||
)
|
||||
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||
from browser_cli.registry import load_registry
|
||||
|
||||
console = Console()
|
||||
@@ -36,23 +35,6 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
|
||||
if alias in profiles and alias != target_profile:
|
||||
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
||||
|
||||
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False, profile_group=None):
|
||||
"""Query clients.list for one target and append each, tagged with *label*."""
|
||||
if quiet_remote_warning:
|
||||
result = send_command(
|
||||
"clients.list",
|
||||
profile=profile,
|
||||
remote=remote,
|
||||
key=key,
|
||||
suppress_pq_warning=True,
|
||||
)
|
||||
else:
|
||||
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = label
|
||||
if profile_group:
|
||||
c["profileGroup"] = profile_group
|
||||
into.append(c)
|
||||
|
||||
@click.group("clients", invoke_without_command=True)
|
||||
@click.pass_context
|
||||
@@ -61,18 +43,20 @@ def clients_group(ctx):
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
all_clients = []
|
||||
|
||||
browser_alias = (ctx.obj or {}).get("browser")
|
||||
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
key = (ctx.obj or {}).get("key")
|
||||
|
||||
if not remote and browser_alias:
|
||||
_collect_remote_alias_clients(all_clients, browser_alias, key)
|
||||
elif remote:
|
||||
_collect_explicit_remote_clients(all_clients, browser_alias, remote, key)
|
||||
else:
|
||||
_collect_local_and_saved_remote_clients(all_clients)
|
||||
try:
|
||||
all_clients = collect_browser_clients(
|
||||
browser_alias=browser_alias,
|
||||
remote=remote,
|
||||
key=key,
|
||||
registry_path=REGISTRY_PATH,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if not all_clients:
|
||||
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
|
||||
@@ -80,98 +64,24 @@ def clients_group(ctx):
|
||||
|
||||
_print_clients(all_clients)
|
||||
|
||||
def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) -> None:
|
||||
resolved = remote_target_for_alias(browser_alias)
|
||||
if not resolved:
|
||||
return
|
||||
try:
|
||||
targets = remote_browser_targets(resolved.remote)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
for target in targets:
|
||||
try:
|
||||
_append_clients(
|
||||
all_clients,
|
||||
target.display_name,
|
||||
profile=target.profile,
|
||||
remote=resolved.remote,
|
||||
key=key,
|
||||
profile_group=target.display_group,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
def _collect_explicit_remote_clients(all_clients: list, browser_alias: str | None, remote: str, key) -> None:
|
||||
try:
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = c.get("profile") or browser_alias or "remote"
|
||||
all_clients.append(c)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
||||
profiles: dict[str, str] = load_registry(REGISTRY_PATH) if REGISTRY_PATH.exists() else {}
|
||||
|
||||
for profile_name, sock_path in profiles.items():
|
||||
display_profile = display_browser_name(profile_name, sock_path)
|
||||
try:
|
||||
_append_clients(all_clients, display_profile, profile=profile_name, profile_group="local")
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
all_clients.append({
|
||||
"profile": display_profile,
|
||||
"profileGroup": "local",
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
})
|
||||
|
||||
targets = active_browser_targets(suppress_pq_warning=True)
|
||||
|
||||
for target in targets:
|
||||
if target.remote is None:
|
||||
continue
|
||||
try:
|
||||
_append_clients(
|
||||
all_clients,
|
||||
target.display_name,
|
||||
profile=target.profile,
|
||||
remote=target.remote,
|
||||
quiet_remote_warning=True,
|
||||
profile_group=target.display_group,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
def _print_clients(all_clients: list) -> None:
|
||||
from rich.table import Table
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Profile", no_wrap=True)
|
||||
table.add_column("Browser")
|
||||
table.add_column("Version")
|
||||
table.add_column("Extension Version")
|
||||
rendered_groups: set[str] = set()
|
||||
groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
|
||||
grouped = bool(groups and groups != {"local"})
|
||||
for c in all_clients:
|
||||
group = c.get("profileGroup") if grouped else None
|
||||
if group:
|
||||
if group not in rendered_groups:
|
||||
table.add_row(f"[bold]{group}[/bold]", "", "", "")
|
||||
rendered_groups.add(group)
|
||||
profile = str(c.get("profile", "")).removeprefix(f"{group}:")
|
||||
profile = f" {profile}"
|
||||
else:
|
||||
profile = c.get("profile", "")
|
||||
table.add_row(
|
||||
profile,
|
||||
c.get("name", ""),
|
||||
c.get("version", ""),
|
||||
c.get("extensionVersion", ""),
|
||||
columns = [
|
||||
("Browser", lambda item: item.get("name", "")),
|
||||
("Version", lambda item: item.get("version", "")),
|
||||
("Extension Version", lambda item: item.get("extensionVersion", "")),
|
||||
]
|
||||
print_browser_grouped_table_rows(
|
||||
all_clients,
|
||||
columns,
|
||||
console=console,
|
||||
empty_message="[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]",
|
||||
browser_getter=lambda item: item.get("profile", ""),
|
||||
group_getter=lambda item: item.get("profileGroup", "") if grouped else "",
|
||||
browser_header="Profile",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
@clients_group.command("rename")
|
||||
@click.option(
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from browser_cli.commands import handle_errors, client_from_ctx
|
||||
from browser_cli.client import active_browser_targets
|
||||
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME, PYPI_PACKAGE_NAME
|
||||
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME
|
||||
from browser_cli.platform import is_windows
|
||||
from browser_cli.version_manager import project_version
|
||||
|
||||
console = Console()
|
||||
|
||||
def _project_version() -> str:
|
||||
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
try:
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
return package_version(PYPI_PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
def _status(ok: bool) -> str:
|
||||
return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
|
||||
|
||||
@@ -39,7 +22,7 @@ def _status(ok: bool) -> str:
|
||||
def cmd_doctor(check_remote):
|
||||
"""Diagnose browser-cli installation, extension, and connection health."""
|
||||
rows: list[tuple[str, bool, str]] = []
|
||||
version = _project_version()
|
||||
version = project_version()
|
||||
rows.append(("Python package", version != "unknown", version))
|
||||
rows.append(("browser-cli executable", shutil.which("browser-cli") is not None, shutil.which("browser-cli") or "not on PATH"))
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ from rich.console import Console
|
||||
|
||||
from browser_cli.constants import (
|
||||
ALLOWED_EXTENSION_IDS,
|
||||
CHROME_WEBSTORE_URL,
|
||||
EXTENSION_ID,
|
||||
FIREFOX_ADDON_URL,
|
||||
FIREFOX_EXTENSION_ID,
|
||||
NATIVE_HOST_DIRS,
|
||||
NATIVE_HOST_NAME,
|
||||
@@ -62,11 +64,44 @@ def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str
|
||||
|
||||
@click.command("install")
|
||||
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
|
||||
def cmd_install(browser):
|
||||
"""Register the native messaging host and print extension load instructions."""
|
||||
@click.option("--dev", is_flag=True, help="Print developer instructions for loading an unpacked/temporary build instead of the public store listing.")
|
||||
def cmd_install(browser, dev):
|
||||
"""Register the native messaging host and print extension install instructions."""
|
||||
host_exe = native_host_exe()
|
||||
write_native_host_exe(host_exe)
|
||||
|
||||
if dev:
|
||||
_print_dev_instructions(browser)
|
||||
else:
|
||||
_print_store_instructions(browser)
|
||||
|
||||
manifest = _native_host_manifest(browser, host_exe)
|
||||
installed = _install_manifest(browser, host_exe, manifest)
|
||||
if not installed:
|
||||
console.print("[red]Failed to install native host manifest[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
for p in installed:
|
||||
label = "Registered native host" if is_windows() else "Wrote native host manifest"
|
||||
console.print(f"[green]✓[/green] {label}: {p}")
|
||||
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
|
||||
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
|
||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||
|
||||
def _print_store_instructions(browser: str) -> None:
|
||||
console.print("\n[bold]Step 1:[/bold] Install the extension")
|
||||
if browser == "firefox":
|
||||
console.print(" Open Firefox Add-ons and click [bold]Add to Firefox[/bold]:")
|
||||
console.print(f" [cyan]{FIREFOX_ADDON_URL}[/cyan]")
|
||||
console.print(" [dim]Firefox support is experimental; tab-group commands require browser tab group APIs.[/dim]\n")
|
||||
else:
|
||||
console.print(f" Open the Chrome Web Store and click [bold]Add to {browser.capitalize()}[/bold]:")
|
||||
console.print(f" [cyan]{CHROME_WEBSTORE_URL}[/cyan]")
|
||||
console.print(" [dim]Brave, Edge, Vivaldi and Chromium can install from the Chrome Web Store too.[/dim]")
|
||||
console.print(" [dim]Developing the extension? Run 'browser-cli install <browser> --dev' for the unpacked-load steps.[/dim]\n")
|
||||
|
||||
def _print_dev_instructions(browser: str) -> None:
|
||||
ext_url = {
|
||||
"chrome": "chrome://extensions",
|
||||
"chromium": "chrome://extensions",
|
||||
@@ -75,7 +110,7 @@ def cmd_install(browser):
|
||||
"vivaldi": "vivaldi://extensions",
|
||||
"firefox": "about:debugging#/runtime/this-firefox",
|
||||
}[browser]
|
||||
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
|
||||
console.print("\n[bold]Step 1:[/bold] Load the unpacked extension (developer mode)")
|
||||
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
||||
if browser == "firefox":
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
@@ -93,20 +128,6 @@ def cmd_install(browser):
|
||||
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
|
||||
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
|
||||
|
||||
manifest = _native_host_manifest(browser, host_exe)
|
||||
installed = _install_manifest(browser, host_exe, manifest)
|
||||
if not installed:
|
||||
console.print("[red]Failed to install native host manifest[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
for p in installed:
|
||||
label = "Registered native host" if is_windows() else "Wrote native host manifest"
|
||||
console.print(f"[green]✓[/green] {label}: {p}")
|
||||
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
|
||||
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
|
||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||
|
||||
def _native_host_manifest(browser: str, host_exe: Path) -> dict:
|
||||
base = {
|
||||
"name": NATIVE_HOST_NAME,
|
||||
|
||||
@@ -4,19 +4,17 @@ import json
|
||||
|
||||
import click
|
||||
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from browser_cli.command_security import assert_command_allowed
|
||||
from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
|
||||
|
||||
@click.command("command")
|
||||
@click.argument("name")
|
||||
@click.argument("args_json", required=False, default="{}")
|
||||
@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
|
||||
@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
|
||||
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
|
||||
@command_policy_options
|
||||
@handle_errors
|
||||
def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous):
|
||||
def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Send a raw browser-cli wire command and print JSON."""
|
||||
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
|
||||
assert_command_allowed(name, policy)
|
||||
args = json.loads(args_json) if args_json else {}
|
||||
result = client_from_ctx().command(name, args)
|
||||
|
||||
@@ -8,8 +8,8 @@ from typing import Any, cast
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from browser_cli.command_security import assert_command_allowed
|
||||
from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -38,17 +38,15 @@ def _parse_step(step):
|
||||
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--json", "json_output", is_flag=True, help="Print all step results as JSON")
|
||||
@click.option("--continue-on-error", is_flag=True, help="Continue after failed steps")
|
||||
@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
|
||||
@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
|
||||
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
|
||||
@command_policy_options
|
||||
@handle_errors
|
||||
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool):
|
||||
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool, allow_all: bool):
|
||||
"""Run a JSON/YAML batch script of browser-cli wire commands."""
|
||||
steps = _load_steps(file)
|
||||
if not isinstance(steps, list):
|
||||
raise click.ClickException("Script root must be a list")
|
||||
client = client_from_ctx()
|
||||
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
|
||||
results = []
|
||||
for index, step in enumerate(steps, start=1):
|
||||
command, args = _parse_step(step)
|
||||
|
||||
@@ -1,61 +1,10 @@
|
||||
import click
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from rich.console import Console
|
||||
from browser_cli.search.engines import DISPLAY_NAMES, SUBCOMMANDS
|
||||
|
||||
console = Console()
|
||||
|
||||
ENGINES = {
|
||||
"google": "https://www.google.com/search?q={query}",
|
||||
"brave": "https://search.brave.com/search?q={query}",
|
||||
"duckduckgo": "https://duckduckgo.com/?q={query}",
|
||||
"ddg": "https://duckduckgo.com/?q={query}",
|
||||
"youtube": "https://www.youtube.com/results?search_query={query}",
|
||||
"yt": "https://www.youtube.com/results?search_query={query}",
|
||||
"spotify": "https://open.spotify.com/search/{query}",
|
||||
"amazon": "https://www.amazon.com/s?k={query}",
|
||||
"ecosia": "https://www.ecosia.org/search?q={query}",
|
||||
"furaffinity": "https://www.furaffinity.net/search/?q={query}",
|
||||
"fa": "https://www.furaffinity.net/search/?q={query}",
|
||||
"bing": "https://www.bing.com/search?q={query}",
|
||||
"github": "https://github.com/search?q={query}",
|
||||
"wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
|
||||
"reddit": "https://www.reddit.com/search/?q={query}",
|
||||
"stackoverflow": "https://stackoverflow.com/search?q={query}",
|
||||
"so": "https://stackoverflow.com/search?q={query}",
|
||||
}
|
||||
|
||||
_DISPLAY_NAMES = {
|
||||
"google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
|
||||
"ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
|
||||
"spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
|
||||
"furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
|
||||
"github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
|
||||
"reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
|
||||
}
|
||||
|
||||
_SUBCOMMANDS = [
|
||||
("google", "Search with Google."),
|
||||
("brave", "Search with Brave Search."),
|
||||
("duckduckgo", "Search with DuckDuckGo."),
|
||||
("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
|
||||
("youtube", "Search YouTube videos."),
|
||||
("yt", "Search YouTube (alias for youtube)."),
|
||||
("spotify", "Search Spotify."),
|
||||
("amazon", "Search Amazon."),
|
||||
("ecosia", "Search with Ecosia."),
|
||||
("furaffinity", "Search FurAffinity."),
|
||||
("fa", "Search FurAffinity (alias for furaffinity)."),
|
||||
("bing", "Search with Bing."),
|
||||
("github", "Search GitHub."),
|
||||
("wikipedia", "Search Wikipedia."),
|
||||
("wiki", "Search Wikipedia (alias for wikipedia)."),
|
||||
("reddit", "Search Reddit."),
|
||||
("stackoverflow", "Search Stack Overflow."),
|
||||
("so", "Search Stack Overflow (alias for stackoverflow)."),
|
||||
]
|
||||
|
||||
|
||||
@click.group("search")
|
||||
def search_group():
|
||||
"""Search the web — open a query in a search engine."""
|
||||
@@ -70,10 +19,10 @@ def _build_command(engine_key: str, help_text: str) -> click.Command:
|
||||
terms = " ".join(query)
|
||||
client_from_ctx().nav.search(engine_key, terms, window=window, group=group)
|
||||
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
||||
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||
display = DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
||||
|
||||
return _cmd
|
||||
|
||||
for _name, _help in _SUBCOMMANDS:
|
||||
for _name, _help in SUBCOMMANDS:
|
||||
search_group.add_command(_build_command(_name, _help))
|
||||
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
import click
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
from browser_cli.commands import command_policy_from_options, command_policy_options
|
||||
from browser_cli.serve.runtime import (
|
||||
_async_framed_send,
|
||||
_async_handle_client,
|
||||
@@ -16,6 +18,7 @@ from browser_cli.serve.runtime import (
|
||||
_serve_async,
|
||||
console,
|
||||
)
|
||||
from browser_cli.serve.security import RateLimiter, ServeSecurity, key_policies_from_authorized_keys
|
||||
from browser_cli.version_manager import get_installed_version
|
||||
|
||||
__all__ = [
|
||||
@@ -27,6 +30,9 @@ __all__ = [
|
||||
"cmd_serve",
|
||||
]
|
||||
|
||||
def _is_loopback(host: str) -> bool:
|
||||
return host in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
@click.command("serve")
|
||||
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
|
||||
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
|
||||
@@ -45,32 +51,79 @@ __all__ = [
|
||||
default=False,
|
||||
help="Disable response compression / msgpack even for clients that support it.",
|
||||
)
|
||||
@click.option(
|
||||
"--rate-limit",
|
||||
default=100.0,
|
||||
show_default=True,
|
||||
type=float,
|
||||
help="Max commands/sec per client key (0 disables).",
|
||||
)
|
||||
@command_policy_options
|
||||
@click.pass_context
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
|
||||
"""Expose this browser over TCP so remote hosts can control it."""
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rate_limit,
|
||||
allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Expose this browser over TCP so remote hosts can control it.
|
||||
|
||||
Commands are gated by a safe-only policy by default; remote clients can only
|
||||
run read-only status/listing commands. Open more with --allow-read-page,
|
||||
--allow-control, --allow-dangerous, or --allow-all (full control). Per-key
|
||||
overrides come from an ``allow:`` token in authorized_keys (set via
|
||||
``auth trust --allow-*``), and --rate-limit throttles each client key.
|
||||
"""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
compress = not no_compress
|
||||
|
||||
if no_auth and not _is_loopback(host):
|
||||
console.print(
|
||||
"[red]Error:[/red] --no-auth is only allowed on loopback hosts "
|
||||
"(127.0.0.1, localhost, ::1). Use --authorized-keys to expose this browser to the network."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if host in ("0.0.0.0", "::"):
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Binding to all interfaces — "
|
||||
"anyone who can reach this port controls your browser."
|
||||
)
|
||||
|
||||
policy = command_policy_from_options(
|
||||
allow_read_page=allow_read_page,
|
||||
allow_control=allow_control,
|
||||
allow_dangerous=allow_dangerous,
|
||||
allow_keys=allow_keys,
|
||||
allow_all=allow_all,
|
||||
)
|
||||
|
||||
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
|
||||
if auth_keys_path is False:
|
||||
sys.exit(1)
|
||||
|
||||
_print_startup(host, port, profile, auth_keys_path, compress)
|
||||
security = _build_security(policy, auth_keys_path, rate_limit)
|
||||
|
||||
_print_startup(host, port, profile, auth_keys_path, compress, security)
|
||||
|
||||
try:
|
||||
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
|
||||
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress, security))
|
||||
except OSError as e:
|
||||
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
console.print("[yellow]Stopped.[/yellow]")
|
||||
|
||||
def _build_security(policy, auth_keys_path, rate_limit) -> ServeSecurity:
|
||||
"""Assemble the serve-time security context from the authorized_keys file."""
|
||||
key_policies: dict = {}
|
||||
key_names: dict = {}
|
||||
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import load_authorized_keys_with_names
|
||||
|
||||
key_names = {pk.strip().lower(): name for pk, name in load_authorized_keys_with_names(auth_keys_path)}
|
||||
key_policies = key_policies_from_authorized_keys(auth_keys_path)
|
||||
|
||||
rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None
|
||||
return ServeSecurity(policy=policy, key_policies=key_policies, key_names=key_names, rate_limiter=rate_limiter)
|
||||
|
||||
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
|
||||
if auth_keys_file:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
@@ -87,8 +140,9 @@ def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path |
|
||||
)
|
||||
return False
|
||||
|
||||
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
|
||||
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool, security: ServeSecurity | None = None) -> None:
|
||||
current_ver = get_installed_version()
|
||||
security = security if security is not None else ServeSecurity()
|
||||
browser_hint = f" (browser: {profile})" if profile else ""
|
||||
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
|
||||
|
||||
@@ -100,11 +154,34 @@ def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Pa
|
||||
else:
|
||||
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||
|
||||
_print_policy_status(security.policy)
|
||||
if security.key_policies:
|
||||
console.print(f" Per-key: [green]{len(security.key_policies)} override(s)[/green] [dim](allow: in authorized_keys)[/dim]")
|
||||
if security.rate_limiter is not None:
|
||||
console.print(f" Rate: [green]{security.rate_limiter.rate:g}/s per key[/green] [dim](burst {security.rate_limiter.capacity:g})[/dim]")
|
||||
else:
|
||||
console.print(" Rate: [yellow]unlimited[/yellow] [dim](--rate-limit 0)[/dim]")
|
||||
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
|
||||
_print_encoding_status(compress)
|
||||
console.print("Ctrl-C to stop.\n")
|
||||
|
||||
def _print_policy_status(policy: CommandPolicy | None) -> None:
|
||||
if policy is None or policy == CommandPolicy.unrestricted():
|
||||
console.print(" Policy: [yellow]unrestricted (--allow-all)[/yellow] [dim](every command allowed, incl. dom.eval/storage)[/dim]")
|
||||
return
|
||||
allowed = ["safe"]
|
||||
if policy.allow_read_page:
|
||||
allowed.append("read-page")
|
||||
if policy.allow_control:
|
||||
allowed.append("control")
|
||||
if policy.allow_dangerous:
|
||||
allowed.append("dangerous")
|
||||
if policy.allow_keys:
|
||||
allowed.append("keys")
|
||||
console.print(f" Policy: [green]restricted[/green] [dim](allowed: {', '.join(allowed)})[/dim]")
|
||||
|
||||
def _print_encoding_status(compress: bool) -> None:
|
||||
if not compress:
|
||||
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
|
||||
|
||||
@@ -10,9 +10,14 @@ from rich.console import Console
|
||||
|
||||
from browser_cli import BrowserCLI
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.commands import command_policy_from_options, command_policy_options
|
||||
from browser_cli.serve.security import RateLimiter
|
||||
|
||||
console = Console()
|
||||
|
||||
# Hard cap on request body size so a bogus Content-Length can't exhaust memory.
|
||||
MAX_BODY_BYTES = 8 * 1024 * 1024
|
||||
|
||||
def _is_loopback(host: str) -> bool:
|
||||
return host in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
@@ -20,13 +25,16 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
client: BrowserCLI
|
||||
token: str | None = None
|
||||
policy: CommandPolicy = CommandPolicy()
|
||||
rate_limiter: RateLimiter | None = None
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
if self.token is None:
|
||||
return True
|
||||
if self.headers.get("Authorization", "") == f"Bearer {self.token}":
|
||||
bearer = self.headers.get("Authorization", "")
|
||||
if bearer.startswith("Bearer ") and secrets.compare_digest(bearer[len("Bearer "):], self.token):
|
||||
return True
|
||||
return self.headers.get("X-Browser-CLI-Token") == self.token
|
||||
header = self.headers.get("X-Browser-CLI-Token")
|
||||
return header is not None and secrets.compare_digest(header, self.token)
|
||||
|
||||
def _require_auth(self) -> bool:
|
||||
if self._authorized():
|
||||
@@ -34,6 +42,12 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
self._send(401, {"error": "missing or invalid token"})
|
||||
return False
|
||||
|
||||
def _within_rate_limit(self) -> bool:
|
||||
if self.rate_limiter is None or self.rate_limiter.allow(self.client_address[0]):
|
||||
return True
|
||||
self._send(429, {"error": "rate limit exceeded; slow down and retry"})
|
||||
return False
|
||||
|
||||
def _send(self, status: int, payload):
|
||||
raw = json.dumps(payload, default=str).encode("utf-8")
|
||||
self.send_response(status)
|
||||
@@ -45,7 +59,10 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
if path != "/health" and not self._require_auth():
|
||||
if path != "/health":
|
||||
if not self._require_auth():
|
||||
return
|
||||
if not self._within_rate_limit():
|
||||
return
|
||||
if path == "/tabs":
|
||||
self._send(200, [t.__dict__ for t in self.client.tabs.list()])
|
||||
@@ -61,16 +78,21 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if path == "/command":
|
||||
if path != "/command":
|
||||
self._send(404, {"error": "not found"})
|
||||
return
|
||||
if not self._require_auth():
|
||||
return
|
||||
if not self._within_rate_limit():
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length > MAX_BODY_BYTES:
|
||||
self._send(413, {"error": f"request body too large (max {MAX_BODY_BYTES} bytes)"})
|
||||
return
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
command = body.get("command")
|
||||
assert_command_allowed(command, self.policy)
|
||||
self._send(200, {"result": self.client.command(command, body.get("args") or {})})
|
||||
else:
|
||||
self._send(404, {"error": "not found"})
|
||||
except PermissionError as exc:
|
||||
self._send(403, {"error": str(exc)})
|
||||
except Exception as exc:
|
||||
@@ -87,23 +109,32 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
@click.option("--key", default=None, help="Remote auth key spec")
|
||||
@click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)")
|
||||
@click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)")
|
||||
@click.option("--allow-read-page", is_flag=True, help="Allow /command to run page-content read commands")
|
||||
@click.option("--allow-control", is_flag=True, help="Allow /command to run browser-control commands")
|
||||
@click.option("--allow-dangerous", is_flag=True, help="Allow /command to run high-risk commands")
|
||||
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous):
|
||||
@click.option("--rate-limit", default=100.0, show_default=True, type=float, help="Max requests/sec per client address (0 disables)")
|
||||
@command_policy_options
|
||||
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, rate_limit, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
|
||||
|
||||
Auth is enabled by default. Pass the printed token as either
|
||||
``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``.
|
||||
|
||||
This gateway speaks plain HTTP — the token is sent in clear text. Keep it on
|
||||
loopback, or put a TLS-terminating reverse proxy in front before exposing it.
|
||||
"""
|
||||
if no_auth and not _is_loopback(host):
|
||||
raise click.ClickException("--no-auth is only allowed on loopback hosts")
|
||||
if not _is_loopback(host):
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] binding beyond loopback — this gateway is plain HTTP and the "
|
||||
"token travels in clear text. Put a TLS-terminating reverse proxy in front, or use "
|
||||
"[bold]browser-cli serve[/bold] (encrypted) instead."
|
||||
)
|
||||
auth_token = None if no_auth else (token or secrets.token_urlsafe(32))
|
||||
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
|
||||
rate_limiter = RateLimiter(rate_limit) if rate_limit and rate_limit > 0 else None
|
||||
handler = type(
|
||||
"BrowserCLIHTTPHandler",
|
||||
(_Handler,),
|
||||
{"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy},
|
||||
{"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy, "rate_limiter": rate_limiter},
|
||||
)
|
||||
server = ThreadingHTTPServer((host, port), handler)
|
||||
console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
|
||||
|
||||
@@ -20,11 +20,17 @@ def _auth_0_9_3(msg: dict) -> dict:
|
||||
pk = msg.get("pubkey")
|
||||
if isinstance(pk, str) and pk:
|
||||
changed["pubkey"] = pk.lower()
|
||||
if msg.get("command") == "browser-cli.auth.trust":
|
||||
if msg.get("command") in {"browser-cli.auth.trust", "browser-cli.auth.policy"}:
|
||||
args = msg.get("args") or {}
|
||||
trust_pk = args.get("pubkey")
|
||||
identifier = args.get("identifier")
|
||||
patched = dict(args)
|
||||
if isinstance(trust_pk, str) and trust_pk:
|
||||
changed["args"] = {**args, "pubkey": trust_pk.lower()}
|
||||
patched["pubkey"] = trust_pk.lower()
|
||||
if isinstance(identifier, str) and identifier and len(identifier) == 64:
|
||||
patched["identifier"] = identifier.lower()
|
||||
if patched != args:
|
||||
changed["args"] = patched
|
||||
return {**msg, **changed} if changed else msg
|
||||
|
||||
|
||||
|
||||
@@ -20,13 +20,31 @@ FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
|
||||
ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
|
||||
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi", "firefox"]
|
||||
|
||||
# Public store listings — the default install path now that the extension is
|
||||
# published. Chromium-family browsers (Brave/Edge/Vivaldi/Chromium) can all
|
||||
# install from the Chrome Web Store.
|
||||
CHROME_WEBSTORE_URL = f"https://chromewebstore.google.com/detail/browser-cli/{WEBSTORE_EXTENSION_ID}"
|
||||
FIREFOX_ADDON_URL = "https://addons.mozilla.org/firefox/addon/browser-cli/"
|
||||
|
||||
PROTOCOL_MIN_CLIENT = "0.9.0"
|
||||
MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||
DEFAULT_REMOTE_PORT = 443
|
||||
DEFAULT_PAGE_SIZE = 100
|
||||
# Count cap requested per page. The extension fills each page up to this many
|
||||
# items OR a byte budget (whichever comes first), so large items (e.g. data-URI
|
||||
# favicons) stay under the 1MB native-messaging limit while small items pack
|
||||
# into far fewer roundtrips.
|
||||
DEFAULT_PAGE_SIZE = 1000
|
||||
# Hard upper bound on total items collected across all pages, and the loop-guard
|
||||
# page count. Kept independent of page size so byte-budgeted small pages don't
|
||||
# falsely trip the guard.
|
||||
MAX_PAGED_ITEMS = 10_000
|
||||
DEFAULT_TRANSPORT_THRESHOLD = 512
|
||||
# How long a remote serve connection stays open waiting for the next command on
|
||||
# an established encrypted session before closing. Lets the client reuse one
|
||||
# authenticated connection for multiple commands instead of re-handshaking.
|
||||
REMOTE_SESSION_IDLE_TIMEOUT = 30
|
||||
|
||||
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
|
||||
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"}
|
||||
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
|
||||
|
||||
PAGEABLE_COMMANDS = {
|
||||
|
||||
@@ -11,6 +11,13 @@ class _HtmlNode:
|
||||
self.text = text
|
||||
self.children = []
|
||||
|
||||
# Cap how deep the parsed tree may nest. Hostile page content (thousands of
|
||||
# nested elements) would otherwise blow Python's recursion limit in the
|
||||
# depth-first render walkers below. Bounding here protects every walker at once.
|
||||
# 200 levels is far beyond any real document; deeper content is flattened, not
|
||||
# dropped (its text still reaches the output).
|
||||
_MAX_TREE_DEPTH = 200
|
||||
|
||||
class _HtmlTreeBuilder(HTMLParser):
|
||||
_VOID_TAGS = {"br", "hr", "img"}
|
||||
|
||||
@@ -22,7 +29,9 @@ class _HtmlTreeBuilder(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
|
||||
self._stack[-1].children.append(node)
|
||||
if node.tag not in self._VOID_TAGS:
|
||||
# Only descend while under the depth cap; beyond it, children of this node
|
||||
# attach to the current (capped) parent — flattened but preserved.
|
||||
if node.tag not in self._VOID_TAGS and len(self._stack) < _MAX_TREE_DEPTH:
|
||||
self._stack.append(node)
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
@@ -57,6 +66,14 @@ def _collapse_blank_lines(value):
|
||||
def _escape_markdown(text):
|
||||
return re.sub(r"([\\`[\]])", r"\\\1", text)
|
||||
|
||||
# Schemes that are dangerous if the produced markdown is later rendered as HTML
|
||||
# by a downstream consumer. The output is plain text here, but neutralising them
|
||||
# keeps the converter from laundering an XSS payload through to such a consumer.
|
||||
_UNSAFE_URL_SCHEME = re.compile(r"^\s*(?:javascript|vbscript|data)\s*:", re.IGNORECASE)
|
||||
|
||||
def _safe_url(url):
|
||||
return "" if _UNSAFE_URL_SCHEME.match(url or "") else url
|
||||
|
||||
def _escape_table_cell(text):
|
||||
return text.replace("|", r"\|").replace("\n", " ").strip()
|
||||
|
||||
@@ -86,14 +103,14 @@ def _inline_text(node):
|
||||
if tag == "br":
|
||||
return "\n"
|
||||
if tag == "img":
|
||||
src = node.attrs.get("src") or ""
|
||||
src = _safe_url(node.attrs.get("src") or "")
|
||||
alt = _normalize_text(node.attrs.get("alt") or "")
|
||||
if not src:
|
||||
return ""
|
||||
return f"" if alt else f""
|
||||
if tag == "a":
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
href = node.attrs.get("href") or ""
|
||||
href = _safe_url(node.attrs.get("href") or "")
|
||||
return f"[{text or href}]({href})" if href else text
|
||||
if tag == "code":
|
||||
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
|
||||
@@ -235,5 +252,10 @@ def _block_to_markdown(node):
|
||||
def convert_html_to_markdown(html, clean_markdown_output):
|
||||
parser = _HtmlTreeBuilder()
|
||||
parser.feed(html or "")
|
||||
try:
|
||||
markdown = _block_to_markdown(parser.root)
|
||||
except RecursionError:
|
||||
# The depth cap should prevent this, but never let hostile page content
|
||||
# crash the caller: fall back to a flat, tag-stripped text extraction.
|
||||
markdown = _normalize_inline(re.sub(r"<[^>]*>", " ", html or ""))
|
||||
return clean_markdown_output(markdown)
|
||||
|
||||
+19
-23
@@ -62,27 +62,27 @@ class Tab:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close this tab."""
|
||||
self._command("tabs.close", {"tabId": self.id})
|
||||
self._b().tabs.close(self.id)
|
||||
|
||||
def activate(self) -> None:
|
||||
"""Switch browser focus to this tab."""
|
||||
self._command("tabs.active", {"tabId": self.id})
|
||||
self._b().tabs.activate(self.id)
|
||||
|
||||
def mute(self) -> None:
|
||||
"""Mute this tab."""
|
||||
self._command("tabs.mute", {"tabId": self.id})
|
||||
self._b().tabs.mute(self.id)
|
||||
|
||||
def unmute(self) -> None:
|
||||
"""Unmute this tab."""
|
||||
self._command("tabs.unmute", {"tabId": self.id})
|
||||
self._b().tabs.unmute(self.id)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload this tab."""
|
||||
self._command("navigate.reload", {"tabId": self.id})
|
||||
self._b().nav.reload(self.id)
|
||||
|
||||
def hard_reload(self) -> None:
|
||||
"""Hard-reload this tab (bypass cache)."""
|
||||
self._command("navigate.hard_reload", {"tabId": self.id})
|
||||
self._b().nav.hard_reload(self.id)
|
||||
|
||||
def move(
|
||||
self, *,
|
||||
@@ -101,18 +101,18 @@ class Tab:
|
||||
window_id: Move to the window with this ID.
|
||||
index: Absolute position index in the target window.
|
||||
"""
|
||||
self._command("tabs.move", {
|
||||
"tabId": self.id,
|
||||
"forward": forward,
|
||||
"backward": backward,
|
||||
"groupId": group_id,
|
||||
"windowId": window_id,
|
||||
"index": index,
|
||||
})
|
||||
self._b().tabs.move(
|
||||
self.id,
|
||||
forward=forward,
|
||||
backward=backward,
|
||||
group_id=group_id,
|
||||
window_id=window_id,
|
||||
index=index,
|
||||
)
|
||||
|
||||
def html(self) -> str:
|
||||
"""Return the full HTML source of this tab."""
|
||||
return self._command("tabs.html", {"tabId": self.id})
|
||||
return self._b().tabs.html(self.id)
|
||||
|
||||
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
|
||||
"""Capture this tab's visible area. Returns a base64 data URL."""
|
||||
@@ -120,11 +120,11 @@ class Tab:
|
||||
|
||||
def pin(self) -> None:
|
||||
"""Pin this tab."""
|
||||
self._command("tabs.pin", {"tabId": self.id})
|
||||
self._b().tabs.pin(self.id)
|
||||
|
||||
def unpin(self) -> None:
|
||||
"""Unpin this tab."""
|
||||
self._command("tabs.unpin", {"tabId": self.id})
|
||||
self._b().tabs.unpin(self.id)
|
||||
|
||||
def refresh(self) -> Tab:
|
||||
"""Return a fresh snapshot of this tab."""
|
||||
@@ -170,7 +170,7 @@ class Group:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Ungroup (and close) this tab group."""
|
||||
self._command("group.close", {"groupId": self.id})
|
||||
self._b().groups.close(self.id)
|
||||
|
||||
def tabs(self) -> list[Tab]:
|
||||
"""Return all tabs inside this group."""
|
||||
@@ -178,11 +178,7 @@ class Group:
|
||||
|
||||
def move(self, *, forward: bool = False, backward: bool = False) -> None:
|
||||
"""Move this group forward or backward among groups."""
|
||||
self._command("group.move", {
|
||||
"group": str(self.id),
|
||||
"forward": forward,
|
||||
"backward": backward,
|
||||
})
|
||||
self._b().groups.move(str(self.id), forward=forward, backward=backward)
|
||||
|
||||
def add_tab(self, url: str | None = None) -> int | None:
|
||||
"""Open a new tab inside this group. Returns the new tab ID."""
|
||||
|
||||
@@ -7,7 +7,6 @@ It relays messages between extension (stdin/stdout Native Messaging protocol)
|
||||
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
@@ -17,7 +16,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.native import local_server, protocol
|
||||
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, PAGEABLE_COMMANDS
|
||||
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, MAX_PAGED_ITEMS, PAGEABLE_COMMANDS
|
||||
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path, runtime_dir
|
||||
from browser_cli.registry import update_registry
|
||||
|
||||
@@ -126,7 +125,10 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
|
||||
offset = 0
|
||||
items = []
|
||||
total = None
|
||||
max_pages = math.ceil(10_000 / PAGE_SIZE)
|
||||
# Independent of PAGE_SIZE: the extension may return fewer items per page than
|
||||
# requested (byte budget), so a page-count guard derived from PAGE_SIZE would
|
||||
# falsely trip. Bound the page count by the absolute item cap instead.
|
||||
max_pages = MAX_PAGED_ITEMS
|
||||
pages_fetched = 0
|
||||
|
||||
while True:
|
||||
@@ -154,7 +156,7 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
|
||||
items.extend(page_items)
|
||||
total = data.get("total", total)
|
||||
next_offset = data.get("nextOffset")
|
||||
if next_offset is None:
|
||||
if next_offset is None or len(items) >= MAX_PAGED_ITEMS:
|
||||
break
|
||||
offset = int(next_offset)
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Per-process pool of authenticated remote connections for reuse.
|
||||
|
||||
A ``browser-cli serve`` connection stays open after its first (encrypted)
|
||||
command, so the client can send further commands over it without re-running the
|
||||
TCP/TLS/challenge/auth handshake (~hundreds of ms each). Only encrypted (PQ)
|
||||
sessions are pooled — plaintext/legacy sessions stay one-shot, matching the
|
||||
server, which only loops for encrypted sessions.
|
||||
|
||||
Connections are checked out exclusively (never shared between threads at once),
|
||||
returned on success, and dropped on any I/O error or once older than an idle
|
||||
bound (kept below the server's idle timeout so we don't reuse a connection the
|
||||
server has already closed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
|
||||
from browser_cli.framing import frame
|
||||
|
||||
# Retire a pooled connection a few seconds before the server would, so we never
|
||||
# hand back one the server has just timed out and closed.
|
||||
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
|
||||
_MAX_PER_ENDPOINT = 8
|
||||
|
||||
class PooledConnection:
|
||||
__slots__ = ("sock", "secret", "last_used")
|
||||
|
||||
def __init__(self, sock: socket.socket, secret: bytes) -> None:
|
||||
self.sock = sock
|
||||
self.secret = secret
|
||||
self.last_used = time.monotonic()
|
||||
|
||||
_POOL: dict[str, list[PooledConnection]] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
def _close(sock: socket.socket) -> None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def checkout(endpoint: str) -> PooledConnection | None:
|
||||
"""Take an idle authenticated connection for *endpoint*, or None."""
|
||||
now = time.monotonic()
|
||||
with _LOCK:
|
||||
conns = _POOL.get(endpoint)
|
||||
while conns:
|
||||
conn = conns.pop()
|
||||
if now - conn.last_used <= _MAX_IDLE_SECONDS:
|
||||
return conn
|
||||
_close(conn.sock) # too old — assume the server has dropped it
|
||||
return None
|
||||
|
||||
def checkin(endpoint: str, conn: PooledConnection) -> None:
|
||||
"""Return a still-healthy connection to the pool for reuse."""
|
||||
conn.last_used = time.monotonic()
|
||||
with _LOCK:
|
||||
bucket = _POOL.setdefault(endpoint, [])
|
||||
if len(bucket) >= _MAX_PER_ENDPOINT:
|
||||
_close(conn.sock)
|
||||
return
|
||||
bucket.append(conn)
|
||||
|
||||
def discard(conn: PooledConnection) -> None:
|
||||
"""Drop a connection that errored or is no longer usable."""
|
||||
_close(conn.sock)
|
||||
|
||||
def close_all() -> None:
|
||||
"""Close every pooled connection (process exit / test isolation)."""
|
||||
with _LOCK:
|
||||
for bucket in _POOL.values():
|
||||
for conn in bucket:
|
||||
_close(conn.sock)
|
||||
_POOL.clear()
|
||||
|
||||
def session_inner_message(msg: dict) -> dict:
|
||||
"""Strip auth/transport fields, leaving the command for an established session."""
|
||||
keep = {"id", "command", "args", "user_agent", "accept_encoding", "_route", "_suppress_pq_warning"}
|
||||
return {k: v for k, v in msg.items() if k in keep}
|
||||
|
||||
def send_over(conn: PooledConnection, msg: dict) -> bytes | None:
|
||||
"""Send one command over an existing encrypted session. Raises on I/O error."""
|
||||
from browser_cli.auth import pq_encrypt
|
||||
from browser_cli.remote.socket import recv_all
|
||||
from browser_cli.remote.transport import _decode_pq_response
|
||||
|
||||
inner = json.dumps(session_inner_message(msg)).encode("utf-8")
|
||||
envelope = json.dumps({"encrypted": pq_encrypt(conn.secret, "request", inner)}).encode("utf-8")
|
||||
conn.sock.sendall(frame(envelope))
|
||||
response = recv_all(conn.sock)
|
||||
if not response:
|
||||
# EOF — an older server (no session loop) closed after one command. Treat as
|
||||
# a transport failure so the caller re-handshakes; never as an app error,
|
||||
# which could double-execute a non-idempotent command on retry.
|
||||
raise EOFError("remote closed the pooled connection")
|
||||
return _decode_pq_response(response, conn.secret)
|
||||
|
||||
atexit.register(close_all)
|
||||
@@ -25,8 +25,8 @@ def split_endpoint(endpoint: str) -> tuple[str, int]:
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
return host, int(port_str)
|
||||
|
||||
@contextmanager
|
||||
def open_socket(endpoint: str):
|
||||
def connect_socket(endpoint: str) -> socket.socket:
|
||||
"""Open and (on :443) TLS-wrap a socket. Caller owns closing it."""
|
||||
host, port = split_endpoint(endpoint)
|
||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw_sock.settimeout(30)
|
||||
@@ -40,6 +40,11 @@ def open_socket(endpoint: str):
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
return sock
|
||||
|
||||
@contextmanager
|
||||
def open_socket(endpoint: str):
|
||||
sock = connect_socket(endpoint)
|
||||
with sock:
|
||||
yield sock
|
||||
|
||||
|
||||
@@ -20,24 +20,50 @@ from browser_cli.remote.auth import (
|
||||
from browser_cli.remote.socket import (
|
||||
async_recv_all as _async_recv_all,
|
||||
async_recv_exact_bytes as _async_recv_exact,
|
||||
connect_socket as _connect_socket,
|
||||
open_async_connection as _open_async_connection,
|
||||
open_socket as _open_socket,
|
||||
recv_all as _recv_all,
|
||||
recv_exact_bytes as _recv_exact,
|
||||
split_endpoint as _split_endpoint,
|
||||
)
|
||||
from browser_cli.remote import pool as _pool
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
# Reuse an already-authenticated connection when one is idle for this endpoint.
|
||||
conn = _pool.checkout(endpoint)
|
||||
if conn is not None:
|
||||
try:
|
||||
response = _pool.send_over(conn, msg)
|
||||
_pool.checkin(endpoint, conn)
|
||||
return response
|
||||
except (OSError, ConnectionError, ValueError, EOFError):
|
||||
_pool.discard(conn) # stale/closed — fall through to a fresh handshake
|
||||
|
||||
return _send_remote_handshake(endpoint, msg, private_key, warn_no_pq=warn_no_pq)
|
||||
|
||||
def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
||||
|
||||
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
||||
from browser_cli.auth import pq_kex_client_encapsulate
|
||||
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
|
||||
|
||||
with _open_socket(endpoint) as sock:
|
||||
sock = _connect_socket(endpoint)
|
||||
try:
|
||||
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
||||
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
||||
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
except BaseException:
|
||||
_pool._close(sock)
|
||||
raise
|
||||
# Only encrypted sessions are reusable — the server keeps those open, and a
|
||||
# fresh AEAD nonce per frame keeps reuse of the shared secret safe.
|
||||
if pq_shared_secret is not None:
|
||||
_pool.checkin(endpoint, _pool.PooledConnection(sock, pq_shared_secret))
|
||||
else:
|
||||
_pool._close(sock)
|
||||
return response
|
||||
|
||||
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
reader, writer = await _open_async_connection(endpoint)
|
||||
|
||||
@@ -114,7 +114,7 @@ class NavigationNS(Namespace):
|
||||
) -> None:
|
||||
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
|
||||
from urllib.parse import quote_plus
|
||||
from browser_cli.commands.search import ENGINES
|
||||
from browser_cli.search.engines import ENGINES
|
||||
template = ENGINES.get(engine)
|
||||
if template is None:
|
||||
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
|
||||
|
||||
@@ -7,12 +7,14 @@ helpers; single-browser mode falls straight through to ``_cmd``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING, Protocol, cast
|
||||
|
||||
from browser_cli.client import BrowserTarget
|
||||
from browser_cli.client.core import _run_concurrent
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.models import BrowserCounts, Tab
|
||||
|
||||
@@ -81,18 +83,28 @@ class RoutingMixin:
|
||||
return targets
|
||||
|
||||
def _collect_multi_browser(self, command: str, args: dict | None = None):
|
||||
results = []
|
||||
targets = self._multi_browser_targets()
|
||||
for target in targets:
|
||||
try:
|
||||
|
||||
def _send(target: BrowserTarget):
|
||||
package = _browser_cli_package()
|
||||
if target.remote:
|
||||
data = _browser_cli_package().send_command(
|
||||
return package.send_command(
|
||||
command, args, profile=target.profile, remote=target.remote, key=self._client._key
|
||||
)
|
||||
else:
|
||||
data = _browser_cli_package().send_command(command, args, profile=target.profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
return package.send_command(command, args, profile=target.profile)
|
||||
|
||||
# Run per-target roundtrips concurrently — each is a blocking, network-bound
|
||||
# send_command, so offloading to threads gives real overlap while still
|
||||
# invoking the (test-patchable) sync entry point.
|
||||
raw = _run_concurrent([
|
||||
(lambda t=t: asyncio.to_thread(_send, t)) for t in targets
|
||||
])
|
||||
results = []
|
||||
for target, data in zip(targets, raw):
|
||||
if isinstance(data, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(data, BaseException):
|
||||
raise data
|
||||
results.append((target, data))
|
||||
if results:
|
||||
return results
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Search metadata and helpers shared by SDK and CLI layers."""
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Shared search-engine metadata for SDK and CLI search commands."""
|
||||
from __future__ import annotations
|
||||
|
||||
ENGINES = {
|
||||
"google": "https://www.google.com/search?q={query}",
|
||||
"brave": "https://search.brave.com/search?q={query}",
|
||||
"duckduckgo": "https://duckduckgo.com/?q={query}",
|
||||
"ddg": "https://duckduckgo.com/?q={query}",
|
||||
"youtube": "https://www.youtube.com/results?search_query={query}",
|
||||
"yt": "https://www.youtube.com/results?search_query={query}",
|
||||
"spotify": "https://open.spotify.com/search/{query}",
|
||||
"amazon": "https://www.amazon.com/s?k={query}",
|
||||
"ecosia": "https://www.ecosia.org/search?q={query}",
|
||||
"furaffinity": "https://www.furaffinity.net/search/?q={query}",
|
||||
"fa": "https://www.furaffinity.net/search/?q={query}",
|
||||
"bing": "https://www.bing.com/search?q={query}",
|
||||
"github": "https://github.com/search?q={query}",
|
||||
"wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
|
||||
"reddit": "https://www.reddit.com/search/?q={query}",
|
||||
"stackoverflow": "https://stackoverflow.com/search?q={query}",
|
||||
"so": "https://stackoverflow.com/search?q={query}",
|
||||
}
|
||||
|
||||
DISPLAY_NAMES = {
|
||||
"google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
|
||||
"ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
|
||||
"spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
|
||||
"furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
|
||||
"github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
|
||||
"reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
|
||||
}
|
||||
|
||||
SUBCOMMANDS = [
|
||||
("google", "Search with Google."),
|
||||
("brave", "Search with Brave Search."),
|
||||
("duckduckgo", "Search with DuckDuckGo."),
|
||||
("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
|
||||
("youtube", "Search YouTube videos."),
|
||||
("yt", "Search YouTube (alias for youtube)."),
|
||||
("spotify", "Search Spotify."),
|
||||
("amazon", "Search Amazon."),
|
||||
("ecosia", "Search with Ecosia."),
|
||||
("furaffinity", "Search FurAffinity."),
|
||||
("fa", "Search FurAffinity (alias for furaffinity)."),
|
||||
("bing", "Search with Bing."),
|
||||
("github", "Search GitHub."),
|
||||
("wikipedia", "Search Wikipedia."),
|
||||
("wiki", "Search Wikipedia (alias for wikipedia)."),
|
||||
("reddit", "Search Reddit."),
|
||||
("stackoverflow", "Search Stack Overflow."),
|
||||
("so", "Search Stack Overflow (alias for stackoverflow)."),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ class ServeControlMixin:
|
||||
addr: tuple
|
||||
command: str
|
||||
auth_keys_path: Path | None
|
||||
auth_label: str | None
|
||||
|
||||
async def send_error(self, msg: str, msg_id=None) -> None: ...
|
||||
async def send_ok(self, payload, command: str | None = None) -> None: ...
|
||||
@@ -23,29 +24,39 @@ class ServeControlMixin:
|
||||
try:
|
||||
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
|
||||
if clients:
|
||||
browser_name = clients[0].get("name")
|
||||
if browser_name:
|
||||
item["browserName"] = browser_name
|
||||
# Carry the full client info so a remote `clients` command can render
|
||||
# from this single roundtrip instead of issuing another clients.list.
|
||||
info = clients[0]
|
||||
for src, dst in (("name", "browserName"), ("version", "version"), ("extensionVersion", "extensionVersion")):
|
||||
value = info.get(src)
|
||||
if value:
|
||||
item[dst] = value
|
||||
except Exception:
|
||||
pass
|
||||
targets.append(item)
|
||||
await self.send_ok(targets, self.command)
|
||||
log_request(self.addr, self.command, None, "OK")
|
||||
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
|
||||
return True
|
||||
|
||||
if self.command == "browser-cli.auth.keys":
|
||||
if self.auth_keys_path is None:
|
||||
await self.send_error("no authorized keys file configured on this server")
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file", identity=self.auth_label)
|
||||
return True
|
||||
from browser_cli.auth import load_authorized_keys_with_names
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(self.auth_keys_path)]
|
||||
from browser_cli.auth import load_authorized_keys_with_policies
|
||||
entries = [
|
||||
{"pubkey": pk, "name": name, "allow": cats}
|
||||
for pk, name, cats in load_authorized_keys_with_policies(self.auth_keys_path)
|
||||
]
|
||||
await self.send_ok(entries, self.command)
|
||||
log_request(self.addr, self.command, None, "OK")
|
||||
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
|
||||
return True
|
||||
|
||||
if self.command == "browser-cli.auth.trust":
|
||||
return await self._handle_trust(msg)
|
||||
|
||||
if self.command == "browser-cli.auth.policy":
|
||||
return await self._handle_policy(msg)
|
||||
return False
|
||||
|
||||
async def _handle_trust(self, msg: dict) -> bool:
|
||||
@@ -57,11 +68,59 @@ class ServeControlMixin:
|
||||
args = msg.get("args") or {}
|
||||
pubkey = str(args.get("pubkey") or "")
|
||||
name = str(args.get("name") or "")
|
||||
categories = args.get("allow")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
|
||||
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
|
||||
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey")
|
||||
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
|
||||
return True
|
||||
added = add_authorized_key(self.auth_keys_path, pubkey, name)
|
||||
if not await self._validate_categories(categories):
|
||||
return True
|
||||
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
|
||||
await self.send_ok({"added": added}, self.command)
|
||||
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED")
|
||||
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED", identity=self.auth_label)
|
||||
return True
|
||||
|
||||
async def _handle_policy(self, msg: dict) -> bool:
|
||||
if self.auth_keys_path is None:
|
||||
await self.send_error("no authorized keys file configured on this server")
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||
return True
|
||||
from browser_cli.auth import set_authorized_key_policy
|
||||
args = msg.get("args") or {}
|
||||
identifier = str(args.get("identifier") or "")
|
||||
categories = args.get("allow")
|
||||
if not identifier.strip():
|
||||
await self.send_error("missing key identifier")
|
||||
log_request(self.addr, self.command, None, "ERROR", "missing identifier", identity=self.auth_label)
|
||||
return True
|
||||
if not await self._validate_categories(categories):
|
||||
return True
|
||||
try:
|
||||
updated = set_authorized_key_policy(self.auth_keys_path, identifier, categories)
|
||||
except ValueError as exc:
|
||||
await self.send_error(str(exc))
|
||||
log_request(self.addr, self.command, None, "ERROR", "ambiguous key", identity=self.auth_label)
|
||||
return True
|
||||
if updated is None:
|
||||
await self.send_error(f"trusted key not found: {identifier}")
|
||||
log_request(self.addr, self.command, None, "ERROR", "key not found", identity=self.auth_label)
|
||||
return True
|
||||
pubkey, name = updated
|
||||
await self.send_ok({"updated": True, "pubkey": pubkey, "name": name, "allow": categories}, self.command)
|
||||
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
|
||||
return True
|
||||
|
||||
async def _validate_categories(self, categories) -> bool:
|
||||
if categories is not None and not isinstance(categories, list):
|
||||
await self.send_error("invalid allow: expected a list of category strings")
|
||||
log_request(self.addr, self.command, None, "ERROR", "invalid allow", identity=self.auth_label)
|
||||
return False
|
||||
if categories is not None:
|
||||
from browser_cli.serve.security import policy_from_categories
|
||||
try:
|
||||
policy_from_categories(categories) # validate before persisting
|
||||
except ValueError as exc:
|
||||
await self.send_error(str(exc))
|
||||
log_request(self.addr, self.command, None, "ERROR", "invalid allow category", identity=self.auth_label)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -6,11 +6,19 @@ from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
def log_request(addr: tuple, command: str, profile: str | None, status: str, error: str | None = None) -> None:
|
||||
def log_request(
|
||||
addr: tuple,
|
||||
command: str,
|
||||
profile: str | None,
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
identity: str | None = None,
|
||||
) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
addr_str = f"{addr[0]}:{addr[1]}"
|
||||
identity_str = f"[magenta]{identity}[/magenta] " if identity else ""
|
||||
profile_str = f"[dim]{profile}[/dim] " if profile else ""
|
||||
if error:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {identity_str}{profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
|
||||
else:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {identity_str}{profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||
|
||||
@@ -18,6 +18,7 @@ class ServeProxyMixin:
|
||||
command: str
|
||||
compress: bool
|
||||
accept_encoding: dict | None
|
||||
auth_label: str | None
|
||||
|
||||
async def send_error(self, msg: str, msg_id=None) -> None: ...
|
||||
async def send_payload(self, data: bytes) -> None: ...
|
||||
@@ -35,7 +36,7 @@ class ServeProxyMixin:
|
||||
sock_path = resolve_socket(resolved_profile)
|
||||
except BrowserNotConnected as e:
|
||||
await self.send_error(str(e))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected")
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected", identity=self.auth_label)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -46,7 +47,7 @@ class ServeProxyMixin:
|
||||
await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile)
|
||||
except (OSError, json.JSONDecodeError, ConnectionError) as e:
|
||||
await self.send_error(str(e))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e), identity=self.auth_label)
|
||||
|
||||
async def _windows_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
@@ -74,6 +75,6 @@ class ServeProxyMixin:
|
||||
else:
|
||||
await self.send_payload(resp_payload)
|
||||
if resp_data.get("success", True):
|
||||
log_request(self.addr, self.command, resolved_profile, "OK")
|
||||
log_request(self.addr, self.command, resolved_profile, "OK", identity=self.auth_label)
|
||||
else:
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""), identity=self.auth_label)
|
||||
|
||||
@@ -9,17 +9,20 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.command_security import assert_command_allowed
|
||||
from browser_cli.compat import adapt_auth
|
||||
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
|
||||
from browser_cli.framing import async_recv_frame, async_send_frame
|
||||
from browser_cli.serve.auth import ServeAuthMixin
|
||||
from browser_cli.serve.challenge import build_challenge as _build_challenge, load_auth_keys as _load_auth_keys
|
||||
from browser_cli.serve.control import ServeControlMixin
|
||||
from browser_cli.serve.logging import console, log_request
|
||||
from browser_cli.serve.proxy import ServeProxyMixin
|
||||
from browser_cli.serve.security import ServeSecurity
|
||||
|
||||
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
|
||||
await async_send_frame(writer, data)
|
||||
@@ -38,12 +41,15 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
|
||||
nonce: str
|
||||
pq_private_key: object | None = None
|
||||
compress: bool = True
|
||||
security: ServeSecurity = field(default_factory=ServeSecurity)
|
||||
|
||||
response_secret: bytes | None = None
|
||||
accept_encoding: dict | None = None
|
||||
client_ver: str = "0"
|
||||
msg_id: object = None
|
||||
command: str = "?"
|
||||
auth_pubkey: str | None = None
|
||||
auth_label: str | None = None
|
||||
|
||||
async def send_payload(self, data: bytes) -> None:
|
||||
if self.response_secret is not None:
|
||||
@@ -89,11 +95,73 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
|
||||
msg = await self.authenticate(msg)
|
||||
if msg is None:
|
||||
return
|
||||
self._apply_identity(msg)
|
||||
await self._dispatch(msg)
|
||||
# Once an encrypted session is established, keep serving further commands on
|
||||
# the same connection — the client may reuse it without re-authenticating.
|
||||
# Safe because every frame carries a fresh AEAD nonce (see pq_encrypt).
|
||||
while self.response_secret is not None:
|
||||
nxt = await self._read_session_message()
|
||||
if nxt is None:
|
||||
return
|
||||
await self._dispatch(nxt)
|
||||
|
||||
def _apply_identity(self, msg: dict) -> None:
|
||||
"""Record the authenticated pubkey (if any) for per-key policy and audit logs."""
|
||||
pub = (msg.get("pubkey") or "").strip().lower()
|
||||
self.auth_pubkey = pub or None
|
||||
self.auth_label = self.security.label_for(self.auth_pubkey)
|
||||
|
||||
async def _enforce_rate_limit(self) -> bool:
|
||||
limiter = self.security.rate_limiter
|
||||
if limiter is None or limiter.allow(self.auth_pubkey or str(self.addr[0])):
|
||||
return True
|
||||
await self.send_error("rate limit exceeded; slow down and retry")
|
||||
log_request(self.addr, self.command, None, "DENIED", "rate limit exceeded", identity=self.auth_label)
|
||||
return False
|
||||
|
||||
async def _dispatch(self, msg: dict) -> None:
|
||||
self.accept_encoding = msg.get("accept_encoding")
|
||||
if not await self._enforce_rate_limit():
|
||||
return
|
||||
# Gate every command — including server control commands like the key-management
|
||||
# ones — so the policy is enforced before handle_control_command acts on it.
|
||||
try:
|
||||
assert_command_allowed(self.command, self.security.effective_policy(self.auth_pubkey))
|
||||
except PermissionError as exc:
|
||||
await self.send_error(str(exc))
|
||||
log_request(self.addr, self.command, None, "DENIED", "blocked by command policy", identity=self.auth_label)
|
||||
return
|
||||
if await self.handle_control_command(msg):
|
||||
return
|
||||
await self.forward_to_browser(msg)
|
||||
|
||||
async def _read_session_message(self) -> dict | None:
|
||||
"""Read the next command on an established encrypted session, or None to close."""
|
||||
try:
|
||||
payload = await asyncio.wait_for(_async_recv_all(self.reader), timeout=REMOTE_SESSION_IDLE_TIMEOUT)
|
||||
except (asyncio.TimeoutError, ConnectionError, OSError):
|
||||
return None
|
||||
if not payload:
|
||||
return None
|
||||
try:
|
||||
outer = json.loads(payload)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(outer, dict) or "encrypted" not in outer:
|
||||
return None # an authenticated session only accepts encrypted frames
|
||||
from browser_cli.auth import pq_decrypt
|
||||
try:
|
||||
inner = json.loads(pq_decrypt(self.response_secret, "request", outer["encrypted"]))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(inner, dict):
|
||||
return None
|
||||
inner = adapt_auth(inner, self.client_ver)
|
||||
self.msg_id = inner.get("id")
|
||||
self.command = inner.get("command", "?")
|
||||
return inner
|
||||
|
||||
async def _async_proxy_request(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
@@ -104,8 +172,12 @@ async def _async_proxy_request(
|
||||
nonce: str,
|
||||
pq_private_key=None,
|
||||
compress: bool = True,
|
||||
security: ServeSecurity | None = None,
|
||||
) -> None:
|
||||
await ServeRequest(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress).run()
|
||||
await ServeRequest(
|
||||
reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress,
|
||||
security if security is not None else ServeSecurity(),
|
||||
).run()
|
||||
|
||||
async def _async_handle_client(
|
||||
reader: asyncio.StreamReader,
|
||||
@@ -115,6 +187,7 @@ async def _async_handle_client(
|
||||
auth_keys_path: Path | None,
|
||||
compress: bool = True,
|
||||
conn_limit: asyncio.Semaphore | None = None,
|
||||
security: ServeSecurity | None = None,
|
||||
) -> None:
|
||||
if conn_limit is None:
|
||||
conn_limit = asyncio.Semaphore(64)
|
||||
@@ -130,7 +203,7 @@ async def _async_handle_client(
|
||||
await _async_framed_send(writer, json.dumps(challenge_msg).encode())
|
||||
except OSError:
|
||||
return
|
||||
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
|
||||
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress, security)
|
||||
finally:
|
||||
conn_limit.release()
|
||||
writer.close()
|
||||
@@ -145,12 +218,13 @@ def _handle_client(
|
||||
profile: str | None,
|
||||
auth_keys_path: Path | None,
|
||||
compress: bool = True,
|
||||
security: ServeSecurity | None = None,
|
||||
) -> None:
|
||||
"""Run one accepted socket through the async serve pipeline."""
|
||||
|
||||
async def _run() -> None:
|
||||
reader, writer = await asyncio.open_connection(sock=client_sock)
|
||||
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress)
|
||||
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress, None, security)
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
@@ -160,12 +234,19 @@ def _handle_client(
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def _serve_async(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
|
||||
async def _serve_async(
|
||||
host: str,
|
||||
port: int,
|
||||
profile: str | None,
|
||||
auth_keys_path: Path | None,
|
||||
compress: bool,
|
||||
security: ServeSecurity | None = None,
|
||||
) -> None:
|
||||
conn_limit = asyncio.Semaphore(64)
|
||||
|
||||
async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
peer = writer.get_extra_info("peername") or ("?", 0)
|
||||
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit)
|
||||
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit, security)
|
||||
|
||||
server = await asyncio.start_server(_client_connected, host, port, backlog=16)
|
||||
async with server:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Server-side authorization, per-key policy and rate limiting for ``browser-cli serve``.
|
||||
|
||||
This bundles the three serve-time security concerns that travel together through
|
||||
the connection-handling chain:
|
||||
|
||||
- ``policy`` the server-wide default ``CommandPolicy`` (from ``--allow-*``)
|
||||
- ``key_policies`` optional per-pubkey overrides parsed from the ``allow:`` token
|
||||
in the ``authorized_keys`` file
|
||||
- ``key_names`` pubkey -> friendly name (from authorized_keys), for audit logs
|
||||
- ``rate_limiter`` optional per-identity token-bucket throttle
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
|
||||
# ── per-key authorization ───────────────────────────────────────────────────────
|
||||
|
||||
_CATEGORY_FLAGS = {
|
||||
"read-page": "allow_read_page",
|
||||
"control": "allow_control",
|
||||
"dangerous": "allow_dangerous",
|
||||
"keys": "allow_keys",
|
||||
}
|
||||
|
||||
def policy_from_categories(categories) -> CommandPolicy:
|
||||
"""Build a CommandPolicy from category strings (``all``/``safe``/``read-page``/``control``/``dangerous``)."""
|
||||
cats = [str(c).strip().lower() for c in categories]
|
||||
if "all" in cats:
|
||||
return CommandPolicy.unrestricted()
|
||||
kwargs: dict[str, bool] = {}
|
||||
for cat in cats:
|
||||
if cat in ("", "safe"):
|
||||
continue
|
||||
flag = _CATEGORY_FLAGS.get(cat)
|
||||
if flag is None:
|
||||
raise ValueError(
|
||||
f"unknown command category {cat!r}; expected one of: all, safe, read-page, control, dangerous"
|
||||
)
|
||||
kwargs[flag] = True
|
||||
return CommandPolicy(**kwargs)
|
||||
|
||||
def key_policies_from_authorized_keys(path: Path | str | None) -> dict[str, CommandPolicy]:
|
||||
"""Build ``{pubkey: CommandPolicy}`` from the ``allow:`` tokens in authorized_keys.
|
||||
|
||||
Only keys that carry an explicit ``allow:`` token get an entry; keys without
|
||||
one fall back to the server-wide default policy. Pubkeys are normalised to
|
||||
lowercase hex. Raises ``ValueError`` on an unknown category so the server fails
|
||||
loudly at startup rather than silently mis-gating.
|
||||
"""
|
||||
if path is None:
|
||||
return {}
|
||||
from browser_cli.auth import load_authorized_keys_with_policies
|
||||
|
||||
out: dict[str, CommandPolicy] = {}
|
||||
for pubkey, _name, categories in load_authorized_keys_with_policies(Path(path)):
|
||||
if categories is not None:
|
||||
out[pubkey.strip().lower()] = policy_from_categories(categories)
|
||||
return out
|
||||
|
||||
# ── per-identity rate limiting ───────────────────────────────────────────────────
|
||||
|
||||
class RateLimiter:
|
||||
"""Token bucket keyed by identity (pubkey, or client address when unauthenticated).
|
||||
|
||||
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
|
||||
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
|
||||
Thread-safe so it can be shared across all connections of one serve process.
|
||||
"""
|
||||
|
||||
def __init__(self, rate: float, burst: float | None = None) -> None:
|
||||
self.rate = float(rate)
|
||||
self.capacity = float(burst) if burst is not None else max(float(rate), 1.0)
|
||||
self._buckets: dict[str, tuple[float, float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
if self.rate <= 0:
|
||||
return True
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
tokens, last = self._buckets.get(key, (self.capacity, now))
|
||||
tokens = min(self.capacity, tokens + (now - last) * self.rate)
|
||||
if tokens < 1.0:
|
||||
self._buckets[key] = (tokens, now)
|
||||
return False
|
||||
self._buckets[key] = (tokens - 1.0, now)
|
||||
return True
|
||||
|
||||
# ── bundled server security context ──────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServeSecurity:
|
||||
policy: CommandPolicy = field(default_factory=CommandPolicy.unrestricted)
|
||||
key_policies: dict[str, CommandPolicy] = field(default_factory=dict)
|
||||
key_names: dict[str, str] = field(default_factory=dict)
|
||||
rate_limiter: RateLimiter | None = None
|
||||
|
||||
def effective_policy(self, pubkey: str | None) -> CommandPolicy:
|
||||
"""Per-key override if one exists for this pubkey, else the server default."""
|
||||
if pubkey and pubkey in self.key_policies:
|
||||
return self.key_policies[pubkey]
|
||||
return self.policy
|
||||
|
||||
def label_for(self, pubkey: str | None) -> str | None:
|
||||
"""Audit label for log lines: ``<name> <short-pubkey>…`` or just the short pubkey."""
|
||||
if not pubkey:
|
||||
return None
|
||||
short = f"{pubkey[:8]}…"
|
||||
name = self.key_names.get(pubkey, "")
|
||||
return f"{name} {short}".strip() if name else short
|
||||
@@ -1,4 +1,5 @@
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT, PYPI_PACKAGE_NAME
|
||||
|
||||
@@ -14,4 +15,19 @@ def get_installed_version() -> str:
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
def project_version() -> str:
|
||||
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
try:
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
if line.startswith("version = "):
|
||||
return line.split('"')[1]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _pkg_version(PYPI_PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
USER_AGENT = f"browser-cli/{get_installed_version()}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "browser-cli",
|
||||
"version": "0.15.6",
|
||||
"version": "0.16.3",
|
||||
"description": "Control your browser from the terminal or Python SDK",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
||||
@@ -17,17 +17,33 @@ function isCommandSpec(entry: CommandEntry): entry is CommandSpec {
|
||||
return typeof entry !== "function";
|
||||
}
|
||||
|
||||
// Fill each page up to a byte budget kept safely under the 1MB native-messaging
|
||||
// limit (extension → host). This makes paging adaptive: many small items pack
|
||||
// into one page, while a few oversized items (e.g. data-URI favicons) split
|
||||
// across pages instead of overflowing the limit.
|
||||
const PAGE_BYTE_BUDGET = 768 * 1024;
|
||||
|
||||
export function makePagedData(items: Serializable[], page: PageRequest) {
|
||||
const total = items.length;
|
||||
const offset = Math.max(0, Number(page.offset) || 0);
|
||||
const requestedLimit = Math.max(1, Number(page.limit) || 100);
|
||||
const limit = Math.min(requestedLimit, 1000);
|
||||
const end = Math.min(offset + limit, total);
|
||||
const maxCount = Math.min(requestedLimit, 1000);
|
||||
|
||||
let end = offset;
|
||||
let bytes = 0;
|
||||
while (end < total && end - offset < maxCount) {
|
||||
const itemBytes = JSON.stringify(items[end]).length + 1; // +1 ≈ separator
|
||||
// Always include at least one item so a single oversized item still advances.
|
||||
if (end > offset && bytes + itemBytes > PAGE_BYTE_BUDGET) break;
|
||||
bytes += itemBytes;
|
||||
end++;
|
||||
}
|
||||
|
||||
return {
|
||||
__browserCliPage: true,
|
||||
items: items.slice(offset, end),
|
||||
offset,
|
||||
limit,
|
||||
limit: maxCount,
|
||||
total,
|
||||
nextOffset: end < total ? end : null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-nocheck
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makePagedData } from '../src/classes/CommandRegistry';
|
||||
|
||||
test('makePagedData packs many small items into one page up to the count cap', () => {
|
||||
const items = Array.from({ length: 1500 }, (_, i) => ({ id: i, url: 'chrome://newtab/' }));
|
||||
const page = makePagedData(items, { offset: 0, limit: 1000 });
|
||||
|
||||
assert.equal(page.items.length, 1000); // count cap, well under the byte budget
|
||||
assert.equal(page.nextOffset, 1000);
|
||||
assert.equal(page.total, 1500);
|
||||
});
|
||||
|
||||
test('makePagedData splits on the byte budget for oversized items', () => {
|
||||
// Each item ~200KB; only a few fit under the 768KB budget per page.
|
||||
const big = 'x'.repeat(200 * 1024);
|
||||
const items = Array.from({ length: 10 }, (_, i) => ({ id: i, favIconUrl: big }));
|
||||
const page = makePagedData(items, { offset: 0, limit: 1000 });
|
||||
|
||||
assert.ok(page.items.length >= 1 && page.items.length < 10, `expected partial page, got ${page.items.length}`);
|
||||
assert.equal(page.nextOffset, page.items.length);
|
||||
});
|
||||
|
||||
test('makePagedData always advances by at least one item', () => {
|
||||
// A single item larger than the whole budget must still be returned alone.
|
||||
const huge = 'x'.repeat(2 * 1024 * 1024);
|
||||
const items = [{ id: 0, favIconUrl: huge }, { id: 1 }];
|
||||
const page = makePagedData(items, { offset: 0, limit: 1000 });
|
||||
|
||||
assert.equal(page.items.length, 1);
|
||||
assert.equal(page.nextOffset, 1);
|
||||
});
|
||||
|
||||
test('makePagedData reports null nextOffset on the final page', () => {
|
||||
const items = [{ id: 0 }, { id: 1 }, { id: 2 }];
|
||||
const page = makePagedData(items, { offset: 2, limit: 1000 });
|
||||
|
||||
assert.equal(page.items.length, 1);
|
||||
assert.equal(page.nextOffset, null);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test-dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,89 @@
|
||||
# n8n-nodes-browser-cli
|
||||
An [n8n](https://n8n.io) community node, published on npm as [`n8n-nodes-browser-cli`](https://www.npmjs.com/package/n8n-nodes-browser-cli), that controls a **real, visible browser**
|
||||
from your workflows via [browser-cli](https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp).
|
||||
|
||||
browser-cli drives a running browser through a native-messaging host and a
|
||||
browser extension — it **cannot be installed inside the n8n container**. So this
|
||||
node speaks the `browser-cli serve` protocol **directly**: a length-framed TCP
|
||||
connection authenticated with an Ed25519 key, with request/response bodies
|
||||
encrypted end-to-end via an ML-KEM-768 (post-quantum) key exchange — the same
|
||||
wire protocol the `browser-cli --remote` client uses.
|
||||
|
||||
```
|
||||
n8n workflow ──TCP (Ed25519 + ML-KEM-768)──▶ browser-cli serve (remote host) ──▶ browser
|
||||
```
|
||||
|
||||
Because the payloads are end-to-end encrypted, the endpoint is safe to expose on
|
||||
an untrusted network without a TLS proxy in front of it.
|
||||
|
||||
## Remote setup (on the browser machine)
|
||||
Install browser-cli, register the extension, trust your n8n key, then start
|
||||
`serve` opening exactly the command tiers you need (it is **safe-only by default**):
|
||||
|
||||
```bash
|
||||
uv tool install real-browser-cli
|
||||
browser-cli install brave # one-time: register the extension/native host
|
||||
|
||||
# On the n8n side, generate a client key and print its public key:
|
||||
browser-cli auth keygen -o n8n_key.pem
|
||||
|
||||
# On the browser machine, trust that public key (optionally scope its policy):
|
||||
browser-cli auth trust <pubkey-hex> --allow-control
|
||||
|
||||
# Expose the browser. Open only what your workflow needs:
|
||||
browser-cli serve --host 0.0.0.0 --port 8765 \
|
||||
--authorized-keys ~/.browser_cli/authorized_keys --allow-read-page --allow-control
|
||||
```
|
||||
|
||||
Paste the contents of `n8n_key.pem` into the n8n credential.
|
||||
|
||||
## n8n credential — "Browser CLI API"
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Host | host of the `serve` endpoint, e.g. `browser-host.example` |
|
||||
| Port | `serve` TCP port (default `8765`) |
|
||||
| Ed25519 Private Key | PKCS8 PEM from `browser-cli auth keygen` (empty only for `--no-auth` loopback) |
|
||||
| Browser Alias | optional `_route` target — required if the endpoint serves multiple browsers |
|
||||
| Use TLS | wrap the connection in TLS (only for a TLS-terminating proxy; the protocol is already encrypted) |
|
||||
| Ignore SSL Issues | when TLS is on, accept a self-signed proxy cert |
|
||||
|
||||
## Operations
|
||||
Every operation maps to one raw browser-cli command, each subject to the server
|
||||
policy tier noted below.
|
||||
|
||||
| Resource | Operation | Command | Server flag needed |
|
||||
|----------|-----------|---------|--------------------|
|
||||
| Tab | List | `tabs.list` | safe (default) |
|
||||
| Tab | Open | `navigate.open` | `--allow-control` |
|
||||
| Tab | Close | `tabs.close` (ids / inactive / duplicates) | `--allow-control` |
|
||||
| Tab | Get HTML | `tabs.html` | `--allow-read-page` |
|
||||
| Page | Get Info | `page.info` | safe (default) |
|
||||
| Page | Extract Text / Links / Images / HTML / Markdown | `extract.*` | `--allow-read-page` |
|
||||
| DOM | Query | `dom.query` | `--allow-read-page` |
|
||||
| DOM | Click / Type | `dom.click` / `dom.type` | `--allow-control` |
|
||||
| DOM | Eval | `dom.eval` | `--allow-dangerous` |
|
||||
| Client | List | `clients.list` | safe (default) |
|
||||
| Command | Execute | any command name + JSON args | per command |
|
||||
| Gateway | Health | pings with `tabs.list` | safe (default) |
|
||||
|
||||
**Command → Execute** is the escape hatch: any command string the server policy
|
||||
allows (`tabs.query`, `session.save`, `windows.list`, …) with a JSON args object.
|
||||
Use it for anything the typed operations don't cover.
|
||||
|
||||
> Note: `serve` returns the **raw** command result (no SDK post-processing).
|
||||
> `extract.markdown` therefore returns the page payload as the extension hands it
|
||||
> back, not the CLI's rendered Markdown. For clean text use **Extract Text**.
|
||||
|
||||
## Develop / build
|
||||
```bash
|
||||
cd n8n-nodes-browser-cli
|
||||
npm install # add --ignore-scripts if a transitive native dep
|
||||
# (isolated-vm) fails to compile on your Node version
|
||||
npm test # pure unit tests: command mapping + crypto known-answer vectors
|
||||
npm run build # tsc -> dist/, copies the icon
|
||||
```
|
||||
|
||||
Install the published package in n8n as a [community node](https://docs.n8n.io/integrations/community-nodes/installation/) using the package name `n8n-nodes-browser-cli`, or symlink `dist/` into `~/.n8n/custom` for local testing.
|
||||
|
||||
## License
|
||||
PolyForm Noncommercial License 1.0.0 — same as browser-cli.
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Credentials for a raw `browser-cli serve` endpoint.
|
||||
*
|
||||
* browser-cli cannot be installed inside n8n, so the node talks directly to a
|
||||
* `serve` instance running on the machine that drives the browser. Start it
|
||||
* there and trust this client's key:
|
||||
*
|
||||
* browser-cli auth keygen # on the n8n side, prints a PEM
|
||||
* browser-cli auth trust <pubkey> --allow-control # on the serve side
|
||||
* browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.browser_cli/authorized_keys
|
||||
*
|
||||
* The connection is authenticated with the Ed25519 private key below and the
|
||||
* request/response bodies are encrypted with an ML-KEM-768 (post-quantum) key
|
||||
* exchange, so it is safe to expose over an untrusted network without TLS.
|
||||
* Leave the key empty only for a loopback `serve --no-auth` instance.
|
||||
*/
|
||||
export class BrowserCliApi implements ICredentialType {
|
||||
name = 'browserCliApi';
|
||||
|
||||
displayName = 'Browser CLI API';
|
||||
|
||||
documentationUrl = 'https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp';
|
||||
|
||||
// The serve protocol is raw TCP, not HTTP, so the declarative HTTP test does
|
||||
// not apply — testing is done by the node method of this name, which runs a
|
||||
// real authenticated handshake against the endpoint.
|
||||
testedBy = 'browserCliApiTest';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Host',
|
||||
name: 'host',
|
||||
type: 'string',
|
||||
default: '127.0.0.1',
|
||||
placeholder: 'browser-host.example',
|
||||
required: true,
|
||||
description: 'Host of the remote `browser-cli serve` endpoint',
|
||||
},
|
||||
{
|
||||
displayName: 'Port',
|
||||
name: 'port',
|
||||
type: 'number',
|
||||
default: 8765,
|
||||
required: true,
|
||||
description: 'TCP port the `serve` endpoint listens on',
|
||||
},
|
||||
{
|
||||
displayName: 'Ed25519 Private Key',
|
||||
name: 'privateKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true, rows: 4 },
|
||||
default: '',
|
||||
placeholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
|
||||
description:
|
||||
'PKCS8 PEM Ed25519 private key (from `browser-cli auth keygen`) whose public key is trusted by the serve endpoint. Leave empty only for a loopback `serve --no-auth` instance.',
|
||||
},
|
||||
{
|
||||
displayName: 'Browser Alias',
|
||||
name: 'browser',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'main',
|
||||
description:
|
||||
'Optional browser alias to route to (the serve `_route` target). Required when the serve endpoint exposes multiple browser instances; leave empty for a single-browser serve.',
|
||||
},
|
||||
{
|
||||
displayName: 'Use TLS',
|
||||
name: 'tls',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to wrap the connection in TLS. Only needed when `serve` sits behind a TLS-terminating proxy; the protocol is already end-to-end encrypted via post-quantum key exchange.',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore SSL Issues',
|
||||
name: 'allowUnauthorizedCerts',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: { show: { tls: [true] } },
|
||||
description: 'Whether to connect even when the TLS certificate cannot be verified (e.g. a self-signed proxy)',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import type {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
IDisplayOptions,
|
||||
INodeCredentialTestResult,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { buildCommand, type CommandParams } from './request';
|
||||
import { sendServeCommand, type ServeConnectOptions } from './serveClient';
|
||||
|
||||
/** Only show a property for the given resource/operation combinations. */
|
||||
function showFor(resource: string, operations: string[]): NonNullable<IDisplayOptions['show']> {
|
||||
return { resource: [resource], operation: operations };
|
||||
}
|
||||
|
||||
export class BrowserCli implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Browser CLI',
|
||||
name: 'browserCli',
|
||||
icon: 'file:browserCli.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Control a remote browser by talking directly to a browser-cli serve endpoint',
|
||||
defaults: { name: 'Browser CLI' },
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
usableAsTool: true,
|
||||
credentials: [{ name: 'browserCliApi', required: true }],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{ name: 'Tab', value: 'tab' },
|
||||
{ name: 'Page', value: 'page' },
|
||||
{ name: 'DOM', value: 'dom' },
|
||||
{ name: 'Client', value: 'client' },
|
||||
{ name: 'Command', value: 'command' },
|
||||
{ name: 'Gateway', value: 'gateway' },
|
||||
],
|
||||
default: 'tab',
|
||||
},
|
||||
|
||||
// --- Tab operations ---------------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['tab'] } },
|
||||
options: [
|
||||
{ name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' },
|
||||
{ name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' },
|
||||
{ name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' },
|
||||
{ name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' },
|
||||
],
|
||||
default: 'list',
|
||||
},
|
||||
|
||||
// --- Page operations --------------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['page'] } },
|
||||
options: [
|
||||
{ name: 'Get Info', value: 'info', action: 'Get page info', description: 'page.info (safe)' },
|
||||
{ name: 'Extract Text', value: 'extractText', action: 'Extract visible text', description: 'extract.text (needs --allow-read-page)' },
|
||||
{ name: 'Extract Links', value: 'extractLinks', action: 'Extract links', description: 'extract.links (needs --allow-read-page)' },
|
||||
{ name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' },
|
||||
{ name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' },
|
||||
{ name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' },
|
||||
],
|
||||
default: 'extractText',
|
||||
},
|
||||
|
||||
// --- DOM operations ---------------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['dom'] } },
|
||||
options: [
|
||||
{ name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' },
|
||||
{ name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' },
|
||||
{ name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' },
|
||||
{ name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
|
||||
],
|
||||
default: 'query',
|
||||
},
|
||||
|
||||
// --- Client operations ------------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['client'] } },
|
||||
options: [
|
||||
{ name: 'List', value: 'list', action: 'List connected browser clients', description: 'clients.list (safe)' },
|
||||
],
|
||||
default: 'list',
|
||||
},
|
||||
|
||||
// --- Command operations -----------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['command'] } },
|
||||
options: [
|
||||
{ name: 'Execute', value: 'execute', action: 'Execute a raw browser-cli command', description: 'Any command name (subject to server policy)' },
|
||||
],
|
||||
default: 'execute',
|
||||
},
|
||||
|
||||
// --- Gateway operations -----------------------------------------------
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['gateway'] } },
|
||||
options: [
|
||||
{ name: 'Health', value: 'health', action: 'Check serve connectivity', description: 'Pings the endpoint with tabs.list (safe)' },
|
||||
],
|
||||
default: 'health',
|
||||
},
|
||||
|
||||
// --- Shared parameter fields -----------------------------------------
|
||||
{
|
||||
displayName: 'URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'https://example.com',
|
||||
displayOptions: { show: showFor('tab', ['open']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Focus Tab',
|
||||
name: 'focus',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to focus the new tab/window (steals OS focus). Off opens in the background.',
|
||||
displayOptions: { show: showFor('tab', ['open']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Close By',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
default: 'ids',
|
||||
options: [
|
||||
{ name: 'Tab IDs', value: 'ids' },
|
||||
{ name: 'Inactive Tabs', value: 'inactive' },
|
||||
{ name: 'Duplicate Tabs', value: 'duplicates' },
|
||||
],
|
||||
displayOptions: { show: showFor('tab', ['close']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Tab IDs',
|
||||
name: 'tabIds',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: '123, 456',
|
||||
description: 'Comma/space separated tab IDs, or a JSON array',
|
||||
displayOptions: { show: { resource: ['tab'], operation: ['close'], mode: ['ids'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Tab ID',
|
||||
name: 'tabId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Target tab ID. Leave 0 for the active tab.',
|
||||
displayOptions: { show: { resource: ['tab'], operation: ['getHtml'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Selector',
|
||||
name: 'selector',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: '#main, .content',
|
||||
description: 'CSS selector. Leave empty on extract operations to use the whole page.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['dom', 'page'],
|
||||
operation: ['query', 'click', 'type', 'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: { show: showFor('dom', ['type']) },
|
||||
},
|
||||
{
|
||||
displayName: 'JavaScript',
|
||||
name: 'code',
|
||||
type: 'string',
|
||||
typeOptions: { rows: 4 },
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'return document.title',
|
||||
description: 'Evaluated in the page (dom.eval). The gateway must be started with --allow-dangerous.',
|
||||
displayOptions: { show: showFor('dom', ['eval']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Tab ID',
|
||||
name: 'tabId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Target tab ID. Leave 0 for the active tab.',
|
||||
displayOptions: { show: { resource: ['dom'], operation: ['eval'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Command',
|
||||
name: 'command',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'tabs.list',
|
||||
description: 'Raw browser-cli command name, e.g. tabs.list, navigate.open, extract.markdown',
|
||||
displayOptions: { show: showFor('command', ['execute']) },
|
||||
},
|
||||
{
|
||||
displayName: 'Arguments',
|
||||
name: 'args',
|
||||
type: 'json',
|
||||
default: '{}',
|
||||
description: 'JSON object of command arguments',
|
||||
displayOptions: { show: showFor('command', ['execute']) },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
credentialTest: {
|
||||
// Runs a real authenticated handshake so "Test" in the credential UI
|
||||
// reflects the actual serve protocol, not a stand-in HTTP request.
|
||||
async browserCliApiTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
const options = connectOptionsFromCredentials((credential.data ?? {}) as IDataObject);
|
||||
options.timeoutMs = 10_000;
|
||||
try {
|
||||
const response = await sendServeCommand(options, 'clients.list', {});
|
||||
if (response && response.success === false) {
|
||||
const message = String(response.error ?? 'serve returned an error');
|
||||
// A rejected key is a real credential failure; any other server-side
|
||||
// message means the handshake + auth already succeeded.
|
||||
if (/unauthorized|untrusted|invalid signature|pubkey auth required/i.test(message)) {
|
||||
return { status: 'Error', message };
|
||||
}
|
||||
return { status: 'OK', message: `Connected and authenticated. Note: ${message.split('\n')[0]}` };
|
||||
}
|
||||
return { status: 'OK', message: 'Connected to browser-cli serve' };
|
||||
} catch (error) {
|
||||
return { status: 'Error', message: (error as Error).message };
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const connectOptions = connectOptionsFromCredentials(
|
||||
(await this.getCredentials('browserCliApi')) as IDataObject,
|
||||
);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const resource = this.getNodeParameter('resource', i) as string;
|
||||
const operation = this.getNodeParameter('operation', i) as string;
|
||||
|
||||
const params = collectParams(this, resource, operation, i);
|
||||
const { command, args } = buildCommand(resource, operation, params);
|
||||
|
||||
const response = await sendServeCommand(connectOptions, command, args);
|
||||
if (response && response.success === false) {
|
||||
throw new Error(String(response.error || 'serve command failed'));
|
||||
}
|
||||
const data = response && typeof response === 'object' && 'data' in response ? response.data : response;
|
||||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
for (const row of rows) {
|
||||
returnData.push({
|
||||
json: isObject(row) ? (row as IDataObject) : { result: row },
|
||||
pairedItem: { item: i },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): boolean {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/** Map decrypted credential fields to serve connection options. */
|
||||
function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions {
|
||||
return {
|
||||
host: String(creds.host || '127.0.0.1'),
|
||||
port: Number(creds.port || 8765),
|
||||
tls: Boolean(creds.tls),
|
||||
rejectUnauthorized: !creds.allowUnauthorizedCerts,
|
||||
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
|
||||
route: creds.browser ? String(creds.browser) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the UI fields relevant to this operation into a plain params object. */
|
||||
function collectParams(
|
||||
ctx: IExecuteFunctions,
|
||||
resource: string,
|
||||
operation: string,
|
||||
i: number,
|
||||
): CommandParams {
|
||||
const get = (name: string, fallback: unknown = undefined) => ctx.getNodeParameter(name, i, fallback);
|
||||
const key = `${resource}:${operation}`;
|
||||
|
||||
switch (key) {
|
||||
case 'command:execute': {
|
||||
const raw = get('args', {});
|
||||
let args: Record<string, unknown> = {};
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim();
|
||||
args = trimmed ? JSON.parse(trimmed) : {};
|
||||
} else if (isObject(raw)) {
|
||||
args = raw as Record<string, unknown>;
|
||||
}
|
||||
return { command: get('command'), args };
|
||||
}
|
||||
case 'tab:open':
|
||||
return { url: get('url'), focus: get('focus', false) };
|
||||
case 'tab:close':
|
||||
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
|
||||
case 'tab:getHtml':
|
||||
return { tabId: get('tabId', 0) };
|
||||
case 'dom:query':
|
||||
case 'dom:click':
|
||||
return { selector: get('selector', '') };
|
||||
case 'dom:type':
|
||||
return { selector: get('selector', ''), text: get('text', '') };
|
||||
case 'dom:eval':
|
||||
return { code: get('code', ''), tabId: get('tabId', 0) };
|
||||
case 'page:extractText':
|
||||
case 'page:extractLinks':
|
||||
case 'page:extractImages':
|
||||
case 'page:extractHtml':
|
||||
case 'page:extractMarkdown':
|
||||
return { selector: get('selector', '') };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
|
||||
<title>browser-cli icon</title>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="16" y1="16" x2="112" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0f766e" />
|
||||
<stop offset="1" stop-color="#0f172a" />
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="32" y1="29" x2="96" y2="99" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#f8fafc" />
|
||||
<stop offset="1" stop-color="#cbd5e1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Chrome Web Store compliant: 96x96 artwork centered in 128x128 canvas. -->
|
||||
<rect x="16" y="16" width="96" height="96" rx="24" fill="url(#bg)" />
|
||||
<rect x="17" y="17" width="94" height="94" rx="23" fill="none" stroke="#ccfbf1" stroke-opacity="0.55" stroke-width="2" />
|
||||
|
||||
<rect x="32" y="31" width="64" height="54" rx="11" fill="url(#panel)" />
|
||||
<path d="M32 42c0-6.075 4.925-11 11-11h42c6.075 0 11 4.925 11 11v3H32z" fill="#94a3b8" />
|
||||
<circle cx="42" cy="38.5" r="2.2" fill="#f8fafc" />
|
||||
<circle cx="49" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.85" />
|
||||
<circle cx="56" cy="38.5" r="2.2" fill="#f8fafc" opacity="0.7" />
|
||||
|
||||
<path d="M49 57 40 64l9 7" fill="none" stroke="#0f172a" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" />
|
||||
<path d="M62 55h17" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
|
||||
<path d="M62 67h23" fill="none" stroke="#0f766e" stroke-linecap="round" stroke-width="7" />
|
||||
|
||||
<rect x="70" y="78" width="22" height="15" rx="5" fill="#14b8a6" />
|
||||
<rect x="59" y="84" width="22" height="15" rx="5" fill="#2dd4bf" />
|
||||
<rect x="48" y="90" width="22" height="15" rx="5" fill="#99f6e4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Pure protocol + crypto for talking to a raw `browser-cli serve` endpoint.
|
||||
*
|
||||
* This module imports nothing from n8n and only touches Node's `crypto` plus a
|
||||
* lazily-loaded ML-KEM implementation, so it can be unit-tested with a plain
|
||||
* esbuild/node toolchain. The socket mechanics live in `serveClient.ts`.
|
||||
*
|
||||
* The wire protocol mirrors the Python client in `browser_cli/remote` and
|
||||
* `browser_cli/auth`:
|
||||
*
|
||||
* 1. Server sends a framed `challenge` JSON: {nonce, min_client_version, pq_kex?}.
|
||||
* 2. Client replies with one framed message. With a private key this is an
|
||||
* Ed25519 signature over `nonce + sha256(canonical_json(msg))`, optionally
|
||||
* bound to an ML-KEM-768 shared secret. When the server offers `pq_kex`
|
||||
* (it always does once authorized_keys is set) the request body is also
|
||||
* ChaCha20-Poly1305 encrypted under that secret.
|
||||
* 3. Server replies with one framed payload, encrypted the same way.
|
||||
*
|
||||
* Every value the client signs must serialize byte-for-byte like Python's
|
||||
* `json.dumps(sort_keys=True, separators=(",", ":"))` with `ensure_ascii=True`
|
||||
* or the signature is rejected — see `canonicalJson` and `protocol.test.ts`.
|
||||
*/
|
||||
import {
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
createHash,
|
||||
createHmac,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
sign as nodeSign,
|
||||
type KeyObject,
|
||||
} from 'node:crypto';
|
||||
|
||||
export const PQ_KEX_ALG = 'ML-KEM-768';
|
||||
export const PQ_TRANSPORT_ALG = 'ML-KEM-768+ChaCha20Poly1305';
|
||||
|
||||
/** Auth-protocol fields that are never part of the signed canonical payload. */
|
||||
const AUTH_FIELDS = new Set(['pubkey', 'sig', 'pq_kex', 'encrypted']);
|
||||
|
||||
// --- Framing ---------------------------------------------------------------
|
||||
|
||||
/** Prefix `payload` with browser-cli's 4-byte little-endian length header. */
|
||||
export function frame(payload: Buffer): Buffer {
|
||||
const header = Buffer.allocUnsafe(4);
|
||||
header.writeUInt32LE(payload.length, 0);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
// --- Canonical JSON (matches Python json.dumps sort_keys + ensure_ascii) ----
|
||||
|
||||
/** Deterministic JSON string identical to the Python signing canonicalization. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return encode(value);
|
||||
}
|
||||
|
||||
function encode(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
const type = typeof value;
|
||||
if (type === 'string') return encodeString(value as string);
|
||||
if (type === 'boolean') return value ? 'true' : 'false';
|
||||
if (type === 'number') {
|
||||
const n = value as number;
|
||||
if (!Number.isFinite(n)) throw new Error('cannot encode non-finite number');
|
||||
// Integers match Python exactly; non-integers are rare in args and fall
|
||||
// back to JS formatting (documented limitation, see protocol.test.ts).
|
||||
return Number.isInteger(n) ? String(n) : JSON.stringify(n);
|
||||
}
|
||||
if (Array.isArray(value)) return '[' + value.map(encode).join(',') + ']';
|
||||
if (type === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj)
|
||||
.filter((key) => obj[key] !== undefined)
|
||||
.sort();
|
||||
return '{' + keys.map((key) => encodeString(key) + ':' + encode(obj[key])).join(',') + '}';
|
||||
}
|
||||
throw new Error(`cannot encode value of type ${type} in canonical JSON`);
|
||||
}
|
||||
|
||||
/** JSON-encode a string and escape every non-ASCII char as \uXXXX, like Python. */
|
||||
function encodeString(str: string): string {
|
||||
return JSON.stringify(str).replace(/[-]/g, (char) =>
|
||||
'\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ed25519 ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reconstruct a clean PEM from a value mangled by a credential field:
|
||||
* surrounding whitespace, newlines escaped to literal `\n`, or — common with
|
||||
* single-line/password inputs — the internal line breaks stripped entirely so
|
||||
* the base64 body and the BEGIN/END markers run together.
|
||||
*/
|
||||
export function normalizePem(input: string): string {
|
||||
let pem = (input || '').trim().replace(/\\n/g, '\n');
|
||||
const match = pem.match(/-----BEGIN ([A-Z0-9 ]+?)-----([\s\S]*?)-----END \1-----/);
|
||||
if (match) {
|
||||
const label = match[1].trim();
|
||||
const body = (match[2].match(/[A-Za-z0-9+/=]+/g) || []).join('');
|
||||
const wrapped = body.match(/.{1,64}/g) || [];
|
||||
pem = `-----BEGIN ${label}-----\n${wrapped.join('\n')}\n-----END ${label}-----\n`;
|
||||
}
|
||||
return pem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a PKCS8 PEM Ed25519 private key, tolerating the ways credential fields
|
||||
* mangle multi-line secrets (see {@link normalizePem}).
|
||||
*/
|
||||
export function loadPrivateKey(privatePem: string): KeyObject {
|
||||
const pem = normalizePem(privatePem);
|
||||
try {
|
||||
return createPrivateKey(pem);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'Invalid Ed25519 private key: expected a PKCS8 PEM block ' +
|
||||
'("-----BEGIN PRIVATE KEY-----"), e.g. the file from `browser-cli auth keygen`. ' +
|
||||
`(${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw 32-byte Ed25519 public key (hex) derived from a PKCS8 PEM private key. */
|
||||
export function ed25519PublicKeyHex(privatePem: string): string {
|
||||
const publicKey = createPublicKey(loadPrivateKey(privatePem));
|
||||
const jwk = publicKey.export({ format: 'jwk' }) as { x?: string };
|
||||
if (!jwk.x) throw new Error('private key is not an Ed25519 key');
|
||||
return Buffer.from(jwk.x, 'base64url').toString('hex');
|
||||
}
|
||||
|
||||
/** Bytes signed for auth: nonce + sha256(canonical) [+ sha256(label + secret)]. */
|
||||
export function authMessage(nonceHex: string, msg: Record<string, unknown>, pqSecret: Buffer | null): Buffer {
|
||||
const nonce = Buffer.from(nonceHex, 'hex');
|
||||
const canonical = createHash('sha256').update(canonicalJson(stripAuthFields(msg)), 'utf8').digest();
|
||||
let data = Buffer.concat([nonce, canonical]);
|
||||
if (pqSecret) {
|
||||
const bound = createHash('sha256')
|
||||
.update(Buffer.concat([Buffer.from('browser-cli ml-kem-768 v1', 'ascii'), pqSecret]))
|
||||
.digest();
|
||||
data = Buffer.concat([data, bound]);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Ed25519 signature (hex) over the canonical auth payload. */
|
||||
export function signAuth(
|
||||
privatePem: string,
|
||||
nonceHex: string,
|
||||
msg: Record<string, unknown>,
|
||||
pqSecret: Buffer | null,
|
||||
): string {
|
||||
return nodeSign(null, authMessage(nonceHex, msg, pqSecret), loadPrivateKey(privatePem)).toString('hex');
|
||||
}
|
||||
|
||||
function stripAuthFields(msg: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(msg)) {
|
||||
if (!AUTH_FIELDS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- ML-KEM-768 transport encryption ---------------------------------------
|
||||
|
||||
// Node has no native ML-KEM, so load @noble/post-quantum at runtime. The
|
||||
// indirection through `Function` keeps a real dynamic `import()` even after
|
||||
// TypeScript downlevels this module to CommonJS (a plain `import()` would be
|
||||
// rewritten to `require()` and fail on the ESM-only package).
|
||||
const importEsm = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<any>;
|
||||
let mlkemPromise: Promise<any> | null = null;
|
||||
|
||||
async function mlKem768(): Promise<any> {
|
||||
if (!mlkemPromise) {
|
||||
mlkemPromise = importEsm('@noble/post-quantum/ml-kem.js').then((mod) => mod.ml_kem768);
|
||||
}
|
||||
return mlkemPromise;
|
||||
}
|
||||
|
||||
/** Encapsulate to the server's ML-KEM public key. Returns (ciphertext hex, secret). */
|
||||
export async function pqEncapsulate(serverPublicKeyHex: string): Promise<{ ciphertextHex: string; secret: Buffer }> {
|
||||
const ml = await mlKem768();
|
||||
const { cipherText, sharedSecret } = ml.encapsulate(Buffer.from(serverPublicKeyHex, 'hex'));
|
||||
return { ciphertextHex: Buffer.from(cipherText).toString('hex'), secret: Buffer.from(sharedSecret) };
|
||||
}
|
||||
|
||||
/** HKDF-SHA256 with a 32-zero-byte salt (Python `salt=None`) and the given info. */
|
||||
export function pqTransportKey(secret: Buffer, direction: string): Buffer {
|
||||
const salt = Buffer.alloc(32, 0);
|
||||
const prk = createHmac('sha256', salt).update(secret).digest();
|
||||
const info = Buffer.concat([Buffer.from(`browser-cli pq transport v1 ${direction}`, 'ascii'), Buffer.from([1])]);
|
||||
return createHmac('sha256', prk).update(info).digest().subarray(0, 32);
|
||||
}
|
||||
|
||||
export interface PqEnvelope {
|
||||
alg?: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
/** ChaCha20-Poly1305 encrypt an app-layer frame (ciphertext field is ct||tag). */
|
||||
export function pqEncrypt(secret: Buffer, direction: string, plaintext: Buffer): PqEnvelope {
|
||||
const key = pqTransportKey(secret, direction);
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
||||
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
alg: PQ_TRANSPORT_ALG,
|
||||
nonce: nonce.toString('hex'),
|
||||
ciphertext: Buffer.concat([body, tag]).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Inverse of {@link pqEncrypt}. */
|
||||
export function pqDecrypt(secret: Buffer, direction: string, envelope: PqEnvelope): Buffer {
|
||||
if (!envelope || envelope.alg !== PQ_TRANSPORT_ALG) {
|
||||
throw new Error('unsupported encrypted transport envelope');
|
||||
}
|
||||
const key = pqTransportKey(secret, direction);
|
||||
const nonce = Buffer.from(envelope.nonce, 'hex');
|
||||
const blob = Buffer.from(envelope.ciphertext, 'hex');
|
||||
const tag = blob.subarray(blob.length - 16);
|
||||
const body = blob.subarray(0, blob.length - 16);
|
||||
const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(body), decipher.final()]);
|
||||
}
|
||||
|
||||
// --- Handshake payload + response decoding ---------------------------------
|
||||
|
||||
export interface Challenge {
|
||||
type?: string;
|
||||
nonce?: string;
|
||||
min_client_version?: string;
|
||||
pq_kex?: { alg?: string; public_key?: string };
|
||||
}
|
||||
|
||||
export interface AuthPayload {
|
||||
payload: Record<string, unknown>;
|
||||
pqSecret: Buffer | null;
|
||||
}
|
||||
|
||||
function pqPublicKey(challenge: Challenge): string | null {
|
||||
const kex = challenge.pq_kex;
|
||||
if (kex && kex.alg === PQ_KEX_ALG && kex.public_key) return String(kex.public_key);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single framed message a client sends in response to the challenge.
|
||||
* Mirrors `browser_cli.remote.auth.build_auth_message` + `signed_payload`.
|
||||
*/
|
||||
export async function buildAuthPayload(
|
||||
baseMsg: Record<string, unknown>,
|
||||
challenge: Challenge,
|
||||
privatePem: string | null,
|
||||
): Promise<AuthPayload> {
|
||||
const nonceHex = challenge.type === 'challenge' ? challenge.nonce : undefined;
|
||||
if (!nonceHex || !privatePem) {
|
||||
// No-auth endpoint (loopback `serve --no-auth`): send the bare message.
|
||||
return { payload: baseMsg, pqSecret: null };
|
||||
}
|
||||
|
||||
const clean = stripAuthFields(baseMsg);
|
||||
let secret: Buffer | null = null;
|
||||
const serverPub = pqPublicKey(challenge);
|
||||
if (serverPub) {
|
||||
const enc = await pqEncapsulate(serverPub);
|
||||
secret = enc.secret;
|
||||
clean.pq_kex = { alg: PQ_KEX_ALG, ciphertext: enc.ciphertextHex };
|
||||
}
|
||||
|
||||
const sig = signAuth(privatePem, nonceHex, clean, secret);
|
||||
const pubkey = ed25519PublicKeyHex(privatePem);
|
||||
|
||||
if (!secret) {
|
||||
return { payload: { ...clean, pubkey, sig }, pqSecret: null };
|
||||
}
|
||||
|
||||
const encrypted = pqEncrypt(secret, 'request', Buffer.from(JSON.stringify(clean), 'utf8'));
|
||||
return {
|
||||
payload: {
|
||||
id: clean.id,
|
||||
user_agent: clean.user_agent,
|
||||
pubkey,
|
||||
sig,
|
||||
pq_kex: clean.pq_kex,
|
||||
encrypted,
|
||||
},
|
||||
pqSecret: secret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decode a framed server response, decrypting the PQ envelope when present. */
|
||||
export function decodeResponse(raw: Buffer, pqSecret: Buffer | null): any {
|
||||
const outer = JSON.parse(raw.toString('utf8'));
|
||||
if (pqSecret && outer && typeof outer === 'object' && 'encrypted' in outer) {
|
||||
return JSON.parse(pqDecrypt(pqSecret, 'response', outer.encrypted).toString('utf8'));
|
||||
}
|
||||
return outer;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Pure (resource, operation) -> browser-cli command mapping.
|
||||
*
|
||||
* This module imports nothing from n8n so it can be unit-tested with a plain
|
||||
* esbuild/node toolchain. The node layer collects UI parameters into a plain
|
||||
* object and asks here for the command + args to run over the `serve` socket
|
||||
* (see `serveClient.ts`). Every operation maps to one raw extension command;
|
||||
* what the server returns is the *raw* command result (no SDK-side rendering),
|
||||
* still subject to the server's --allow-* policy noted per operation.
|
||||
*/
|
||||
|
||||
export type CommandParams = Record<string, unknown>;
|
||||
|
||||
export interface BrowserCommand {
|
||||
/** Raw browser-cli command name, e.g. "tabs.list" or "navigate.open". */
|
||||
command: string;
|
||||
/** Argument object forwarded to the command. */
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function str(params: CommandParams, key: string): string {
|
||||
const value = params[key];
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
/** Drop keys whose value is undefined/null/"" so we don't send empty args. */
|
||||
function compact(args: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (value !== undefined && value !== null && value !== '') out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a (resource, operation) pair plus collected parameters to a single raw
|
||||
* browser-cli command. Throws on an unknown pairing so the node fails loudly
|
||||
* rather than silently issuing a wrong call.
|
||||
*/
|
||||
export function buildCommand(
|
||||
resource: string,
|
||||
operation: string,
|
||||
params: CommandParams,
|
||||
): BrowserCommand {
|
||||
const key = `${resource}:${operation}`;
|
||||
switch (key) {
|
||||
// --- Raw escape hatch -------------------------------------------------
|
||||
case 'command:execute':
|
||||
return { command: str(params, 'command'), args: (params.args as Record<string, unknown>) ?? {} };
|
||||
|
||||
// --- Tabs -------------------------------------------------------------
|
||||
case 'tab:list':
|
||||
return { command: 'tabs.list', args: {} };
|
||||
case 'tab:open': {
|
||||
const focus = Boolean(params.focus);
|
||||
return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) };
|
||||
}
|
||||
case 'tab:close': {
|
||||
const mode = str(params, 'mode') || 'ids';
|
||||
if (mode === 'inactive') return { command: 'tabs.close', args: { inactive: true } };
|
||||
if (mode === 'duplicates') return { command: 'tabs.close', args: { duplicates: true } };
|
||||
return { command: 'tabs.close', args: { tabIds: parseTabIds(params.tabIds) } };
|
||||
}
|
||||
case 'tab:getHtml':
|
||||
return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) };
|
||||
|
||||
// --- Page / extraction ------------------------------------------------
|
||||
case 'page:info':
|
||||
return { command: 'page.info', args: {} };
|
||||
case 'page:extractText':
|
||||
return { command: 'extract.text', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractLinks':
|
||||
return { command: 'extract.links', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractImages':
|
||||
return { command: 'extract.images', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractHtml':
|
||||
return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) };
|
||||
case 'page:extractMarkdown':
|
||||
return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) };
|
||||
|
||||
// --- DOM --------------------------------------------------------------
|
||||
case 'dom:query':
|
||||
return { command: 'dom.query', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:click':
|
||||
return { command: 'dom.click', args: { selector: str(params, 'selector') } };
|
||||
case 'dom:type':
|
||||
return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } };
|
||||
case 'dom:eval':
|
||||
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
|
||||
|
||||
// --- Clients ----------------------------------------------------------
|
||||
case 'client:list':
|
||||
return { command: 'clients.list', args: {} };
|
||||
|
||||
// --- Gateway: serve has no health route, so ping with a safe command --
|
||||
case 'gateway:health':
|
||||
return { command: 'tabs.list', args: {} };
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A tab ID arg; 0 (the UI default) means "active tab", so it is omitted. */
|
||||
function tabIdArg(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/** Accept an array, a JSON array string, or a comma/space separated list. */
|
||||
export function parseTabIds(value: unknown): number[] {
|
||||
if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite);
|
||||
if (typeof value === 'number') return [value];
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return [];
|
||||
if (raw.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed.map(Number).filter(Number.isFinite);
|
||||
} catch {
|
||||
/* fall through to split */
|
||||
}
|
||||
}
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part))
|
||||
.filter(Number.isFinite);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Socket client for a raw `browser-cli serve` endpoint.
|
||||
*
|
||||
* One command per connection: connect, read the challenge frame, send the
|
||||
* authenticated (and PQ-encrypted) request frame, read the response frame,
|
||||
* close. The crypto and payload shapes live in `protocol.ts`.
|
||||
*/
|
||||
import { connect as netConnect, isIP } from 'node:net';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
import type { Socket } from 'node:net';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { buildAuthPayload, decodeResponse, frame, type Challenge } from './protocol';
|
||||
|
||||
/** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT
|
||||
* (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this
|
||||
* client implements. */
|
||||
const CLIENT_VERSION = '0.15.4';
|
||||
const USER_AGENT = `browser-cli/${CLIENT_VERSION}`;
|
||||
// Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed.
|
||||
const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] };
|
||||
const MAX_MSG_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface ServeConnectOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
/** Wrap the TCP connection in TLS (for a serve behind a TLS-terminating proxy). */
|
||||
tls?: boolean;
|
||||
/** Reject self-signed / invalid certs when `tls` is on. */
|
||||
rejectUnauthorized?: boolean;
|
||||
/** Ed25519 PKCS8 PEM private key, or null/empty for a `--no-auth` endpoint. */
|
||||
privateKeyPem?: string | null;
|
||||
/** Optional `_route` target for a multi-browser serve. */
|
||||
route?: string | null;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Reassembles browser-cli's 4-byte length-prefixed frames from a socket. */
|
||||
class FrameReader {
|
||||
private buffer = Buffer.alloc(0);
|
||||
private waiters: Array<(frame: Buffer) => void> = [];
|
||||
private failed: Error | null = null;
|
||||
|
||||
constructor(socket: Socket) {
|
||||
socket.on('data', (chunk: Buffer) => this.onData(chunk));
|
||||
}
|
||||
|
||||
private onData(chunk: Buffer): void {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
while (this.buffer.length >= 4) {
|
||||
const length = this.buffer.readUInt32LE(0);
|
||||
if (length > MAX_MSG_BYTES) {
|
||||
this.fail(new Error(`serve frame too large (${length} bytes)`));
|
||||
return;
|
||||
}
|
||||
if (this.buffer.length < 4 + length) break;
|
||||
const payload = this.buffer.subarray(4, 4 + length);
|
||||
this.buffer = this.buffer.subarray(4 + length);
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter) waiter(Buffer.from(payload));
|
||||
}
|
||||
}
|
||||
|
||||
fail(error: Error): void {
|
||||
this.failed = error;
|
||||
}
|
||||
|
||||
/** Resolve with the next complete frame, or reject on error/EOF/timeout. */
|
||||
next(): Promise<Buffer> {
|
||||
if (this.failed) return Promise.reject(this.failed);
|
||||
return new Promise((resolve) => this.waiters.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function openSocket(opts: ServeConnectOptions): Socket {
|
||||
if (opts.tls) {
|
||||
return tlsConnect({
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
rejectUnauthorized: opts.rejectUnauthorized !== false,
|
||||
// SNI must be a hostname; Node rejects an IP literal as servername.
|
||||
...(isIP(opts.host) ? {} : { servername: opts.host }),
|
||||
});
|
||||
}
|
||||
return netConnect({ host: opts.host, port: opts.port });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single browser-cli command against a `serve` endpoint and return the
|
||||
* server's response object: `{id, success, data}` or `{id, success:false, error}`.
|
||||
*/
|
||||
export async function sendServeCommand(
|
||||
opts: ServeConnectOptions,
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<any> {
|
||||
const socket = openSocket(opts);
|
||||
socket.setNoDelay(true);
|
||||
const reader = new FrameReader(socket);
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
fn();
|
||||
};
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish(() => reject(new Error(`serve request to ${opts.host}:${opts.port} timed out after ${timeoutMs}ms`))),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
socket.on('error', (err) => finish(() => reject(err)));
|
||||
socket.on('close', () => finish(() => reject(new Error('serve connection closed before a response was received'))));
|
||||
|
||||
const run = async () => {
|
||||
const challengeRaw = await reader.next();
|
||||
const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge;
|
||||
|
||||
const baseMsg: Record<string, unknown> = {
|
||||
id: randomUUID(),
|
||||
command,
|
||||
args: args ?? {},
|
||||
user_agent: USER_AGENT,
|
||||
accept_encoding: ACCEPT_ENCODING,
|
||||
};
|
||||
if (opts.route) baseMsg._route = opts.route;
|
||||
|
||||
const { payload, pqSecret } = await buildAuthPayload(baseMsg, challenge, opts.privateKeyPem || null);
|
||||
socket.write(frame(Buffer.from(JSON.stringify(payload), 'utf8')));
|
||||
|
||||
const responseRaw = await reader.next();
|
||||
const response = decodeResponse(responseRaw, pqSecret);
|
||||
finish(() => resolve(response));
|
||||
};
|
||||
|
||||
// A TLS socket is ready only after 'secureConnect'; a plain socket on 'connect'.
|
||||
socket.once(opts.tls ? 'secureConnect' : 'connect', () => {
|
||||
run().catch((err) => finish(() => reject(err)));
|
||||
});
|
||||
});
|
||||
}
|
||||
Generated
+1825
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "n8n-nodes-browser-cli",
|
||||
"version": "0.2.4",
|
||||
"description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)",
|
||||
"keywords": [
|
||||
"n8n-community-node-package",
|
||||
"browser-cli",
|
||||
"browser",
|
||||
"automation",
|
||||
"n8n"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://chromewebstore.google.com/detail/browser-cli/hekaebjhbhhdbmakimmaklbblbmccahp",
|
||||
"author": "Daniel Dolezal",
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/copy-assets.mjs",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node scripts/run-tests.mjs",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"n8n": {
|
||||
"n8nNodesApiVersion": 1,
|
||||
"credentials": [
|
||||
"dist/credentials/BrowserCliApi.credentials.js"
|
||||
],
|
||||
"nodes": [
|
||||
"dist/nodes/BrowserCli/BrowserCli.node.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"n8n-workflow": "*",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"n8n-workflow": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/post-quantum": "^0.6.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copy node icons next to their compiled .node.js files. n8n loads the icon
|
||||
// from a path relative to the node file in dist/, so the SVG must travel along.
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
const assets = [
|
||||
['nodes/BrowserCli/browserCli.svg', 'dist/nodes/BrowserCli/browserCli.svg'],
|
||||
];
|
||||
|
||||
for (const [from, to] of assets) {
|
||||
await mkdir(dirname(join(root, to)), { recursive: true });
|
||||
await cp(join(root, from), join(root, to));
|
||||
console.log(`copied ${from} -> ${to}`);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Build the pure-logic tests to ESM and run them with node --test. request.ts
|
||||
// and protocol.ts import nothing from n8n, so this needs only esbuild + node.
|
||||
// node_modules stay external (--packages=external) so the @noble/post-quantum
|
||||
// dynamic import in protocol.ts resolves at runtime instead of being bundled.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const run = (cmd, args) => spawnSync(cmd, args, { cwd: root, stdio: 'inherit' });
|
||||
|
||||
const suites = ['request', 'protocol'];
|
||||
|
||||
const build = run('npx', [
|
||||
'esbuild',
|
||||
...suites.map((name) => `test/${name}.test.ts`),
|
||||
'--bundle',
|
||||
'--format=esm',
|
||||
'--platform=node',
|
||||
'--packages=external',
|
||||
'--outdir=test-dist',
|
||||
'--out-extension:.js=.mjs',
|
||||
]);
|
||||
if (build.status !== 0) process.exit(build.status ?? 1);
|
||||
|
||||
const test = run('node', ['--test', ...suites.map((name) => `test-dist/${name}.test.mjs`)]);
|
||||
process.exit(test.status ?? 1);
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
authMessage,
|
||||
buildAuthPayload,
|
||||
canonicalJson,
|
||||
decodeResponse,
|
||||
ed25519PublicKeyHex,
|
||||
frame,
|
||||
pqDecrypt,
|
||||
pqEncrypt,
|
||||
pqTransportKey,
|
||||
signAuth,
|
||||
} from '../nodes/BrowserCli/protocol';
|
||||
|
||||
// Known-answer vectors produced by the real Python implementation
|
||||
// (browser_cli.auth.*) so the TypeScript port is provably byte-compatible.
|
||||
// Regenerate with the snippet in the PR description if the protocol changes.
|
||||
const PEM =
|
||||
'-----BEGIN PRIVATE KEY-----\n' +
|
||||
'MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f\n' +
|
||||
'-----END PRIVATE KEY-----\n';
|
||||
const PUB_HEX = '03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8';
|
||||
const MSG = {
|
||||
id: 'abc-123',
|
||||
command: 'dom.type',
|
||||
args: { selector: '#q', text: 'héllo ☃ "x"' },
|
||||
user_agent: 'browser-cli/0.15.4',
|
||||
accept_encoding: { ser: ['json'], comp: [] },
|
||||
};
|
||||
const CANON =
|
||||
'{"accept_encoding":{"comp":[],"ser":["json"]},"args":{"selector":"#q",' +
|
||||
'"text":"h\\u00e9llo \\u2603 \\"x\\""},"command":"dom.type","id":"abc-123",' +
|
||||
'"user_agent":"browser-cli/0.15.4"}';
|
||||
const NONCE_HEX = '11'.repeat(32);
|
||||
const SIG_NOPQ =
|
||||
'13734cce77d1c861995e003041c66e555569d53df660b0584858247eee2e98fc' +
|
||||
'ad13a4cef880c0fe732f8559e990e889d343f002f2e6554d4149bb5d7e31670e';
|
||||
const SECRET_HEX = '202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f';
|
||||
const SIG_PQ =
|
||||
'e06f97ee0b628d7c84f570dc5ea07490eade9372da2ef145c9f06521b05778c8' +
|
||||
'25b9b268e15e75b7e0bbde784071852b9c6046dfee0839057f5e096b968e8f04';
|
||||
const TKEY_REQ = 'cf792b1e9b96642ef86c113b4ab5826661fb64e9f04d028c7f10f514ae6553d4';
|
||||
const TKEY_RESP = '72f3219bc809b7fbec0185f514b577cfec60d1264193da6afcc27d860f0382b7';
|
||||
const DECRYPT_ENV = {
|
||||
alg: 'ML-KEM-768+ChaCha20Poly1305',
|
||||
nonce: 'a77e8a72e13f49aab713e0dc',
|
||||
ciphertext: '7e4f75fa68098ea9a162dfd49af7824526186b77e9ac346b58f30d73df2bef88d5e6cd',
|
||||
};
|
||||
const DECRYPT_PLAIN = 'hello world payload';
|
||||
|
||||
test('canonicalJson matches Python json.dumps(sort_keys, ensure_ascii)', () => {
|
||||
assert.equal(canonicalJson(MSG), CANON);
|
||||
});
|
||||
|
||||
test('canonicalJson sorts nested keys and omits undefined', () => {
|
||||
assert.equal(canonicalJson({ b: 1, a: { d: 4, c: 3 }, z: undefined }), '{"a":{"c":3,"d":4},"b":1}');
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex derives the raw public key from the PEM', () => {
|
||||
assert.equal(ed25519PublicKeyHex(PEM), PUB_HEX);
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex tolerates escaped newlines and surrounding whitespace', () => {
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '\\n')), PUB_HEX);
|
||||
assert.equal(ed25519PublicKeyHex(` \n${PEM}\n `), PUB_HEX);
|
||||
});
|
||||
|
||||
test('ed25519PublicKeyHex rebuilds a PEM with its line breaks stripped', () => {
|
||||
// What a single-line/password credential field does to a multi-line PEM.
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, '')), PUB_HEX);
|
||||
assert.equal(ed25519PublicKeyHex(PEM.replace(/\n/g, ' ')), PUB_HEX);
|
||||
});
|
||||
|
||||
test('loadPrivateKey throws a helpful error on a non-PEM value', () => {
|
||||
assert.throws(() => ed25519PublicKeyHex(PUB_HEX), /Invalid Ed25519 private key/);
|
||||
});
|
||||
|
||||
test('signAuth reproduces the Python signature without PQ', () => {
|
||||
assert.equal(signAuth(PEM, NONCE_HEX, MSG, null), SIG_NOPQ);
|
||||
});
|
||||
|
||||
test('signAuth reproduces the Python signature bound to a PQ secret', () => {
|
||||
assert.equal(signAuth(PEM, NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')), SIG_PQ);
|
||||
});
|
||||
|
||||
test('authMessage binds nonce + payload hash (+ secret) at the expected lengths', () => {
|
||||
assert.equal(authMessage(NONCE_HEX, MSG, null).length, 64);
|
||||
assert.equal(authMessage(NONCE_HEX, MSG, Buffer.from(SECRET_HEX, 'hex')).length, 96);
|
||||
});
|
||||
|
||||
test('pqTransportKey matches Python HKDF for both directions', () => {
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
assert.equal(pqTransportKey(secret, 'request').toString('hex'), TKEY_REQ);
|
||||
assert.equal(pqTransportKey(secret, 'response').toString('hex'), TKEY_RESP);
|
||||
});
|
||||
|
||||
test('pqDecrypt opens a Python-produced ChaCha20-Poly1305 envelope', () => {
|
||||
const plain = pqDecrypt(Buffer.from(SECRET_HEX, 'hex'), 'response', DECRYPT_ENV);
|
||||
assert.equal(plain.toString('utf8'), DECRYPT_PLAIN);
|
||||
});
|
||||
|
||||
test('pqEncrypt/pqDecrypt round-trip', () => {
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
const env = pqEncrypt(secret, 'request', Buffer.from('round trip ✓', 'utf8'));
|
||||
assert.equal(env.alg, 'ML-KEM-768+ChaCha20Poly1305');
|
||||
assert.equal(pqDecrypt(secret, 'request', env).toString('utf8'), 'round trip ✓');
|
||||
});
|
||||
|
||||
test('pqDecrypt rejects an unknown envelope', () => {
|
||||
assert.throws(() => pqDecrypt(Buffer.alloc(32), 'response', { alg: 'nope', nonce: '00', ciphertext: '00' }), /unsupported/);
|
||||
});
|
||||
|
||||
test('frame prepends a 4-byte little-endian length', () => {
|
||||
const out = frame(Buffer.from('abc'));
|
||||
assert.equal(out.readUInt32LE(0), 3);
|
||||
assert.equal(out.subarray(4).toString(), 'abc');
|
||||
});
|
||||
|
||||
test('buildAuthPayload signs in the clear when no PQ kex is offered', async () => {
|
||||
const { payload, pqSecret } = await buildAuthPayload({ ...MSG }, { type: 'challenge', nonce: NONCE_HEX }, PEM);
|
||||
assert.equal(pqSecret, null);
|
||||
assert.equal((payload as any).pubkey, PUB_HEX);
|
||||
assert.equal((payload as any).sig, SIG_NOPQ);
|
||||
});
|
||||
|
||||
test('buildAuthPayload returns the bare message for a no-auth endpoint', async () => {
|
||||
const base = { ...MSG };
|
||||
const { payload, pqSecret } = await buildAuthPayload(base, { type: 'challenge', nonce: NONCE_HEX }, null);
|
||||
assert.equal(pqSecret, null);
|
||||
assert.equal(payload, base);
|
||||
});
|
||||
|
||||
test('decodeResponse parses plain JSON and decrypts PQ envelopes', () => {
|
||||
assert.deepEqual(decodeResponse(Buffer.from('{"success":true,"data":[]}'), null), { success: true, data: [] });
|
||||
const secret = Buffer.from(SECRET_HEX, 'hex');
|
||||
const env = pqEncrypt(secret, 'response', Buffer.from('{"success":true,"data":1}', 'utf8'));
|
||||
const raw = Buffer.from(JSON.stringify({ encrypted: env }));
|
||||
assert.deepEqual(decodeResponse(raw, secret), { success: true, data: 1 });
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { buildCommand, parseTabIds } from '../nodes/BrowserCli/request';
|
||||
|
||||
test('tab:list maps to tabs.list', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'list', {}), { command: 'tabs.list', args: {} });
|
||||
});
|
||||
|
||||
test('client:list maps to clients.list', () => {
|
||||
assert.deepEqual(buildCommand('client', 'list', {}), { command: 'clients.list', args: {} });
|
||||
});
|
||||
|
||||
test('gateway:health pings with tabs.list (serve has no health route)', () => {
|
||||
assert.deepEqual(buildCommand('gateway', 'health', {}), { command: 'tabs.list', args: {} });
|
||||
});
|
||||
|
||||
test('tab:open sends navigate.open with background derived from focus', () => {
|
||||
const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false });
|
||||
assert.deepEqual(bg, {
|
||||
command: 'navigate.open',
|
||||
args: { url: 'https://example.com', focus: false, background: true },
|
||||
});
|
||||
|
||||
const fg = buildCommand('tab', 'open', { url: 'https://example.com', focus: true });
|
||||
assert.equal(fg.args.focus, true);
|
||||
assert.equal(fg.args.background, false, 'focused open sends background:false (matches SDK)');
|
||||
});
|
||||
|
||||
test('tab:close supports ids, inactive, duplicates', () => {
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'ids', tabIds: '12, 34' }), {
|
||||
command: 'tabs.close',
|
||||
args: { tabIds: [12, 34] },
|
||||
});
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'inactive' }).args, { inactive: true });
|
||||
assert.deepEqual(buildCommand('tab', 'close', { mode: 'duplicates' }).args, { duplicates: true });
|
||||
});
|
||||
|
||||
test('dom:type sends dom.type with selector + text', () => {
|
||||
assert.deepEqual(buildCommand('dom', 'type', { selector: '#q', text: 'hello' }), {
|
||||
command: 'dom.type',
|
||||
args: { selector: '#q', text: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
test('dom:eval includes tabId only when set', () => {
|
||||
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 0 }).args, { code: 'return 1' });
|
||||
assert.deepEqual(buildCommand('dom', 'eval', { code: 'return 1', tabId: 7 }).args, { code: 'return 1', tabId: 7 });
|
||||
});
|
||||
|
||||
test('page:extractMarkdown omits empty selector', () => {
|
||||
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: '' }).args, {});
|
||||
assert.deepEqual(buildCommand('page', 'extractMarkdown', { selector: 'main' }).args, { selector: 'main' });
|
||||
});
|
||||
|
||||
test('command:execute passes command and args through', () => {
|
||||
assert.deepEqual(buildCommand('command', 'execute', { command: 'tabs.query', args: { search: 'docs' } }), {
|
||||
command: 'tabs.query',
|
||||
args: { search: 'docs' },
|
||||
});
|
||||
});
|
||||
|
||||
test('unknown operation throws', () => {
|
||||
assert.throws(() => buildCommand('tab', 'nope', {}), /Unsupported operation/);
|
||||
});
|
||||
|
||||
test('parseTabIds accepts array, json, and delimited strings', () => {
|
||||
assert.deepEqual(parseTabIds([1, 2, 3]), [1, 2, 3]);
|
||||
assert.deepEqual(parseTabIds('[4, 5]'), [4, 5]);
|
||||
assert.deepEqual(parseTabIds('6, 7 8'), [6, 7, 8]);
|
||||
assert.deepEqual(parseTabIds(''), []);
|
||||
assert.deepEqual(parseTabIds(9), [9]);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2021"],
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["credentials/**/*.ts", "nodes/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
Generated
+107
-107
@@ -13,9 +13,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -30,9 +30,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -47,9 +47,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -64,9 +64,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -81,9 +81,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -98,9 +98,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -115,9 +115,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -132,9 +132,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -149,9 +149,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -166,9 +166,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -183,9 +183,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -200,9 +200,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -217,9 +217,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -234,9 +234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -251,9 +251,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -268,9 +268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -285,9 +285,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -302,9 +302,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -319,9 +319,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -336,9 +336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -353,9 +353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -370,9 +370,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -387,9 +387,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -404,9 +404,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -421,9 +421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -438,9 +438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -497,9 +497,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -510,32 +510,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "real-browser-cli"
|
||||
version = "0.15.6"
|
||||
version = "0.16.3"
|
||||
description = "Control your real running browser from the terminal or Python SDK"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"cryptography>=48",
|
||||
"rich>=13",
|
||||
"msgpack>=1",
|
||||
"questionary>=2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
+7
-1
@@ -8,9 +8,16 @@ They are automatically skipped if the native host socket is not reachable.
|
||||
import time
|
||||
import pytest
|
||||
from browser_cli.client import send_command, BrowserNotConnected
|
||||
from browser_cli.remote import pool as _remote_pool
|
||||
|
||||
TEST_BROWSER_PROFILE = "testing"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_remote_pool():
|
||||
"""Close any pooled remote connections between tests so a connection opened
|
||||
against one test's throwaway server can't leak into the next."""
|
||||
yield
|
||||
_remote_pool.close_all()
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser():
|
||||
@@ -27,7 +34,6 @@ def browser():
|
||||
|
||||
return _browser
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def http_tab(browser):
|
||||
"""Opens a dedicated http/https tab for the current test and returns its tab info."""
|
||||
|
||||
+16
-10
@@ -32,6 +32,12 @@ GROUP_DATA = {
|
||||
"tabCount": 3,
|
||||
}
|
||||
|
||||
def tab_close_args(tab_id: int):
|
||||
return {"tabId": tab_id, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"}
|
||||
|
||||
def group_close_args(group_id: int):
|
||||
return {"groupId": group_id, "gentleMode": "auto"}
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_send():
|
||||
"""Patch send_command for the duration of one test.
|
||||
@@ -454,7 +460,7 @@ class TestTabs:
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="default"),
|
||||
call("tabs.list", {}, profile="work"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote=None, key=None),
|
||||
call("tabs.close", tab_close_args(11), profile="work", remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
||||
@@ -473,7 +479,7 @@ class TestTabs:
|
||||
assert [tab.browser for tab in tabs] == ["work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
|
||||
call("tabs.close", tab_close_args(10), profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_tabs_list_remote_bound_actions_preserve_key(self, mock_send):
|
||||
@@ -488,7 +494,7 @@ class TestTabs:
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("tabs.close", tab_close_args(10), profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_list_browser_host_alias_fans_out_to_remote_targets(self, mock_send):
|
||||
@@ -510,7 +516,7 @@ class TestTabs:
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.close", {"tabId": 11}, profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
call("tabs.close", tab_close_args(11), profile="work", remote="browser-host.example:8765", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_active_returns_active_tab(self, b, mock_send):
|
||||
@@ -690,7 +696,7 @@ class TestGroups:
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="default"),
|
||||
call("group.list", {}, profile="work"),
|
||||
call("group.close", {"groupId": 99}, profile="work", remote=None, key=None),
|
||||
call("group.close", group_close_args(99), profile="work", remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
||||
@@ -709,7 +715,7 @@ class TestGroups:
|
||||
assert [group.browser for group in groups] == ["work"]
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="work", remote="host:8765", key=None),
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
|
||||
call("group.close", group_close_args(42), profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_group_list_remote_bound_actions_preserve_key(self, mock_send):
|
||||
@@ -724,7 +730,7 @@ class TestGroups:
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("group.close", group_close_args(42), profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||
@@ -954,7 +960,7 @@ class TestTabModel:
|
||||
|
||||
def test_close(self, tab, mock_send):
|
||||
tab.close()
|
||||
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
mock_send.assert_called_once_with("tabs.close", tab_close_args(10), profile=None, remote=None, key=None)
|
||||
|
||||
def test_activate(self, tab, mock_send):
|
||||
tab.activate()
|
||||
@@ -1043,7 +1049,7 @@ class TestGroupModel:
|
||||
|
||||
def test_close(self, group, mock_send):
|
||||
group.close()
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
mock_send.assert_called_once_with("group.close", group_close_args(42), profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs(self, group, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
@@ -1115,7 +1121,7 @@ class TestSDKDecorators:
|
||||
remote=None,
|
||||
key=None,
|
||||
),
|
||||
call("tabs.close", {"tabId": 123}, profile=None, remote=None, key=None),
|
||||
call("tabs.close", tab_close_args(123), profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_wait_for_selector_runs_before_function_and_can_inject_result(self, b, mock_send):
|
||||
|
||||
+39
-17
@@ -28,8 +28,8 @@ def test_long_version_option():
|
||||
assert result.output.strip() == _expected_version()
|
||||
|
||||
def test_project_version_falls_back_to_installed_package_metadata():
|
||||
with patch("browser_cli.cli.Path.read_text", side_effect=OSError), patch(
|
||||
"browser_cli.cli.package_version", return_value="9.9.9"
|
||||
with patch("browser_cli.version_manager.Path.read_text", side_effect=OSError), patch(
|
||||
"browser_cli.version_manager._pkg_version", return_value="9.9.9"
|
||||
):
|
||||
assert _project_version() == "9.9.9"
|
||||
|
||||
@@ -114,8 +114,8 @@ def test_install_writes_testing_and_webstore_allowed_origins(tmp_path):
|
||||
],
|
||||
}
|
||||
]
|
||||
assert "Testing extension ID" in result.output
|
||||
assert "Chrome Web Store extension ID" in result.output
|
||||
assert "chromewebstore.google.com" in result.output
|
||||
assert "Add to Brave" in result.output
|
||||
|
||||
def test_install_writes_firefox_allowed_extensions(tmp_path):
|
||||
manifests = []
|
||||
@@ -139,12 +139,34 @@ def test_install_writes_firefox_allowed_extensions(tmp_path):
|
||||
"allowed_extensions": ["browser-cli@yiprawr.dev"],
|
||||
}
|
||||
]
|
||||
assert "addons.mozilla.org/firefox/addon/browser-cli" in result.output
|
||||
assert "Add to Firefox" in result.output
|
||||
|
||||
def test_install_dev_flag_prints_unpacked_instructions(tmp_path):
|
||||
with patch("browser_cli.commands.install.native_host_exe", return_value=tmp_path / "browser-cli-native-host"), patch(
|
||||
"browser_cli.commands.install.write_native_host_exe"
|
||||
), patch("browser_cli.commands.install._install_manifest", return_value=[tmp_path / "com.browsercli.host.json"]):
|
||||
result = CliRunner().invoke(main, ["install", "brave", "--dev"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Load unpacked" in result.output
|
||||
assert "Developer mode" in result.output
|
||||
assert "Testing extension ID" in result.output
|
||||
assert "Chrome Web Store extension ID" in result.output
|
||||
assert "chromewebstore.google.com" not in result.output # store path is the non-dev default
|
||||
|
||||
def test_install_dev_flag_prints_firefox_unpacked_instructions(tmp_path):
|
||||
with patch("browser_cli.commands.install.native_host_exe", return_value=tmp_path / "browser-cli-native-host"), patch(
|
||||
"browser_cli.commands.install.write_native_host_exe"
|
||||
), patch("browser_cli.commands.install._install_manifest", return_value=[tmp_path / "com.browsercli.host.json"]):
|
||||
result = CliRunner().invoke(main, ["install", "firefox", "--dev"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "about:debugging#/runtime/this-firefox" in result.output
|
||||
assert "npm run package:extension:firefox" in result.output
|
||||
output_unwrapped = result.output.replace("\n", "")
|
||||
assert "dist/extension-package-firefox/manifest.json" in output_unwrapped
|
||||
assert "Do not select extension/manifest.json" in output_unwrapped
|
||||
assert "Firefox extension ID" in result.output
|
||||
|
||||
def test_install_windows_registers_native_host(tmp_path):
|
||||
writes = []
|
||||
@@ -205,7 +227,7 @@ def test_write_native_host_exe_windows(tmp_path):
|
||||
|
||||
def test_clients_exits_cleanly_when_registry_is_missing():
|
||||
with patch("browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
|
||||
"browser_cli.commands.clients.active_browser_targets", return_value=[]
|
||||
"browser_cli.client.core.active_browser_targets", return_value=[]
|
||||
):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
@@ -239,8 +261,8 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
|
||||
return [remote_target]
|
||||
|
||||
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
|
||||
"browser_cli.commands.clients.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.commands.clients.active_browser_targets", side_effect=fake_active_browser_targets) as active_targets:
|
||||
"browser_cli.client.core.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.client.core.active_browser_targets", side_effect=fake_active_browser_targets) as active_targets:
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -263,8 +285,8 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
||||
return [{"profile": "main", "name": "Chrome", "version": "1", "extensionVersion": "0.8.2"}]
|
||||
|
||||
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
|
||||
"browser_cli.commands.clients.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.commands.clients.active_browser_targets", return_value=[]):
|
||||
"browser_cli.client.core.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.client.core.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -280,7 +302,7 @@ def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
|
||||
), patch("browser_cli.commands.clients.send_command", side_effect=fake_send_command) as send_command:
|
||||
), patch("browser_cli.client.core.send_command", side_effect=fake_send_command) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -290,7 +312,7 @@ def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||
assert "2.3.4" in result.output
|
||||
|
||||
def test_clients_remote_respects_global_browser_route():
|
||||
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.commands.clients.send_command", return_value=[]) as send_command:
|
||||
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.client.core.send_command", return_value=[]) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
@@ -316,10 +338,10 @@ def test_clients_browser_alias_resolves_to_remote():
|
||||
return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"browser_cli.commands.clients.remote_target_for_alias", return_value=resolved_target
|
||||
"browser_cli.client.core.remote_target_for_alias", return_value=resolved_target
|
||||
), patch(
|
||||
"browser_cli.commands.clients.remote_browser_targets", return_value=all_remote_targets
|
||||
), patch("browser_cli.commands.clients.send_command", side_effect=fake_send_command) as send_command:
|
||||
"browser_cli.client.core.remote_browser_targets", return_value=all_remote_targets
|
||||
), patch("browser_cli.client.core.send_command", side_effect=fake_send_command) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "browser-host.example", "clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -346,8 +368,8 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
||||
return responses[profile]
|
||||
|
||||
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
|
||||
"browser_cli.commands.clients.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.commands.clients.active_browser_targets", return_value=[]):
|
||||
"browser_cli.client.core.send_command", side_effect=fake_send_command
|
||||
), patch("browser_cli.client.core.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(main, ["clients"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
+129
-8
@@ -461,8 +461,8 @@ def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_pa
|
||||
assert len(targets) == 1
|
||||
assert targets[0].display_name == "browsercli.yiprawr.dev:automatisation"
|
||||
|
||||
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
|
||||
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it."""
|
||||
def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path):
|
||||
"""--key is a one-shot override; use `browser-cli remote trust` to remember it."""
|
||||
import json as _json
|
||||
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
@@ -491,16 +491,12 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
||||
|
||||
# First call with explicit --key agent
|
||||
send_command("tabs.list", remote="host:8765", key=_Path("agent"))
|
||||
assert used_keys[-1] == "agent"
|
||||
assert key_for_remote("host:8765") is None
|
||||
|
||||
# Key must be persisted now
|
||||
assert key_for_remote("host:8765") == "agent"
|
||||
|
||||
# Second call without --key — should reuse saved "agent"
|
||||
send_command("tabs.list", remote="host:8765")
|
||||
assert used_keys[-1] == "agent"
|
||||
assert used_keys[-1] is None
|
||||
|
||||
# ── async command transport ──────────────────────────────────────────────────
|
||||
|
||||
@@ -557,3 +553,128 @@ def test_send_command_async_local_unix_roundtrip(monkeypatch, tmp_path):
|
||||
assert asyncio.run(run()) == "local-ok"
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
assert seen[0]["args"] == {}
|
||||
|
||||
def test_run_concurrent_preserves_order_and_surfaces_exceptions():
|
||||
"""_run_concurrent mirrors input order and returns exceptions in-slot."""
|
||||
from browser_cli.client.core import _run_concurrent
|
||||
|
||||
async def ok(value):
|
||||
return value
|
||||
|
||||
async def boom():
|
||||
raise RuntimeError("nope")
|
||||
|
||||
results = _run_concurrent([lambda: ok("a"), lambda: boom(), lambda: ok("c")])
|
||||
assert results[0] == "a"
|
||||
assert isinstance(results[1], RuntimeError)
|
||||
assert results[2] == "c"
|
||||
|
||||
def test_run_concurrent_falls_back_when_loop_running():
|
||||
"""Inside a running loop, _run_concurrent stays correct (sequential fallback)."""
|
||||
from browser_cli.client.core import _run_concurrent
|
||||
|
||||
async def ok(value):
|
||||
return value
|
||||
|
||||
async def driver():
|
||||
# asyncio.run would raise here; _run_concurrent must fall back instead.
|
||||
return _run_concurrent([lambda: ok(1), lambda: ok(2)])
|
||||
|
||||
assert asyncio.run(driver()) == [1, 2]
|
||||
|
||||
def test_remote_browser_targets_query_endpoints_concurrently(monkeypatch, tmp_path):
|
||||
"""Multiple remotes are discovered in parallel, not serially."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
remotes_path = tmp_path / "remotes.json"
|
||||
remotes_path.write_text(
|
||||
json.dumps({"host-a.example:8765": {}, "host-b.example:8765": {}, "host-c.example:8765": {}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
|
||||
active = 0
|
||||
max_active = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None, **kwargs):
|
||||
nonlocal active, max_active
|
||||
with lock:
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
time.sleep(0.1)
|
||||
with lock:
|
||||
active -= 1
|
||||
return [{"profile": "work", "displayName": "work"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
||||
|
||||
targets = active_browser_targets()
|
||||
|
||||
assert len(targets) == 3
|
||||
assert max_active >= 2, f"expected concurrent endpoint discovery, peak was {max_active}"
|
||||
|
||||
def test_collect_browser_clients_uses_cached_target_version(monkeypatch, tmp_path):
|
||||
"""A remote target advertising version/extVersion skips the clients.list roundtrip."""
|
||||
from browser_cli.client import collect_browser_clients
|
||||
import browser_cli.client.core as core
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
|
||||
cached_target = BrowserTarget(
|
||||
profile="work",
|
||||
display_name="host.example:work",
|
||||
socket_path="",
|
||||
remote="host.example:8765",
|
||||
browser_name="Firefox",
|
||||
display_group="host.example",
|
||||
version="151.0",
|
||||
extension_version="0.15.6",
|
||||
)
|
||||
monkeypatch.setattr(core, "active_browser_targets", lambda **kw: [cached_target])
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(core, "send_command", lambda *a, **k: calls.append((a, k)) or [])
|
||||
|
||||
rows = collect_browser_clients(registry_path=tmp_path / "missing-registry.json")
|
||||
|
||||
assert calls == [] # no clients.list roundtrip was needed
|
||||
assert rows == [{
|
||||
"profile": "host.example:work",
|
||||
"profileGroup": "host.example",
|
||||
"name": "Firefox",
|
||||
"version": "151.0",
|
||||
"extensionVersion": "0.15.6",
|
||||
}]
|
||||
|
||||
def test_collect_browser_clients_falls_back_when_version_unknown(monkeypatch, tmp_path):
|
||||
"""An older remote (no advertised version) still triggers a clients.list query."""
|
||||
from browser_cli.client import collect_browser_clients
|
||||
import browser_cli.client.core as core
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
|
||||
legacy_target = BrowserTarget(
|
||||
profile="work",
|
||||
display_name="host.example:work",
|
||||
socket_path="",
|
||||
remote="host.example:8765",
|
||||
browser_name="Firefox",
|
||||
display_group="host.example",
|
||||
)
|
||||
monkeypatch.setattr(core, "active_browser_targets", lambda **kw: [legacy_target])
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_send(command, args=None, profile=None, remote=None, key=None, **kw):
|
||||
calls.append(command)
|
||||
return [{"name": "Firefox", "version": "151.0", "extensionVersion": "0.15.2"}]
|
||||
|
||||
monkeypatch.setattr(core, "send_command", fake_send)
|
||||
|
||||
rows = collect_browser_clients(registry_path=tmp_path / "missing-registry.json")
|
||||
|
||||
assert calls == ["clients.list"] # fell back to a query
|
||||
assert rows[0]["version"] == "151.0"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Security/robustness tests for the HTML→Markdown converter on hostile page content."""
|
||||
from browser_cli.markdown.html import _MAX_TREE_DEPTH, convert_html_to_markdown
|
||||
from browser_cli.markdown.render import render_markdown
|
||||
|
||||
def _identity(markdown):
|
||||
return markdown
|
||||
|
||||
# ── depth-bounded recursion (Finding 4: HIGH/DoS) ─────────────────────────────────
|
||||
|
||||
def test_deeply_nested_html_does_not_crash():
|
||||
"""Thousands of nested elements must not raise RecursionError."""
|
||||
depth = 5000
|
||||
html = "<div>" * depth + "deep content" + "</div>" * depth
|
||||
out = convert_html_to_markdown(html, _identity)
|
||||
assert "deep content" in out # text preserved despite flattening
|
||||
|
||||
def test_deeply_nested_via_render_markdown_entrypoint():
|
||||
html = "<div>" * 3000 + "x" + "</div>" * 3000
|
||||
out = render_markdown(html) # routes HTML through the converter
|
||||
assert "x" in out
|
||||
|
||||
def test_nesting_within_cap_is_preserved_structurally():
|
||||
# A modest list nesting (well under the cap) still renders as a list.
|
||||
html = "<ul><li>a<ul><li>b</li></ul></li></ul>"
|
||||
out = convert_html_to_markdown(html, _identity)
|
||||
assert "- a" in out
|
||||
assert "b" in out
|
||||
|
||||
def test_max_tree_depth_is_sane():
|
||||
# Cap must be high enough for real documents, low enough to stay under the
|
||||
# interpreter recursion limit with a few frames per level.
|
||||
assert 50 <= _MAX_TREE_DEPTH <= 400
|
||||
|
||||
# ── unsafe URL schemes (Finding 5: LOW) ───────────────────────────────────────────
|
||||
|
||||
def test_javascript_url_in_link_is_neutralised():
|
||||
# Anchors render their href only in inline context (inside a block like <p>).
|
||||
out = convert_html_to_markdown('<p><a href="javascript:alert(1)">click</a></p>', _identity)
|
||||
assert "javascript:" not in out
|
||||
assert "click" in out # link text kept, dangerous href dropped
|
||||
|
||||
def test_data_and_vbscript_urls_dropped():
|
||||
assert "vbscript:" not in convert_html_to_markdown('<p><a href="vbscript:x">y</a></p>', _identity)
|
||||
assert "data:" not in convert_html_to_markdown('<img src="data:text/html,<script>">', _identity)
|
||||
|
||||
def test_normal_urls_pass_through():
|
||||
out = convert_html_to_markdown('<p><a href="https://example.com">site</a></p>', _identity)
|
||||
assert "(https://example.com)" in out
|
||||
img = convert_html_to_markdown('<img src="https://example.com/x.png" alt="pic">', _identity)
|
||||
assert "(https://example.com/x.png)" in img
|
||||
@@ -287,24 +287,44 @@ def test_collect_paged_browser_command_propagates_error(monkeypatch):
|
||||
assert "extension crash" in result["error"]
|
||||
|
||||
def test_collect_paged_browser_command_max_pages_guard(monkeypatch):
|
||||
"""If paging never ends, the loop guard kicks in and returns an error."""
|
||||
monkeypatch.setattr(native_host, "PAGE_SIZE", 1)
|
||||
"""A runaway extension (empty pages, advancing nextOffset) trips the guard."""
|
||||
monkeypatch.setattr(native_host, "MAX_PAGED_ITEMS", 5)
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def _infinite_pages(cmd):
|
||||
def _infinite_empty_pages(cmd):
|
||||
# Empty items so the item cap never bites — only the page guard can stop this.
|
||||
call_count[0] += 1
|
||||
return {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": [call_count[0]], "total": 9999, "nextOffset": call_count[0]},
|
||||
"data": {"__browserCliPage": True, "items": [], "total": 9999, "nextOffset": call_count[0]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _infinite_pages)
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _infinite_empty_pages)
|
||||
result = native_host._collect_paged_browser_command({"id": "loop", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "paging loop exceeded" in result["error"]
|
||||
|
||||
def test_collect_paged_browser_command_stops_at_item_cap(monkeypatch):
|
||||
"""Paging stops once MAX_PAGED_ITEMS is reached, returning bounded data."""
|
||||
monkeypatch.setattr(native_host, "MAX_PAGED_ITEMS", 5)
|
||||
|
||||
offset = [0]
|
||||
|
||||
def _endless_items(cmd):
|
||||
offset[0] += 2
|
||||
return {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": [1, 2], "total": 100, "nextOffset": offset[0]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _endless_items)
|
||||
result = native_host._collect_paged_browser_command({"id": "cap", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is True
|
||||
assert len(result["data"]) >= 5 # stopped at/just past the cap, not unbounded
|
||||
|
||||
def test_collect_paged_browser_command_invalid_items(monkeypatch):
|
||||
"""If items is not a list the command returns an error dict."""
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -10,6 +10,7 @@ from browser_cli import BrowserCLI
|
||||
from browser_cli.client import BrowserTarget
|
||||
from browser_cli.cli import main
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category
|
||||
from browser_cli.commands import command_policy_from_options
|
||||
|
||||
def test_extension_info_cli_renders_capabilities():
|
||||
with patch("browser_cli.send_command", return_value={"version": "1.2.3", "capabilities": ["extension.info"]}):
|
||||
@@ -148,6 +149,264 @@ def test_serve_http_no_auth_rejected_on_public_host():
|
||||
assert result.exit_code != 0
|
||||
assert "--no-auth is only allowed on loopback" in result.output
|
||||
|
||||
def test_serve_tcp_no_auth_rejected_on_public_host():
|
||||
result = CliRunner().invoke(main, ["serve", "--host", "0.0.0.0", "--no-auth"])
|
||||
assert result.exit_code != 0
|
||||
assert "--no-auth is only allowed on loopback" in result.output
|
||||
|
||||
def test_serve_tcp_no_auth_allowed_on_loopback():
|
||||
# Should pass the loopback guard and only fail later when trying to bind/serve.
|
||||
# We stop it before serve_forever by mocking _serve_async to a no-op.
|
||||
with patch("browser_cli.commands.serve._serve_async", return_value=None) as serve_async:
|
||||
result = CliRunner().invoke(main, ["serve", "--host", "127.0.0.1", "--no-auth"])
|
||||
assert result.exit_code == 0
|
||||
assert serve_async.called
|
||||
|
||||
def _serve_security_for(args):
|
||||
"""Invoke `serve` with the given args and return the ServeSecurity handed to _serve_async."""
|
||||
with patch("browser_cli.commands.serve._serve_async", return_value=None) as serve_async:
|
||||
result = CliRunner().invoke(main, ["serve", "--host", "127.0.0.1", "--no-auth", *args])
|
||||
assert result.exit_code == 0, result.output
|
||||
# _serve_async(host, port, profile, auth_keys_path, compress, security)
|
||||
return serve_async.call_args.args[5]
|
||||
|
||||
def _serve_policy_for(args):
|
||||
"""Convenience: the server-default CommandPolicy from a `serve` invocation."""
|
||||
return _serve_security_for(args).policy
|
||||
|
||||
def test_serve_tcp_defaults_to_safe_only_policy():
|
||||
policy = _serve_policy_for([])
|
||||
assert policy == CommandPolicy() # safe-only, nothing opened
|
||||
assert_command_allowed("tabs.list", policy)
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("dom.eval", policy)
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("navigate.open", policy)
|
||||
|
||||
def test_serve_tcp_allow_all_yields_unrestricted_policy():
|
||||
policy = _serve_policy_for(["--allow-all"])
|
||||
assert policy == CommandPolicy.unrestricted()
|
||||
assert_command_allowed("dom.eval", policy)
|
||||
assert_command_allowed("storage.get", policy)
|
||||
|
||||
def test_serve_tcp_allow_control_opens_only_control():
|
||||
policy = _serve_policy_for(["--allow-control"])
|
||||
assert_command_allowed("navigate.open", policy)
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("dom.eval", policy) # dangerous still blocked
|
||||
|
||||
def test_serve_tcp_default_rate_limit_active():
|
||||
security = _serve_security_for([])
|
||||
assert security.rate_limiter is not None
|
||||
assert security.rate_limiter.rate == 100.0 # default
|
||||
|
||||
def test_serve_tcp_rate_limit_zero_disables():
|
||||
security = _serve_security_for(["--rate-limit", "0"])
|
||||
assert security.rate_limiter is None
|
||||
|
||||
def test_serve_tcp_per_key_policies_loaded_from_authorized_keys(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text("abc123 reader allow:read-page\ndef456 admin allow:all\nghi789 plain\n")
|
||||
security = _serve_security_for(["--authorized-keys", str(keys)])
|
||||
assert security.key_policies["abc123"] == CommandPolicy(allow_read_page=True)
|
||||
assert security.key_policies["def456"] == CommandPolicy.unrestricted()
|
||||
assert "ghi789" not in security.key_policies # falls back to server default
|
||||
assert security.key_names["def456"] == "admin"
|
||||
|
||||
def test_auth_trust_writes_inline_policy_token(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
result = CliRunner().invoke(main, [
|
||||
"auth", "trust", pub, "--name", "ci bot", "--file", str(keys),
|
||||
"--allow-read-page", "--allow-control",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
line = keys.read_text().strip()
|
||||
assert line == f"{pub} ci bot allow:read-page,control"
|
||||
|
||||
def test_auth_trust_without_allow_flags_writes_no_token(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
result = CliRunner().invoke(main, ["auth", "trust", pub, "--name", "plain", "--file", str(keys)])
|
||||
assert result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} plain"
|
||||
|
||||
def test_auth_keys_local_shows_policy_column(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} reader allow:read-page\n{'b' * 64} admin allow:all\n{'c' * 64} plain\n")
|
||||
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
|
||||
assert result.exit_code == 0
|
||||
assert "Policy" in result.output
|
||||
assert "Description" in result.output
|
||||
assert "read-page" in result.output
|
||||
assert "all" in result.output
|
||||
assert "server default" in result.output
|
||||
assert "read page content" in result.output
|
||||
assert "Full access" in result.output
|
||||
|
||||
def test_auth_policy_updates_existing_key_policy(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n")
|
||||
result = CliRunner().invoke(main, [
|
||||
"auth", "policy", pub, "--file", str(keys), "--allow-read-page", "--allow-control",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
|
||||
assert "Updated policy" in result.output
|
||||
|
||||
def test_auth_policy_can_set_safe_and_server_default_by_name(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
keys.write_text(f"{pub} laptop allow:all\n")
|
||||
|
||||
safe_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe"])
|
||||
assert safe_result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} laptop allow:"
|
||||
|
||||
default_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--server-default"])
|
||||
assert default_result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} laptop"
|
||||
|
||||
def test_auth_policy_requires_policy_mode_when_not_interactive(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} laptop\n")
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys)])
|
||||
assert result.exit_code == 1
|
||||
assert "Choose a policy mode" in result.output
|
||||
|
||||
def test_auth_policy_rejects_conflicting_policy_modes(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} laptop\n")
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe", "--allow-all"])
|
||||
assert result.exit_code == 1
|
||||
assert "Choose exactly one policy mode" in result.output
|
||||
|
||||
def test_parse_interactive_policy_selection():
|
||||
from browser_cli.commands.auth import _parse_checkbox_policy_selection, _parse_policy_selection
|
||||
|
||||
assert _parse_policy_selection("1,2") == ["read-page", "control"]
|
||||
assert _parse_policy_selection("read-page control") == ["read-page", "control"]
|
||||
assert _parse_policy_selection("all") == ["all"]
|
||||
assert _parse_policy_selection("safe") == []
|
||||
assert _parse_policy_selection("default") is None
|
||||
assert _parse_checkbox_policy_selection(["read-page", "control"]) == ["read-page", "control"]
|
||||
assert _parse_checkbox_policy_selection(["__all__"]) == ["all"]
|
||||
assert _parse_checkbox_policy_selection(["__safe__"]) == []
|
||||
assert _parse_checkbox_policy_selection(["__server_default__"]) is None
|
||||
|
||||
def test_auth_policy_without_identifier_requires_interactive_picker():
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "--allow-all"])
|
||||
assert result.exit_code == 1
|
||||
assert "Missing key identifier" in result.output
|
||||
|
||||
def test_auth_policy_remote_sends_policy_command():
|
||||
pub = "c" * 64
|
||||
with patch("browser_cli.client.send_command", return_value={"pubkey": pub, "name": "remote key", "allow": ["all"]}) as send:
|
||||
result = CliRunner().invoke(main, ["--remote", "browser-host.example:8765", "auth", "policy", pub, "--allow-all"])
|
||||
assert result.exit_code == 0
|
||||
send.assert_called_once()
|
||||
assert send.call_args.kwargs["args"] == {"identifier": pub, "allow": ["all"]}
|
||||
assert "Updated policy" in result.output
|
||||
|
||||
def test_auth_keys_remote_unreachable_clean_error():
|
||||
"""`auth keys --remote` on an unreachable host shows a clean error, not a traceback."""
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
|
||||
with patch("browser_cli.client.send_command", side_effect=BrowserNotConnected("Cannot connect to remote browser at x.")):
|
||||
result = CliRunner().invoke(main, ["--remote", "x.example:8765", "auth", "keys"])
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, SystemExit) # handled, not a raw exception
|
||||
assert "Error:" in result.output
|
||||
assert "Cannot connect" in result.output
|
||||
|
||||
def test_auth_trust_remote_unreachable_clean_error():
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
|
||||
with patch("browser_cli.client.send_command", side_effect=BrowserNotConnected("Cannot connect to remote browser at x.")):
|
||||
result = CliRunner().invoke(main, ["--remote", "x.example:8765", "auth", "trust", "a" * 64])
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, SystemExit)
|
||||
assert "Error:" in result.output
|
||||
|
||||
def test_serve_http_token_check_is_constant_time():
|
||||
"""The bearer-token comparison uses secrets.compare_digest, not ==."""
|
||||
from browser_cli.commands.serve_http import _Handler
|
||||
|
||||
handler = _Handler.__new__(_Handler)
|
||||
handler.token = "s3cret-token"
|
||||
handler.headers = {"Authorization": "Bearer s3cret-token"}
|
||||
assert handler._authorized() is True
|
||||
handler.headers = {"Authorization": "Bearer wrong"}
|
||||
assert handler._authorized() is False
|
||||
handler.headers = {"X-Browser-CLI-Token": "s3cret-token"}
|
||||
assert handler._authorized() is True
|
||||
handler.headers = {"X-Browser-CLI-Token": "nope"}
|
||||
assert handler._authorized() is False
|
||||
handler.headers = {}
|
||||
assert handler._authorized() is False
|
||||
# No token configured → open.
|
||||
handler.token = None
|
||||
assert handler._authorized() is True
|
||||
|
||||
def test_serve_http_uses_compare_digest():
|
||||
import inspect
|
||||
from browser_cli.commands import serve_http
|
||||
|
||||
src = inspect.getsource(serve_http._Handler._authorized)
|
||||
assert "compare_digest" in src
|
||||
assert "== f\"Bearer" not in src
|
||||
|
||||
def test_serve_http_rate_limiter_blocks_when_exhausted():
|
||||
"""A burst-1 limiter lets the first request through, then sends 429."""
|
||||
from browser_cli.commands.serve_http import _Handler
|
||||
from browser_cli.serve.security import RateLimiter
|
||||
|
||||
handler = _Handler.__new__(_Handler)
|
||||
handler.client_address = ("203.0.113.5", 5000)
|
||||
handler.rate_limiter = RateLimiter(rate=0.001, burst=1)
|
||||
sent = []
|
||||
handler._send = lambda status, payload: sent.append((status, payload))
|
||||
|
||||
assert handler._within_rate_limit() is True
|
||||
assert handler._within_rate_limit() is False
|
||||
assert sent and sent[-1][0] == 429
|
||||
|
||||
def test_serve_http_rate_limiter_none_never_limits():
|
||||
from browser_cli.commands.serve_http import _Handler
|
||||
|
||||
handler = _Handler.__new__(_Handler)
|
||||
handler.client_address = ("203.0.113.5", 5000)
|
||||
handler.rate_limiter = None
|
||||
assert all(handler._within_rate_limit() for _ in range(100))
|
||||
|
||||
def test_serve_http_default_rate_limit_active():
|
||||
from browser_cli.commands.serve_http import _Handler
|
||||
|
||||
with patch("browser_cli.commands.serve_http.BrowserCLI"), \
|
||||
patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls:
|
||||
server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt
|
||||
CliRunner().invoke(main, ["serve-http", "--no-auth"])
|
||||
# The handler class passed to the server carries an active RateLimiter by default.
|
||||
handler_cls = server_cls.call_args.args[1]
|
||||
assert handler_cls.rate_limiter is not None
|
||||
assert handler_cls.rate_limiter.rate == 100.0
|
||||
|
||||
def test_serve_http_non_loopback_warns_about_cleartext():
|
||||
with patch("browser_cli.commands.serve_http.BrowserCLI"), \
|
||||
patch("browser_cli.commands.serve_http.ThreadingHTTPServer") as server_cls:
|
||||
server_cls.return_value.serve_forever.side_effect = KeyboardInterrupt
|
||||
result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--token", "x"])
|
||||
assert "clear text" in result.output
|
||||
|
||||
def test_command_policy_allow_all_grants_everything():
|
||||
policy = command_policy_from_options(
|
||||
allow_read_page=False, allow_control=False, allow_dangerous=False, allow_all=True
|
||||
)
|
||||
assert policy == CommandPolicy.unrestricted()
|
||||
assert_command_allowed("dom.eval", policy)
|
||||
assert_command_allowed("storage.get", policy)
|
||||
|
||||
def test_raw_command_blocks_dangerous_by_default():
|
||||
result = CliRunner().invoke(main, ["command", "dom.eval", '{"code":"document.title"}'])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Unit tests for the remote connection pool (browser_cli.remote.pool)."""
|
||||
import socket
|
||||
|
||||
from browser_cli.remote import pool
|
||||
|
||||
def _socketpair():
|
||||
a, b = socket.socketpair()
|
||||
return a, b
|
||||
|
||||
def test_checkin_then_checkout_returns_same_connection():
|
||||
pool.close_all()
|
||||
a, b = _socketpair()
|
||||
conn = pool.PooledConnection(a, b"secret")
|
||||
pool.checkin("host:1", conn)
|
||||
assert pool.checkout("host:1") is conn
|
||||
assert pool.checkout("host:1") is None # only one was pooled
|
||||
b.close()
|
||||
pool.close_all()
|
||||
|
||||
def test_checkout_drops_stale_connection(monkeypatch):
|
||||
pool.close_all()
|
||||
a, b = _socketpair()
|
||||
conn = pool.PooledConnection(a, b"secret")
|
||||
pool.checkin("host:2", conn)
|
||||
# Make the pooled connection look older than the idle bound.
|
||||
conn.last_used -= (pool._MAX_IDLE_SECONDS + 1)
|
||||
assert pool.checkout("host:2") is None # stale → dropped, not returned
|
||||
b.close()
|
||||
pool.close_all()
|
||||
|
||||
def test_checkin_caps_pool_size():
|
||||
pool.close_all()
|
||||
sockets = []
|
||||
for i in range(pool._MAX_PER_ENDPOINT + 3):
|
||||
a, b = _socketpair()
|
||||
sockets.append(b)
|
||||
pool.checkin("host:3", pool.PooledConnection(a, b"secret"))
|
||||
drained = 0
|
||||
while pool.checkout("host:3") is not None:
|
||||
drained += 1
|
||||
assert drained == pool._MAX_PER_ENDPOINT
|
||||
for b in sockets:
|
||||
b.close()
|
||||
pool.close_all()
|
||||
|
||||
def test_session_inner_message_strips_auth_fields():
|
||||
msg = {
|
||||
"id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1",
|
||||
"pubkey": "x", "sig": "y", "pq_kex": {}, "encrypted": {}, "accept_encoding": {"x": 1},
|
||||
}
|
||||
inner = pool.session_inner_message(msg)
|
||||
assert inner == {"id": "1", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/1", "accept_encoding": {"x": 1}}
|
||||
@@ -167,7 +167,10 @@ def test_current_client_plaintext_transport_is_rejected(auth_material):
|
||||
client.close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_send_command_uses_encrypted_remote_transport(auth_material):
|
||||
def test_send_command_uses_encrypted_remote_transport(auth_material, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.remote.registry.REMOTE_REGISTRY_PATH", tmp_path / "remotes.json"
|
||||
)
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
@@ -187,7 +190,10 @@ def test_send_command_uses_encrypted_remote_transport(auth_material):
|
||||
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch):
|
||||
def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.remote.registry.REMOTE_REGISTRY_PATH", tmp_path / "remotes.json"
|
||||
)
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: None)
|
||||
|
||||
@@ -211,3 +217,61 @@ def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch
|
||||
|
||||
assert "not using a post-quantum key exchange" in stderr.getvalue()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def _run_pool_server(server, auth_path, connections):
|
||||
server.settimeout(3)
|
||||
while True:
|
||||
try:
|
||||
conn, addr = server.accept()
|
||||
except OSError:
|
||||
return
|
||||
connections.append(conn)
|
||||
threading.Thread(target=_handle_client, args=(conn, addr, None, auth_path), daemon=True).start()
|
||||
|
||||
def test_send_command_reuses_pooled_connection(auth_material):
|
||||
"""Two sequential commands to one endpoint share a single authenticated connection."""
|
||||
from browser_cli.remote import pool
|
||||
pool.close_all()
|
||||
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(2)
|
||||
host, port = server.getsockname()
|
||||
connections = []
|
||||
threading.Thread(target=_run_pool_server, args=(server, auth_path, connections), daemon=True).start()
|
||||
|
||||
endpoint = f"{host}:{port}"
|
||||
try:
|
||||
for _ in range(2):
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=endpoint, profile="default", key=key_path)
|
||||
assert len(connections) == 1 # the second command reused the first connection
|
||||
finally:
|
||||
pool.close_all()
|
||||
server.close()
|
||||
|
||||
def test_send_command_opens_new_connection_when_pool_empty(auth_material):
|
||||
"""With no pooled connection to reuse, each command opens its own."""
|
||||
from browser_cli.remote import pool
|
||||
|
||||
key_path, auth_path, _priv, _pub = auth_material
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(2)
|
||||
host, port = server.getsockname()
|
||||
connections = []
|
||||
threading.Thread(target=_run_pool_server, args=(server, auth_path, connections), daemon=True).start()
|
||||
|
||||
endpoint = f"{host}:{port}"
|
||||
try:
|
||||
for _ in range(2):
|
||||
pool.close_all() # drop the pool before each call → no reuse
|
||||
with pytest.raises(RuntimeError, match="browser|connected"):
|
||||
send_command("tabs.list", remote=endpoint, profile="default", key=key_path)
|
||||
assert len(connections) == 2 # each command handshaked its own connection
|
||||
finally:
|
||||
pool.close_all()
|
||||
server.close()
|
||||
|
||||
+167
-3
@@ -13,7 +13,6 @@ from browser_cli.commands.serve import _handle_client
|
||||
|
||||
FAKE_UA = "browser-cli/0.9.3"
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _send_framed(sock: socket.socket, data: bytes) -> None:
|
||||
@@ -35,10 +34,10 @@ def _recv_framed(sock: socket.socket) -> dict:
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
|
||||
def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
|
||||
def _spawn(server_sock: socket.socket, auth_keys_path, security=None) -> threading.Thread:
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server_sock, ("127.0.0.1", 9999), None, auth_keys_path),
|
||||
args=(server_sock, ("127.0.0.1", 9999), None, auth_keys_path, True, security),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
@@ -366,6 +365,171 @@ class TestAuthSuccess:
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
# ── command policy gating ────────────────────────────────────────────────────────
|
||||
|
||||
class TestCommandPolicy:
|
||||
def test_restricted_policy_blocks_dangerous_command(self, monkeypatch):
|
||||
"""A restricted policy denies dom.eval before it ever reaches the browser proxy."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
from browser_cli.serve.security import ServeSecurity
|
||||
|
||||
# If the policy were not enforced, this would be hit and raise a different error.
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None, ServeSecurity(policy=CommandPolicy())) # safe-only
|
||||
_recv_framed(client) # challenge
|
||||
msg = {"id": "x", "command": "dom.eval", "args": {"code": "1"}, "user_agent": "browser-cli/0.9.5"}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "dangerous" in resp["error"].lower() and "blocked" in resp["error"].lower()
|
||||
assert "browser" not in resp["error"].lower() # never reached the proxy
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_restricted_policy_allows_safe_command(self, monkeypatch):
|
||||
"""Safe commands pass the policy gate and reach the proxy even when restricted."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
from browser_cli.serve.security import ServeSecurity
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None, ServeSecurity(policy=CommandPolicy())) # safe-only
|
||||
_recv_framed(client)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "blocked" not in resp["error"].lower()
|
||||
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_unrestricted_policy_allows_dangerous_command(self, monkeypatch):
|
||||
"""The default unrestricted policy lets dom.eval through to the proxy."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
from browser_cli.serve.security import ServeSecurity
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None, ServeSecurity(policy=CommandPolicy.unrestricted()))
|
||||
_recv_framed(client)
|
||||
msg = {"id": "x", "command": "dom.eval", "args": {"code": "1"}, "user_agent": "browser-cli/0.9.5"}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "blocked" not in resp["error"].lower()
|
||||
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
# ── per-key authorization + rate limiting (integration) ──────────────────────────
|
||||
|
||||
class TestPerKeyPolicy:
|
||||
def test_per_key_policy_overrides_server_default(self, tmp_path, monkeypatch):
|
||||
"""A safe-only per-key override blocks dom.eval even when the server default is unrestricted."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
from browser_cli.serve.security import ServeSecurity
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + "\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
security = ServeSecurity(
|
||||
policy=CommandPolicy.unrestricted(), # server default: full access
|
||||
key_policies={pub.lower(): CommandPolicy()}, # this key: safe-only
|
||||
)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path, security)
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "dom.eval", "args": {"code": "1"}, "user_agent": FAKE_UA, "pubkey": pub}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "blocked" in resp["error"].lower() # per-key safe-only wins over server unrestricted
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def _trust_and_query_keys(self, tmp_path, monkeypatch, server_policy):
|
||||
"""Authenticate, then send browser-cli.auth.keys; return the response dict."""
|
||||
from browser_cli.serve.security import ServeSecurity
|
||||
|
||||
path = tmp_path / "authorized_keys"
|
||||
pem, pub = generate_keypair()
|
||||
path.write_text(pub + " mykey\n")
|
||||
key_path = tmp_path / "client.key.pem"
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path, ServeSecurity(policy=server_policy))
|
||||
challenge = _recv_framed(client)
|
||||
nonce = bytes.fromhex(challenge["nonce"])
|
||||
msg = {"id": "x", "command": "browser-cli.auth.keys", "args": {}, "user_agent": FAKE_UA, "pubkey": pub}
|
||||
msg["sig"] = sign(priv, nonce, msg).hex()
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
return resp
|
||||
|
||||
def test_key_management_blocked_without_keys_grant(self, tmp_path, monkeypatch):
|
||||
"""Even full control+dangerous can't list keys — the control command is gated."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
|
||||
resp = self._trust_and_query_keys(
|
||||
tmp_path, monkeypatch,
|
||||
CommandPolicy(allow_read_page=True, allow_control=True, allow_dangerous=True),
|
||||
)
|
||||
assert resp["success"] is False
|
||||
assert "blocked" in resp["error"].lower() and "keys" in resp["error"].lower()
|
||||
|
||||
def test_key_management_allowed_with_keys_grant(self, tmp_path, monkeypatch):
|
||||
"""With allow_keys, the control command runs and returns the trusted-key list."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
|
||||
resp = self._trust_and_query_keys(tmp_path, monkeypatch, CommandPolicy(allow_keys=True))
|
||||
assert resp["success"] is True
|
||||
assert resp["data"][0]["name"] == "mykey"
|
||||
|
||||
class TestRateLimit:
|
||||
def test_shared_rate_limiter_blocks_second_command(self, monkeypatch):
|
||||
"""A burst-1 limiter shared across connections allows the first command, denies the next."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
from browser_cli.serve.security import RateLimiter, ServeSecurity
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
# rate tiny so the bucket never refills within the test; burst 1 = one command total.
|
||||
security = ServeSecurity(policy=CommandPolicy.unrestricted(), rate_limiter=RateLimiter(rate=0.001, burst=1))
|
||||
|
||||
def one_command():
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None, security) # no-auth → keyed by address (127.0.0.1)
|
||||
_recv_framed(client)
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
resp = _recv_framed(client)
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
return resp
|
||||
|
||||
first = one_command()
|
||||
second = one_command()
|
||||
assert "rate limit" not in (first.get("error") or "").lower()
|
||||
assert "rate limit" in (second.get("error") or "").lower()
|
||||
|
||||
# ── response encoding (compression / msgpack) ───────────────────────────────────
|
||||
|
||||
def _recv_framed_raw(sock: socket.socket) -> bytes:
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Unit tests for serve-side security: per-key policy loading, rate limiting, context."""
|
||||
import pytest
|
||||
|
||||
from browser_cli.auth.keys import (
|
||||
_parse_authorized_line,
|
||||
format_authorized_line,
|
||||
load_authorized_keys_with_names,
|
||||
load_authorized_keys_with_policies,
|
||||
set_authorized_key_policy,
|
||||
)
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.serve.security import (
|
||||
RateLimiter,
|
||||
ServeSecurity,
|
||||
key_policies_from_authorized_keys,
|
||||
policy_from_categories,
|
||||
)
|
||||
|
||||
# ── policy_from_categories ───────────────────────────────────────────────────────
|
||||
|
||||
def test_policy_from_categories_all_is_unrestricted():
|
||||
assert policy_from_categories(["all"]) == CommandPolicy.unrestricted()
|
||||
|
||||
def test_policy_from_categories_subset():
|
||||
policy = policy_from_categories(["read-page", "control"])
|
||||
assert policy == CommandPolicy(allow_read_page=True, allow_control=True)
|
||||
assert policy.allow_dangerous is False
|
||||
|
||||
def test_policy_from_categories_safe_and_empty_are_noops():
|
||||
assert policy_from_categories(["safe"]) == CommandPolicy()
|
||||
assert policy_from_categories([]) == CommandPolicy()
|
||||
|
||||
def test_policy_from_categories_rejects_unknown():
|
||||
with pytest.raises(ValueError, match="unknown command category"):
|
||||
policy_from_categories(["bogus"])
|
||||
|
||||
def test_policy_from_categories_keys():
|
||||
assert policy_from_categories(["keys"]) == CommandPolicy(allow_keys=True)
|
||||
|
||||
# ── keys category gating ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_key_commands_are_keys_category():
|
||||
from browser_cli.command_security import command_category
|
||||
assert command_category("browser-cli.auth.keys") == "keys"
|
||||
assert command_category("browser-cli.auth.trust") == "keys"
|
||||
assert command_category("browser-cli.auth.policy") == "keys"
|
||||
assert command_category("browser-cli.targets") == "safe" # discovery stays open
|
||||
|
||||
def test_key_commands_blocked_without_allow_keys():
|
||||
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"):
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed(cmd, CommandPolicy()) # safe-only default
|
||||
assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant
|
||||
assert_command_allowed(cmd, CommandPolicy.unrestricted()) # all includes keys
|
||||
|
||||
def test_full_control_still_cannot_manage_keys():
|
||||
"""A key with control+dangerous (but not keys) cannot list/trust keys."""
|
||||
policy = CommandPolicy(allow_read_page=True, allow_control=True, allow_dangerous=True)
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("browser-cli.auth.trust", policy)
|
||||
|
||||
# ── set_authorized_key_policy ────────────────────────────────────────────────────
|
||||
|
||||
def test_set_policy_updates_by_pubkey(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
path.write_text(f"{pub} laptop\n")
|
||||
assert set_authorized_key_policy(path, pub, ["control"]) == (pub, "laptop")
|
||||
assert load_authorized_keys_with_policies(path) == [(pub, "laptop", ["control"])]
|
||||
|
||||
def test_set_policy_by_name_and_remove_with_none(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
path.write_text(f"{pub} ci-bot allow:all\n")
|
||||
assert set_authorized_key_policy(path, "ci-bot", None) == (pub, "ci-bot") # remove token
|
||||
assert load_authorized_keys_with_policies(path) == [(pub, "ci-bot", None)]
|
||||
|
||||
def test_set_policy_safe_only_writes_empty_token(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
pub = "c" * 64
|
||||
path.write_text(f"{pub} reader\n")
|
||||
set_authorized_key_policy(path, pub, [])
|
||||
assert path.read_text().strip() == f"{pub} reader allow:"
|
||||
|
||||
def test_set_policy_not_found_returns_none(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text(f"{'a' * 64} laptop\n")
|
||||
assert set_authorized_key_policy(path, "nonexistent", ["control"]) is None
|
||||
|
||||
def test_set_policy_ambiguous_name_raises(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text(f"{'a' * 64} dup\n{'b' * 64} dup\n")
|
||||
with pytest.raises(ValueError, match="ambiguous"):
|
||||
set_authorized_key_policy(path, "dup", ["control"])
|
||||
|
||||
def test_set_policy_preserves_other_lines(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
a, b = "a" * 64, "b" * 64
|
||||
path.write_text(f"{a} first\n{b} second allow:read-page\n")
|
||||
set_authorized_key_policy(path, a, ["control"])
|
||||
assert load_authorized_keys_with_policies(path) == [
|
||||
(a, "first", ["control"]),
|
||||
(b, "second", ["read-page"]), # untouched
|
||||
]
|
||||
|
||||
# ── authorized_keys line parsing ─────────────────────────────────────────────────
|
||||
|
||||
def test_parse_line_pubkey_only():
|
||||
assert _parse_authorized_line("abc123") == ("abc123", "", None)
|
||||
|
||||
def test_parse_line_name_with_spaces_no_policy():
|
||||
# A multi-word name (e.g. "YubiKey 5C NFC FIPS") must stay intact, policy None.
|
||||
assert _parse_authorized_line("abc YubiKey 5C NFC FIPS") == ("abc", "YubiKey 5C NFC FIPS", None)
|
||||
|
||||
def test_parse_line_name_with_spaces_and_policy():
|
||||
pub, name, cats = _parse_authorized_line("abc YubiKey 5C NFC FIPS allow:read-page,control")
|
||||
assert pub == "abc"
|
||||
assert name == "YubiKey 5C NFC FIPS" # allow: token stripped out of the name
|
||||
assert cats == ["read-page", "control"]
|
||||
|
||||
def test_parse_line_empty_allow_is_safe():
|
||||
assert _parse_authorized_line("abc name allow:") == ("abc", "name", [])
|
||||
|
||||
def test_parse_line_skips_comments_and_blanks():
|
||||
assert _parse_authorized_line("# comment") is None
|
||||
assert _parse_authorized_line(" ") is None
|
||||
|
||||
def test_format_authorized_line_roundtrips():
|
||||
line = format_authorized_line("abc", "my laptop", ["read-page", "control"])
|
||||
assert line == "abc my laptop allow:read-page,control"
|
||||
assert _parse_authorized_line(line) == ("abc", "my laptop", ["read-page", "control"])
|
||||
# No categories → no allow token.
|
||||
assert format_authorized_line("abc", "laptop") == "abc laptop"
|
||||
|
||||
# ── key_policies_from_authorized_keys ────────────────────────────────────────────
|
||||
|
||||
def test_key_policies_from_authorized_keys(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text(
|
||||
"AABBCC laptop allow:all\n"
|
||||
"ddee01 ci-bot allow:read-page,control\n"
|
||||
"112233 readonly\n" # no allow token → no override entry
|
||||
)
|
||||
policies = key_policies_from_authorized_keys(path)
|
||||
assert policies["aabbcc"] == CommandPolicy.unrestricted() # normalised to lowercase
|
||||
assert policies["ddee01"] == CommandPolicy(allow_read_page=True, allow_control=True)
|
||||
assert "112233" not in policies # falls back to server default
|
||||
|
||||
def test_key_policies_none_returns_empty():
|
||||
assert key_policies_from_authorized_keys(None) == {}
|
||||
|
||||
def test_key_policies_rejects_unknown_category(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text("abc name allow:bogus\n")
|
||||
with pytest.raises(ValueError, match="unknown command category"):
|
||||
key_policies_from_authorized_keys(path)
|
||||
|
||||
def test_load_with_names_ignores_allow_token(tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
path.write_text("abc my laptop allow:control\n")
|
||||
assert load_authorized_keys_with_names(path) == [("abc", "my laptop")]
|
||||
assert load_authorized_keys_with_policies(path) == [("abc", "my laptop", ["control"])]
|
||||
|
||||
# ── RateLimiter ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_rate_limiter_zero_rate_never_limits():
|
||||
limiter = RateLimiter(rate=0)
|
||||
assert all(limiter.allow("k") for _ in range(1000))
|
||||
|
||||
def test_rate_limiter_burst_then_block():
|
||||
limiter = RateLimiter(rate=0.0001, burst=3)
|
||||
assert limiter.allow("k") is True
|
||||
assert limiter.allow("k") is True
|
||||
assert limiter.allow("k") is True
|
||||
assert limiter.allow("k") is False # bucket drained, refill negligible
|
||||
|
||||
def test_rate_limiter_is_per_key():
|
||||
limiter = RateLimiter(rate=0.0001, burst=1)
|
||||
assert limiter.allow("a") is True
|
||||
assert limiter.allow("b") is True # different key has its own bucket
|
||||
assert limiter.allow("a") is False
|
||||
assert limiter.allow("b") is False
|
||||
|
||||
# ── ServeSecurity ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_effective_policy_prefers_per_key_override():
|
||||
sec = ServeSecurity(
|
||||
policy=CommandPolicy.unrestricted(),
|
||||
key_policies={"abc": CommandPolicy()},
|
||||
)
|
||||
assert sec.effective_policy("abc") == CommandPolicy() # override
|
||||
assert sec.effective_policy("other") == CommandPolicy.unrestricted() # default
|
||||
assert sec.effective_policy(None) == CommandPolicy.unrestricted()
|
||||
# And the override actually gates a dangerous command:
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed("dom.eval", sec.effective_policy("abc"))
|
||||
|
||||
def test_label_for_renders_name_and_short_pubkey():
|
||||
sec = ServeSecurity(key_names={"ab12cd34ef": "laptop"})
|
||||
assert sec.label_for("ab12cd34ef") == "laptop ab12cd34…"
|
||||
assert sec.label_for("ffeeddccbb") == "ffeeddcc…" # unknown key → short pubkey only
|
||||
assert sec.label_for(None) is None
|
||||
|
||||
def test_serve_security_defaults_are_safe():
|
||||
sec = ServeSecurity()
|
||||
assert sec.key_policies == {}
|
||||
assert sec.key_names == {}
|
||||
assert sec.rate_limiter is None
|
||||
@@ -413,6 +413,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@@ -463,14 +475,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "questionary"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "prompt-toolkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "real-browser-cli"
|
||||
version = "0.15.6"
|
||||
version = "0.16.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "msgpack" },
|
||||
{ name = "questionary" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
@@ -491,6 +516,7 @@ requires-dist = [
|
||||
{ name = "click", specifier = ">=8" },
|
||||
{ name = "cryptography", specifier = ">=48" },
|
||||
{ name = "msgpack", specifier = ">=1" },
|
||||
{ name = "questionary", specifier = ">=2" },
|
||||
{ name = "rich", specifier = ">=13" },
|
||||
{ name = "zstandard", marker = "extra == 'fast'", specifier = ">=0.22" },
|
||||
]
|
||||
@@ -579,6 +605,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
Reference in New Issue
Block a user