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:
@@ -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,),
|
||||
|
||||
Reference in New Issue
Block a user