5cec57e06d
Testing / remote-protocol-compat (0.9.3) (push) Successful in 40s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 38s
Testing / test (push) Failing after 1m3s
Package Extension / package-extension (push) Successful in 29s
Build & Publish Package / publish (push) Successful in 33s
- Add safe-by-default policy gates for raw command surfaces: command, script, and serve-http /command. - Require explicit opt-ins for page reads, browser control, and high-risk commands such as dom.eval, storage.*, and screenshots. - Remove all cookies support from CLI, SDK, extension commands, permissions, constants, docs, and tests. - Add diagnostic, events, watch, workspace, remote, raw command, script, HTTP gateway, tree-view, session import/export, and extension info/capability commands. - Add Chrome Web Store packaging that strips manifest.key while keeping local packages with a stable native-messaging extension ID. - Bump browser-cli and extension version to 0.14.1 and cover the new behavior with pytest and extension packaging tests. BREAKING CHANGE: cookies commands and the b.cookies SDK namespace have been removed; generic raw command execution now blocks non-safe commands unless explicitly allowed.
165 lines
5.9 KiB
Python
165 lines
5.9 KiB
Python
"""Click command for exposing a browser over TCP."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
from browser_cli import transport
|
|
from browser_cli.serve.runtime import (
|
|
_async_framed_send,
|
|
_async_handle_client,
|
|
_async_recv_all,
|
|
_handle_client,
|
|
_serve_async,
|
|
console,
|
|
)
|
|
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",
|
|
]
|
|
|
|
@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.",
|
|
)
|
|
@click.option(
|
|
"--no-compress",
|
|
"no_compress",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Disable response compression / msgpack even for clients that support it.",
|
|
)
|
|
@click.option(
|
|
"--rpc",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Also expose a ServiceLink HTTP /rpc endpoint (for mesh nodes) in the same process.",
|
|
)
|
|
@click.option("--rpc-port", default=8770, show_default=True, type=int, help="Port for the /rpc endpoint (with --rpc).")
|
|
@click.option(
|
|
"--rpc-token",
|
|
default=None,
|
|
metavar="SECRET",
|
|
help="Bearer token required on /rpc (with --rpc).",
|
|
)
|
|
@click.option(
|
|
"--rpc-insecure",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Allow --rpc with no token (DANGEROUS: full browser control to anyone who can reach the port).",
|
|
)
|
|
@click.pass_context
|
|
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rpc, rpc_port, rpc_token, rpc_insecure):
|
|
"""Expose this browser over TCP so remote hosts can control it.
|
|
|
|
With --rpc, additionally serve the ServiceLink mesh over HTTP /rpc on
|
|
--rpc-port, so the native TCP protocol and the node mesh share one daemon.
|
|
"""
|
|
profile = ctx.obj.get("browser") if ctx.obj else None
|
|
compress = not no_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."
|
|
)
|
|
|
|
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
|
|
if auth_keys_path is False:
|
|
sys.exit(1)
|
|
|
|
if rpc and not rpc_token and not rpc_insecure:
|
|
console.print(
|
|
"[red]Error:[/red] --rpc requires --rpc-token (this endpoint can control your "
|
|
"browser and read page/storage data). Use --rpc-insecure to override on a trusted host."
|
|
)
|
|
sys.exit(1)
|
|
|
|
_print_startup(host, port, profile, auth_keys_path, compress)
|
|
if rpc:
|
|
console.print(f" Mesh: [green]ServiceLink HTTP[/green] [cyan]{host}:{rpc_port}/rpc[/cyan]")
|
|
if not rpc_token:
|
|
console.print("[yellow] /rpc auth disabled (--rpc-insecure)[/yellow]")
|
|
|
|
try:
|
|
if rpc:
|
|
asyncio.run(_serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token))
|
|
else:
|
|
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]")
|
|
|
|
async def _serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token):
|
|
"""Run the native TCP server and the ServiceLink HTTP /rpc server together."""
|
|
from browser_cli.commands import link_serve
|
|
|
|
sl = link_serve._import_servicelink()
|
|
await asyncio.gather(
|
|
_serve_async(host, port, profile, auth_keys_path, compress),
|
|
link_serve._serve(sl, host, rpc_port, profile, rpc_token),
|
|
)
|
|
|
|
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
|
|
|
|
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]")
|
|
|
|
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]")
|
|
|
|
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_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]"
|
|
)
|