feat: expose browser control via ServiceLink RPC
Testing / test (push) Successful in 1m30s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m23s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m12s
Build & Publish Package / publish (push) Successful in 1m2s
Package Extension / package-extension (push) Successful in 1m12s

- Add the ServiceLink submodule and register it in .gitmodules.
- Add a link-serve command that exposes selected browser-cli commands over an HTTP /rpc endpoint.
- Require bearer-token authentication by default, with explicit insecure opt-in for trusted loopback/local deployments.
- Allow the existing serve daemon to run the ServiceLink RPC endpoint alongside the native TCP remote server.
This commit is contained in:
2026-06-13 23:56:23 +02:00
parent 076914e5b7
commit 965793dd8c
4 changed files with 217 additions and 3 deletions
+50 -3
View File
@@ -45,9 +45,32 @@ __all__ = [
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):
"""Expose this browser over TCP so remote hosts can control it."""
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
@@ -61,16 +84,40 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
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 its cookies). 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:
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
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