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
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:
@@ -17,9 +17,11 @@ from browser_cli.auth.agent import (
|
||||
)
|
||||
from browser_cli.auth.keys import (
|
||||
add_authorized_key,
|
||||
format_authorized_line,
|
||||
generate_keypair,
|
||||
load_authorized_keys,
|
||||
load_authorized_keys_with_names,
|
||||
load_authorized_keys_with_policies,
|
||||
load_private_key,
|
||||
public_key_hex,
|
||||
)
|
||||
@@ -51,9 +53,11 @@ __all__ = [
|
||||
"agent_list_keys",
|
||||
"agent_sign_raw",
|
||||
"canonical_payload",
|
||||
"format_authorized_line",
|
||||
"generate_keypair",
|
||||
"load_authorized_keys",
|
||||
"load_authorized_keys_with_names",
|
||||
"load_authorized_keys_with_policies",
|
||||
"load_private_key",
|
||||
"new_nonce",
|
||||
"pq_decrypt",
|
||||
|
||||
@@ -29,31 +29,63 @@ def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
|
||||
return key.pubkey_bytes.hex()
|
||||
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
||||
|
||||
def _parse_authorized_line(line: str) -> tuple[str, str, list[str] | None] | None:
|
||||
"""Parse one authorized_keys line into (pubkey, name, categories).
|
||||
|
||||
Line format: ``<pubkey> [name words...] [allow:cat,cat,...]``. The optional
|
||||
``allow:`` token may appear anywhere after the pubkey (conventionally last);
|
||||
the remaining words form the name. ``categories`` is None when no ``allow:``
|
||||
token is present (the key falls back to the server-wide policy), or a list of
|
||||
category strings (possibly empty) otherwise.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
tokens = line.split()
|
||||
pubkey = tokens[0]
|
||||
categories: list[str] | None = None
|
||||
name_tokens: list[str] = []
|
||||
for tok in tokens[1:]:
|
||||
if tok.startswith("allow:"):
|
||||
categories = [c for c in tok[len("allow:"):].split(",") if c]
|
||||
else:
|
||||
name_tokens.append(tok)
|
||||
return pubkey, " ".join(name_tokens), categories
|
||||
|
||||
def format_authorized_line(pub_hex: str, name: str = "", categories: list[str] | None = None) -> str:
|
||||
"""Render an authorized_keys line. Inverse of :func:`_parse_authorized_line`."""
|
||||
parts = [pub_hex]
|
||||
if name:
|
||||
parts.append(name)
|
||||
if categories is not None:
|
||||
parts.append("allow:" + ",".join(categories))
|
||||
return " ".join(parts)
|
||||
|
||||
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
|
||||
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
|
||||
return [(pubkey, name) for pubkey, name, _cats in load_authorized_keys_with_policies(path)]
|
||||
|
||||
def load_authorized_keys_with_policies(path: Path) -> list[tuple[str, str, list[str] | None]]:
|
||||
"""Return list of (pubkey_hex, name, categories) triples. categories is None when unset."""
|
||||
if not path.exists():
|
||||
return []
|
||||
result = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
pubkey = parts[0]
|
||||
name = parts[1].strip() if len(parts) > 1 else ""
|
||||
result.append((pubkey, name))
|
||||
parsed = _parse_authorized_line(line)
|
||||
if parsed is not None:
|
||||
result.append(parsed)
|
||||
return result
|
||||
|
||||
def load_authorized_keys(path: Path) -> list[str]:
|
||||
return [pubkey for pubkey, _name in load_authorized_keys_with_names(path)]
|
||||
|
||||
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
|
||||
def add_authorized_key(path: Path, pub_hex: str, name: str = "", categories: list[str] | None = None) -> bool:
|
||||
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)}
|
||||
if pub_hex in existing:
|
||||
return False
|
||||
line = (f"{pub_hex} {name}".rstrip()) + "\n"
|
||||
line = format_authorized_line(pub_hex, name, categories) + "\n"
|
||||
with open(path, "a", encoding="utf-8") as file:
|
||||
file.write(line)
|
||||
return True
|
||||
|
||||
+1
-19
@@ -5,9 +5,6 @@ browser-cli — Control your running browser from the terminal.
|
||||
import click
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.commands.navigate import nav_group
|
||||
@@ -35,7 +32,7 @@ from browser_cli.commands.serve_http import cmd_serve_http
|
||||
from browser_cli.commands.watch import watch_group
|
||||
from browser_cli.commands.workspace import workspace_group
|
||||
from browser_cli.commands.raw import cmd_command
|
||||
from browser_cli.constants import PYPI_PACKAGE_NAME
|
||||
from browser_cli.version_manager import project_version as _project_version
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -53,21 +50,6 @@ def _patched_group_shell_complete(self, ctx, incomplete):
|
||||
|
||||
click.Group.shell_complete = _patched_group_shell_complete
|
||||
|
||||
def _project_version() -> str:
|
||||
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
try:
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return package_version(PYPI_PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
def _print_version(ctx, param, value):
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
@@ -7,6 +7,7 @@ from browser_cli.client.core import (
|
||||
_send_remote,
|
||||
_send_remote_async,
|
||||
active_browser_targets,
|
||||
collect_browser_clients,
|
||||
remote_browser_targets,
|
||||
remote_browser_targets_async,
|
||||
remote_target_for_alias,
|
||||
@@ -39,6 +40,7 @@ __all__ = [
|
||||
"_send_remote",
|
||||
"_send_remote_async",
|
||||
"active_browser_targets",
|
||||
"collect_browser_clients",
|
||||
"display_browser_name",
|
||||
"remote_browser_targets",
|
||||
"remote_browser_targets_async",
|
||||
|
||||
+203
-8
@@ -15,11 +15,39 @@ from browser_cli import local_transport
|
||||
from browser_cli.client import auth, messages, targets as target_discovery
|
||||
from browser_cli.client.targets import BrowserTarget
|
||||
from browser_cli.remote import registry as remote_registry
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.endpoints import _remote_display_name
|
||||
from browser_cli.endpoints import _remote_display_name, display_browser_name
|
||||
from browser_cli.registry import load_registry
|
||||
from browser_cli.remote.transport import _send_remote, _send_remote_async
|
||||
|
||||
def _run_concurrent(factories: list) -> list:
|
||||
"""Run async thunks concurrently, returning results in order.
|
||||
|
||||
Each item in *factories* is a zero-arg callable returning a coroutine. The
|
||||
return list mirrors the input order; a thunk that raises yields its exception
|
||||
object in that slot (callers filter as they would in a sequential loop). Falls
|
||||
back to sequential execution if an event loop is already running on this
|
||||
thread (e.g. inside the async serve handler), where ``asyncio.run`` is illegal.
|
||||
"""
|
||||
if not factories:
|
||||
return []
|
||||
|
||||
async def _gather():
|
||||
return await asyncio.gather(*(factory() for factory in factories), return_exceptions=True)
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_gather())
|
||||
|
||||
# An event loop is already running on this thread (e.g. the async serve
|
||||
# handler), where asyncio.run is illegal. Run the gather on a worker thread
|
||||
# that has no loop of its own, preserving concurrency and result order.
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
return executor.submit(lambda: asyncio.run(_gather())).result()
|
||||
|
||||
def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for item in items or []:
|
||||
@@ -27,6 +55,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
||||
display = str(item.get("displayName") or profile)
|
||||
display_name = _remote_display_name(endpoint, profile, display)
|
||||
browser_name = item.get("browserName") or item.get("name")
|
||||
version = item.get("version")
|
||||
extension_version = item.get("extensionVersion")
|
||||
targets.append(
|
||||
BrowserTarget(
|
||||
profile=profile,
|
||||
@@ -35,6 +65,8 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
|
||||
remote=endpoint,
|
||||
browser_name=str(browser_name) if browser_name else None,
|
||||
display_group=display_name.rsplit(":", 1)[0],
|
||||
version=str(version) if version else None,
|
||||
extension_version=str(extension_version) if extension_version else None,
|
||||
)
|
||||
)
|
||||
return targets
|
||||
@@ -48,12 +80,20 @@ def remote_browser_targets(endpoint: str, key=None, *, suppress_pq_warning: bool
|
||||
)
|
||||
|
||||
def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
endpoints = list(remote_registry.load_remotes())
|
||||
if not endpoints:
|
||||
return []
|
||||
results = _run_concurrent([
|
||||
(lambda ep=ep: asyncio.to_thread(remote_browser_targets, ep, key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
for ep in endpoints
|
||||
])
|
||||
targets: list[BrowserTarget] = []
|
||||
for endpoint in remote_registry.load_remotes():
|
||||
try:
|
||||
targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
for result in results:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
targets.extend(result)
|
||||
return targets
|
||||
|
||||
def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
|
||||
@@ -111,6 +151,160 @@ def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_p
|
||||
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
return targets
|
||||
|
||||
def _cached_client_row(target: BrowserTarget) -> dict | None:
|
||||
"""Build a clients row from a target's discovery data, skipping a roundtrip.
|
||||
|
||||
Returns None when the remote didn't advertise its version (older serve), so
|
||||
callers fall back to an explicit ``clients.list`` query.
|
||||
"""
|
||||
if target.version is None and target.extension_version is None:
|
||||
return None
|
||||
return {
|
||||
"profile": target.display_name,
|
||||
"profileGroup": target.display_group,
|
||||
"name": target.browser_name or "",
|
||||
"version": target.version or "",
|
||||
"extensionVersion": target.extension_version or "",
|
||||
}
|
||||
|
||||
def _rows_from_result(result, label: str, profile_group: str | None) -> list[dict]:
|
||||
rows = []
|
||||
for item in result or []:
|
||||
row = dict(item)
|
||||
row["profile"] = label
|
||||
if profile_group:
|
||||
row["profileGroup"] = profile_group
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
async def _client_rows_async(
|
||||
label: str,
|
||||
*,
|
||||
profile: str | None = None,
|
||||
remote: str | None = None,
|
||||
key=None,
|
||||
suppress_pq_warning: bool = False,
|
||||
profile_group: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return display-ready clients.list rows for one browser target."""
|
||||
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
|
||||
result = await asyncio.to_thread(
|
||||
send_command,
|
||||
"clients.list",
|
||||
profile=profile,
|
||||
remote=remote,
|
||||
key=key,
|
||||
**kwargs,
|
||||
)
|
||||
return _rows_from_result(result, label, profile_group)
|
||||
|
||||
def collect_browser_clients(
|
||||
*,
|
||||
browser_alias: str | None = None,
|
||||
remote: str | None = None,
|
||||
key=None,
|
||||
registry_path=None,
|
||||
) -> list[dict]:
|
||||
"""Return display-ready browser client rows for CLI/SDK consumers.
|
||||
|
||||
Rows preserve the CLI-facing shape: ``profile``, optional ``profileGroup``,
|
||||
``name``, ``version``, and ``extensionVersion``.
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
|
||||
if not remote and browser_alias:
|
||||
resolved = remote_target_for_alias(browser_alias)
|
||||
if not resolved:
|
||||
return rows
|
||||
targets = remote_browser_targets(resolved.remote)
|
||||
uncached = []
|
||||
for target in targets:
|
||||
cached = _cached_client_row(target)
|
||||
if cached is not None:
|
||||
rows.append(cached)
|
||||
else:
|
||||
uncached.append(target)
|
||||
results = _run_concurrent([
|
||||
(lambda t=t: _client_rows_async(
|
||||
t.display_name,
|
||||
profile=t.profile,
|
||||
remote=resolved.remote,
|
||||
key=key,
|
||||
profile_group=t.display_group,
|
||||
))
|
||||
for t in uncached
|
||||
])
|
||||
for result in results:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
rows.extend(result)
|
||||
return rows
|
||||
|
||||
if remote:
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||
for item in result or []:
|
||||
row = dict(item)
|
||||
row["profile"] = row.get("profile") or browser_alias or "remote"
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
path = registry_path or target_discovery.REGISTRY_PATH
|
||||
profiles: dict[str, str] = load_registry(path) if path.exists() else {}
|
||||
local_items = list(profiles.items())
|
||||
|
||||
remote_targets = []
|
||||
cached_remote_rows = [] # deferred so local profiles still render first
|
||||
for target in active_browser_targets(suppress_pq_warning=True):
|
||||
if target.remote is None:
|
||||
continue
|
||||
cached = _cached_client_row(target)
|
||||
if cached is not None:
|
||||
cached_remote_rows.append(cached) # discovery already carried version/extVersion — no extra roundtrip
|
||||
else:
|
||||
remote_targets.append(target)
|
||||
|
||||
factories = [
|
||||
(lambda name=name, sock=sock: _client_rows_async(
|
||||
display_browser_name(name, sock), profile=name, profile_group="local",
|
||||
))
|
||||
for name, sock in local_items
|
||||
] + [
|
||||
(lambda t=t: _client_rows_async(
|
||||
t.display_name,
|
||||
profile=t.profile,
|
||||
remote=t.remote,
|
||||
suppress_pq_warning=True,
|
||||
profile_group=t.display_group,
|
||||
))
|
||||
for t in remote_targets
|
||||
]
|
||||
results = _run_concurrent(factories)
|
||||
|
||||
for (name, sock), result in zip(local_items, results[:len(local_items)]):
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
rows.append({
|
||||
"profile": display_browser_name(name, sock),
|
||||
"profileGroup": "local",
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
})
|
||||
elif isinstance(result, BaseException):
|
||||
raise result
|
||||
else:
|
||||
rows.extend(result)
|
||||
|
||||
for result in results[len(local_items):]:
|
||||
if isinstance(result, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
rows.extend(result)
|
||||
rows.extend(cached_remote_rows)
|
||||
return rows
|
||||
|
||||
def _auto_route_remote(endpoint: str, key=None) -> str | None:
|
||||
targets = remote_browser_targets(endpoint, key=key)
|
||||
if len(targets) == 1:
|
||||
@@ -159,11 +353,12 @@ def send_command(
|
||||
|
||||
return messages.decode_response(response)
|
||||
|
||||
async def remote_browser_targets_async(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
async def remote_browser_targets_async(endpoint: str, key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
"""Async variant of :func:`remote_browser_targets`."""
|
||||
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
|
||||
return _remote_target_items(
|
||||
endpoint,
|
||||
await send_command_async("browser-cli.targets", remote=endpoint, key=key),
|
||||
await send_command_async("browser-cli.targets", remote=endpoint, key=key, **kwargs),
|
||||
)
|
||||
|
||||
async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
|
||||
|
||||
@@ -19,6 +19,11 @@ class BrowserTarget:
|
||||
remote: str | None = None
|
||||
browser_name: str | None = None
|
||||
display_group: str | None = None
|
||||
# Populated from a remote ``browser-cli.targets`` response when the remote is
|
||||
# new enough to advertise them, letting ``clients`` skip a redundant
|
||||
# ``clients.list`` roundtrip. None means "unknown — fall back to a query".
|
||||
version: str | None = None
|
||||
extension_version: str | None = None
|
||||
|
||||
def is_reachable_unix_endpoint(endpoint: str) -> bool:
|
||||
"""Return True when a Unix socket path exists and accepts connections."""
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
SAFE_COMMANDS = {
|
||||
"browser-cli.targets",
|
||||
"clients.list",
|
||||
"extension.capabilities",
|
||||
"extension.info",
|
||||
@@ -74,15 +75,23 @@ DANGEROUS_PREFIXES = (
|
||||
"storage.",
|
||||
)
|
||||
|
||||
# Server-side key-management control commands. Gated separately so a key can be
|
||||
# trusted for browser use without also being able to list or add trusted keys.
|
||||
KEY_COMMANDS = {
|
||||
"browser-cli.auth.keys",
|
||||
"browser-cli.auth.trust",
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandPolicy:
|
||||
allow_read_page: bool = False
|
||||
allow_control: bool = False
|
||||
allow_dangerous: bool = False
|
||||
allow_keys: bool = False
|
||||
|
||||
@classmethod
|
||||
def unrestricted(cls) -> "CommandPolicy":
|
||||
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True)
|
||||
return cls(allow_read_page=True, allow_control=True, allow_dangerous=True, allow_keys=True)
|
||||
|
||||
def _is_control(command: str) -> bool:
|
||||
if command in CONTROL_COMMANDS:
|
||||
@@ -93,6 +102,8 @@ def _is_control(command: str) -> bool:
|
||||
|
||||
def command_category(command: str) -> str:
|
||||
name = str(command or "")
|
||||
if name in KEY_COMMANDS:
|
||||
return "keys"
|
||||
if name in DANGEROUS_COMMANDS or any(name.startswith(prefix) for prefix in DANGEROUS_PREFIXES):
|
||||
return "dangerous"
|
||||
if name in READ_PAGE_COMMANDS:
|
||||
@@ -113,7 +124,9 @@ def assert_command_allowed(command: str, policy: CommandPolicy) -> None:
|
||||
return
|
||||
if category == "dangerous" and policy.allow_dangerous:
|
||||
return
|
||||
if category == "keys" and policy.allow_keys:
|
||||
return
|
||||
raise PermissionError(
|
||||
f"Raw command '{command}' is {category} and blocked by default; "
|
||||
"use --allow-read-page, --allow-control, or --allow-dangerous explicitly"
|
||||
"use --allow-read-page, --allow-control, --allow-dangerous, or --allow-keys explicitly"
|
||||
)
|
||||
|
||||
@@ -31,6 +31,66 @@ def gentle_mode_option(help_text: str):
|
||||
help=help_text,
|
||||
)
|
||||
|
||||
def command_policy_options(fn):
|
||||
"""Reusable raw-command safety flags for /command-like entry points."""
|
||||
fn = click.option(
|
||||
"--allow-all",
|
||||
is_flag=True,
|
||||
help="Allow every command (equivalent to --allow-read-page --allow-control --allow-dangerous --allow-keys)",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--allow-keys",
|
||||
is_flag=True,
|
||||
help="Allow key-management commands (list/trust authorized keys over --remote)",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--allow-dangerous",
|
||||
is_flag=True,
|
||||
help="Allow high-risk commands such as dom.eval, storage.*, screenshots",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--allow-control",
|
||||
is_flag=True,
|
||||
help="Allow browser-control commands such as nav.*, tabs.close, dom.click",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--allow-read-page",
|
||||
is_flag=True,
|
||||
help="Allow page-content read commands such as extract.* and dom.text",
|
||||
)(fn)
|
||||
return fn
|
||||
|
||||
def command_policy_from_options(*, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool = False, allow_all: bool = False):
|
||||
"""Build a CommandPolicy from shared raw-command safety flags."""
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
|
||||
if allow_all:
|
||||
return CommandPolicy.unrestricted()
|
||||
return CommandPolicy(
|
||||
allow_read_page=allow_read_page,
|
||||
allow_control=allow_control,
|
||||
allow_dangerous=allow_dangerous,
|
||||
allow_keys=allow_keys,
|
||||
)
|
||||
|
||||
def command_categories_from_options(*, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool = False, allow_all: bool = False):
|
||||
"""Convert the shared --allow-* flags into a category list, or None if none were set.
|
||||
|
||||
None means "no explicit policy" — the key falls back to the server-wide default.
|
||||
"""
|
||||
if allow_all:
|
||||
return ["all"]
|
||||
cats = []
|
||||
if allow_read_page:
|
||||
cats.append("read-page")
|
||||
if allow_control:
|
||||
cats.append("control")
|
||||
if allow_dangerous:
|
||||
cats.append("dangerous")
|
||||
if allow_keys:
|
||||
cats.append("keys")
|
||||
return cats or None
|
||||
|
||||
def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
|
||||
"""Render a count result.
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.commands import command_categories_from_options, command_policy_options, handle_errors
|
||||
|
||||
console = Console()
|
||||
|
||||
@click.group("auth")
|
||||
@@ -39,9 +41,15 @@ def cmd_auth_keygen(output, force):
|
||||
@click.argument("pubkey")
|
||||
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@command_policy_options
|
||||
@click.pass_context
|
||||
def cmd_auth_trust(ctx, pubkey, name, keys_file):
|
||||
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
|
||||
@handle_errors
|
||||
def cmd_auth_trust(ctx, pubkey, name, keys_file, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Add a public key to the authorized keys file (locally or on a remote serve host).
|
||||
|
||||
Pass --allow-read-page/--allow-control/--allow-dangerous/--allow-all to record a
|
||||
per-key policy (an ``allow:`` token); without any, the key uses the server default.
|
||||
"""
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
|
||||
|
||||
if len(pubkey) != 64:
|
||||
@@ -53,28 +61,37 @@ def cmd_auth_trust(ctx, pubkey, name, keys_file):
|
||||
console.print("[red]Invalid public key:[/red] not valid hex")
|
||||
sys.exit(1)
|
||||
|
||||
categories = command_categories_from_options(
|
||||
allow_read_page=allow_read_page, allow_control=allow_control,
|
||||
allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all,
|
||||
)
|
||||
policy_label = f" [dim]allow:{','.join(categories)}[/dim]" if categories else ""
|
||||
|
||||
remote = (ctx.obj or {}).get("remote")
|
||||
if remote:
|
||||
from browser_cli.client import send_command
|
||||
args = {"pubkey": pubkey, "name": name}
|
||||
if categories is not None:
|
||||
args["allow"] = categories
|
||||
result = send_command(
|
||||
"browser-cli.auth.trust",
|
||||
args={"pubkey": pubkey, "name": name},
|
||||
args=args,
|
||||
remote=remote,
|
||||
key=(ctx.obj or {}).get("key"),
|
||||
)
|
||||
added = (result or {}).get("added", False)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]{policy_label}")
|
||||
else:
|
||||
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
|
||||
return
|
||||
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
added = add_authorized_key(path, pubkey, name)
|
||||
added = add_authorized_key(path, pubkey, name, categories)
|
||||
label = f" ({name})" if name else ""
|
||||
if added:
|
||||
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
|
||||
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]{policy_label}")
|
||||
console.print(f" File: {path}")
|
||||
console.print("\nStart the server with:")
|
||||
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
|
||||
@@ -123,6 +140,7 @@ def cmd_auth_show(key_src):
|
||||
@auth_group.command("keys")
|
||||
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
|
||||
@click.pass_context
|
||||
@handle_errors
|
||||
def cmd_auth_keys(ctx, keys_file):
|
||||
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
|
||||
from rich.table import Table
|
||||
@@ -138,9 +156,9 @@ def cmd_auth_keys(ctx, keys_file):
|
||||
entries = result or []
|
||||
source_label = remote
|
||||
else:
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
|
||||
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_policies
|
||||
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
|
||||
entries = [{"pubkey": pk, "name": name, "allow": cats} for pk, name, cats in load_authorized_keys_with_policies(path)]
|
||||
source_label = str(path)
|
||||
|
||||
if not entries:
|
||||
@@ -151,7 +169,16 @@ def cmd_auth_keys(ctx, keys_file):
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Public Key")
|
||||
table.add_column("Policy")
|
||||
for entry in entries:
|
||||
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)
|
||||
|
||||
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
@@ -11,11 +11,10 @@ from browser_cli.client import (
|
||||
BrowserNotConnected,
|
||||
REGISTRY_PATH,
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
remote_browser_targets,
|
||||
remote_target_for_alias,
|
||||
collect_browser_clients,
|
||||
send_command,
|
||||
)
|
||||
from browser_cli.commands.rendering import print_browser_grouped_table_rows
|
||||
from browser_cli.registry import load_registry
|
||||
|
||||
console = Console()
|
||||
@@ -36,23 +35,6 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
|
||||
if alias in profiles and alias != target_profile:
|
||||
raise click.ClickException(f"Browser alias '{alias}' already exists")
|
||||
|
||||
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False, profile_group=None):
|
||||
"""Query clients.list for one target and append each, tagged with *label*."""
|
||||
if quiet_remote_warning:
|
||||
result = send_command(
|
||||
"clients.list",
|
||||
profile=profile,
|
||||
remote=remote,
|
||||
key=key,
|
||||
suppress_pq_warning=True,
|
||||
)
|
||||
else:
|
||||
result = send_command("clients.list", profile=profile, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = label
|
||||
if profile_group:
|
||||
c["profileGroup"] = profile_group
|
||||
into.append(c)
|
||||
|
||||
@click.group("clients", invoke_without_command=True)
|
||||
@click.pass_context
|
||||
@@ -61,18 +43,20 @@ def clients_group(ctx):
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
all_clients = []
|
||||
|
||||
browser_alias = (ctx.obj or {}).get("browser")
|
||||
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
key = (ctx.obj or {}).get("key")
|
||||
|
||||
if not remote and browser_alias:
|
||||
_collect_remote_alias_clients(all_clients, browser_alias, key)
|
||||
elif remote:
|
||||
_collect_explicit_remote_clients(all_clients, browser_alias, remote, key)
|
||||
else:
|
||||
_collect_local_and_saved_remote_clients(all_clients)
|
||||
try:
|
||||
all_clients = collect_browser_clients(
|
||||
browser_alias=browser_alias,
|
||||
remote=remote,
|
||||
key=key,
|
||||
registry_path=REGISTRY_PATH,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if not all_clients:
|
||||
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
|
||||
@@ -80,98 +64,24 @@ def clients_group(ctx):
|
||||
|
||||
_print_clients(all_clients)
|
||||
|
||||
def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) -> None:
|
||||
resolved = remote_target_for_alias(browser_alias)
|
||||
if not resolved:
|
||||
return
|
||||
try:
|
||||
targets = remote_browser_targets(resolved.remote)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
for target in targets:
|
||||
try:
|
||||
_append_clients(
|
||||
all_clients,
|
||||
target.display_name,
|
||||
profile=target.profile,
|
||||
remote=resolved.remote,
|
||||
key=key,
|
||||
profile_group=target.display_group,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
def _collect_explicit_remote_clients(all_clients: list, browser_alias: str | None, remote: str, key) -> None:
|
||||
try:
|
||||
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
|
||||
for c in (result or []):
|
||||
c["profile"] = c.get("profile") or browser_alias or "remote"
|
||||
all_clients.append(c)
|
||||
except (BrowserNotConnected, RuntimeError) as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
|
||||
profiles: dict[str, str] = load_registry(REGISTRY_PATH) if REGISTRY_PATH.exists() else {}
|
||||
|
||||
for profile_name, sock_path in profiles.items():
|
||||
display_profile = display_browser_name(profile_name, sock_path)
|
||||
try:
|
||||
_append_clients(all_clients, display_profile, profile=profile_name, profile_group="local")
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
all_clients.append({
|
||||
"profile": display_profile,
|
||||
"profileGroup": "local",
|
||||
"name": "—",
|
||||
"version": "—",
|
||||
"extensionVersion": "disconnected",
|
||||
})
|
||||
|
||||
targets = active_browser_targets(suppress_pq_warning=True)
|
||||
|
||||
for target in targets:
|
||||
if target.remote is None:
|
||||
continue
|
||||
try:
|
||||
_append_clients(
|
||||
all_clients,
|
||||
target.display_name,
|
||||
profile=target.profile,
|
||||
remote=target.remote,
|
||||
quiet_remote_warning=True,
|
||||
profile_group=target.display_group,
|
||||
)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
|
||||
def _print_clients(all_clients: list) -> None:
|
||||
from rich.table import Table
|
||||
table = Table(show_header=True, header_style="bold cyan")
|
||||
table.add_column("Profile", no_wrap=True)
|
||||
table.add_column("Browser")
|
||||
table.add_column("Version")
|
||||
table.add_column("Extension Version")
|
||||
rendered_groups: set[str] = set()
|
||||
groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
|
||||
grouped = bool(groups and groups != {"local"})
|
||||
for c in all_clients:
|
||||
group = c.get("profileGroup") if grouped else None
|
||||
if group:
|
||||
if group not in rendered_groups:
|
||||
table.add_row(f"[bold]{group}[/bold]", "", "", "")
|
||||
rendered_groups.add(group)
|
||||
profile = str(c.get("profile", "")).removeprefix(f"{group}:")
|
||||
profile = f" {profile}"
|
||||
else:
|
||||
profile = c.get("profile", "")
|
||||
table.add_row(
|
||||
profile,
|
||||
c.get("name", ""),
|
||||
c.get("version", ""),
|
||||
c.get("extensionVersion", ""),
|
||||
)
|
||||
console.print(table)
|
||||
columns = [
|
||||
("Browser", lambda item: item.get("name", "")),
|
||||
("Version", lambda item: item.get("version", "")),
|
||||
("Extension Version", lambda item: item.get("extensionVersion", "")),
|
||||
]
|
||||
print_browser_grouped_table_rows(
|
||||
all_clients,
|
||||
columns,
|
||||
console=console,
|
||||
empty_message="[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]",
|
||||
browser_getter=lambda item: item.get("profile", ""),
|
||||
group_getter=lambda item: item.get("profileGroup", "") if grouped else "",
|
||||
browser_header="Profile",
|
||||
)
|
||||
|
||||
@clients_group.command("rename")
|
||||
@click.option(
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from browser_cli.commands import handle_errors, client_from_ctx
|
||||
from browser_cli.client import active_browser_targets
|
||||
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME, PYPI_PACKAGE_NAME
|
||||
from browser_cli.constants import NATIVE_HOST_DIRS, NATIVE_HOST_NAME
|
||||
from browser_cli.platform import is_windows
|
||||
from browser_cli.version_manager import project_version
|
||||
|
||||
console = Console()
|
||||
|
||||
def _project_version() -> str:
|
||||
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
try:
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
return package_version(PYPI_PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
def _status(ok: bool) -> str:
|
||||
return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
|
||||
|
||||
@@ -39,7 +22,7 @@ def _status(ok: bool) -> str:
|
||||
def cmd_doctor(check_remote):
|
||||
"""Diagnose browser-cli installation, extension, and connection health."""
|
||||
rows: list[tuple[str, bool, str]] = []
|
||||
version = _project_version()
|
||||
version = project_version()
|
||||
rows.append(("Python package", version != "unknown", version))
|
||||
rows.append(("browser-cli executable", shutil.which("browser-cli") is not None, shutil.which("browser-cli") or "not on PATH"))
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ from rich.console import Console
|
||||
|
||||
from browser_cli.constants import (
|
||||
ALLOWED_EXTENSION_IDS,
|
||||
CHROME_WEBSTORE_URL,
|
||||
EXTENSION_ID,
|
||||
FIREFOX_ADDON_URL,
|
||||
FIREFOX_EXTENSION_ID,
|
||||
NATIVE_HOST_DIRS,
|
||||
NATIVE_HOST_NAME,
|
||||
@@ -62,11 +64,44 @@ def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str
|
||||
|
||||
@click.command("install")
|
||||
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
|
||||
def cmd_install(browser):
|
||||
"""Register the native messaging host and print extension load instructions."""
|
||||
@click.option("--dev", is_flag=True, help="Print developer instructions for loading an unpacked/temporary build instead of the public store listing.")
|
||||
def cmd_install(browser, dev):
|
||||
"""Register the native messaging host and print extension install instructions."""
|
||||
host_exe = native_host_exe()
|
||||
write_native_host_exe(host_exe)
|
||||
|
||||
if dev:
|
||||
_print_dev_instructions(browser)
|
||||
else:
|
||||
_print_store_instructions(browser)
|
||||
|
||||
manifest = _native_host_manifest(browser, host_exe)
|
||||
installed = _install_manifest(browser, host_exe, manifest)
|
||||
if not installed:
|
||||
console.print("[red]Failed to install native host manifest[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
for p in installed:
|
||||
label = "Registered native host" if is_windows() else "Wrote native host manifest"
|
||||
console.print(f"[green]✓[/green] {label}: {p}")
|
||||
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
|
||||
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
|
||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||
|
||||
def _print_store_instructions(browser: str) -> None:
|
||||
console.print("\n[bold]Step 1:[/bold] Install the extension")
|
||||
if browser == "firefox":
|
||||
console.print(" Open Firefox Add-ons and click [bold]Add to Firefox[/bold]:")
|
||||
console.print(f" [cyan]{FIREFOX_ADDON_URL}[/cyan]")
|
||||
console.print(" [dim]Firefox support is experimental; tab-group commands require browser tab group APIs.[/dim]\n")
|
||||
else:
|
||||
console.print(f" Open the Chrome Web Store and click [bold]Add to {browser.capitalize()}[/bold]:")
|
||||
console.print(f" [cyan]{CHROME_WEBSTORE_URL}[/cyan]")
|
||||
console.print(" [dim]Brave, Edge, Vivaldi and Chromium can install from the Chrome Web Store too.[/dim]")
|
||||
console.print(" [dim]Developing the extension? Run 'browser-cli install <browser> --dev' for the unpacked-load steps.[/dim]\n")
|
||||
|
||||
def _print_dev_instructions(browser: str) -> None:
|
||||
ext_url = {
|
||||
"chrome": "chrome://extensions",
|
||||
"chromium": "chrome://extensions",
|
||||
@@ -75,7 +110,7 @@ def cmd_install(browser):
|
||||
"vivaldi": "vivaldi://extensions",
|
||||
"firefox": "about:debugging#/runtime/this-firefox",
|
||||
}[browser]
|
||||
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
|
||||
console.print("\n[bold]Step 1:[/bold] Load the unpacked extension (developer mode)")
|
||||
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
||||
if browser == "firefox":
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
@@ -93,20 +128,6 @@ def cmd_install(browser):
|
||||
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
|
||||
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
|
||||
|
||||
manifest = _native_host_manifest(browser, host_exe)
|
||||
installed = _install_manifest(browser, host_exe, manifest)
|
||||
if not installed:
|
||||
console.print("[red]Failed to install native host manifest[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
for p in installed:
|
||||
label = "Registered native host" if is_windows() else "Wrote native host manifest"
|
||||
console.print(f"[green]✓[/green] {label}: {p}")
|
||||
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
|
||||
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
|
||||
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||
|
||||
def _native_host_manifest(browser: str, host_exe: Path) -> dict:
|
||||
base = {
|
||||
"name": NATIVE_HOST_NAME,
|
||||
|
||||
+10
-12
@@ -4,20 +4,18 @@ import json
|
||||
|
||||
import click
|
||||
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from browser_cli.command_security import assert_command_allowed
|
||||
from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
|
||||
|
||||
@click.command("command")
|
||||
@click.argument("name")
|
||||
@click.argument("args_json", required=False, default="{}")
|
||||
@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
|
||||
@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
|
||||
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
|
||||
@command_policy_options
|
||||
@handle_errors
|
||||
def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous):
|
||||
"""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)
|
||||
assert_command_allowed(name, policy)
|
||||
args = json.loads(args_json) if args_json else {}
|
||||
result = client_from_ctx().command(name, args)
|
||||
click.echo(json.dumps(result, indent=2, default=str))
|
||||
def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Send a raw browser-cli wire command and print JSON."""
|
||||
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
|
||||
assert_command_allowed(name, policy)
|
||||
args = json.loads(args_json) if args_json else {}
|
||||
result = client_from_ctx().command(name, args)
|
||||
click.echo(json.dumps(result, indent=2, default=str))
|
||||
|
||||
@@ -8,8 +8,8 @@ from typing import Any, cast
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from browser_cli.command_security import assert_command_allowed
|
||||
from browser_cli.commands import command_policy_from_options, command_policy_options, client_from_ctx, handle_errors
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -38,17 +38,15 @@ def _parse_step(step):
|
||||
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--json", "json_output", is_flag=True, help="Print all step results as JSON")
|
||||
@click.option("--continue-on-error", is_flag=True, help="Continue after failed steps")
|
||||
@click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
|
||||
@click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
|
||||
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
|
||||
@command_policy_options
|
||||
@handle_errors
|
||||
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool):
|
||||
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool, allow_keys: bool, allow_all: bool):
|
||||
"""Run a JSON/YAML batch script of browser-cli wire commands."""
|
||||
steps = _load_steps(file)
|
||||
if not isinstance(steps, list):
|
||||
raise click.ClickException("Script root must be a list")
|
||||
client = client_from_ctx()
|
||||
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
|
||||
policy = command_policy_from_options(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous, allow_keys=allow_keys, allow_all=allow_all)
|
||||
results = []
|
||||
for index, step in enumerate(steps, start=1):
|
||||
command, args = _parse_step(step)
|
||||
|
||||
@@ -1,61 +1,10 @@
|
||||
import click
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
from rich.console import Console
|
||||
from browser_cli.search.engines import DISPLAY_NAMES, SUBCOMMANDS
|
||||
|
||||
console = Console()
|
||||
|
||||
ENGINES = {
|
||||
"google": "https://www.google.com/search?q={query}",
|
||||
"brave": "https://search.brave.com/search?q={query}",
|
||||
"duckduckgo": "https://duckduckgo.com/?q={query}",
|
||||
"ddg": "https://duckduckgo.com/?q={query}",
|
||||
"youtube": "https://www.youtube.com/results?search_query={query}",
|
||||
"yt": "https://www.youtube.com/results?search_query={query}",
|
||||
"spotify": "https://open.spotify.com/search/{query}",
|
||||
"amazon": "https://www.amazon.com/s?k={query}",
|
||||
"ecosia": "https://www.ecosia.org/search?q={query}",
|
||||
"furaffinity": "https://www.furaffinity.net/search/?q={query}",
|
||||
"fa": "https://www.furaffinity.net/search/?q={query}",
|
||||
"bing": "https://www.bing.com/search?q={query}",
|
||||
"github": "https://github.com/search?q={query}",
|
||||
"wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
|
||||
"reddit": "https://www.reddit.com/search/?q={query}",
|
||||
"stackoverflow": "https://stackoverflow.com/search?q={query}",
|
||||
"so": "https://stackoverflow.com/search?q={query}",
|
||||
}
|
||||
|
||||
_DISPLAY_NAMES = {
|
||||
"google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
|
||||
"ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
|
||||
"spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
|
||||
"furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
|
||||
"github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
|
||||
"reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
|
||||
}
|
||||
|
||||
_SUBCOMMANDS = [
|
||||
("google", "Search with Google."),
|
||||
("brave", "Search with Brave Search."),
|
||||
("duckduckgo", "Search with DuckDuckGo."),
|
||||
("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
|
||||
("youtube", "Search YouTube videos."),
|
||||
("yt", "Search YouTube (alias for youtube)."),
|
||||
("spotify", "Search Spotify."),
|
||||
("amazon", "Search Amazon."),
|
||||
("ecosia", "Search with Ecosia."),
|
||||
("furaffinity", "Search FurAffinity."),
|
||||
("fa", "Search FurAffinity (alias for furaffinity)."),
|
||||
("bing", "Search with Bing."),
|
||||
("github", "Search GitHub."),
|
||||
("wikipedia", "Search Wikipedia."),
|
||||
("wiki", "Search Wikipedia (alias for wikipedia)."),
|
||||
("reddit", "Search Reddit."),
|
||||
("stackoverflow", "Search Stack Overflow."),
|
||||
("so", "Search Stack Overflow (alias for stackoverflow)."),
|
||||
]
|
||||
|
||||
|
||||
@click.group("search")
|
||||
def search_group():
|
||||
"""Search the web — open a query in a search engine."""
|
||||
@@ -70,10 +19,10 @@ def _build_command(engine_key: str, help_text: str) -> click.Command:
|
||||
terms = " ".join(query)
|
||||
client_from_ctx().nav.search(engine_key, terms, window=window, group=group)
|
||||
suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
|
||||
display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||
display = DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
|
||||
console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
|
||||
|
||||
return _cmd
|
||||
|
||||
for _name, _help in _SUBCOMMANDS:
|
||||
for _name, _help in SUBCOMMANDS:
|
||||
search_group.add_command(_build_command(_name, _help))
|
||||
|
||||
+155
-78
@@ -8,110 +8,187 @@ from pathlib import Path
|
||||
import click
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
from browser_cli.commands import command_policy_from_options, command_policy_options
|
||||
from browser_cli.serve.runtime import (
|
||||
_async_framed_send,
|
||||
_async_handle_client,
|
||||
_async_recv_all,
|
||||
_handle_client,
|
||||
_serve_async,
|
||||
console,
|
||||
_async_framed_send,
|
||||
_async_handle_client,
|
||||
_async_recv_all,
|
||||
_handle_client,
|
||||
_serve_async,
|
||||
console,
|
||||
)
|
||||
from browser_cli.serve.security import RateLimiter, ServeSecurity, key_policies_from_authorized_keys
|
||||
from browser_cli.version_manager import get_installed_version
|
||||
|
||||
__all__ = [
|
||||
"_async_framed_send",
|
||||
"_async_handle_client",
|
||||
"_async_recv_all",
|
||||
"_handle_client",
|
||||
"_serve_async",
|
||||
"cmd_serve",
|
||||
"_async_framed_send",
|
||||
"_async_handle_client",
|
||||
"_async_recv_all",
|
||||
"_handle_client",
|
||||
"_serve_async",
|
||||
"cmd_serve",
|
||||
]
|
||||
|
||||
def _is_loopback(host: str) -> bool:
|
||||
return host in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
@click.command("serve")
|
||||
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
|
||||
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
|
||||
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
|
||||
@click.option(
|
||||
"--authorized-keys",
|
||||
"auth_keys_file",
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
|
||||
"--authorized-keys",
|
||||
"auth_keys_file",
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-compress",
|
||||
"no_compress",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable response compression / msgpack even for clients that support it.",
|
||||
"--no-compress",
|
||||
"no_compress",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable response compression / msgpack even for clients that support it.",
|
||||
)
|
||||
@click.option(
|
||||
"--rate-limit",
|
||||
default=100.0,
|
||||
show_default=True,
|
||||
type=float,
|
||||
help="Max commands/sec per client key (0 disables).",
|
||||
)
|
||||
@command_policy_options
|
||||
@click.pass_context
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
|
||||
"""Expose this browser over TCP so remote hosts can control it."""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
compress = not no_compress
|
||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rate_limit,
|
||||
allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Expose this browser over TCP so remote hosts can control it.
|
||||
|
||||
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."
|
||||
)
|
||||
Commands are gated by a safe-only policy by default; remote clients can only
|
||||
run read-only status/listing commands. Open more with --allow-read-page,
|
||||
--allow-control, --allow-dangerous, or --allow-all (full control). Per-key
|
||||
overrides come from an ``allow:`` token in authorized_keys (set via
|
||||
``auth trust --allow-*``), and --rate-limit throttles each client key.
|
||||
"""
|
||||
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||
compress = not no_compress
|
||||
|
||||
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
|
||||
if auth_keys_path is False:
|
||||
sys.exit(1)
|
||||
if no_auth and not _is_loopback(host):
|
||||
console.print(
|
||||
"[red]Error:[/red] --no-auth is only allowed on loopback hosts "
|
||||
"(127.0.0.1, localhost, ::1). Use --authorized-keys to expose this browser to the network."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
_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:
|
||||
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
|
||||
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]")
|
||||
policy = command_policy_from_options(
|
||||
allow_read_page=allow_read_page,
|
||||
allow_control=allow_control,
|
||||
allow_dangerous=allow_dangerous,
|
||||
allow_keys=allow_keys,
|
||||
allow_all=allow_all,
|
||||
)
|
||||
|
||||
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
|
||||
if auth_keys_path is False:
|
||||
sys.exit(1)
|
||||
|
||||
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:
|
||||
if auth_keys_file:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
if auth_keys_file:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
|
||||
auth_keys_path = Path(auth_keys_file)
|
||||
if not load_authorized_keys(auth_keys_path):
|
||||
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
|
||||
return auth_keys_path
|
||||
if no_auth:
|
||||
return None
|
||||
console.print(
|
||||
"[red]Error:[/red] --authorized-keys FILE is required. "
|
||||
"Use --no-auth to explicitly disable auth (dangerous)."
|
||||
)
|
||||
return False
|
||||
auth_keys_path = Path(auth_keys_file)
|
||||
if not load_authorized_keys(auth_keys_path):
|
||||
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
|
||||
return auth_keys_path
|
||||
if no_auth:
|
||||
return None
|
||||
console.print(
|
||||
"[red]Error:[/red] --authorized-keys FILE is required. "
|
||||
"Use --no-auth to explicitly disable auth (dangerous)."
|
||||
)
|
||||
return False
|
||||
|
||||
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
|
||||
current_ver = get_installed_version()
|
||||
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]")
|
||||
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool, security: ServeSecurity | None = None) -> None:
|
||||
current_ver = get_installed_version()
|
||||
security = security if security is not None else ServeSecurity()
|
||||
browser_hint = f" (browser: {profile})" if profile else ""
|
||||
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
|
||||
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
if auth_keys_path is not None:
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
|
||||
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 ''})")
|
||||
else:
|
||||
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||
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 ''})")
|
||||
else:
|
||||
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||
|
||||
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")
|
||||
_print_policy_status(security.policy)
|
||||
if security.key_policies:
|
||||
console.print(f" Per-key: [green]{len(security.key_policies)} override(s)[/green] [dim](allow: in authorized_keys)[/dim]")
|
||||
if security.rate_limiter is not None:
|
||||
console.print(f" Rate: [green]{security.rate_limiter.rate:g}/s per key[/green] [dim](burst {security.rate_limiter.capacity:g})[/dim]")
|
||||
else:
|
||||
console.print(" Rate: [yellow]unlimited[/yellow] [dim](--rate-limit 0)[/dim]")
|
||||
|
||||
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
|
||||
_print_encoding_status(compress)
|
||||
console.print("Ctrl-C to stop.\n")
|
||||
|
||||
def _print_policy_status(policy: CommandPolicy | None) -> None:
|
||||
if policy is None or policy == CommandPolicy.unrestricted():
|
||||
console.print(" Policy: [yellow]unrestricted (--allow-all)[/yellow] [dim](every command allowed, incl. dom.eval/storage)[/dim]")
|
||||
return
|
||||
allowed = ["safe"]
|
||||
if policy.allow_read_page:
|
||||
allowed.append("read-page")
|
||||
if policy.allow_control:
|
||||
allowed.append("control")
|
||||
if policy.allow_dangerous:
|
||||
allowed.append("dangerous")
|
||||
if policy.allow_keys:
|
||||
allowed.append("keys")
|
||||
console.print(f" Policy: [green]restricted[/green] [dim](allowed: {', '.join(allowed)})[/dim]")
|
||||
|
||||
def _print_encoding_status(compress: bool) -> None:
|
||||
if not compress:
|
||||
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
|
||||
return
|
||||
codecs = "+".join(transport.supported_compression())
|
||||
sers = "+".join(transport.supported_serialization())
|
||||
console.print(
|
||||
" Encode: [green]on[/green] "
|
||||
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
|
||||
)
|
||||
if not compress:
|
||||
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
|
||||
return
|
||||
codecs = "+".join(transport.supported_compression())
|
||||
sers = "+".join(transport.supported_serialization())
|
||||
console.print(
|
||||
" Encode: [green]on[/green] "
|
||||
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from rich.console import Console
|
||||
|
||||
from browser_cli import BrowserCLI
|
||||
from browser_cli.command_security import CommandPolicy, assert_command_allowed
|
||||
from browser_cli.commands import command_policy_from_options, command_policy_options
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -24,9 +25,11 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
def _authorized(self) -> bool:
|
||||
if self.token is None:
|
||||
return True
|
||||
if self.headers.get("Authorization", "") == f"Bearer {self.token}":
|
||||
bearer = self.headers.get("Authorization", "")
|
||||
if bearer.startswith("Bearer ") and secrets.compare_digest(bearer[len("Bearer "):], self.token):
|
||||
return True
|
||||
return self.headers.get("X-Browser-CLI-Token") == self.token
|
||||
header = self.headers.get("X-Browser-CLI-Token")
|
||||
return header is not None and secrets.compare_digest(header, self.token)
|
||||
|
||||
def _require_auth(self) -> bool:
|
||||
if self._authorized():
|
||||
@@ -87,10 +90,8 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
@click.option("--key", default=None, help="Remote auth key spec")
|
||||
@click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)")
|
||||
@click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)")
|
||||
@click.option("--allow-read-page", is_flag=True, help="Allow /command to run page-content read commands")
|
||||
@click.option("--allow-control", is_flag=True, help="Allow /command to run browser-control commands")
|
||||
@click.option("--allow-dangerous", is_flag=True, help="Allow /command to run high-risk commands")
|
||||
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous):
|
||||
@command_policy_options
|
||||
def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous, allow_keys, allow_all):
|
||||
"""Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
|
||||
|
||||
Auth is enabled by default. Pass the printed token as either
|
||||
@@ -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):
|
||||
raise click.ClickException("--no-auth is only allowed on loopback hosts")
|
||||
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(
|
||||
"BrowserCLIHTTPHandler",
|
||||
(_Handler,),
|
||||
|
||||
@@ -20,11 +20,29 @@ FIREFOX_EXTENSION_ID = "browser-cli@yiprawr.dev"
|
||||
ALLOWED_EXTENSION_IDS = [EXTENSION_ID, WEBSTORE_EXTENSION_ID]
|
||||
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi", "firefox"]
|
||||
|
||||
# Public store listings — the default install path now that the extension is
|
||||
# published. Chromium-family browsers (Brave/Edge/Vivaldi/Chromium) can all
|
||||
# install from the Chrome Web Store.
|
||||
CHROME_WEBSTORE_URL = f"https://chromewebstore.google.com/detail/browser-cli/{WEBSTORE_EXTENSION_ID}"
|
||||
FIREFOX_ADDON_URL = "https://addons.mozilla.org/firefox/addon/browser-cli/"
|
||||
|
||||
PROTOCOL_MIN_CLIENT = "0.9.0"
|
||||
MAX_MSG_BYTES = 32 * 1024 * 1024
|
||||
DEFAULT_REMOTE_PORT = 443
|
||||
DEFAULT_PAGE_SIZE = 100
|
||||
# Count cap requested per page. The extension fills each page up to this many
|
||||
# items OR a byte budget (whichever comes first), so large items (e.g. data-URI
|
||||
# favicons) stay under the 1MB native-messaging limit while small items pack
|
||||
# into far fewer roundtrips.
|
||||
DEFAULT_PAGE_SIZE = 1000
|
||||
# Hard upper bound on total items collected across all pages, and the loop-guard
|
||||
# page count. Kept independent of page size so byte-budgeted small pages don't
|
||||
# falsely trip the guard.
|
||||
MAX_PAGED_ITEMS = 10_000
|
||||
DEFAULT_TRANSPORT_THRESHOLD = 512
|
||||
# How long a remote serve connection stays open waiting for the next command on
|
||||
# an established encrypted session before closing. Lets the client reuse one
|
||||
# authenticated connection for multiple commands instead of re-handshaking.
|
||||
REMOTE_SESSION_IDLE_TIMEOUT = 30
|
||||
|
||||
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
|
||||
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
|
||||
|
||||
+19
-23
@@ -62,27 +62,27 @@ class Tab:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close this tab."""
|
||||
self._command("tabs.close", {"tabId": self.id})
|
||||
self._b().tabs.close(self.id)
|
||||
|
||||
def activate(self) -> None:
|
||||
"""Switch browser focus to this tab."""
|
||||
self._command("tabs.active", {"tabId": self.id})
|
||||
self._b().tabs.activate(self.id)
|
||||
|
||||
def mute(self) -> None:
|
||||
"""Mute this tab."""
|
||||
self._command("tabs.mute", {"tabId": self.id})
|
||||
self._b().tabs.mute(self.id)
|
||||
|
||||
def unmute(self) -> None:
|
||||
"""Unmute this tab."""
|
||||
self._command("tabs.unmute", {"tabId": self.id})
|
||||
self._b().tabs.unmute(self.id)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload this tab."""
|
||||
self._command("navigate.reload", {"tabId": self.id})
|
||||
self._b().nav.reload(self.id)
|
||||
|
||||
def hard_reload(self) -> None:
|
||||
"""Hard-reload this tab (bypass cache)."""
|
||||
self._command("navigate.hard_reload", {"tabId": self.id})
|
||||
self._b().nav.hard_reload(self.id)
|
||||
|
||||
def move(
|
||||
self, *,
|
||||
@@ -101,18 +101,18 @@ class Tab:
|
||||
window_id: Move to the window with this ID.
|
||||
index: Absolute position index in the target window.
|
||||
"""
|
||||
self._command("tabs.move", {
|
||||
"tabId": self.id,
|
||||
"forward": forward,
|
||||
"backward": backward,
|
||||
"groupId": group_id,
|
||||
"windowId": window_id,
|
||||
"index": index,
|
||||
})
|
||||
self._b().tabs.move(
|
||||
self.id,
|
||||
forward=forward,
|
||||
backward=backward,
|
||||
group_id=group_id,
|
||||
window_id=window_id,
|
||||
index=index,
|
||||
)
|
||||
|
||||
def html(self) -> str:
|
||||
"""Return the full HTML source of this tab."""
|
||||
return self._command("tabs.html", {"tabId": self.id})
|
||||
return self._b().tabs.html(self.id)
|
||||
|
||||
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
|
||||
"""Capture this tab's visible area. Returns a base64 data URL."""
|
||||
@@ -120,11 +120,11 @@ class Tab:
|
||||
|
||||
def pin(self) -> None:
|
||||
"""Pin this tab."""
|
||||
self._command("tabs.pin", {"tabId": self.id})
|
||||
self._b().tabs.pin(self.id)
|
||||
|
||||
def unpin(self) -> None:
|
||||
"""Unpin this tab."""
|
||||
self._command("tabs.unpin", {"tabId": self.id})
|
||||
self._b().tabs.unpin(self.id)
|
||||
|
||||
def refresh(self) -> Tab:
|
||||
"""Return a fresh snapshot of this tab."""
|
||||
@@ -170,7 +170,7 @@ class Group:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Ungroup (and close) this tab group."""
|
||||
self._command("group.close", {"groupId": self.id})
|
||||
self._b().groups.close(self.id)
|
||||
|
||||
def tabs(self) -> list[Tab]:
|
||||
"""Return all tabs inside this group."""
|
||||
@@ -178,11 +178,7 @@ class Group:
|
||||
|
||||
def move(self, *, forward: bool = False, backward: bool = False) -> None:
|
||||
"""Move this group forward or backward among groups."""
|
||||
self._command("group.move", {
|
||||
"group": str(self.id),
|
||||
"forward": forward,
|
||||
"backward": backward,
|
||||
})
|
||||
self._b().groups.move(str(self.id), forward=forward, backward=backward)
|
||||
|
||||
def add_tab(self, url: str | None = None) -> int | None:
|
||||
"""Open a new tab inside this group. Returns the new tab ID."""
|
||||
|
||||
@@ -7,7 +7,6 @@ It relays messages between extension (stdin/stdout Native Messaging protocol)
|
||||
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
@@ -17,7 +16,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.native import local_server, protocol
|
||||
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, PAGEABLE_COMMANDS
|
||||
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, MAX_PAGED_ITEMS, PAGEABLE_COMMANDS
|
||||
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path, runtime_dir
|
||||
from browser_cli.registry import update_registry
|
||||
|
||||
@@ -126,7 +125,10 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
|
||||
offset = 0
|
||||
items = []
|
||||
total = None
|
||||
max_pages = math.ceil(10_000 / PAGE_SIZE)
|
||||
# Independent of PAGE_SIZE: the extension may return fewer items per page than
|
||||
# requested (byte budget), so a page-count guard derived from PAGE_SIZE would
|
||||
# falsely trip. Bound the page count by the absolute item cap instead.
|
||||
max_pages = MAX_PAGED_ITEMS
|
||||
pages_fetched = 0
|
||||
|
||||
while True:
|
||||
@@ -154,7 +156,7 @@ def _collect_paged_browser_command(cmd: dict) -> dict:
|
||||
items.extend(page_items)
|
||||
total = data.get("total", total)
|
||||
next_offset = data.get("nextOffset")
|
||||
if next_offset is None:
|
||||
if next_offset is None or len(items) >= MAX_PAGED_ITEMS:
|
||||
break
|
||||
offset = int(next_offset)
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Per-process pool of authenticated remote connections for reuse.
|
||||
|
||||
A ``browser-cli serve`` connection stays open after its first (encrypted)
|
||||
command, so the client can send further commands over it without re-running the
|
||||
TCP/TLS/challenge/auth handshake (~hundreds of ms each). Only encrypted (PQ)
|
||||
sessions are pooled — plaintext/legacy sessions stay one-shot, matching the
|
||||
server, which only loops for encrypted sessions.
|
||||
|
||||
Connections are checked out exclusively (never shared between threads at once),
|
||||
returned on success, and dropped on any I/O error or once older than an idle
|
||||
bound (kept below the server's idle timeout so we don't reuse a connection the
|
||||
server has already closed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
|
||||
from browser_cli.framing import frame
|
||||
|
||||
# Retire a pooled connection a few seconds before the server would, so we never
|
||||
# hand back one the server has just timed out and closed.
|
||||
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
|
||||
_MAX_PER_ENDPOINT = 8
|
||||
|
||||
class PooledConnection:
|
||||
__slots__ = ("sock", "secret", "last_used")
|
||||
|
||||
def __init__(self, sock: socket.socket, secret: bytes) -> None:
|
||||
self.sock = sock
|
||||
self.secret = secret
|
||||
self.last_used = time.monotonic()
|
||||
|
||||
_POOL: dict[str, list[PooledConnection]] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
def _close(sock: socket.socket) -> None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def checkout(endpoint: str) -> PooledConnection | None:
|
||||
"""Take an idle authenticated connection for *endpoint*, or None."""
|
||||
now = time.monotonic()
|
||||
with _LOCK:
|
||||
conns = _POOL.get(endpoint)
|
||||
while conns:
|
||||
conn = conns.pop()
|
||||
if now - conn.last_used <= _MAX_IDLE_SECONDS:
|
||||
return conn
|
||||
_close(conn.sock) # too old — assume the server has dropped it
|
||||
return None
|
||||
|
||||
def checkin(endpoint: str, conn: PooledConnection) -> None:
|
||||
"""Return a still-healthy connection to the pool for reuse."""
|
||||
conn.last_used = time.monotonic()
|
||||
with _LOCK:
|
||||
bucket = _POOL.setdefault(endpoint, [])
|
||||
if len(bucket) >= _MAX_PER_ENDPOINT:
|
||||
_close(conn.sock)
|
||||
return
|
||||
bucket.append(conn)
|
||||
|
||||
def discard(conn: PooledConnection) -> None:
|
||||
"""Drop a connection that errored or is no longer usable."""
|
||||
_close(conn.sock)
|
||||
|
||||
def close_all() -> None:
|
||||
"""Close every pooled connection (process exit / test isolation)."""
|
||||
with _LOCK:
|
||||
for bucket in _POOL.values():
|
||||
for conn in bucket:
|
||||
_close(conn.sock)
|
||||
_POOL.clear()
|
||||
|
||||
def session_inner_message(msg: dict) -> dict:
|
||||
"""Strip auth/transport fields, leaving the command for an established session."""
|
||||
keep = {"id", "command", "args", "user_agent", "accept_encoding", "_route", "_suppress_pq_warning"}
|
||||
return {k: v for k, v in msg.items() if k in keep}
|
||||
|
||||
def send_over(conn: PooledConnection, msg: dict) -> bytes | None:
|
||||
"""Send one command over an existing encrypted session. Raises on I/O error."""
|
||||
from browser_cli.auth import pq_encrypt
|
||||
from browser_cli.remote.socket import recv_all
|
||||
from browser_cli.remote.transport import _decode_pq_response
|
||||
|
||||
inner = json.dumps(session_inner_message(msg)).encode("utf-8")
|
||||
envelope = json.dumps({"encrypted": pq_encrypt(conn.secret, "request", inner)}).encode("utf-8")
|
||||
conn.sock.sendall(frame(envelope))
|
||||
response = recv_all(conn.sock)
|
||||
if not response:
|
||||
# EOF — an older server (no session loop) closed after one command. Treat as
|
||||
# a transport failure so the caller re-handshakes; never as an app error,
|
||||
# which could double-execute a non-idempotent command on retry.
|
||||
raise EOFError("remote closed the pooled connection")
|
||||
return _decode_pq_response(response, conn.secret)
|
||||
|
||||
atexit.register(close_all)
|
||||
@@ -25,8 +25,8 @@ def split_endpoint(endpoint: str) -> tuple[str, int]:
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
return host, int(port_str)
|
||||
|
||||
@contextmanager
|
||||
def open_socket(endpoint: str):
|
||||
def connect_socket(endpoint: str) -> socket.socket:
|
||||
"""Open and (on :443) TLS-wrap a socket. Caller owns closing it."""
|
||||
host, port = split_endpoint(endpoint)
|
||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw_sock.settimeout(30)
|
||||
@@ -40,6 +40,11 @@ def open_socket(endpoint: str):
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
return sock
|
||||
|
||||
@contextmanager
|
||||
def open_socket(endpoint: str):
|
||||
sock = connect_socket(endpoint)
|
||||
with sock:
|
||||
yield sock
|
||||
|
||||
|
||||
@@ -20,24 +20,50 @@ from browser_cli.remote.auth import (
|
||||
from browser_cli.remote.socket import (
|
||||
async_recv_all as _async_recv_all,
|
||||
async_recv_exact_bytes as _async_recv_exact,
|
||||
connect_socket as _connect_socket,
|
||||
open_async_connection as _open_async_connection,
|
||||
open_socket as _open_socket,
|
||||
recv_all as _recv_all,
|
||||
recv_exact_bytes as _recv_exact,
|
||||
split_endpoint as _split_endpoint,
|
||||
)
|
||||
from browser_cli.remote import pool as _pool
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
# Reuse an already-authenticated connection when one is idle for this endpoint.
|
||||
conn = _pool.checkout(endpoint)
|
||||
if conn is not None:
|
||||
try:
|
||||
response = _pool.send_over(conn, msg)
|
||||
_pool.checkin(endpoint, conn)
|
||||
return response
|
||||
except (OSError, ConnectionError, ValueError, EOFError):
|
||||
_pool.discard(conn) # stale/closed — fall through to a fresh handshake
|
||||
|
||||
return _send_remote_handshake(endpoint, msg, private_key, warn_no_pq=warn_no_pq)
|
||||
|
||||
def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
||||
|
||||
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
||||
from browser_cli.auth import pq_kex_client_encapsulate
|
||||
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
|
||||
|
||||
with _open_socket(endpoint) as sock:
|
||||
sock = _connect_socket(endpoint)
|
||||
try:
|
||||
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
||||
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
||||
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
except BaseException:
|
||||
_pool._close(sock)
|
||||
raise
|
||||
# Only encrypted sessions are reusable — the server keeps those open, and a
|
||||
# fresh AEAD nonce per frame keeps reuse of the shared secret safe.
|
||||
if pq_shared_secret is not None:
|
||||
_pool.checkin(endpoint, _pool.PooledConnection(sock, pq_shared_secret))
|
||||
else:
|
||||
_pool._close(sock)
|
||||
return response
|
||||
|
||||
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
reader, writer = await _open_async_connection(endpoint)
|
||||
|
||||
@@ -114,7 +114,7 @@ class NavigationNS(Namespace):
|
||||
) -> None:
|
||||
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
|
||||
from urllib.parse import quote_plus
|
||||
from browser_cli.commands.search import ENGINES
|
||||
from browser_cli.search.engines import ENGINES
|
||||
template = ENGINES.get(engine)
|
||||
if template is None:
|
||||
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
|
||||
|
||||
+22
-10
@@ -7,12 +7,14 @@ helpers; single-browser mode falls straight through to ``_cmd``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING, Protocol, cast
|
||||
|
||||
from browser_cli.client import BrowserTarget
|
||||
from browser_cli.client.core import _run_concurrent
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.models import BrowserCounts, Tab
|
||||
|
||||
@@ -81,18 +83,28 @@ class RoutingMixin:
|
||||
return targets
|
||||
|
||||
def _collect_multi_browser(self, command: str, args: dict | None = None):
|
||||
results = []
|
||||
targets = self._multi_browser_targets()
|
||||
for target in targets:
|
||||
try:
|
||||
if target.remote:
|
||||
data = _browser_cli_package().send_command(
|
||||
command, args, profile=target.profile, remote=target.remote, key=self._client._key
|
||||
)
|
||||
else:
|
||||
data = _browser_cli_package().send_command(command, args, profile=target.profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
|
||||
def _send(target: BrowserTarget):
|
||||
package = _browser_cli_package()
|
||||
if target.remote:
|
||||
return package.send_command(
|
||||
command, args, profile=target.profile, remote=target.remote, key=self._client._key
|
||||
)
|
||||
return package.send_command(command, args, profile=target.profile)
|
||||
|
||||
# Run per-target roundtrips concurrently — each is a blocking, network-bound
|
||||
# send_command, so offloading to threads gives real overlap while still
|
||||
# invoking the (test-patchable) sync entry point.
|
||||
raw = _run_concurrent([
|
||||
(lambda t=t: asyncio.to_thread(_send, t)) for t in targets
|
||||
])
|
||||
results = []
|
||||
for target, data in zip(targets, raw):
|
||||
if isinstance(data, (BrowserNotConnected, RuntimeError)):
|
||||
continue
|
||||
if isinstance(data, BaseException):
|
||||
raise data
|
||||
results.append((target, data))
|
||||
if results:
|
||||
return results
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Search metadata and helpers shared by SDK and CLI layers."""
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Shared search-engine metadata for SDK and CLI search commands."""
|
||||
from __future__ import annotations
|
||||
|
||||
ENGINES = {
|
||||
"google": "https://www.google.com/search?q={query}",
|
||||
"brave": "https://search.brave.com/search?q={query}",
|
||||
"duckduckgo": "https://duckduckgo.com/?q={query}",
|
||||
"ddg": "https://duckduckgo.com/?q={query}",
|
||||
"youtube": "https://www.youtube.com/results?search_query={query}",
|
||||
"yt": "https://www.youtube.com/results?search_query={query}",
|
||||
"spotify": "https://open.spotify.com/search/{query}",
|
||||
"amazon": "https://www.amazon.com/s?k={query}",
|
||||
"ecosia": "https://www.ecosia.org/search?q={query}",
|
||||
"furaffinity": "https://www.furaffinity.net/search/?q={query}",
|
||||
"fa": "https://www.furaffinity.net/search/?q={query}",
|
||||
"bing": "https://www.bing.com/search?q={query}",
|
||||
"github": "https://github.com/search?q={query}",
|
||||
"wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
|
||||
"reddit": "https://www.reddit.com/search/?q={query}",
|
||||
"stackoverflow": "https://stackoverflow.com/search?q={query}",
|
||||
"so": "https://stackoverflow.com/search?q={query}",
|
||||
}
|
||||
|
||||
DISPLAY_NAMES = {
|
||||
"google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
|
||||
"ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
|
||||
"spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
|
||||
"furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
|
||||
"github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
|
||||
"reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
|
||||
}
|
||||
|
||||
SUBCOMMANDS = [
|
||||
("google", "Search with Google."),
|
||||
("brave", "Search with Brave Search."),
|
||||
("duckduckgo", "Search with DuckDuckGo."),
|
||||
("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
|
||||
("youtube", "Search YouTube videos."),
|
||||
("yt", "Search YouTube (alias for youtube)."),
|
||||
("spotify", "Search Spotify."),
|
||||
("amazon", "Search Amazon."),
|
||||
("ecosia", "Search with Ecosia."),
|
||||
("furaffinity", "Search FurAffinity."),
|
||||
("fa", "Search FurAffinity (alias for furaffinity)."),
|
||||
("bing", "Search with Bing."),
|
||||
("github", "Search GitHub."),
|
||||
("wikipedia", "Search Wikipedia."),
|
||||
("wiki", "Search Wikipedia (alias for wikipedia)."),
|
||||
("reddit", "Search Reddit."),
|
||||
("stackoverflow", "Search Stack Overflow."),
|
||||
("so", "Search Stack Overflow (alias for stackoverflow)."),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ class ServeControlMixin:
|
||||
addr: tuple
|
||||
command: str
|
||||
auth_keys_path: Path | None
|
||||
auth_label: str | None
|
||||
|
||||
async def send_error(self, msg: str, msg_id=None) -> None: ...
|
||||
async def send_ok(self, payload, command: str | None = None) -> None: ...
|
||||
@@ -23,25 +24,32 @@ class ServeControlMixin:
|
||||
try:
|
||||
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
|
||||
if clients:
|
||||
browser_name = clients[0].get("name")
|
||||
if browser_name:
|
||||
item["browserName"] = browser_name
|
||||
# Carry the full client info so a remote `clients` command can render
|
||||
# from this single roundtrip instead of issuing another clients.list.
|
||||
info = clients[0]
|
||||
for src, dst in (("name", "browserName"), ("version", "version"), ("extensionVersion", "extensionVersion")):
|
||||
value = info.get(src)
|
||||
if value:
|
||||
item[dst] = value
|
||||
except Exception:
|
||||
pass
|
||||
targets.append(item)
|
||||
await self.send_ok(targets, self.command)
|
||||
log_request(self.addr, self.command, None, "OK")
|
||||
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
|
||||
return True
|
||||
|
||||
if self.command == "browser-cli.auth.keys":
|
||||
if self.auth_keys_path is None:
|
||||
await self.send_error("no authorized keys file configured on this server")
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file", identity=self.auth_label)
|
||||
return True
|
||||
from browser_cli.auth import load_authorized_keys_with_names
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(self.auth_keys_path)]
|
||||
from browser_cli.auth import load_authorized_keys_with_policies
|
||||
entries = [
|
||||
{"pubkey": pk, "name": name, "allow": cats}
|
||||
for pk, name, cats in load_authorized_keys_with_policies(self.auth_keys_path)
|
||||
]
|
||||
await self.send_ok(entries, self.command)
|
||||
log_request(self.addr, self.command, None, "OK")
|
||||
log_request(self.addr, self.command, None, "OK", identity=self.auth_label)
|
||||
return True
|
||||
|
||||
if self.command == "browser-cli.auth.trust":
|
||||
@@ -54,14 +62,27 @@ class ServeControlMixin:
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||
return True
|
||||
from browser_cli.auth import add_authorized_key
|
||||
from browser_cli.serve.security import policy_from_categories
|
||||
args = msg.get("args") or {}
|
||||
pubkey = str(args.get("pubkey") or "")
|
||||
name = str(args.get("name") or "")
|
||||
categories = args.get("allow")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
|
||||
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
|
||||
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey")
|
||||
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey", identity=self.auth_label)
|
||||
return True
|
||||
added = add_authorized_key(self.auth_keys_path, pubkey, name)
|
||||
if 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)
|
||||
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
|
||||
|
||||
@@ -6,11 +6,19 @@ from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
def log_request(addr: tuple, command: str, profile: str | None, status: str, error: str | None = None) -> None:
|
||||
def log_request(
|
||||
addr: tuple,
|
||||
command: str,
|
||||
profile: str | None,
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
identity: str | None = None,
|
||||
) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
addr_str = f"{addr[0]}:{addr[1]}"
|
||||
identity_str = f"[magenta]{identity}[/magenta] " if identity else ""
|
||||
profile_str = f"[dim]{profile}[/dim] " if profile else ""
|
||||
if error:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {identity_str}{profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
|
||||
else:
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||
console.print(f"[dim]{ts}[/dim] {addr_str} {identity_str}{profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||
|
||||
@@ -18,6 +18,7 @@ class ServeProxyMixin:
|
||||
command: str
|
||||
compress: bool
|
||||
accept_encoding: dict | None
|
||||
auth_label: str | None
|
||||
|
||||
async def send_error(self, msg: str, msg_id=None) -> None: ...
|
||||
async def send_payload(self, data: bytes) -> None: ...
|
||||
@@ -35,7 +36,7 @@ class ServeProxyMixin:
|
||||
sock_path = resolve_socket(resolved_profile)
|
||||
except BrowserNotConnected as e:
|
||||
await self.send_error(str(e))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected")
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected", identity=self.auth_label)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -46,7 +47,7 @@ class ServeProxyMixin:
|
||||
await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile)
|
||||
except (OSError, json.JSONDecodeError, ConnectionError) as e:
|
||||
await self.send_error(str(e))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e), identity=self.auth_label)
|
||||
|
||||
async def _windows_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
@@ -74,6 +75,6 @@ class ServeProxyMixin:
|
||||
else:
|
||||
await self.send_payload(resp_payload)
|
||||
if resp_data.get("success", True):
|
||||
log_request(self.addr, self.command, resolved_profile, "OK")
|
||||
log_request(self.addr, self.command, resolved_profile, "OK", identity=self.auth_label)
|
||||
else:
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""), identity=self.auth_label)
|
||||
|
||||
@@ -9,17 +9,20 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.command_security import assert_command_allowed
|
||||
from browser_cli.compat import adapt_auth
|
||||
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
|
||||
from browser_cli.framing import async_recv_frame, async_send_frame
|
||||
from browser_cli.serve.auth import ServeAuthMixin
|
||||
from browser_cli.serve.challenge import build_challenge as _build_challenge, load_auth_keys as _load_auth_keys
|
||||
from browser_cli.serve.control import ServeControlMixin
|
||||
from browser_cli.serve.logging import console, log_request
|
||||
from browser_cli.serve.proxy import ServeProxyMixin
|
||||
from browser_cli.serve.security import ServeSecurity
|
||||
|
||||
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
|
||||
await async_send_frame(writer, data)
|
||||
@@ -38,12 +41,15 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
|
||||
nonce: str
|
||||
pq_private_key: object | None = None
|
||||
compress: bool = True
|
||||
security: ServeSecurity = field(default_factory=ServeSecurity)
|
||||
|
||||
response_secret: bytes | None = None
|
||||
accept_encoding: dict | None = None
|
||||
client_ver: str = "0"
|
||||
msg_id: object = None
|
||||
command: str = "?"
|
||||
auth_pubkey: str | None = None
|
||||
auth_label: str | None = None
|
||||
|
||||
async def send_payload(self, data: bytes) -> None:
|
||||
if self.response_secret is not None:
|
||||
@@ -89,11 +95,73 @@ class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
|
||||
msg = await self.authenticate(msg)
|
||||
if msg is None:
|
||||
return
|
||||
self._apply_identity(msg)
|
||||
await self._dispatch(msg)
|
||||
# Once an encrypted session is established, keep serving further commands on
|
||||
# the same connection — the client may reuse it without re-authenticating.
|
||||
# Safe because every frame carries a fresh AEAD nonce (see pq_encrypt).
|
||||
while self.response_secret is not None:
|
||||
nxt = await self._read_session_message()
|
||||
if nxt is None:
|
||||
return
|
||||
await self._dispatch(nxt)
|
||||
|
||||
def _apply_identity(self, msg: dict) -> None:
|
||||
"""Record the authenticated pubkey (if any) for per-key policy and audit logs."""
|
||||
pub = (msg.get("pubkey") or "").strip().lower()
|
||||
self.auth_pubkey = pub or None
|
||||
self.auth_label = self.security.label_for(self.auth_pubkey)
|
||||
|
||||
async def _enforce_rate_limit(self) -> bool:
|
||||
limiter = self.security.rate_limiter
|
||||
if limiter is None or limiter.allow(self.auth_pubkey or str(self.addr[0])):
|
||||
return True
|
||||
await self.send_error("rate limit exceeded; slow down and retry")
|
||||
log_request(self.addr, self.command, None, "DENIED", "rate limit exceeded", identity=self.auth_label)
|
||||
return False
|
||||
|
||||
async def _dispatch(self, msg: dict) -> None:
|
||||
self.accept_encoding = msg.get("accept_encoding")
|
||||
if not await self._enforce_rate_limit():
|
||||
return
|
||||
# Gate every command — including server control commands like the key-management
|
||||
# ones — so the policy is enforced before handle_control_command acts on it.
|
||||
try:
|
||||
assert_command_allowed(self.command, self.security.effective_policy(self.auth_pubkey))
|
||||
except PermissionError as exc:
|
||||
await self.send_error(str(exc))
|
||||
log_request(self.addr, self.command, None, "DENIED", "blocked by command policy", identity=self.auth_label)
|
||||
return
|
||||
if await self.handle_control_command(msg):
|
||||
return
|
||||
await self.forward_to_browser(msg)
|
||||
|
||||
async def _read_session_message(self) -> dict | None:
|
||||
"""Read the next command on an established encrypted session, or None to close."""
|
||||
try:
|
||||
payload = await asyncio.wait_for(_async_recv_all(self.reader), timeout=REMOTE_SESSION_IDLE_TIMEOUT)
|
||||
except (asyncio.TimeoutError, ConnectionError, OSError):
|
||||
return None
|
||||
if not payload:
|
||||
return None
|
||||
try:
|
||||
outer = json.loads(payload)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(outer, dict) or "encrypted" not in outer:
|
||||
return None # an authenticated session only accepts encrypted frames
|
||||
from browser_cli.auth import pq_decrypt
|
||||
try:
|
||||
inner = json.loads(pq_decrypt(self.response_secret, "request", outer["encrypted"]))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(inner, dict):
|
||||
return None
|
||||
inner = adapt_auth(inner, self.client_ver)
|
||||
self.msg_id = inner.get("id")
|
||||
self.command = inner.get("command", "?")
|
||||
return inner
|
||||
|
||||
async def _async_proxy_request(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
@@ -104,8 +172,12 @@ async def _async_proxy_request(
|
||||
nonce: str,
|
||||
pq_private_key=None,
|
||||
compress: bool = True,
|
||||
security: ServeSecurity | None = None,
|
||||
) -> None:
|
||||
await ServeRequest(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress).run()
|
||||
await ServeRequest(
|
||||
reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress,
|
||||
security if security is not None else ServeSecurity(),
|
||||
).run()
|
||||
|
||||
async def _async_handle_client(
|
||||
reader: asyncio.StreamReader,
|
||||
@@ -115,6 +187,7 @@ async def _async_handle_client(
|
||||
auth_keys_path: Path | None,
|
||||
compress: bool = True,
|
||||
conn_limit: asyncio.Semaphore | None = None,
|
||||
security: ServeSecurity | None = None,
|
||||
) -> None:
|
||||
if conn_limit is None:
|
||||
conn_limit = asyncio.Semaphore(64)
|
||||
@@ -130,7 +203,7 @@ async def _async_handle_client(
|
||||
await _async_framed_send(writer, json.dumps(challenge_msg).encode())
|
||||
except OSError:
|
||||
return
|
||||
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
|
||||
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress, security)
|
||||
finally:
|
||||
conn_limit.release()
|
||||
writer.close()
|
||||
@@ -145,12 +218,13 @@ def _handle_client(
|
||||
profile: str | None,
|
||||
auth_keys_path: Path | None,
|
||||
compress: bool = True,
|
||||
security: ServeSecurity | None = None,
|
||||
) -> None:
|
||||
"""Run one accepted socket through the async serve pipeline."""
|
||||
|
||||
async def _run() -> None:
|
||||
reader, writer = await asyncio.open_connection(sock=client_sock)
|
||||
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress)
|
||||
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress, None, security)
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
@@ -160,12 +234,19 @@ def _handle_client(
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def _serve_async(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
|
||||
async def _serve_async(
|
||||
host: str,
|
||||
port: int,
|
||||
profile: str | None,
|
||||
auth_keys_path: Path | None,
|
||||
compress: bool,
|
||||
security: ServeSecurity | None = None,
|
||||
) -> None:
|
||||
conn_limit = asyncio.Semaphore(64)
|
||||
|
||||
async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
peer = writer.get_extra_info("peername") or ("?", 0)
|
||||
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit)
|
||||
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit, security)
|
||||
|
||||
server = await asyncio.start_server(_client_connected, host, port, backlog=16)
|
||||
async with server:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Server-side authorization, per-key policy and rate limiting for ``browser-cli serve``.
|
||||
|
||||
This bundles the three serve-time security concerns that travel together through
|
||||
the connection-handling chain:
|
||||
|
||||
- ``policy`` the server-wide default ``CommandPolicy`` (from ``--allow-*``)
|
||||
- ``key_policies`` optional per-pubkey overrides parsed from the ``allow:`` token
|
||||
in the ``authorized_keys`` file
|
||||
- ``key_names`` pubkey -> friendly name (from authorized_keys), for audit logs
|
||||
- ``rate_limiter`` optional per-identity token-bucket throttle
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.command_security import CommandPolicy
|
||||
|
||||
# ── per-key authorization ───────────────────────────────────────────────────────
|
||||
|
||||
_CATEGORY_FLAGS = {
|
||||
"read-page": "allow_read_page",
|
||||
"control": "allow_control",
|
||||
"dangerous": "allow_dangerous",
|
||||
"keys": "allow_keys",
|
||||
}
|
||||
|
||||
def policy_from_categories(categories) -> CommandPolicy:
|
||||
"""Build a CommandPolicy from category strings (``all``/``safe``/``read-page``/``control``/``dangerous``)."""
|
||||
cats = [str(c).strip().lower() for c in categories]
|
||||
if "all" in cats:
|
||||
return CommandPolicy.unrestricted()
|
||||
kwargs: dict[str, bool] = {}
|
||||
for cat in cats:
|
||||
if cat in ("", "safe"):
|
||||
continue
|
||||
flag = _CATEGORY_FLAGS.get(cat)
|
||||
if flag is None:
|
||||
raise ValueError(
|
||||
f"unknown command category {cat!r}; expected one of: all, safe, read-page, control, dangerous"
|
||||
)
|
||||
kwargs[flag] = True
|
||||
return CommandPolicy(**kwargs)
|
||||
|
||||
def key_policies_from_authorized_keys(path: Path | str | None) -> dict[str, CommandPolicy]:
|
||||
"""Build ``{pubkey: CommandPolicy}`` from the ``allow:`` tokens in authorized_keys.
|
||||
|
||||
Only keys that carry an explicit ``allow:`` token get an entry; keys without
|
||||
one fall back to the server-wide default policy. Pubkeys are normalised to
|
||||
lowercase hex. Raises ``ValueError`` on an unknown category so the server fails
|
||||
loudly at startup rather than silently mis-gating.
|
||||
"""
|
||||
if path is None:
|
||||
return {}
|
||||
from browser_cli.auth import load_authorized_keys_with_policies
|
||||
|
||||
out: dict[str, CommandPolicy] = {}
|
||||
for pubkey, _name, categories in load_authorized_keys_with_policies(Path(path)):
|
||||
if categories is not None:
|
||||
out[pubkey.strip().lower()] = policy_from_categories(categories)
|
||||
return out
|
||||
|
||||
# ── per-identity rate limiting ───────────────────────────────────────────────────
|
||||
|
||||
class RateLimiter:
|
||||
"""Token bucket keyed by identity (pubkey, or client address when unauthenticated).
|
||||
|
||||
``rate`` is the sustained refill in tokens/second; ``burst`` is the bucket
|
||||
capacity (defaults to ``rate``). ``rate <= 0`` disables limiting entirely.
|
||||
Thread-safe so it can be shared across all connections of one serve process.
|
||||
"""
|
||||
|
||||
def __init__(self, rate: float, burst: float | None = None) -> None:
|
||||
self.rate = float(rate)
|
||||
self.capacity = float(burst) if burst is not None else max(float(rate), 1.0)
|
||||
self._buckets: dict[str, tuple[float, float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
if self.rate <= 0:
|
||||
return True
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
tokens, last = self._buckets.get(key, (self.capacity, now))
|
||||
tokens = min(self.capacity, tokens + (now - last) * self.rate)
|
||||
if tokens < 1.0:
|
||||
self._buckets[key] = (tokens, now)
|
||||
return False
|
||||
self._buckets[key] = (tokens - 1.0, now)
|
||||
return True
|
||||
|
||||
# ── bundled server security context ──────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServeSecurity:
|
||||
policy: CommandPolicy = field(default_factory=CommandPolicy.unrestricted)
|
||||
key_policies: dict[str, CommandPolicy] = field(default_factory=dict)
|
||||
key_names: dict[str, str] = field(default_factory=dict)
|
||||
rate_limiter: RateLimiter | None = None
|
||||
|
||||
def effective_policy(self, pubkey: str | None) -> CommandPolicy:
|
||||
"""Per-key override if one exists for this pubkey, else the server default."""
|
||||
if pubkey and pubkey in self.key_policies:
|
||||
return self.key_policies[pubkey]
|
||||
return self.policy
|
||||
|
||||
def label_for(self, pubkey: str | None) -> str | None:
|
||||
"""Audit label for log lines: ``<name> <short-pubkey>…`` or just the short pubkey."""
|
||||
if not pubkey:
|
||||
return None
|
||||
short = f"{pubkey[:8]}…"
|
||||
name = self.key_names.get(pubkey, "")
|
||||
return f"{name} {short}".strip() if name else short
|
||||
@@ -1,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
|
||||
|
||||
def parse_version(v: str) -> tuple[int, ...]:
|
||||
try:
|
||||
return tuple(int(x) for x in v.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0,)
|
||||
try:
|
||||
return tuple(int(x) for x in v.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0,)
|
||||
|
||||
def get_installed_version() -> str:
|
||||
try:
|
||||
return _pkg_version(PYPI_PACKAGE_NAME)
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
try:
|
||||
return _pkg_version(PYPI_PACKAGE_NAME)
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
def project_version() -> str:
|
||||
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
try:
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
if line.startswith("version = "):
|
||||
return line.split('"')[1]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _pkg_version(PYPI_PACKAGE_NAME)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
USER_AGENT = f"browser-cli/{get_installed_version()}"
|
||||
|
||||
Reference in New Issue
Block a user