feat: harden remote serve and reuse connections
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s

- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels.
- Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips.
- Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely.
- Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows.
- Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0.
- Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
This commit is contained in:
2026-06-18 14:24:15 +02:00
parent 8dece7800f
commit 6fa931aa36
49 changed files with 3407 additions and 1878 deletions
+38 -9
View File
@@ -53,8 +53,10 @@ Every response:
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox **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 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 ```sh
uv tool install real-browser-cli uv tool install real-browser-cli
@@ -76,21 +78,31 @@ To upgrade later:
uv tool upgrade real-browser-cli 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 ### Install from source
```sh ```sh
git clone <repo> git clone <repo>
cd browser-cli cd browser-cli
uv sync 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: Omit `--dev` to be pointed at the public store listing instead of loading the unpacked build.
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`
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. 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.
@@ -305,6 +317,13 @@ PUBKEY=$(browser-cli auth show --key ~/.config/browser-cli/client.key | tail -n1
browser-cli auth trust "$PUBKEY" browser-cli auth trust "$PUBKEY"
browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.config/browser-cli/authorized_keys 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 # From another machine
browser-cli --remote browser-host.example:8765 --key ~/.config/browser-cli/client.key tabs list 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 browser-cli remote trust browser-host.example:8765 ~/.config/browser-cli/client.key
@@ -317,6 +336,16 @@ 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. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
#### 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`, 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`). 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 …` (works locally and over `--remote`); `auth keys` shows each key's policy.
- **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), but the bearer token travels in **clear text over plain HTTP**. It binds to loopback by default; `--no-auth` is only permitted there. If you must expose it beyond loopback, put it behind a TLS-terminating reverse proxy — never send the token over an untrusted network unencrypted.
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 SDK
@@ -513,7 +542,7 @@ nix-shell # automatically runs npm ci when node_modules is missing/outdated
npm run check:extension 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: Packaging:
+4
View File
@@ -17,9 +17,11 @@ from browser_cli.auth.agent import (
) )
from browser_cli.auth.keys import ( from browser_cli.auth.keys import (
add_authorized_key, add_authorized_key,
format_authorized_line,
generate_keypair, generate_keypair,
load_authorized_keys, load_authorized_keys,
load_authorized_keys_with_names, load_authorized_keys_with_names,
load_authorized_keys_with_policies,
load_private_key, load_private_key,
public_key_hex, public_key_hex,
) )
@@ -51,9 +53,11 @@ __all__ = [
"agent_list_keys", "agent_list_keys",
"agent_sign_raw", "agent_sign_raw",
"canonical_payload", "canonical_payload",
"format_authorized_line",
"generate_keypair", "generate_keypair",
"load_authorized_keys", "load_authorized_keys",
"load_authorized_keys_with_names", "load_authorized_keys_with_names",
"load_authorized_keys_with_policies",
"load_private_key", "load_private_key",
"new_nonce", "new_nonce",
"pq_decrypt", "pq_decrypt",
+41 -9
View File
@@ -29,31 +29,63 @@ def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
return key.pubkey_bytes.hex() return key.pubkey_bytes.hex()
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).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]]: 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 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(): if not path.exists():
return [] return []
result = [] result = []
for line in path.read_text(encoding="utf-8").splitlines(): for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip() parsed = _parse_authorized_line(line)
if not line or line.startswith("#"): if parsed is not None:
continue result.append(parsed)
parts = line.split(None, 1)
pubkey = parts[0]
name = parts[1].strip() if len(parts) > 1 else ""
result.append((pubkey, name))
return result return result
def load_authorized_keys(path: Path) -> list[str]: def load_authorized_keys(path: Path) -> list[str]:
return [pubkey for pubkey, _name in load_authorized_keys_with_names(path)] 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.""" """Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)} existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)}
if pub_hex in existing: if pub_hex in existing:
return False 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: with open(path, "a", encoding="utf-8") as file:
file.write(line) file.write(line)
return True return True
+1 -19
View File
@@ -5,9 +5,6 @@ browser-cli — Control your running browser from the terminal.
import click import click
import os import os
import shutil import shutil
import re
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path
from rich.console import Console from rich.console import Console
from browser_cli.commands.navigate import nav_group 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.watch import watch_group
from browser_cli.commands.workspace import workspace_group from browser_cli.commands.workspace import workspace_group
from browser_cli.commands.raw import cmd_command 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() console = Console()
@@ -53,21 +50,6 @@ def _patched_group_shell_complete(self, ctx, incomplete):
click.Group.shell_complete = _patched_group_shell_complete 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): def _print_version(ctx, param, value):
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
return return
+2
View File
@@ -7,6 +7,7 @@ from browser_cli.client.core import (
_send_remote, _send_remote,
_send_remote_async, _send_remote_async,
active_browser_targets, active_browser_targets,
collect_browser_clients,
remote_browser_targets, remote_browser_targets,
remote_browser_targets_async, remote_browser_targets_async,
remote_target_for_alias, remote_target_for_alias,
@@ -39,6 +40,7 @@ __all__ = [
"_send_remote", "_send_remote",
"_send_remote_async", "_send_remote_async",
"active_browser_targets", "active_browser_targets",
"collect_browser_clients",
"display_browser_name", "display_browser_name",
"remote_browser_targets", "remote_browser_targets",
"remote_browser_targets_async", "remote_browser_targets_async",
+203 -8
View File
@@ -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 import auth, messages, targets as target_discovery
from browser_cli.client.targets import BrowserTarget from browser_cli.client.targets import BrowserTarget
from browser_cli.remote import registry as remote_registry from browser_cli.remote import registry as remote_registry
from browser_cli.errors import BrowserNotConnected 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 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]: def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = [] targets: list[BrowserTarget] = []
for item in items or []: 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 = str(item.get("displayName") or profile)
display_name = _remote_display_name(endpoint, profile, display) display_name = _remote_display_name(endpoint, profile, display)
browser_name = item.get("browserName") or item.get("name") browser_name = item.get("browserName") or item.get("name")
version = item.get("version")
extension_version = item.get("extensionVersion")
targets.append( targets.append(
BrowserTarget( BrowserTarget(
profile=profile, profile=profile,
@@ -35,6 +65,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
remote=endpoint, remote=endpoint,
browser_name=str(browser_name) if browser_name else None, browser_name=str(browser_name) if browser_name else None,
display_group=display_name.rsplit(":", 1)[0], 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 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]: 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] = [] targets: list[BrowserTarget] = []
for endpoint in remote_registry.load_remotes(): for result in results:
try: if isinstance(result, (BrowserNotConnected, RuntimeError)):
targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
except (BrowserNotConnected, RuntimeError):
continue continue
if isinstance(result, BaseException):
raise result
targets.extend(result)
return targets return targets
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]: 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)) targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
return targets 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: def _auto_route_remote(endpoint: str, key=None) -> str | None:
targets = remote_browser_targets(endpoint, key=key) targets = remote_browser_targets(endpoint, key=key)
if len(targets) == 1: if len(targets) == 1:
@@ -159,11 +353,12 @@ def send_command(
return messages.decode_response(response) 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`.""" """Async variant of :func:`remote_browser_targets`."""
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
return _remote_target_items( return _remote_target_items(
endpoint, 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: async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
+5
View File
@@ -19,6 +19,11 @@ class BrowserTarget:
remote: str | None = None remote: str | None = None
browser_name: str | None = None browser_name: str | None = None
display_group: 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: def is_reachable_unix_endpoint(endpoint: str) -> bool:
"""Return True when a Unix socket path exists and accepts connections.""" """Return True when a Unix socket path exists and accepts connections."""
+15 -2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
SAFE_COMMANDS = { SAFE_COMMANDS = {
"browser-cli.targets",
"clients.list", "clients.list",
"extension.capabilities", "extension.capabilities",
"extension.info", "extension.info",
@@ -74,15 +75,23 @@ DANGEROUS_PREFIXES = (
"storage.", "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",
}
@dataclass(frozen=True) @dataclass(frozen=True)
class CommandPolicy: class CommandPolicy:
allow_read_page: bool = False allow_read_page: bool = False
allow_control: bool = False allow_control: bool = False
allow_dangerous: bool = False allow_dangerous: bool = False
allow_keys: bool = False
@classmethod @classmethod
def unrestricted(cls) -> "CommandPolicy": 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: def _is_control(command: str) -> bool:
if command in CONTROL_COMMANDS: if command in CONTROL_COMMANDS:
@@ -93,6 +102,8 @@ def _is_control(command: str) -> bool:
def command_category(command: str) -> str: def command_category(command: str) -> str:
name = str(command or "") 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): if name in DANGEROUS_COMMANDS or any(name.startswith(prefix) for prefix in DANGEROUS_PREFIXES):
return "dangerous" return "dangerous"
if name in READ_PAGE_COMMANDS: if name in READ_PAGE_COMMANDS:
@@ -113,7 +124,9 @@ def assert_command_allowed(command: str, policy: CommandPolicy) -> None:
return return
if category == "dangerous" and policy.allow_dangerous: if category == "dangerous" and policy.allow_dangerous:
return return
if category == "keys" and policy.allow_keys:
return
raise PermissionError( raise PermissionError(
f"Raw command '{command}' is {category} and blocked by default; " 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"
) )
+60
View File
@@ -31,6 +31,66 @@ def gentle_mode_option(help_text: str):
help=help_text, 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: def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
"""Render a count result. """Render a count result.
+36 -9
View File
@@ -8,6 +8,8 @@ from pathlib import Path
import click import click
from rich.console import Console from rich.console import Console
from browser_cli.commands import command_categories_from_options, command_policy_options, handle_errors
console = Console() console = Console()
@click.group("auth") @click.group("auth")
@@ -39,9 +41,15 @@ def cmd_auth_keygen(output, force):
@click.argument("pubkey") @click.argument("pubkey")
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.") @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).") @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 @click.pass_context
def cmd_auth_trust(ctx, pubkey, name, keys_file): @handle_errors
"""Add a public key to the authorized keys file (locally or on a remote serve host).""" 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 from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
if len(pubkey) != 64: if len(pubkey) != 64:
@@ -53,28 +61,37 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file):
console.print("[red]Invalid public key:[/red] not valid hex") console.print("[red]Invalid public key:[/red] not valid hex")
sys.exit(1) 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") remote = (ctx.obj or {}).get("remote")
if remote: if remote:
from browser_cli.client import send_command from browser_cli.client import send_command
args = {"pubkey": pubkey, "name": name}
if categories is not None:
args["allow"] = categories
result = send_command( result = send_command(
"browser-cli.auth.trust", "browser-cli.auth.trust",
args={"pubkey": pubkey, "name": name}, args=args,
remote=remote, remote=remote,
key=(ctx.obj or {}).get("key"), key=(ctx.obj or {}).get("key"),
) )
added = (result or {}).get("added", False) added = (result or {}).get("added", False)
label = f" ({name})" if name else "" label = f" ({name})" if name else ""
if added: 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: else:
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}") console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
return return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH 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 "" label = f" ({name})" if name else ""
if added: 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(f" File: {path}")
console.print("\nStart the server with:") console.print("\nStart the server with:")
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]") console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
@@ -123,6 +140,7 @@ def cmd_auth_show(key_src):
@auth_group.command("keys") @auth_group.command("keys")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).") @click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context @click.pass_context
@handle_errors
def cmd_auth_keys(ctx, keys_file): def cmd_auth_keys(ctx, keys_file):
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server.""" """List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
from rich.table import Table from rich.table import Table
@@ -138,9 +156,9 @@ def cmd_auth_keys(ctx, keys_file):
entries = result or [] entries = result or []
source_label = remote source_label = remote
else: 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 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) source_label = str(path)
if not entries: if not entries:
@@ -151,7 +169,16 @@ def cmd_auth_keys(ctx, keys_file):
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name") table.add_column("Name")
table.add_column("Public Key") table.add_column("Public Key")
table.add_column("Policy")
for entry in entries: for entry in entries:
name = entry.get("name") or "[dim]—[/dim]" name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", "")) table.add_row(name, entry.get("pubkey", ""), _policy_label(entry.get("allow")))
console.print(table) console.print(table)
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"
+26 -116
View File
@@ -11,11 +11,10 @@ from browser_cli.client import (
BrowserNotConnected, BrowserNotConnected,
REGISTRY_PATH, REGISTRY_PATH,
active_browser_targets, active_browser_targets,
display_browser_name, collect_browser_clients,
remote_browser_targets,
remote_target_for_alias,
send_command, send_command,
) )
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.registry import load_registry from browser_cli.registry import load_registry
console = Console() 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: if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists") 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.group("clients", invoke_without_command=True)
@click.pass_context @click.pass_context
@@ -61,18 +43,20 @@ def clients_group(ctx):
if ctx.invoked_subcommand is not None: if ctx.invoked_subcommand is not None:
return return
all_clients = []
browser_alias = (ctx.obj or {}).get("browser") browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE") remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
key = (ctx.obj or {}).get("key") key = (ctx.obj or {}).get("key")
if not remote and browser_alias: try:
_collect_remote_alias_clients(all_clients, browser_alias, key) all_clients = collect_browser_clients(
elif remote: browser_alias=browser_alias,
_collect_explicit_remote_clients(all_clients, browser_alias, remote, key) remote=remote,
else: key=key,
_collect_local_and_saved_remote_clients(all_clients) registry_path=REGISTRY_PATH,
)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
if not all_clients: if not all_clients:
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]") 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) _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: 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")} groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
grouped = bool(groups and groups != {"local"}) grouped = bool(groups and groups != {"local"})
for c in all_clients: columns = [
group = c.get("profileGroup") if grouped else None ("Browser", lambda item: item.get("name", "")),
if group: ("Version", lambda item: item.get("version", "")),
if group not in rendered_groups: ("Extension Version", lambda item: item.get("extensionVersion", "")),
table.add_row(f"[bold]{group}[/bold]", "", "", "") ]
rendered_groups.add(group) print_browser_grouped_table_rows(
profile = str(c.get("profile", "")).removeprefix(f"{group}:") all_clients,
profile = f" {profile}" columns,
else: console=console,
profile = c.get("profile", "") empty_message="[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]",
table.add_row( browser_getter=lambda item: item.get("profile", ""),
profile, group_getter=lambda item: item.get("profileGroup", "") if grouped else "",
c.get("name", ""), browser_header="Profile",
c.get("version", ""), )
c.get("extensionVersion", ""),
)
console.print(table)
@clients_group.command("rename") @clients_group.command("rename")
@click.option( @click.option(
+3 -20
View File
@@ -1,35 +1,18 @@
from __future__ import annotations from __future__ import annotations
import re
import shutil import shutil
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path
import click import click
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from browser_cli.commands import handle_errors, client_from_ctx from browser_cli.commands import handle_errors, client_from_ctx
from browser_cli.client import active_browser_targets 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.platform import is_windows
from browser_cli.version_manager import project_version
console = Console() 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: def _status(ok: bool) -> str:
return "[green]OK[/green]" if ok else "[red]FAIL[/red]" return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
@@ -39,7 +22,7 @@ def _status(ok: bool) -> str:
def cmd_doctor(check_remote): def cmd_doctor(check_remote):
"""Diagnose browser-cli installation, extension, and connection health.""" """Diagnose browser-cli installation, extension, and connection health."""
rows: list[tuple[str, bool, str]] = [] rows: list[tuple[str, bool, str]] = []
version = _project_version() version = project_version()
rows.append(("Python package", version != "unknown", 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")) rows.append(("browser-cli executable", shutil.which("browser-cli") is not None, shutil.which("browser-cli") or "not on PATH"))
+38 -17
View File
@@ -10,7 +10,9 @@ from rich.console import Console
from browser_cli.constants import ( from browser_cli.constants import (
ALLOWED_EXTENSION_IDS, ALLOWED_EXTENSION_IDS,
CHROME_WEBSTORE_URL,
EXTENSION_ID, EXTENSION_ID,
FIREFOX_ADDON_URL,
FIREFOX_EXTENSION_ID, FIREFOX_EXTENSION_ID,
NATIVE_HOST_DIRS, NATIVE_HOST_DIRS,
NATIVE_HOST_NAME, NATIVE_HOST_NAME,
@@ -62,11 +64,44 @@ def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str
@click.command("install") @click.command("install")
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome") @click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
def cmd_install(browser): @click.option("--dev", is_flag=True, help="Print developer instructions for loading an unpacked/temporary build instead of the public store listing.")
"""Register the native messaging host and print extension load instructions.""" def cmd_install(browser, dev):
"""Register the native messaging host and print extension install instructions."""
host_exe = native_host_exe() host_exe = native_host_exe()
write_native_host_exe(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 = { ext_url = {
"chrome": "chrome://extensions", "chrome": "chrome://extensions",
"chromium": "chrome://extensions", "chromium": "chrome://extensions",
@@ -75,7 +110,7 @@ def cmd_install(browser):
"vivaldi": "vivaldi://extensions", "vivaldi": "vivaldi://extensions",
"firefox": "about:debugging#/runtime/this-firefox", "firefox": "about:debugging#/runtime/this-firefox",
}[browser] }[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]") console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
if browser == "firefox": if browser == "firefox":
repo_root = Path(__file__).parent.parent.parent 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" 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") 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: def _native_host_manifest(browser: str, host_exe: Path) -> dict:
base = { base = {
"name": NATIVE_HOST_NAME, "name": NATIVE_HOST_NAME,
+10 -12
View File
@@ -4,20 +4,18 @@ import json
import click import click
from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.command_security import assert_command_allowed
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
@click.command("command") @click.command("command")
@click.argument("name") @click.argument("name")
@click.argument("args_json", required=False, default="{}") @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") @command_policy_options
@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")
@handle_errors @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.""" """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) assert_command_allowed(name, policy)
args = json.loads(args_json) if args_json else {} args = json.loads(args_json) if args_json else {}
result = client_from_ctx().command(name, args) result = client_from_ctx().command(name, args)
click.echo(json.dumps(result, indent=2, default=str)) click.echo(json.dumps(result, indent=2, default=str))
+5 -7
View File
@@ -8,8 +8,8 @@ from typing import Any, cast
import click import click
from rich.console import Console from rich.console import Console
from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.command_security import assert_command_allowed
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
console = Console() 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.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("--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("--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") @command_policy_options
@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")
@handle_errors @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.""" """Run a JSON/YAML batch script of browser-cli wire commands."""
steps = _load_steps(file) steps = _load_steps(file)
if not isinstance(steps, list): if not isinstance(steps, list):
raise click.ClickException("Script root must be a list") raise click.ClickException("Script root must be a list")
client = client_from_ctx() 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 = [] results = []
for index, step in enumerate(steps, start=1): for index, step in enumerate(steps, start=1):
command, args = _parse_step(step) command, args = _parse_step(step)
+3 -54
View File
@@ -1,61 +1,10 @@
import click import click
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import client_from_ctx, handle_errors
from rich.console import Console from rich.console import Console
from browser_cli.search.engines import DISPLAY_NAMES, SUBCOMMANDS
console = Console() 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") @click.group("search")
def search_group(): def search_group():
"""Search the web — open a query in a search engine.""" """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) terms = " ".join(query)
client_from_ctx().nav.search(engine_key, terms, window=window, group=group) 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 "") 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}") console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
return _cmd return _cmd
for _name, _help in _SUBCOMMANDS: for _name, _help in SUBCOMMANDS:
search_group.add_command(_build_command(_name, _help)) search_group.add_command(_build_command(_name, _help))
+155 -78
View File
@@ -8,110 +8,187 @@ from pathlib import Path
import click import click
from browser_cli import transport 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 ( from browser_cli.serve.runtime import (
_async_framed_send, _async_framed_send,
_async_handle_client, _async_handle_client,
_async_recv_all, _async_recv_all,
_handle_client, _handle_client,
_serve_async, _serve_async,
console, console,
) )
from browser_cli.serve.security import RateLimiter, ServeSecurity, key_policies_from_authorized_keys
from browser_cli.version_manager import get_installed_version from browser_cli.version_manager import get_installed_version
__all__ = [ __all__ = [
"_async_framed_send", "_async_framed_send",
"_async_handle_client", "_async_handle_client",
"_async_recv_all", "_async_recv_all",
"_handle_client", "_handle_client",
"_serve_async", "_serve_async",
"cmd_serve", "cmd_serve",
] ]
def _is_loopback(host: str) -> bool:
return host in {"127.0.0.1", "localhost", "::1"}
@click.command("serve") @click.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.") @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.") @click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).") @click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option( @click.option(
"--authorized-keys", "--authorized-keys",
"auth_keys_file", "auth_keys_file",
default=None, default=None,
metavar="FILE", metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.", help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
) )
@click.option( @click.option(
"--no-compress", "--no-compress",
"no_compress", "no_compress",
is_flag=True, is_flag=True,
default=False, default=False,
help="Disable response compression / msgpack even for clients that support it.", 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 @click.pass_context
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress): def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rate_limit,
"""Expose this browser over TCP so remote hosts can control it.""" allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
profile = ctx.obj.get("browser") if ctx.obj else None """Expose this browser over TCP so remote hosts can control it.
compress = not no_compress
if host in ("0.0.0.0", "::"): Commands are gated by a safe-only policy by default; remote clients can only
console.print( run read-only status/listing commands. Open more with --allow-read-page,
"[yellow]Warning:[/yellow] Binding to all interfaces — " --allow-control, --allow-dangerous, or --allow-all (full control). Per-key
"anyone who can reach this port controls your browser." 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
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth) if no_auth and not _is_loopback(host):
if auth_keys_path is False: console.print(
sys.exit(1) "[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)
_print_startup(host, port, profile, auth_keys_path, compress) 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."
)
try: policy = command_policy_from_options(
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress)) allow_read_page=allow_read_page,
except OSError as e: allow_control=allow_control,
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}") allow_dangerous=allow_dangerous,
sys.exit(1) allow_keys=allow_keys,
except KeyboardInterrupt: allow_all=allow_all,
console.print("[yellow]Stopped.[/yellow]") )
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
if auth_keys_path is False:
sys.exit(1)
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, 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: def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
if auth_keys_file: if auth_keys_file:
from browser_cli.auth import load_authorized_keys from browser_cli.auth import load_authorized_keys
auth_keys_path = Path(auth_keys_file) auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path): if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}") console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
return auth_keys_path return auth_keys_path
if no_auth: if no_auth:
return None return None
console.print( console.print(
"[red]Error:[/red] --authorized-keys FILE is required. " "[red]Error:[/red] --authorized-keys FILE is required. "
"Use --no-auth to explicitly disable auth (dangerous)." "Use --no-auth to explicitly disable auth (dangerous)."
) )
return False 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() current_ver = get_installed_version()
browser_hint = f" (browser: {profile})" if profile else "" security = security if security is not None else ServeSecurity()
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]") 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]")
if auth_keys_path is not None: if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path)) n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})") console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
else: else:
console.print("[yellow] Auth disabled (--no-auth)[/yellow]") console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]") _print_policy_status(security.policy)
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]") if security.key_policies:
_print_encoding_status(compress) console.print(f" Per-key: [green]{len(security.key_policies)} override(s)[/green] [dim](allow: in authorized_keys)[/dim]")
console.print("Ctrl-C to stop.\n") 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: def _print_encoding_status(compress: bool) -> None:
if not compress: if not compress:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]") console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
return return
codecs = "+".join(transport.supported_compression()) codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization()) sers = "+".join(transport.supported_serialization())
console.print( console.print(
" Encode: [green]on[/green] " " Encode: [green]on[/green] "
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]" f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
) )
+8 -7
View File
@@ -10,6 +10,7 @@ from rich.console import Console
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.command_security import CommandPolicy, assert_command_allowed from browser_cli.command_security import CommandPolicy, assert_command_allowed
from browser_cli.commands import command_policy_from_options, command_policy_options
console = Console() console = Console()
@@ -24,9 +25,11 @@ class _Handler(BaseHTTPRequestHandler):
def _authorized(self) -> bool: def _authorized(self) -> bool:
if self.token is None: if self.token is None:
return True 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 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: def _require_auth(self) -> bool:
if self._authorized(): if self._authorized():
@@ -87,10 +90,8 @@ class _Handler(BaseHTTPRequestHandler):
@click.option("--key", default=None, help="Remote auth key spec") @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("--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("--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") @command_policy_options
@click.option("--allow-control", is_flag=True, help="Allow /command to run browser-control commands") def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
@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):
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command). """Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
Auth is enabled by default. Pass the printed token as either Auth is enabled by default. Pass the printed token as either
@@ -99,7 +100,7 @@ def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_
if no_auth and not _is_loopback(host): if no_auth and not _is_loopback(host):
raise click.ClickException("--no-auth is only allowed on loopback hosts") raise click.ClickException("--no-auth is only allowed on loopback hosts")
auth_token = None if no_auth else (token or secrets.token_urlsafe(32)) 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)
handler = type( handler = type(
"BrowserCLIHTTPHandler", "BrowserCLIHTTPHandler",
(_Handler,), (_Handler,),
+19 -1
View File
@@ -20,11 +20,29 @@ FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID] ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi", "firefox"] 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" PROTOCOL_MIN_CLIENT = "0.9.0"
MAX_MSG_BYTES = 32 * 1024 * 1024 MAX_MSG_BYTES = 32 * 1024 * 1024
DEFAULT_REMOTE_PORT = 443 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 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"}
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"] GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
+19 -23
View File
@@ -62,27 +62,27 @@ class Tab:
def close(self) -> None: def close(self) -> None:
"""Close this tab.""" """Close this tab."""
self._command("tabs.close", {"tabId": self.id}) self._b().tabs.close(self.id)
def activate(self) -> None: def activate(self) -> None:
"""Switch browser focus to this tab.""" """Switch browser focus to this tab."""
self._command("tabs.active", {"tabId": self.id}) self._b().tabs.activate(self.id)
def mute(self) -> None: def mute(self) -> None:
"""Mute this tab.""" """Mute this tab."""
self._command("tabs.mute", {"tabId": self.id}) self._b().tabs.mute(self.id)
def unmute(self) -> None: def unmute(self) -> None:
"""Unmute this tab.""" """Unmute this tab."""
self._command("tabs.unmute", {"tabId": self.id}) self._b().tabs.unmute(self.id)
def reload(self) -> None: def reload(self) -> None:
"""Reload this tab.""" """Reload this tab."""
self._command("navigate.reload", {"tabId": self.id}) self._b().nav.reload(self.id)
def hard_reload(self) -> None: def hard_reload(self) -> None:
"""Hard-reload this tab (bypass cache).""" """Hard-reload this tab (bypass cache)."""
self._command("navigate.hard_reload", {"tabId": self.id}) self._b().nav.hard_reload(self.id)
def move( def move(
self, *, self, *,
@@ -101,18 +101,18 @@ class Tab:
window_id: Move to the window with this ID. window_id: Move to the window with this ID.
index: Absolute position index in the target window. index: Absolute position index in the target window.
""" """
self._command("tabs.move", { self._b().tabs.move(
"tabId": self.id, self.id,
"forward": forward, forward=forward,
"backward": backward, backward=backward,
"groupId": group_id, group_id=group_id,
"windowId": window_id, window_id=window_id,
"index": index, index=index,
}) )
def html(self) -> str: def html(self) -> str:
"""Return the full HTML source of this tab.""" """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: def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
"""Capture this tab's visible area. Returns a base64 data URL.""" """Capture this tab's visible area. Returns a base64 data URL."""
@@ -120,11 +120,11 @@ class Tab:
def pin(self) -> None: def pin(self) -> None:
"""Pin this tab.""" """Pin this tab."""
self._command("tabs.pin", {"tabId": self.id}) self._b().tabs.pin(self.id)
def unpin(self) -> None: def unpin(self) -> None:
"""Unpin this tab.""" """Unpin this tab."""
self._command("tabs.unpin", {"tabId": self.id}) self._b().tabs.unpin(self.id)
def refresh(self) -> Tab: def refresh(self) -> Tab:
"""Return a fresh snapshot of this tab.""" """Return a fresh snapshot of this tab."""
@@ -170,7 +170,7 @@ class Group:
def close(self) -> None: def close(self) -> None:
"""Ungroup (and close) this tab group.""" """Ungroup (and close) this tab group."""
self._command("group.close", {"groupId": self.id}) self._b().groups.close(self.id)
def tabs(self) -> list[Tab]: def tabs(self) -> list[Tab]:
"""Return all tabs inside this group.""" """Return all tabs inside this group."""
@@ -178,11 +178,7 @@ class Group:
def move(self, *, forward: bool = False, backward: bool = False) -> None: def move(self, *, forward: bool = False, backward: bool = False) -> None:
"""Move this group forward or backward among groups.""" """Move this group forward or backward among groups."""
self._command("group.move", { self._b().groups.move(str(self.id), forward=forward, backward=backward)
"group": str(self.id),
"forward": forward,
"backward": backward,
})
def add_tab(self, url: str | None = None) -> int | None: def add_tab(self, url: str | None = None) -> int | None:
"""Open a new tab inside this group. Returns the new tab ID.""" """Open a new tab inside this group. Returns the new tab ID."""
+6 -4
View File
@@ -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). and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
""" """
import json import json
import math
import os import os
import queue import queue
import socket import socket
@@ -17,7 +16,7 @@ import uuid
from pathlib import Path from pathlib import Path
from browser_cli.native import local_server, protocol 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.platform import endpoint_for_alias, is_windows, registry_path, runtime_dir
from browser_cli.registry import update_registry from browser_cli.registry import update_registry
@@ -126,7 +125,10 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
offset = 0 offset = 0
items = [] items = []
total = None 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 pages_fetched = 0
while True: while True:
@@ -154,7 +156,7 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
items.extend(page_items) items.extend(page_items)
total = data.get("total", total) total = data.get("total", total)
next_offset = data.get("nextOffset") next_offset = data.get("nextOffset")
if next_offset is None: if next_offset is None or len(items) >= MAX_PAGED_ITEMS:
break break
offset = int(next_offset) offset = int(next_offset)
+103
View File
@@ -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)
+7 -2
View File
@@ -25,8 +25,8 @@ def split_endpoint(endpoint: str) -> tuple[str, int]:
host, _, port_str = connect_ep.rpartition(":") host, _, port_str = connect_ep.rpartition(":")
return host, int(port_str) return host, int(port_str)
@contextmanager def connect_socket(endpoint: str) -> socket.socket:
def open_socket(endpoint: str): """Open and (on :443) TLS-wrap a socket. Caller owns closing it."""
host, port = split_endpoint(endpoint) host, port = split_endpoint(endpoint)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30) raw_sock.settimeout(30)
@@ -40,6 +40,11 @@ def open_socket(endpoint: str):
except Exception: except Exception:
raw_sock.close() raw_sock.close()
raise raise
return sock
@contextmanager
def open_socket(endpoint: str):
sock = connect_socket(endpoint)
with sock: with sock:
yield sock yield sock
+28 -2
View File
@@ -20,24 +20,50 @@ from browser_cli.remote.auth import (
from browser_cli.remote.socket import ( from browser_cli.remote.socket import (
async_recv_all as _async_recv_all, async_recv_all as _async_recv_all,
async_recv_exact_bytes as _async_recv_exact, async_recv_exact_bytes as _async_recv_exact,
connect_socket as _connect_socket,
open_async_connection as _open_async_connection, open_async_connection as _open_async_connection,
open_socket as _open_socket, open_socket as _open_socket,
recv_all as _recv_all, recv_all as _recv_all,
recv_exact_bytes as _recv_exact, recv_exact_bytes as _recv_exact,
split_endpoint as _split_endpoint, 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: 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 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): def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
from browser_cli.auth import pq_kex_client_encapsulate 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) 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) 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"))) 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: 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) reader, writer = await _open_async_connection(endpoint)
+1 -1
View File
@@ -114,7 +114,7 @@ class NavigationNS(Namespace):
) -> None: ) -> None:
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg').""" """Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
from urllib.parse import quote_plus from urllib.parse import quote_plus
from browser_cli.commands.search import ENGINES from browser_cli.search.engines import ENGINES
template = ENGINES.get(engine) template = ENGINES.get(engine)
if template is None: if template is None:
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}") raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
+22 -10
View File
@@ -7,12 +7,14 @@ helpers; single-browser mode falls straight through to ``_cmd``.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import importlib import importlib
import sys import sys
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Protocol, cast from typing import TYPE_CHECKING, Protocol, cast
from browser_cli.client import BrowserTarget from browser_cli.client import BrowserTarget
from browser_cli.client.core import _run_concurrent
from browser_cli.errors import BrowserNotConnected from browser_cli.errors import BrowserNotConnected
from browser_cli.models import BrowserCounts, Tab from browser_cli.models import BrowserCounts, Tab
@@ -81,18 +83,28 @@ class RoutingMixin:
return targets return targets
def _collect_multi_browser(self, command: str, args: dict | None = None): def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
targets = self._multi_browser_targets() targets = self._multi_browser_targets()
for target in targets:
try: def _send(target: BrowserTarget):
if target.remote: package = _browser_cli_package()
data = _browser_cli_package().send_command( if target.remote:
command, args, profile=target.profile, remote=target.remote, key=self._client._key 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) return package.send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
# 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 continue
if isinstance(data, BaseException):
raise data
results.append((target, data)) results.append((target, data))
if results: if results:
return results return results
+1
View File
@@ -0,0 +1 @@
"""Search metadata and helpers shared by SDK and CLI layers."""
+53
View File
@@ -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)."),
]
+32 -11
View File
@@ -10,6 +10,7 @@ class ServeControlMixin:
addr: tuple addr: tuple
command: str command: str
auth_keys_path: Path | None auth_keys_path: Path | None
auth_label: str | None
async def send_error(self, msg: str, msg_id=None) -> None: ... async def send_error(self, msg: str, msg_id=None) -> None: ...
async def send_ok(self, payload, command: str | None = None) -> None: ... async def send_ok(self, payload, command: str | None = None) -> None: ...
@@ -23,25 +24,32 @@ class ServeControlMixin:
try: try:
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True) clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
if clients: if clients:
browser_name = clients[0].get("name") # Carry the full client info so a remote `clients` command can render
if browser_name: # from this single roundtrip instead of issuing another clients.list.
item["browserName"] = browser_name info = clients[0]
for src, dst in (("name", "browserName"), ("version", "version"), ("extensionVersion", "extensionVersion")):
value = info.get(src)
if value:
item[dst] = value
except Exception: except Exception:
pass pass
targets.append(item) targets.append(item)
await self.send_ok(targets, self.command) 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 return True
if self.command == "browser-cli.auth.keys": if self.command == "browser-cli.auth.keys":
if self.auth_keys_path is None: if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server") 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 return True
from browser_cli.auth import load_authorized_keys_with_names from browser_cli.auth import load_authorized_keys_with_policies
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(self.auth_keys_path)] 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) 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 return True
if self.command == "browser-cli.auth.trust": if self.command == "browser-cli.auth.trust":
@@ -54,14 +62,27 @@ class ServeControlMixin:
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file") log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True return True
from browser_cli.auth import add_authorized_key from browser_cli.auth import add_authorized_key
from browser_cli.serve.security import policy_from_categories
args = msg.get("args") or {} args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "") pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "") name = str(args.get("name") or "")
categories = args.get("allow")
if not re.fullmatch(r"[0-9a-f]{64}", pubkey): if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
await self.send_error("invalid pubkey: expected 64 lowercase hex characters") 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 return True
added = add_authorized_key(self.auth_keys_path, pubkey, name) if categories is not None:
if 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 True
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 True
added = add_authorized_key(self.auth_keys_path, pubkey, name, categories)
await self.send_ok({"added": added}, self.command) 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 return True
+11 -3
View File
@@ -6,11 +6,19 @@ from rich.console import Console
console = 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") ts = datetime.now().strftime("%H:%M:%S")
addr_str = f"{addr[0]}:{addr[1]}" 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 "" profile_str = f"[dim]{profile}[/dim] " if profile else ""
if error: 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: 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]")
+5 -4
View File
@@ -18,6 +18,7 @@ class ServeProxyMixin:
command: str command: str
compress: bool compress: bool
accept_encoding: dict | None accept_encoding: dict | None
auth_label: str | None
async def send_error(self, msg: str, msg_id=None) -> None: ... async def send_error(self, msg: str, msg_id=None) -> None: ...
async def send_payload(self, data: bytes) -> None: ... async def send_payload(self, data: bytes) -> None: ...
@@ -35,7 +36,7 @@ class ServeProxyMixin:
sock_path = resolve_socket(resolved_profile) sock_path = resolve_socket(resolved_profile)
except BrowserNotConnected as e: except BrowserNotConnected as e:
await self.send_error(str(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 return
try: try:
@@ -46,7 +47,7 @@ class ServeProxyMixin:
await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile) await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile)
except (OSError, json.JSONDecodeError, ConnectionError) as e: except (OSError, json.JSONDecodeError, ConnectionError) as e:
await self.send_error(str(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: async def _windows_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
from multiprocessing.connection import Client as PipeClient from multiprocessing.connection import Client as PipeClient
@@ -74,6 +75,6 @@ class ServeProxyMixin:
else: else:
await self.send_payload(resp_payload) await self.send_payload(resp_payload)
if resp_data.get("success", True): 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: 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)
+87 -6
View File
@@ -9,17 +9,20 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import socket import socket
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from browser_cli import transport from browser_cli import transport
from browser_cli.command_security import assert_command_allowed
from browser_cli.compat import adapt_auth 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.framing import async_recv_frame, async_send_frame
from browser_cli.serve.auth import ServeAuthMixin 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.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.control import ServeControlMixin
from browser_cli.serve.logging import console, log_request from browser_cli.serve.logging import console, log_request
from browser_cli.serve.proxy import ServeProxyMixin 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: async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
await async_send_frame(writer, data) await async_send_frame(writer, data)
@@ -38,12 +41,15 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
nonce: str nonce: str
pq_private_key: object | None = None pq_private_key: object | None = None
compress: bool = True compress: bool = True
security: ServeSecurity = field(default_factory=ServeSecurity)
response_secret: bytes | None = None response_secret: bytes | None = None
accept_encoding: dict | None = None accept_encoding: dict | None = None
client_ver: str = "0" client_ver: str = "0"
msg_id: object = None msg_id: object = None
command: str = "?" command: str = "?"
auth_pubkey: str | None = None
auth_label: str | None = None
async def send_payload(self, data: bytes) -> None: async def send_payload(self, data: bytes) -> None:
if self.response_secret is not None: if self.response_secret is not None:
@@ -89,11 +95,73 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
msg = await self.authenticate(msg) msg = await self.authenticate(msg)
if msg is None: if msg is None:
return 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") 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): if await self.handle_control_command(msg):
return return
await self.forward_to_browser(msg) 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( async def _async_proxy_request(
reader: asyncio.StreamReader, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter, writer: asyncio.StreamWriter,
@@ -104,8 +172,12 @@ async def _async_proxy_request(
nonce: str, nonce: str,
pq_private_key=None, pq_private_key=None,
compress: bool = True, compress: bool = True,
security: ServeSecurity | None = 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( async def _async_handle_client(
reader: asyncio.StreamReader, reader: asyncio.StreamReader,
@@ -115,6 +187,7 @@ async def _async_handle_client(
auth_keys_path: Path | None, auth_keys_path: Path | None,
compress: bool = True, compress: bool = True,
conn_limit: asyncio.Semaphore | None = None, conn_limit: asyncio.Semaphore | None = None,
security: ServeSecurity | None = None,
) -> None: ) -> None:
if conn_limit is None: if conn_limit is None:
conn_limit = asyncio.Semaphore(64) conn_limit = asyncio.Semaphore(64)
@@ -130,7 +203,7 @@ async def _async_handle_client(
await _async_framed_send(writer, json.dumps(challenge_msg).encode()) await _async_framed_send(writer, json.dumps(challenge_msg).encode())
except OSError: except OSError:
return 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: finally:
conn_limit.release() conn_limit.release()
writer.close() writer.close()
@@ -145,12 +218,13 @@ def _handle_client(
profile: str | None, profile: str | None,
auth_keys_path: Path | None, auth_keys_path: Path | None,
compress: bool = True, compress: bool = True,
security: ServeSecurity | None = None,
) -> None: ) -> None:
"""Run one accepted socket through the async serve pipeline.""" """Run one accepted socket through the async serve pipeline."""
async def _run() -> None: async def _run() -> None:
reader, writer = await asyncio.open_connection(sock=client_sock) 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: try:
asyncio.run(_run()) asyncio.run(_run())
@@ -160,12 +234,19 @@ def _handle_client(
except OSError: except OSError:
pass 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) conn_limit = asyncio.Semaphore(64)
async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
peer = writer.get_extra_info("peername") or ("?", 0) 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) server = await asyncio.start_server(_client_connected, host, port, backlog=16)
async with server: async with server:
+115
View File
@@ -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
+25 -9
View File
@@ -1,17 +1,33 @@
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 from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT, PYPI_PACKAGE_NAME
def parse_version(v: str) -> tuple[int, ...]: def parse_version(v: str) -> tuple[int, ...]:
try: try:
return tuple(int(x) for x in v.lstrip("v").split(".")) return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError: except ValueError:
return (0,) return (0,)
def get_installed_version() -> str: def get_installed_version() -> str:
try: try:
return _pkg_version(PYPI_PACKAGE_NAME) return _pkg_version(PYPI_PACKAGE_NAME)
except Exception: except Exception:
return "0.0.0" 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()}" USER_AGENT = f"browser-cli/{get_installed_version()}"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.15.6", "version": "0.16.0",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
+19 -3
View File
@@ -17,17 +17,33 @@ function isCommandSpec(entry: CommandEntry): entry is CommandSpec {
return typeof entry !== "function"; 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) { export function makePagedData(items: Serializable[], page: PageRequest) {
const total = items.length; const total = items.length;
const offset = Math.max(0, Number(page.offset) || 0); const offset = Math.max(0, Number(page.offset) || 0);
const requestedLimit = Math.max(1, Number(page.limit) || 100); const requestedLimit = Math.max(1, Number(page.limit) || 100);
const limit = Math.min(requestedLimit, 1000); const maxCount = Math.min(requestedLimit, 1000);
const end = Math.min(offset + limit, total);
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 { return {
__browserCliPage: true, __browserCliPage: true,
items: items.slice(offset, end), items: items.slice(offset, end),
offset, offset,
limit, limit: maxCount,
total, total,
nextOffset: end < total ? end : null, nextOffset: end < total ? end : null,
}; };
+41
View File
@@ -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);
});
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.6" version = "0.16.0"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+35 -29
View File
@@ -8,45 +8,51 @@ They are automatically skipped if the native host socket is not reachable.
import time import time
import pytest import pytest
from browser_cli.client import send_command, BrowserNotConnected from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.remote import pool as _remote_pool
TEST_BROWSER_PROFILE = "testing" 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") @pytest.fixture(scope="session")
def browser(): def browser():
"""Returns a connected send_command callable for the testing profile, or skips the test.""" """Returns a connected send_command callable for the testing profile, or skips the test."""
try: try:
send_command("tabs.list", profile=TEST_BROWSER_PROFILE) send_command("tabs.list", profile=TEST_BROWSER_PROFILE)
except (BrowserNotConnected, RuntimeError) as e: except (BrowserNotConnected, RuntimeError) as e:
pytest.skip( pytest.skip(
f"Browser 'testing' not connected — start Brave/Chrome with the extension loaded for that profile ({e})" f"Browser 'testing' not connected — start Brave/Chrome with the extension loaded for that profile ({e})"
) )
def _browser(command, args=None): def _browser(command, args=None):
return send_command(command, args, profile=TEST_BROWSER_PROFILE) return send_command(command, args, profile=TEST_BROWSER_PROFILE)
return _browser
return _browser
@pytest.fixture() @pytest.fixture()
def http_tab(browser): def http_tab(browser):
"""Opens a dedicated http/https tab for the current test and returns its tab info.""" """Opens a dedicated http/https tab for the current test and returns its tab info."""
created = browser("navigate.open", {"url": "https://example.com", "background": True}) created = browser("navigate.open", {"url": "https://example.com", "background": True})
tab_id = created["id"] tab_id = created["id"]
tab = None tab = None
try:
for _ in range(30):
tabs = browser("tabs.list")
tab = next((t for t in tabs if t.get("id") == tab_id and t.get("url", "").startswith("http")), None)
if tab is not None:
break
time.sleep(0.1)
if tab is None:
pytest.skip("Dedicated http/https test tab did not finish loading")
yield tab
finally:
try: try:
for _ in range(30): browser("tabs.close", {"tabId": tab_id})
tabs = browser("tabs.list") except Exception:
tab = next((t for t in tabs if t.get("id") == tab_id and t.get("url", "").startswith("http")), None) pass
if tab is not None:
break
time.sleep(0.1)
if tab is None:
pytest.skip("Dedicated http/https test tab did not finish loading")
yield tab
finally:
try:
browser("tabs.close", {"tabId": tab_id})
except Exception:
pass
+16 -10
View File
@@ -32,6 +32,12 @@ GROUP_DATA = {
"tabCount": 3, "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() @pytest.fixture()
def mock_send(): def mock_send():
"""Patch send_command for the duration of one test. """Patch send_command for the duration of one test.
@@ -454,7 +460,7 @@ class TestTabs:
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("tabs.list", {}, profile="default"), call("tabs.list", {}, profile="default"),
call("tabs.list", {}, profile="work"), 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): 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 [tab.browser for tab in tabs] == ["work"]
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="host:8765", key=None), 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): def test_tabs_list_remote_bound_actions_preserve_key(self, mock_send):
@@ -488,7 +494,7 @@ class TestTabs:
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="browser-host.example", key="agent"), 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): 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 == [ assert mock_send.call_args_list == [
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key="agent"), 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.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): def test_tabs_active_returns_active_tab(self, b, mock_send):
@@ -690,7 +696,7 @@ class TestGroups:
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("group.list", {}, profile="default"), call("group.list", {}, profile="default"),
call("group.list", {}, profile="work"), 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): 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 [group.browser for group in groups] == ["work"]
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="host:8765", key=None), 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): def test_group_list_remote_bound_actions_preserve_key(self, mock_send):
@@ -724,7 +730,7 @@ class TestGroups:
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="browser-host.example", key="agent"), 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): 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): def test_close(self, tab, mock_send):
tab.close() 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): def test_activate(self, tab, mock_send):
tab.activate() tab.activate()
@@ -1043,7 +1049,7 @@ class TestGroupModel:
def test_close(self, group, mock_send): def test_close(self, group, mock_send):
group.close() 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): def test_tabs(self, group, mock_send):
mock_send.return_value = [TAB_DATA] mock_send.return_value = [TAB_DATA]
@@ -1115,7 +1121,7 @@ class TestSDKDecorators:
remote=None, remote=None,
key=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): def test_wait_for_selector_runs_before_function_and_can_inject_result(self, b, mock_send):
+39 -17
View File
@@ -28,8 +28,8 @@ def test_long_version_option():
assert result.output.strip() == _expected_version() assert result.output.strip() == _expected_version()
def test_project_version_falls_back_to_installed_package_metadata(): def test_project_version_falls_back_to_installed_package_metadata():
with patch("browser_cli.cli.Path.read_text", side_effect=OSError), patch( with patch("browser_cli.version_manager.Path.read_text", side_effect=OSError), patch(
"browser_cli.cli.package_version", return_value="9.9.9" "browser_cli.version_manager._pkg_version", return_value="9.9.9"
): ):
assert _project_version() == "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 "chromewebstore.google.com" in result.output
assert "Chrome Web Store extension ID" in result.output assert "Add to Brave" in result.output
def test_install_writes_firefox_allowed_extensions(tmp_path): def test_install_writes_firefox_allowed_extensions(tmp_path):
manifests = [] manifests = []
@@ -139,12 +139,34 @@ def test_install_writes_firefox_allowed_extensions(tmp_path):
"allowed_extensions": ["browser-cli@yiprawr.dev"], "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 "about:debugging#/runtime/this-firefox" in result.output
assert "npm run package:extension:firefox" in result.output assert "npm run package:extension:firefox" in result.output
output_unwrapped = result.output.replace("\n", "") output_unwrapped = result.output.replace("\n", "")
assert "dist/extension-package-firefox/manifest.json" in output_unwrapped assert "dist/extension-package-firefox/manifest.json" in output_unwrapped
assert "Do not select extension/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): def test_install_windows_registers_native_host(tmp_path):
writes = [] writes = []
@@ -205,7 +227,7 @@ def test_write_native_host_exe_windows(tmp_path):
def test_clients_exits_cleanly_when_registry_is_missing(): def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch( 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"]) 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] return [remote_target]
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch( with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
"browser_cli.commands.clients.send_command", side_effect=fake_send_command "browser_cli.client.core.send_command", side_effect=fake_send_command
), patch("browser_cli.commands.clients.active_browser_targets", side_effect=fake_active_browser_targets) as active_targets: ), patch("browser_cli.client.core.active_browser_targets", side_effect=fake_active_browser_targets) as active_targets:
result = CliRunner().invoke(main, ["clients"]) result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 0 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"}] return [{"profile": "main", "name": "Chrome", "version": "1", "extensionVersion": "0.8.2"}]
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch( with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
"browser_cli.commands.clients.send_command", side_effect=fake_send_command "browser_cli.client.core.send_command", side_effect=fake_send_command
), patch("browser_cli.commands.clients.active_browser_targets", return_value=[]): ), patch("browser_cli.client.core.active_browser_targets", return_value=[]):
result = CliRunner().invoke(main, ["clients"]) result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 0 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( with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.commands.clients.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json") "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"]) result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
assert result.exit_code == 0 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 assert "2.3.4" in result.output
def test_clients_remote_respects_global_browser_route(): 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"]) result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
assert result.exit_code == 1 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"}] return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
with patch.dict(os.environ, {}, clear=True), patch( 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( ), patch(
"browser_cli.commands.clients.remote_browser_targets", return_value=all_remote_targets "browser_cli.client.core.remote_browser_targets", return_value=all_remote_targets
), 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, ["--browser", "browser-host.example", "clients"]) result = CliRunner().invoke(main, ["--browser", "browser-host.example", "clients"])
assert result.exit_code == 0 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] return responses[profile]
with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch( with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch(
"browser_cli.commands.clients.send_command", side_effect=fake_send_command "browser_cli.client.core.send_command", side_effect=fake_send_command
), patch("browser_cli.commands.clients.active_browser_targets", return_value=[]): ), patch("browser_cli.client.core.active_browser_targets", return_value=[]):
result = CliRunner().invoke(main, ["clients"]) result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 0 assert result.exit_code == 0
+518 -393
View File
File diff suppressed because it is too large Load Diff
+295 -275
View File
@@ -9,412 +9,432 @@ from browser_cli import framing, local_transport
from browser_cli.native import local_server, protocol as native_protocol from browser_cli.native import local_server, protocol as native_protocol
def _raise_system_exit(code: int): def _raise_system_exit(code: int):
raise SystemExit(code) raise SystemExit(code)
def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path): def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
alias = "work" alias = "work"
socket_path = tmp_path / "work.sock" socket_path = tmp_path / "work.sock"
socket_path.write_text("") socket_path.write_text("")
registry_path = tmp_path / "registry.json" registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")})) registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path) monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path)) monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
monkeypatch.setattr(native_host, "is_windows", lambda: False) monkeypatch.setattr(native_host, "is_windows", lambda: False)
native_host._cleanup(alias) native_host._cleanup(alias)
assert not socket_path.exists() assert not socket_path.exists()
assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")} assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
def test_stdin_reader_cleans_up_on_eof(monkeypatch): def test_stdin_reader_cleans_up_on_eof(monkeypatch):
cleaned = [] cleaned = []
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: None) monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: None)
monkeypatch.setattr(native_host, "_cleanup", cleaned.append) monkeypatch.setattr(native_host, "_cleanup", cleaned.append)
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit) monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object())) monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
with pytest.raises(SystemExit, match="0"): with pytest.raises(SystemExit, match="0"):
native_host.stdin_reader("work") native_host.stdin_reader("work")
assert cleaned == ["work"] assert cleaned == ["work"]
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path): def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json" registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"})) registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path) monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "is_windows", lambda: True) monkeypatch.setattr(native_host, "is_windows", lambda: True)
native_host._cleanup("work") native_host._cleanup("work")
assert json.loads(registry_path.read_text()) == {} assert json.loads(registry_path.read_text()) == {}
def test_stdin_reader_cleans_up_on_bye(monkeypatch): def test_stdin_reader_cleans_up_on_bye(monkeypatch):
cleaned = [] cleaned = []
messages = iter([{"type": "bye"}]) messages = iter([{"type": "bye"}])
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages)) monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages))
monkeypatch.setattr(native_host, "_cleanup", cleaned.append) monkeypatch.setattr(native_host, "_cleanup", cleaned.append)
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit) monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object())) monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
with pytest.raises(SystemExit, match="0"): with pytest.raises(SystemExit, match="0"):
native_host.stdin_reader("work") native_host.stdin_reader("work")
assert cleaned == ["work"] assert cleaned == ["work"]
def test_stdin_reader_routes_response_messages(monkeypatch): def test_stdin_reader_routes_response_messages(monkeypatch):
response_queue = native_host.queue.Queue() response_queue = native_host.queue.Queue()
native_host.PENDING["msg-1"] = response_queue native_host.PENDING["msg-1"] = response_queue
messages = iter([{"type": "hello"}, {"id": "msg-1", "success": True}, None]) messages = iter([{"type": "hello"}, {"id": "msg-1", "success": True}, None])
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages)) monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages))
monkeypatch.setattr(native_host, "_cleanup", lambda alias: None) monkeypatch.setattr(native_host, "_cleanup", lambda alias: None)
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit) monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object())) monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
with pytest.raises(SystemExit, match="0"): with pytest.raises(SystemExit, match="0"):
native_host.stdin_reader("work") native_host.stdin_reader("work")
assert response_queue.get_nowait() == {"id": "msg-1", "success": True} assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
native_host.PENDING.clear() native_host.PENDING.clear()
def test_collect_paged_browser_command_accumulates_pages(monkeypatch): def test_collect_paged_browser_command_accumulates_pages(monkeypatch):
calls = [] calls = []
pages = iter([ pages = iter([
{"success": True, "data": {"__browserCliPage": True, "items": [1, 2], "total": 3, "nextOffset": 2}}, {"success": True, "data": {"__browserCliPage": True, "items": [1, 2], "total": 3, "nextOffset": 2}},
{"success": True, "data": {"__browserCliPage": True, "items": [3], "total": 3, "nextOffset": None}}, {"success": True, "data": {"__browserCliPage": True, "items": [3], "total": 3, "nextOffset": None}},
]) ])
def fake_send(cmd): def fake_send(cmd):
calls.append(cmd) calls.append(cmd)
return next(pages) return next(pages)
monkeypatch.setattr(native_host, "PAGE_SIZE", 2) monkeypatch.setattr(native_host, "PAGE_SIZE", 2)
monkeypatch.setattr(native_host, "_send_browser_command", fake_send) monkeypatch.setattr(native_host, "_send_browser_command", fake_send)
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {"foo": "bar"}}) result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {"foo": "bar"}})
assert result == {"id": "orig", "success": True, "data": [1, 2, 3], "pageSize": 2, "total": 3} assert result == {"id": "orig", "success": True, "data": [1, 2, 3], "pageSize": 2, "total": 3}
assert [call["args"]["__page"] for call in calls] == [ assert [call["args"]["__page"] for call in calls] == [
{"offset": 0, "limit": 2}, {"offset": 0, "limit": 2},
{"offset": 2, "limit": 2}, {"offset": 2, "limit": 2},
] ]
assert all(call["args"]["foo"] == "bar" for call in calls) assert all(call["args"]["foo"] == "bar" for call in calls)
assert all(call["id"] != "orig" for call in calls) assert all(call["id"] != "orig" for call in calls)
def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch): def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch):
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}}) monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}})
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {}}) result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
assert result == {"id": "orig", "success": True, "data": {"value": 1}} assert result == {"id": "orig", "success": True, "data": {"value": 1}}
def test_handle_browser_command_pages_known_list_commands(monkeypatch): def test_handle_browser_command_pages_known_list_commands(monkeypatch):
seen = [] seen = []
monkeypatch.setattr(native_host, "_collect_paged_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": []}) monkeypatch.setattr(native_host, "_collect_paged_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": []})
result = native_host._handle_browser_command({"id": "orig", "command": "tabs.list", "args": {}}) result = native_host._handle_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
assert result == {"success": True, "data": []} assert result == {"success": True, "data": []}
assert seen[0]["command"] == "tabs.list" assert seen[0]["command"] == "tabs.list"
def test_handle_browser_command_sends_non_pageable_directly(monkeypatch): def test_handle_browser_command_sends_non_pageable_directly(monkeypatch):
seen = [] seen = []
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": "ok"}) monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": "ok"})
result = native_host._handle_browser_command({"id": "x", "command": "navigate.open", "args": {}}) result = native_host._handle_browser_command({"id": "x", "command": "navigate.open", "args": {}})
assert result == {"success": True, "data": "ok"} assert result == {"success": True, "data": "ok"}
assert seen[0]["command"] == "navigate.open" assert seen[0]["command"] == "navigate.open"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _read_exact_stream # _read_exact_stream
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_read_exact_stream_full_read(): def test_read_exact_stream_full_read():
"""Returns the exact bytes when stream delivers them in one shot.""" """Returns the exact bytes when stream delivers them in one shot."""
import io import io
stream = io.BytesIO(b"hello") stream = io.BytesIO(b"hello")
assert native_protocol.read_exact_stream(stream, 5) == b"hello" assert native_protocol.read_exact_stream(stream, 5) == b"hello"
def test_read_exact_stream_partial_chunks(): def test_read_exact_stream_partial_chunks():
"""Accumulates multiple short chunks until n bytes are read.""" """Accumulates multiple short chunks until n bytes are read."""
import io import io
class _ChunkyStream: class _ChunkyStream:
def __init__(self, data, chunk_size): def __init__(self, data, chunk_size):
self._data = data self._data = data
self._pos = 0 self._pos = 0
self._chunk_size = chunk_size self._chunk_size = chunk_size
def read(self, n): def read(self, n):
end = min(self._pos + self._chunk_size, len(self._data)) end = min(self._pos + self._chunk_size, len(self._data))
chunk = self._data[self._pos:end] chunk = self._data[self._pos:end]
self._pos = end self._pos = end
return chunk return chunk
stream = _ChunkyStream(b"abcdefgh", 3) stream = _ChunkyStream(b"abcdefgh", 3)
assert native_protocol.read_exact_stream(stream, 8) == b"abcdefgh" assert native_protocol.read_exact_stream(stream, 8) == b"abcdefgh"
def test_read_exact_stream_eof_returns_none(): def test_read_exact_stream_eof_returns_none():
"""Returns None if stream is exhausted before n bytes are delivered.""" """Returns None if stream is exhausted before n bytes are delivered."""
import io import io
stream = io.BytesIO(b"ab") # only 2 bytes, asking for 4 stream = io.BytesIO(b"ab") # only 2 bytes, asking for 4
assert native_protocol.read_exact_stream(stream, 4) is None assert native_protocol.read_exact_stream(stream, 4) is None
def test_read_exact_stream_immediate_eof(): def test_read_exact_stream_immediate_eof():
"""Returns None on an empty stream.""" """Returns None on an empty stream."""
import io import io
stream = io.BytesIO(b"") stream = io.BytesIO(b"")
assert native_protocol.read_exact_stream(stream, 1) is None assert native_protocol.read_exact_stream(stream, 1) is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# write_native_message / read_native_message round-trip # write_native_message / read_native_message round-trip
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_write_and_read_native_message_roundtrip(): def test_write_and_read_native_message_roundtrip():
"""write_native_message followed by read_native_message recovers the original dict.""" """write_native_message followed by read_native_message recovers the original dict."""
import io import io
buf = io.BytesIO() buf = io.BytesIO()
msg = {"id": "abc", "command": "tabs.list", "args": {}} msg = {"id": "abc", "command": "tabs.list", "args": {}}
native_protocol.write_native_message(buf, msg) native_protocol.write_native_message(buf, msg)
buf.seek(0) buf.seek(0)
result = native_protocol.read_native_message(buf) result = native_protocol.read_native_message(buf)
assert result == msg assert result == msg
def test_read_native_message_eof_at_length_prefix(): def test_read_native_message_eof_at_length_prefix():
"""Returns None when the stream is empty (no length prefix).""" """Returns None when the stream is empty (no length prefix)."""
import io import io
stream = io.BytesIO(b"") stream = io.BytesIO(b"")
assert native_protocol.read_native_message(stream) is None assert native_protocol.read_native_message(stream) is None
def test_read_native_message_eof_at_body(): def test_read_native_message_eof_at_body():
"""Returns None when the body is truncated after reading the length prefix.""" """Returns None when the body is truncated after reading the length prefix."""
import io import io
import struct import struct
# Write a 10-byte length prefix but only 5 bytes of body # Write a 10-byte length prefix but only 5 bytes of body
buf = struct.pack("<I", 10) + b"hello" buf = struct.pack("<I", 10) + b"hello"
stream = io.BytesIO(buf) stream = io.BytesIO(buf)
assert native_protocol.read_native_message(stream) is None assert native_protocol.read_native_message(stream) is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# framing helpers # framing helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_recv_exact_accumulates_data(): def test_recv_exact_accumulates_data():
"""framing.recv_exact receives exactly n bytes from a socket-like object.""" """framing.recv_exact receives exactly n bytes from a socket-like object."""
class _FakeSock: class _FakeSock:
def __init__(self, data): def __init__(self, data):
self._data = data self._data = data
self._pos = 0 self._pos = 0
def recv(self, n): def recv(self, n):
chunk = self._data[self._pos:self._pos + n] chunk = self._data[self._pos:self._pos + n]
self._pos += len(chunk) self._pos += len(chunk)
return chunk return chunk
sock = _FakeSock(b"0123456789") sock = _FakeSock(b"0123456789")
assert framing.recv_exact(sock, 5) == b"01234" assert framing.recv_exact(sock, 5) == b"01234"
assert framing.recv_exact(sock, 5) == b"56789" assert framing.recv_exact(sock, 5) == b"56789"
def test_recv_exact_eof_returns_none(): def test_recv_exact_eof_returns_none():
class _EmptySock: class _EmptySock:
def recv(self, n): def recv(self, n):
return b"" return b""
assert framing.recv_exact(_EmptySock(), 4, allow_eof=True) is None assert framing.recv_exact(_EmptySock(), 4, allow_eof=True) is None
def test_send_all_and_recv_all(): def test_send_all_and_recv_all():
"""framing.send_frame frames data; framing.recv_frame strips it.""" """framing.send_frame frames data; framing.recv_frame strips it."""
import socket import socket
a, b = socket.socketpair() a, b = socket.socketpair()
try: try:
payload = b'{"command": "tabs.list"}' payload = b'{"command": "tabs.list"}'
framing.send_frame(a, payload) framing.send_frame(a, payload)
received = framing.recv_frame(b, allow_eof=True) received = framing.recv_frame(b, allow_eof=True)
assert received == payload assert received == payload
finally: finally:
a.close() a.close()
b.close() b.close()
def test_recv_all_truncated_body(): def test_recv_all_truncated_body():
"""_recv_all returns None when the body is shorter than the prefix promises.""" """_recv_all returns None when the body is shorter than the prefix promises."""
import socket import socket
import struct import struct
a, b = socket.socketpair() a, b = socket.socketpair()
try: try:
# Send a length of 100 but only 4 bytes of body # Send a length of 100 but only 4 bytes of body
a.sendall(struct.pack("<I", 100) + b"tiny") a.sendall(struct.pack("<I", 100) + b"tiny")
a.close() a.close()
result = framing.recv_frame(b, allow_eof=True) result = framing.recv_frame(b, allow_eof=True)
assert result is None assert result is None
finally: finally:
b.close() b.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _send_browser_command — timeout path # _send_browser_command — timeout path
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_send_browser_command_timeout(monkeypatch): def test_send_browser_command_timeout(monkeypatch):
"""_send_browser_command returns an error dict when the response queue times out.""" """_send_browser_command returns an error dict when the response queue times out."""
import io import io
buf = io.BytesIO() buf = io.BytesIO()
monkeypatch.setattr(native_host.sys, "stdout", SimpleNamespace(buffer=buf)) monkeypatch.setattr(native_host.sys, "stdout", SimpleNamespace(buffer=buf))
# Do not put anything into the response queue → timeout after 0 s # Do not put anything into the response queue → timeout after 0 s
result = native_host._send_browser_command({"id": "t1", "command": "test", "args": {}}, timeout=0) result = native_host._send_browser_command({"id": "t1", "command": "test", "args": {}}, timeout=0)
assert result["success"] is False assert result["success"] is False
assert "timeout" in result["error"] assert "timeout" in result["error"]
# Clean up PENDING # Clean up PENDING
native_host.PENDING.clear() native_host.PENDING.clear()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _collect_paged_browser_command — error and loop-guard paths # _collect_paged_browser_command — error and loop-guard paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_collect_paged_browser_command_propagates_error(monkeypatch): def test_collect_paged_browser_command_propagates_error(monkeypatch):
"""If _send_browser_command returns success=False the error is propagated.""" """If _send_browser_command returns success=False the error is propagated."""
monkeypatch.setattr( monkeypatch.setattr(
native_host, "_send_browser_command", native_host, "_send_browser_command",
lambda cmd: {"id": cmd["id"], "success": False, "error": "extension crash"}, lambda cmd: {"id": cmd["id"], "success": False, "error": "extension crash"},
) )
result = native_host._collect_paged_browser_command({"id": "e1", "command": "tabs.list", "args": {}}) result = native_host._collect_paged_browser_command({"id": "e1", "command": "tabs.list", "args": {}})
assert result["success"] is False assert result["success"] is False
assert "extension crash" in result["error"] assert "extension crash" in result["error"]
def test_collect_paged_browser_command_max_pages_guard(monkeypatch): def test_collect_paged_browser_command_max_pages_guard(monkeypatch):
"""If paging never ends, the loop guard kicks in and returns an error.""" """A runaway extension (empty pages, advancing nextOffset) trips the guard."""
monkeypatch.setattr(native_host, "PAGE_SIZE", 1) monkeypatch.setattr(native_host, "MAX_PAGED_ITEMS", 5)
call_count = [0] call_count = [0]
def _infinite_pages(cmd): def _infinite_empty_pages(cmd):
call_count[0] += 1 # Empty items so the item cap never bites — only the page guard can stop this.
return { call_count[0] += 1
"id": cmd["id"], return {
"success": True, "id": cmd["id"],
"data": {"__browserCliPage": True, "items": [call_count[0]], "total": 9999, "nextOffset": call_count[0]}, "success": True,
} "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": {}}) result = native_host._collect_paged_browser_command({"id": "loop", "command": "tabs.list", "args": {}})
assert result["success"] is False assert result["success"] is False
assert "paging loop exceeded" in result["error"] 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): def test_collect_paged_browser_command_invalid_items(monkeypatch):
"""If items is not a list the command returns an error dict.""" """If items is not a list the command returns an error dict."""
monkeypatch.setattr( monkeypatch.setattr(
native_host, "_send_browser_command", native_host, "_send_browser_command",
lambda cmd: { lambda cmd: {
"id": cmd["id"], "id": cmd["id"],
"success": True, "success": True,
"data": {"__browserCliPage": True, "items": "not-a-list", "total": 1, "nextOffset": None}, "data": {"__browserCliPage": True, "items": "not-a-list", "total": 1, "nextOffset": None},
}, },
) )
result = native_host._collect_paged_browser_command({"id": "bad", "command": "tabs.list", "args": {}}) result = native_host._collect_paged_browser_command({"id": "bad", "command": "tabs.list", "args": {}})
assert result["success"] is False assert result["success"] is False
assert "invalid paged response" in result["error"] assert "invalid paged response" in result["error"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _resolve_profile_alias # _resolve_profile_alias
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_resolve_profile_alias_uses_hello_alias(): def test_resolve_profile_alias_uses_hello_alias():
alias = native_host._resolve_profile_alias({"type": "hello", "alias": "brave-work"}) alias = native_host._resolve_profile_alias({"type": "hello", "alias": "brave-work"})
assert alias == "brave-work" assert alias == "brave-work"
def test_resolve_profile_alias_no_hello_returns_uuid(): def test_resolve_profile_alias_no_hello_returns_uuid():
alias = native_host._resolve_profile_alias(None) alias = native_host._resolve_profile_alias(None)
import uuid import uuid
uuid.UUID(alias) # raises ValueError if not a valid UUID uuid.UUID(alias) # raises ValueError if not a valid UUID
def test_resolve_profile_alias_default_alias_returns_uuid(): def test_resolve_profile_alias_default_alias_returns_uuid():
from browser_cli.platform import DEFAULT_ALIAS from browser_cli.platform import DEFAULT_ALIAS
alias = native_host._resolve_profile_alias({"type": "hello", "alias": DEFAULT_ALIAS}) alias = native_host._resolve_profile_alias({"type": "hello", "alias": DEFAULT_ALIAS})
import uuid import uuid
uuid.UUID(alias) uuid.UUID(alias)
def test_resolve_profile_alias_non_hello_type_returns_uuid(): def test_resolve_profile_alias_non_hello_type_returns_uuid():
alias = native_host._resolve_profile_alias({"type": "bye", "alias": "some"}) alias = native_host._resolve_profile_alias({"type": "bye", "alias": "some"})
import uuid import uuid
uuid.UUID(alias) uuid.UUID(alias)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# asyncio Unix-socket server path # asyncio Unix-socket server path
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_async_recv_all_and_send_all_roundtrip(): def test_async_recv_all_and_send_all_roundtrip():
"""local_transport async framing mirrors the sync length-prefixed socket framing.""" """local_transport async framing mirrors the sync length-prefixed socket framing."""
import asyncio import asyncio
async def run(): async def run():
async def handle(reader, writer): async def handle(reader, writer):
payload = await local_transport.async_recv_all(reader) payload = await local_transport.async_recv_all(reader)
await local_transport.async_send_all(writer, payload + b"-reply") await local_transport.async_send_all(writer, payload + b"-reply")
writer.close() writer.close()
await writer.wait_closed() await writer.wait_closed()
server = await asyncio.start_server(handle, "127.0.0.1", 0) server = await asyncio.start_server(handle, "127.0.0.1", 0)
host, port = server.sockets[0].getsockname() host, port = server.sockets[0].getsockname()
async with server: async with server:
reader, writer = await asyncio.open_connection(host, port) reader, writer = await asyncio.open_connection(host, port)
await local_transport.async_send_all(writer, b"hello") await local_transport.async_send_all(writer, b"hello")
assert await local_transport.async_recv_all(reader) == b"hello-reply" assert await local_transport.async_recv_all(reader) == b"hello-reply"
writer.close() writer.close()
await writer.wait_closed() await writer.wait_closed()
asyncio.run(run()) asyncio.run(run())
def test_async_socket_server_handles_cli_request(monkeypatch, tmp_path): def test_async_socket_server_handles_cli_request(monkeypatch, tmp_path):
"""Unix CLI socket server accepts requests concurrently via asyncio.""" """Unix CLI socket server accepts requests concurrently via asyncio."""
import asyncio import asyncio
import struct import struct
async def read_frame(reader): async def read_frame(reader):
raw_len = await reader.readexactly(4) raw_len = await reader.readexactly(4)
msg_len = struct.unpack("<I", raw_len)[0] msg_len = struct.unpack("<I", raw_len)[0]
return await reader.readexactly(msg_len) return await reader.readexactly(msg_len)
async def run(): async def run():
sock_path = tmp_path / "browser.sock" sock_path = tmp_path / "browser.sock"
seen = [] seen = []
monkeypatch.setattr( monkeypatch.setattr(
native_host, native_host,
"_handle_browser_command", "_handle_browser_command",
lambda cmd: seen.append(cmd) or {"id": cmd["id"], "success": True, "data": "ok"}, lambda cmd: seen.append(cmd) or {"id": cmd["id"], "success": True, "data": "ok"},
) )
task = asyncio.create_task( task = asyncio.create_task(
local_server.async_socket_server( local_server.async_socket_server(
str(sock_path), str(sock_path),
native_host._handle_cli_payload, native_host._handle_cli_payload,
native_host._error_response, native_host._error_response,
) )
) )
for _ in range(100): for _ in range(100):
if sock_path.exists(): if sock_path.exists():
break break
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
reader, writer = await asyncio.open_unix_connection(str(sock_path)) reader, writer = await asyncio.open_unix_connection(str(sock_path))
await local_transport.async_send_all(writer, json.dumps({"command": "tabs.list", "args": {}}).encode()) await local_transport.async_send_all(writer, json.dumps({"command": "tabs.list", "args": {}}).encode())
response = json.loads((await read_frame(reader)).decode()) response = json.loads((await read_frame(reader)).decode())
writer.close() writer.close()
await writer.wait_closed() await writer.wait_closed()
task.cancel() task.cancel()
try: try:
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
assert response["success"] is True assert response["success"] is True
assert response["data"] == "ok" assert response["data"] == "ok"
assert seen[0]["command"] == "tabs.list" assert seen[0]["command"] == "tabs.list"
assert "id" in seen[0] assert "id" in seen[0]
asyncio.run(run()) asyncio.run(run())
+287 -138
View File
@@ -10,179 +10,328 @@ from browser_cli import BrowserCLI
from browser_cli.client import BrowserTarget from browser_cli.client import BrowserTarget
from browser_cli.cli import main from browser_cli.cli import main
from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category 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(): def test_extension_info_cli_renders_capabilities():
with patch("browser_cli.send_command", return_value={"version": "1.2.3", "capabilities": ["extension.info"]}): with patch("browser_cli.send_command", return_value={"version": "1.2.3", "capabilities": ["extension.info"]}):
result = CliRunner().invoke(main, ["extension", "info"]) result = CliRunner().invoke(main, ["extension", "info"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "1.2.3" in result.output assert "1.2.3" in result.output
assert "extension.info" in result.output assert "extension.info" in result.output
def test_script_runs_raw_commands(tmp_path: Path): def test_script_runs_raw_commands(tmp_path: Path):
script = tmp_path / "workflow.json" script = tmp_path / "workflow.json"
script.write_text(json.dumps([{"tabs.count": {"pattern": "example.com"}}]), encoding="utf-8") script.write_text(json.dumps([{"tabs.count": {"pattern": "example.com"}}]), encoding="utf-8")
with patch("browser_cli.send_command", return_value={"count": 2}) as send_command: with patch("browser_cli.send_command", return_value={"count": 2}) as send_command:
result = CliRunner().invoke(main, ["script", str(script), "--json"]) result = CliRunner().invoke(main, ["script", str(script), "--json"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "tabs.count" in result.output assert "tabs.count" in result.output
send_command.assert_called_once_with("tabs.count", {"pattern": "example.com"}, profile=None, remote=None, key=None) send_command.assert_called_once_with("tabs.count", {"pattern": "example.com"}, profile=None, remote=None, key=None)
def test_session_export_cli_prints_json(): def test_session_export_cli_prints_json():
with patch("browser_cli.send_command", return_value={"name": "work", "session": {"tabs": ["https://example.com"]}}): with patch("browser_cli.send_command", return_value={"name": "work", "session": {"tabs": ["https://example.com"]}}):
result = CliRunner().invoke(main, ["session", "export", "work"]) result = CliRunner().invoke(main, ["session", "export", "work"])
assert result.exit_code == 0 assert result.exit_code == 0
assert '"name": "work"' in result.output assert '"name": "work"' in result.output
def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new(): def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
calls = [] calls = []
def sender(command, args=None, **kwargs): def sender(command, args=None, **kwargs):
calls.append((command, args)) calls.append((command, args))
if command == "tabs.list": if command == "tabs.list":
return [{"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": "https://example.com"}] return [{"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": "https://example.com"}]
return {} return {}
BrowserCLI(browser="testing", _command_sender=sender).nav.open("https://example.com", reuse=True) BrowserCLI(browser="testing", _command_sender=sender).nav.open("https://example.com", reuse=True)
assert calls == [ assert calls == [
("tabs.list", {}), ("tabs.list", {}),
("navigate.to", {"tabId": 7, "url": "https://example.com"}), ("navigate.to", {"tabId": 7, "url": "https://example.com"}),
] ]
def _tree_sender(tabs, groups): def _tree_sender(tabs, groups):
def sender(command, args=None, **kwargs): def sender(command, args=None, **kwargs):
if command == "tabs.list": if command == "tabs.list":
return tabs return tabs
if command == "group.list": if command == "group.list":
return groups return groups
return [] return []
return sender return sender
def test_tabs_tree_command_available(): def test_tabs_tree_command_available():
with patch("browser_cli.send_command", side_effect=_tree_sender([], [])): with patch("browser_cli.send_command", side_effect=_tree_sender([], [])):
result = CliRunner().invoke(main, ["tabs", "tree"]) result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Tabs" in result.output assert "Tabs" in result.output
def test_tabs_tree_handles_tabs_without_index_from_older_extension(): def test_tabs_tree_handles_tabs_without_index_from_older_extension():
tabs = [{ tabs = [{
"id": 7, "id": 7,
"windowId": 1, "windowId": 1,
"active": True, "active": True,
"muted": False, "muted": False,
"title": "Example", "title": "Example",
"url": "https://example.com", "url": "https://example.com",
"groupId": None, "groupId": None,
}] }]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])): with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree"]) result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Example" in result.output assert "Example" in result.output
def test_tabs_tree_preserves_window_tab_order_and_truncates_long_lines(): def test_tabs_tree_preserves_window_tab_order_and_truncates_long_lines():
tabs = [ tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None}, {"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "[Gold] Grouped", "url": "https://example.com/grouped", "groupId": 20}, {"id": 2, "windowId": 1, "index": 1, "active": False, "title": "[Gold] Grouped", "url": "https://example.com/grouped", "groupId": 20},
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/" + "x" * 200, "groupId": None}, {"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/" + "x" * 200, "groupId": None},
] ]
groups = [{"id": 20, "title": "Group Name", "color": "blue", "collapsed": False, "tabCount": 1, "windowId": 1}] groups = [{"id": 20, "title": "Group Name", "color": "blue", "collapsed": False, "tabCount": 1, "windowId": 1}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)): with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
result = CliRunner().invoke(main, ["tabs", "tree"]) result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0 assert result.exit_code == 0
output = result.output output = result.output
assert output.index("Before") < output.index("Group Name") < output.index("[Gold] Grouped") < output.index("After") assert output.index("Before") < output.index("Group Name") < output.index("[Gold] Grouped") < output.index("After")
assert "https://example.com/before" not in output assert "https://example.com/before" not in output
assert "https://example.com/grouped" not in output assert "https://example.com/grouped" not in output
assert "https://example.com/" + "x" * 200 not in output assert "https://example.com/" + "x" * 200 not in output
def test_tabs_tree_adds_each_browser_node_only_once(): def test_tabs_tree_adds_each_browser_node_only_once():
tabs = [ tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "One", "url": "https://example.com/one", "groupId": None, "browser": "work"}, {"id": 1, "windowId": 1, "index": 0, "active": False, "title": "One", "url": "https://example.com/one", "groupId": None, "browser": "work"},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Two", "url": "https://example.com/two", "groupId": None, "browser": "work"}, {"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Two", "url": "https://example.com/two", "groupId": None, "browser": "work"},
] ]
targets = [ targets = [
BrowserTarget("work", "work", "/tmp/work.sock"), BrowserTarget("work", "work", "/tmp/work.sock"),
BrowserTarget("personal", "personal", "/tmp/personal.sock"), BrowserTarget("personal", "personal", "/tmp/personal.sock"),
] ]
with patch("browser_cli.active_browser_targets", return_value=targets), \ with patch("browser_cli.active_browser_targets", return_value=targets), \
patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])): patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree"]) result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output.count("work") == 1 assert result.output.count("work") == 1
assert result.output.count("personal") == 1 assert result.output.count("personal") == 1
assert "One" in result.output assert "One" in result.output
assert "Two" in result.output assert "Two" in result.output
def test_tabs_tree_shows_tabs_inside_collapsed_browser_groups(): def test_tabs_tree_shows_tabs_inside_collapsed_browser_groups():
tabs = [ tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None}, {"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Hidden", "url": "https://example.com/hidden", "groupId": 20}, {"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Hidden", "url": "https://example.com/hidden", "groupId": 20},
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/after", "groupId": None}, {"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/after", "groupId": None},
] ]
groups = [{"id": 20, "title": "Collapsed Group", "color": "orange", "collapsed": True, "tabCount": 1, "windowId": 1}] groups = [{"id": 20, "title": "Collapsed Group", "color": "orange", "collapsed": True, "tabCount": 1, "windowId": 1}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)): with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
result = CliRunner().invoke(main, ["tabs", "tree"]) result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Collapsed Group" in result.output assert "Collapsed Group" in result.output
assert "1 tab" in result.output assert "1 tab" in result.output
assert "collapsed" in result.output assert "collapsed" in result.output
assert "Hidden" in result.output assert "Hidden" in result.output
def test_tabs_tree_can_show_shortened_urls_on_request(): def test_tabs_tree_can_show_shortened_urls_on_request():
tabs = [{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Long URL", "url": "https://example.com/" + "x" * 200, "groupId": None}] tabs = [{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Long URL", "url": "https://example.com/" + "x" * 200, "groupId": None}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])): with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree", "--urls"]) result = CliRunner().invoke(main, ["tabs", "tree", "--urls"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "https://example.com/" in result.output assert "https://example.com/" in result.output
assert "https://example.com/" + "x" * 200 not in result.output assert "https://example.com/" + "x" * 200 not in result.output
assert "" in result.output assert "" in result.output
def test_doctor_command_reports_connection_failure_cleanly(): def test_doctor_command_reports_connection_failure_cleanly():
with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \ with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \
patch("browser_cli.send_command", side_effect=RuntimeError("no browser")): patch("browser_cli.send_command", side_effect=RuntimeError("no browser")):
result = CliRunner().invoke(main, ["doctor"]) result = CliRunner().invoke(main, ["doctor"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "Connection" in result.output assert "Connection" in result.output
def test_serve_http_no_auth_rejected_on_public_host(): def test_serve_http_no_auth_rejected_on_public_host():
result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--no-auth"]) result = CliRunner().invoke(main, ["serve-http", "--host", "0.0.0.0", "--no-auth"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "--no-auth is only allowed on loopback" in result.output 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 "read-page" in result.output
assert "all" in result.output
assert "server default" 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_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(): def test_raw_command_blocks_dangerous_by_default():
result = CliRunner().invoke(main, ["command", "dom.eval", '{"code":"document.title"}']) result = CliRunner().invoke(main, ["command", "dom.eval", '{"code":"document.title"}'])
assert result.exit_code != 0 assert result.exit_code != 0
assert "blocked by default" in result.output assert "blocked by default" in result.output
def test_raw_command_allows_dangerous_with_explicit_flag(): def test_raw_command_allows_dangerous_with_explicit_flag():
with patch("browser_cli.send_command", return_value="Example") as send_command: with patch("browser_cli.send_command", return_value="Example") as send_command:
result = CliRunner().invoke(main, ["command", "--allow-dangerous", "dom.eval", '{"code":"document.title"}']) result = CliRunner().invoke(main, ["command", "--allow-dangerous", "dom.eval", '{"code":"document.title"}'])
assert result.exit_code == 0 assert result.exit_code == 0
send_command.assert_called_once_with("dom.eval", {"code": "document.title"}, profile=None, remote=None, key=None) send_command.assert_called_once_with("dom.eval", {"code": "document.title"}, profile=None, remote=None, key=None)
def test_script_blocks_control_without_explicit_flag(tmp_path: Path): def test_script_blocks_control_without_explicit_flag(tmp_path: Path):
script = tmp_path / "workflow.json" script = tmp_path / "workflow.json"
script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8") script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8")
result = CliRunner().invoke(main, ["script", str(script), "--json"]) result = CliRunner().invoke(main, ["script", str(script), "--json"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "blocked by default" in result.output assert "blocked by default" in result.output
def test_script_allows_control_with_explicit_flag(tmp_path: Path): def test_script_allows_control_with_explicit_flag(tmp_path: Path):
script = tmp_path / "workflow.json" script = tmp_path / "workflow.json"
script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8") script.write_text(json.dumps([{"navigate.open": {"url": "https://example.com"}}]), encoding="utf-8")
with patch("browser_cli.send_command", return_value={}) as send_command: with patch("browser_cli.send_command", return_value={}) as send_command:
result = CliRunner().invoke(main, ["script", str(script), "--json", "--allow-control"]) result = CliRunner().invoke(main, ["script", str(script), "--json", "--allow-control"])
assert result.exit_code == 0 assert result.exit_code == 0
send_command.assert_called_once_with("navigate.open", {"url": "https://example.com"}, profile=None, remote=None, key=None) send_command.assert_called_once_with("navigate.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
def test_command_policy_categories_and_flags(): def test_command_policy_categories_and_flags():
assert command_category("tabs.list") == "safe" assert command_category("tabs.list") == "safe"
assert command_category("extract.text") == "read-page" assert command_category("extract.text") == "read-page"
assert command_category("dom.click") == "control" assert command_category("dom.click") == "control"
assert command_category("storage.get") == "dangerous" assert command_category("storage.get") == "dangerous"
assert_command_allowed("tabs.list", CommandPolicy()) assert_command_allowed("tabs.list", CommandPolicy())
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
assert_command_allowed("extract.text", CommandPolicy()) assert_command_allowed("extract.text", CommandPolicy())
assert_command_allowed("extract.text", CommandPolicy(allow_read_page=True)) assert_command_allowed("extract.text", CommandPolicy(allow_read_page=True))
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
assert_command_allowed("storage.get", CommandPolicy(allow_read_page=True, allow_control=True)) assert_command_allowed("storage.get", CommandPolicy(allow_read_page=True, allow_control=True))
assert_command_allowed("storage.get", CommandPolicy(allow_dangerous=True)) assert_command_allowed("storage.get", CommandPolicy(allow_dangerous=True))
+52
View File
@@ -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}}
+209 -145
View File
@@ -16,198 +16,262 @@ import threading
import pytest import pytest
from browser_cli.auth import ( from browser_cli.auth import (
generate_keypair, generate_keypair,
load_private_key, load_private_key,
pq_decrypt, pq_decrypt,
pq_encrypt, pq_encrypt,
pq_kex_client_encapsulate, pq_kex_client_encapsulate,
pq_kex_server_decapsulate, pq_kex_server_decapsulate,
pq_kex_server_keypair, pq_kex_server_keypair,
sign, sign,
) )
from browser_cli.client import BrowserNotConnected, send_command from browser_cli.client import BrowserNotConnected, send_command
from browser_cli.commands.serve import _handle_client from browser_cli.commands.serve import _handle_client
def _send_framed(sock: socket.socket, msg: dict) -> None: def _send_framed(sock: socket.socket, msg: dict) -> None:
payload = json.dumps(msg).encode("utf-8") payload = json.dumps(msg).encode("utf-8")
sock.sendall(struct.pack("<I", len(payload)) + payload) sock.sendall(struct.pack("<I", len(payload)) + payload)
def _recv_framed(sock: socket.socket) -> dict: def _recv_framed(sock: socket.socket) -> dict:
raw_len = b"" raw_len = b""
while len(raw_len) < 4: while len(raw_len) < 4:
chunk = sock.recv(4 - len(raw_len)) chunk = sock.recv(4 - len(raw_len))
if not chunk: if not chunk:
raise ConnectionError("socket closed before response header") raise ConnectionError("socket closed before response header")
raw_len += chunk raw_len += chunk
msg_len = struct.unpack("<I", raw_len)[0] msg_len = struct.unpack("<I", raw_len)[0]
data = b"" data = b""
while len(data) < msg_len: while len(data) < msg_len:
chunk = sock.recv(msg_len - len(data)) chunk = sock.recv(msg_len - len(data))
if not chunk: if not chunk:
raise ConnectionError("socket closed mid-response") raise ConnectionError("socket closed mid-response")
data += chunk data += chunk
return json.loads(data) return json.loads(data)
@pytest.fixture() @pytest.fixture()
def auth_material(tmp_path): def auth_material(tmp_path):
pem, pub = generate_keypair() pem, pub = generate_keypair()
key_path = tmp_path / "client.key.pem" key_path = tmp_path / "client.key.pem"
key_path.write_bytes(pem) key_path.write_bytes(pem)
auth_path = tmp_path / "authorized_keys" auth_path = tmp_path / "authorized_keys"
auth_path.write_text(pub + "\n", encoding="utf-8") auth_path.write_text(pub + "\n", encoding="utf-8")
return key_path, auth_path, load_private_key(key_path), pub return key_path, auth_path, load_private_key(key_path), pub
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def no_browser(monkeypatch): def no_browser(monkeypatch):
def _raise_no_browser(*_args, **_kwargs): def _raise_no_browser(*_args, **_kwargs):
raise BrowserNotConnected("no browser") raise BrowserNotConnected("no browser")
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _raise_no_browser) monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _raise_no_browser)
def _connect(auth_keys_path): def _connect(auth_keys_path):
client, server = socket.socketpair() client, server = socket.socketpair()
thread = threading.Thread( thread = threading.Thread(
target=_handle_client, target=_handle_client,
args=(server, ("127.0.0.1", 9999), None, auth_keys_path), args=(server, ("127.0.0.1", 9999), None, auth_keys_path),
daemon=True, daemon=True,
) )
thread.start() thread.start()
challenge = _recv_framed(client) challenge = _recv_framed(client)
return client, thread, challenge return client, thread, challenge
def _pq_auth_message(priv, pub: str, nonce_hex: str, command_msg: dict, challenge: dict, *, encrypted: bool) -> tuple[dict, bytes]: def _pq_auth_message(priv, pub: str, nonce_hex: str, command_msg: dict, challenge: dict, *, encrypted: bool) -> tuple[dict, bytes]:
if "pq_kex" not in challenge: if "pq_kex" not in challenge:
pytest.skip("ML-KEM backend not available") pytest.skip("ML-KEM backend not available")
ciphertext_hex, shared_secret = pq_kex_client_encapsulate(challenge["pq_kex"]["public_key"]) ciphertext_hex, shared_secret = pq_kex_client_encapsulate(challenge["pq_kex"]["public_key"])
clean_msg = { clean_msg = {
**command_msg, **command_msg,
"pq_kex": {"alg": "ML-KEM-768", "ciphertext": ciphertext_hex}, "pq_kex": {"alg": "ML-KEM-768", "ciphertext": ciphertext_hex},
} }
sig = sign(priv, bytes.fromhex(nonce_hex), clean_msg, shared_secret).hex() sig = sign(priv, bytes.fromhex(nonce_hex), clean_msg, shared_secret).hex()
if not encrypted: if not encrypted:
return {**clean_msg, "pubkey": pub, "sig": sig}, shared_secret return {**clean_msg, "pubkey": pub, "sig": sig}, shared_secret
envelope = { envelope = {
"id": clean_msg["id"], "id": clean_msg["id"],
"user_agent": clean_msg["user_agent"], "user_agent": clean_msg["user_agent"],
"pubkey": pub, "pubkey": pub,
"sig": sig, "sig": sig,
"pq_kex": clean_msg["pq_kex"], "pq_kex": clean_msg["pq_kex"],
"encrypted": pq_encrypt(shared_secret, "request", json.dumps(clean_msg).encode("utf-8")), "encrypted": pq_encrypt(shared_secret, "request", json.dumps(clean_msg).encode("utf-8")),
} }
return envelope, shared_secret return envelope, shared_secret
def _assert_browser_not_connected(resp: dict) -> None: def _assert_browser_not_connected(resp: dict) -> None:
assert resp.get("success") is False assert resp.get("success") is False
assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower() assert "browser" in resp.get("error", "").lower() or "connected" in resp.get("error", "").lower()
def test_real_mlkem_primitive_roundtrip(): def test_real_mlkem_primitive_roundtrip():
keypair = pq_kex_server_keypair() keypair = pq_kex_server_keypair()
if keypair is None: if keypair is None:
pytest.skip("ML-KEM backend not available") pytest.skip("ML-KEM backend not available")
private_key, public_key = keypair private_key, public_key = keypair
ciphertext_hex, client_secret = pq_kex_client_encapsulate(public_key.hex()) ciphertext_hex, client_secret = pq_kex_client_encapsulate(public_key.hex())
server_secret = pq_kex_server_decapsulate(private_key, ciphertext_hex) server_secret = pq_kex_server_decapsulate(private_key, ciphertext_hex)
assert server_secret == client_secret assert server_secret == client_secret
@pytest.mark.parametrize( @pytest.mark.parametrize(
("client_version", "encrypted", "expect_encrypted_response"), ("client_version", "encrypted", "expect_encrypted_response"),
[ [
("0.9.3", False, False), # legacy client stays compatible ("0.9.3", False, False), # legacy client stays compatible
("0.9.5", True, True), # current client must use encrypted transport ("0.9.5", True, True), # current client must use encrypted transport
], ],
) )
def test_remote_protocol_version_matrix(auth_material, client_version, encrypted, expect_encrypted_response): def test_remote_protocol_version_matrix(auth_material, client_version, encrypted, expect_encrypted_response):
selected_version = os.environ.get("BROWSER_CLI_COMPAT_CLIENT_VERSION") selected_version = os.environ.get("BROWSER_CLI_COMPAT_CLIENT_VERSION")
if selected_version and selected_version != client_version: if selected_version and selected_version != client_version:
pytest.skip(f"compat matrix selected {selected_version}") pytest.skip(f"compat matrix selected {selected_version}")
_key_path, auth_path, priv, pub = auth_material _key_path, auth_path, priv, pub = auth_material
client, thread, challenge = _connect(auth_path) client, thread, challenge = _connect(auth_path)
msg = { msg = {
"id": f"tabs-{client_version}", "id": f"tabs-{client_version}",
"command": "tabs.list", "command": "tabs.list",
"args": {}, "args": {},
"user_agent": f"browser-cli/{client_version}", "user_agent": f"browser-cli/{client_version}",
} }
wire_msg, shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=encrypted) wire_msg, shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=encrypted)
_send_framed(client, wire_msg) _send_framed(client, wire_msg)
resp = _recv_framed(client) resp = _recv_framed(client)
if expect_encrypted_response: if expect_encrypted_response:
assert set(resp) == {"encrypted"} assert set(resp) == {"encrypted"}
resp = json.loads(pq_decrypt(shared_secret, "response", resp["encrypted"])) resp = json.loads(pq_decrypt(shared_secret, "response", resp["encrypted"]))
else: else:
assert "encrypted" not in resp assert "encrypted" not in resp
_assert_browser_not_connected(resp) _assert_browser_not_connected(resp)
client.close() client.close()
thread.join(timeout=2) thread.join(timeout=2)
def test_current_client_plaintext_transport_is_rejected(auth_material): def test_current_client_plaintext_transport_is_rejected(auth_material):
_key_path, auth_path, priv, pub = auth_material _key_path, auth_path, priv, pub = auth_material
client, thread, challenge = _connect(auth_path) client, thread, challenge = _connect(auth_path)
msg = { msg = {
"id": "new-plain", "id": "new-plain",
"command": "tabs.list", "command": "tabs.list",
"args": {}, "args": {},
"user_agent": "browser-cli/0.9.5", "user_agent": "browser-cli/0.9.5",
} }
wire_msg, _shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=False) wire_msg, _shared_secret = _pq_auth_message(priv, pub, challenge["nonce"], msg, challenge, encrypted=False)
_send_framed(client, wire_msg) _send_framed(client, wire_msg)
resp = _recv_framed(client) resp = _recv_framed(client)
assert resp.get("success") is False assert resp.get("success") is False
assert "encrypted transport" in resp.get("error", "").lower() assert "encrypted transport" in resp.get("error", "").lower()
client.close() client.close()
thread.join(timeout=2) 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):
key_path, auth_path, _priv, _pub = auth_material monkeypatch.setattr(
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) "browser_cli.remote.registry.REMOTE_REGISTRY_PATH", tmp_path / "remotes.json"
server.bind(("127.0.0.1", 0)) )
server.listen(1) key_path, auth_path, _priv, _pub = auth_material
host, port = server.getsockname() server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 0))
server.listen(1)
host, port = server.getsockname()
def _accept_once(): def _accept_once():
conn, addr = server.accept() conn, addr = server.accept()
_handle_client(conn, addr, None, auth_path) _handle_client(conn, addr, None, auth_path)
server.close() server.close()
thread = threading.Thread(target=_accept_once, daemon=True) thread = threading.Thread(target=_accept_once, daemon=True)
thread.start() thread.start()
with pytest.raises(RuntimeError, match="browser|connected"):
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
thread.join(timeout=2)
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)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 0))
server.listen(1)
host, port = server.getsockname()
def _accept_once():
conn, addr = server.accept()
_handle_client(conn, addr, None, auth_path)
server.close()
thread = threading.Thread(target=_accept_once, daemon=True)
thread.start()
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
with pytest.raises(RuntimeError, match="browser|connected"): with pytest.raises(RuntimeError, match="browser|connected"):
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path) send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
thread.join(timeout=2) assert "not using a post-quantum key exchange" in stderr.getvalue()
thread.join(timeout=2)
def test_no_mlkem_backend_falls_back_and_client_warns(auth_material, monkeypatch): def _run_pool_server(server, auth_path, connections):
key_path, auth_path, _priv, _pub = auth_material server.settimeout(3)
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: None) 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()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) def test_send_command_reuses_pooled_connection(auth_material):
server.bind(("127.0.0.1", 0)) """Two sequential commands to one endpoint share a single authenticated connection."""
server.listen(1) from browser_cli.remote import pool
host, port = server.getsockname() pool.close_all()
def _accept_once(): key_path, auth_path, _priv, _pub = auth_material
conn, addr = server.accept() server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_handle_client(conn, addr, None, auth_path) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.close() 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()
thread = threading.Thread(target=_accept_once, daemon=True) endpoint = f"{host}:{port}"
thread.start() 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()
stderr = io.StringIO() def test_send_command_opens_new_connection_when_pool_empty(auth_material):
with contextlib.redirect_stderr(stderr): """With no pooled connection to reuse, each command opens its own."""
with pytest.raises(RuntimeError, match="browser|connected"): from browser_cli.remote import pool
send_command("tabs.list", remote=f"{host}:{port}", profile="default", key=key_path)
assert "not using a post-quantum key exchange" in stderr.getvalue() key_path, auth_path, _priv, _pub = auth_material
thread.join(timeout=2) 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()
+544 -422
View File
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
"""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,
)
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.targets") == "safe" # discovery stays open
def test_key_commands_blocked_without_allow_keys():
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust"):
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)
# ── 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
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.6" version = "0.16.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },