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

- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels.
- Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips.
- Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely.
- Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows.
- Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0.
- Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
This commit is contained in:
2026-06-18 14:24:15 +02:00
parent 8dece7800f
commit 6fa931aa36
49 changed files with 3407 additions and 1878 deletions
+60
View File
@@ -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.
+36 -9
View File
@@ -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
View File
@@ -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(
+3 -20
View File
@@ -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"))
+38 -17
View File
@@ -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
View File
@@ -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))
+5 -7
View File
@@ -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)
+3 -54
View File
@@ -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
View File
@@ -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]"
)
+8 -7
View File
@@ -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,),