feat: token-auth removal, security hardening, Stripe-style compat layer (v0.9.2)
Testing / test (push) Successful in 41s
Package Extension / package-extension (push) Successful in 35s
Build & Publish Package / publish (push) Successful in 46s

- Remove token auth entirely; only Ed25519 pubkey auth or --no-auth
- Add 32 MB message-size cap in serve and client (DoS protection)
- Set Unix socket to 0o600 after bind in native_host (multi-user hardening)
- Enforce browser-cli/VERSION user-agent on all TCP connections
- Add PROTOCOL_MIN_CLIENT check (>= 0.9.0) server- and client-side
- Include server_version + min_client_version in challenge frame
- Add browser_cli/version_manager.py: parse_version, get_installed_version
- Add browser_cli/compat.py: Stripe-style versioning layer with adapt_request
  / adapt_response hooks; baseline 0.9.2, no shims needed yet
- Fix BrowserCLI key handling: no Path() wrap for agent specs
- Fix _multi_browser_targets() to forward key to remote_browser_targets()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 21:59:46 +02:00
parent b98c4ae116
commit c1a5ef9dd7
17 changed files with 267 additions and 237 deletions
+8 -17
View File
@@ -18,7 +18,6 @@ Usage:
"""
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from browser_cli.models import Group, Tab
@@ -34,7 +33,7 @@ class BrowserCounts:
class BrowserCLI:
def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None, key: str | None = None):
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
"""
Args:
browser: Profile alias to target. Required when multiple browser
@@ -43,24 +42,22 @@ class BrowserCLI:
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
Can be combined with ``browser`` to route to a specific
remote profile.
token: Auth token for the remote serve instance (legacy token auth).
key: Path to Ed25519 private key PEM for pubkey auth. When set,
overrides token auth. Defaults to ``~/.config/browser-cli/client.key.pem``
if that file exists.
key: Path to Ed25519 private key PEM for pubkey auth, or ``"agent"``
to use a key from the SSH agent (YubiKey, gpg-agent, etc.).
Defaults to ``~/.config/browser-cli/client.key.pem`` if that file exists.
"""
self._browser = browser
self._remote = remote
self._token = token
self._key = Path(key) if key else None
self._key = key if key else None
def _cmd(self, command: str, args: dict | None = None):
return send_command(command, args, profile=self._browser, remote=self._remote, token=self._token, key=self._key)
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
def _multi_browser_targets(self):
if self._browser is not None:
return []
if self._remote:
targets = remote_browser_targets(self._remote, self._token)
targets = remote_browser_targets(self._remote, key=self._key)
else:
targets = active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets):
@@ -73,7 +70,7 @@ class BrowserCLI:
for target in targets:
try:
if target.remote:
data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token, key=self._key)
data = send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
else:
data = send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
@@ -98,7 +95,6 @@ class BrowserCLI:
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_token: str | None = None,
) -> Tab:
tab = Tab(
id=data["id"],
@@ -113,7 +109,6 @@ class BrowserCLI:
tab._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
token=browser_token,
)
return tab
@@ -124,7 +119,6 @@ class BrowserCLI:
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_token: str | None = None,
) -> Group:
group = Group(
id=data["id"],
@@ -137,7 +131,6 @@ class BrowserCLI:
group._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
token=browser_token,
)
return group
@@ -237,7 +230,6 @@ class BrowserCLI:
browser_profile=target.profile,
browser_name=target.display_name,
browser_remote=target.remote,
browser_token=target.token,
)
for target, tabs in multi_results
for tab in (tabs or [])
@@ -392,7 +384,6 @@ class BrowserCLI:
browser_profile=target.profile,
browser_name=target.display_name,
browser_remote=target.remote,
browser_token=target.token,
)
for target, groups in multi_results
for group in (groups or [])
+5 -15
View File
@@ -30,7 +30,6 @@ from browser_cli.client import (
REGISTRY_PATH,
active_browser_targets,
display_browser_name,
save_remote_token,
remote_target_for_alias,
remote_browser_targets,
)
@@ -191,16 +190,12 @@ def _print_version(ctx, param, value):
"--remote", default=None, metavar="HOST:PORT",
help="Connect to a remote browser exposed via 'browser-cli serve'.",
)
@click.option(
"--token", default=None, metavar="TOKEN",
help="Auth token for the remote browser-cli serve instance.",
)
@click.option(
"--key", default=None, metavar="PATH",
help="Ed25519 private key PEM for pubkey auth with a remote serve instance.",
)
@click.pass_context
def main(ctx, browser, remote, token, key):
def main(ctx, browser, remote, key):
"""Control your running browser from the terminal via a Chrome extension."""
ctx.ensure_object(dict)
ctx.obj["browser"] = browser
@@ -209,13 +204,10 @@ def main(ctx, browser, remote, token, key):
os.environ["BROWSER_CLI_PROFILE"] = browser
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
ctx.obj["remote"] = remote
ctx.obj["token"] = token
ctx.obj["key"] = key
if remote:
os.environ["BROWSER_CLI_REMOTE"] = remote
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_REMOTE", None))
if token:
save_remote_token(remote, token)
if key:
os.environ["BROWSER_CLI_KEY"] = key
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
@@ -399,7 +391,6 @@ def clients_group(ctx):
browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
token = (ctx.obj or {}).get("token") or os.environ.get("BROWSER_CLI_TOKEN")
key = (ctx.obj or {}).get("key")
if not remote and browser_alias:
@@ -407,15 +398,14 @@ def clients_group(ctx):
# then show ALL clients from that remote (not just the one resolved profile).
resolved = remote_target_for_alias(browser_alias)
if resolved:
resolved_token = token or resolved.token
try:
targets = remote_browser_targets(resolved.remote, resolved_token)
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:
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, token=resolved_token, key=key)
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, key=key)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
@@ -423,7 +413,7 @@ def clients_group(ctx):
continue
elif remote:
try:
result = send_command("clients.list", profile=browser_alias, remote=remote, token=token, key=key)
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)
@@ -455,7 +445,7 @@ def clients_group(ctx):
if target.remote is None:
continue
try:
result = send_command("clients.list", profile=target.profile, remote=target.remote, token=target.token)
result = send_command("clients.list", profile=target.profile, remote=target.remote)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
+33 -36
View File
@@ -21,6 +21,12 @@ from typing import Any
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
from browser_cli.registry import load_registry
try:
from importlib.metadata import version as _pkg_version
_USER_AGENT = f"browser-cli/{_pkg_version('browser-cli')}"
except Exception:
_USER_AGENT = "browser-cli/0"
REGISTRY_PATH = registry_path()
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
@@ -36,7 +42,6 @@ class BrowserTarget:
display_name: str
socket_path: str
remote: str | None = None
token: str | None = None
def _active_endpoints(reg: dict) -> dict:
@@ -64,27 +69,6 @@ def _load_remotes() -> dict[str, dict[str, str]]:
return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
def save_remote_token(endpoint: str, token: str | None) -> None:
"""Persist the auth token for a remote endpoint used by this client."""
if not endpoint or not token:
return
remotes = _load_remotes()
current = remotes.get(endpoint, {})
current["token"] = token
remotes[endpoint] = current
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True))
def token_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
return None
cfg = _load_remotes().get(endpoint) or {}
token = cfg.get("token")
return str(token) if token else None
def _is_valid_key_spec(s: str) -> bool:
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
@@ -127,9 +111,9 @@ def _remote_display_name(endpoint: str, profile_name: str, display_name: str) ->
return f"{remote_name}:{display_name or profile_name}"
def remote_browser_targets(endpoint: str, token: str | None = None, key=None) -> list[BrowserTarget]:
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
"""Return browser targets advertised by a single remote endpoint."""
remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token, key=key)
remote_targets = send_command("browser-cli.targets", remote=endpoint, key=key)
targets: list[BrowserTarget] = []
for item in remote_targets or []:
profile = str(item.get("profile") or "default")
@@ -140,7 +124,6 @@ def remote_browser_targets(endpoint: str, token: str | None = None, key=None) ->
display_name=_remote_display_name(endpoint, profile, display),
socket_path="",
remote=endpoint,
token=token,
)
)
return targets
@@ -148,10 +131,9 @@ def remote_browser_targets(endpoint: str, token: str | None = None, key=None) ->
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for endpoint, cfg in _load_remotes().items():
token = str(cfg.get("token") or "") or None
for endpoint in _load_remotes():
try:
targets.extend(remote_browser_targets(endpoint, token, key=key))
targets.extend(remote_browser_targets(endpoint, key=key))
except (BrowserNotConnected, RuntimeError):
continue
return targets
@@ -273,6 +255,19 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
except (json.JSONDecodeError, AttributeError):
nonce_hex = None
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if min_ver:
from browser_cli.version_manager import parse_version
try:
client_ver = _USER_AGENT.split("/", 1)[1]
if parse_version(client_ver) < parse_version(min_ver):
raise BrowserNotConnected(
f"Client version {client_ver} is too old for this server "
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
)
except (IndexError, ValueError):
pass
if nonce_hex and private_key is not None:
from browser_cli.auth import sign, public_key_hex
nonce = bytes.fromhex(nonce_hex)
@@ -286,8 +281,8 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
return _recv_all(sock)
def _auto_route_remote(endpoint: str, token: str | None, key=None) -> str | None:
targets = remote_browser_targets(endpoint, token, key=key)
def _auto_route_remote(endpoint: str, key=None) -> str | None:
targets = remote_browser_targets(endpoint, key=key)
if len(targets) == 1:
return targets[0].profile
if len(targets) > 1:
@@ -300,7 +295,7 @@ def _auto_route_remote(endpoint: str, token: str | None, key=None) -> str | None
return None
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None, key: "Path | None" = None) -> Any:
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, key: "Path | None" = None) -> Any:
"""Send a command to the browser and return the response data."""
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
@@ -311,26 +306,23 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
remote_endpoint = remote_alias_target.remote
requested_profile = remote_alias_target.profile
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or (remote_alias_target.token if remote_alias_target else None) or token_for_remote(remote_endpoint)
msg = {
"id": str(uuid.uuid4()),
"command": command,
"args": args or {},
}
if remote_endpoint:
msg["user_agent"] = _USER_AGENT
# key priority: explicit flag > saved per-remote config > BROWSER_CLI_KEY env > default file
key_spec = key if key is not None else key_for_remote(remote_endpoint)
private_key = _load_private_key(key_spec)
# persist explicit key spec so future calls don't need --key
if key is not None:
save_remote_key(remote_endpoint, str(key))
# use token auth only when no Ed25519 key is available
if private_key is None and resolved_token:
msg["token"] = resolved_token
route_profile = requested_profile
_no_route_commands = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
if not route_profile and command not in _no_route_commands:
route_profile = _auto_route_remote(remote_endpoint, resolved_token, key=key_spec)
route_profile = _auto_route_remote(remote_endpoint, key=key_spec)
if route_profile:
msg["_route"] = route_profile
else:
@@ -376,9 +368,14 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
return result.get("data")
_MAX_MSG_BYTES = 32 * 1024 * 1024
def _recv_all(sock: socket.socket) -> bytes:
raw_len = _recv_exact(sock, 4)
msg_len = struct.unpack("<I", raw_len)[0]
if msg_len > _MAX_MSG_BYTES:
raise ConnectionError(f"Response too large ({msg_len} bytes)")
return _recv_exact(sock, msg_len)
+5 -5
View File
@@ -17,10 +17,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None, token=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -33,7 +33,7 @@ def _multi_browser_targets():
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"), key=key)
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
@@ -78,7 +78,7 @@ def group_list():
if targets:
groups = []
for target in targets:
result = _handle_multi("group.list", profile=target.profile, remote=target.remote, token=target.token)
result = _handle_multi("group.list", profile=target.profile, remote=target.remote)
if result is None:
continue
groups.extend({**group, "browser": target.display_name} for group in result)
@@ -111,7 +111,7 @@ def group_count():
total = 0
rows = 0
for target in targets:
count = _handle_multi("group.count", profile=target.profile, remote=target.remote, token=target.token)
count = _handle_multi("group.count", profile=target.profile, remote=target.remote)
if count is None:
continue
count = int(count or 0)
+58 -38
View File
@@ -1,7 +1,12 @@
import hmac, threading, secrets, socket, struct, click, json, sys
import re, threading, secrets, socket, struct, click, json, sys, os
from pathlib import Path
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, parse_version, get_installed_version
from browser_cli.compat import adapt_request, adapt_response
_UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
_MAX_MSG_BYTES = 32 * 1024 * 1024
from rich.console import Console
from datetime import datetime
@@ -25,17 +30,10 @@ def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=N
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str) -> None:
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
payload = _recv_exact(client_sock, msg_len)
except (ConnectionError, OSError):
return
def _send_error(msg_id, msg:str) -> None:
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
@@ -43,6 +41,16 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
except OSError:
pass
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
if msg_len > _MAX_MSG_BYTES:
_send_error(None, f"message too large ({msg_len} bytes)")
return
payload = _recv_exact(client_sock, msg_len)
except (ConnectionError, OSError):
return
try:
msg = json.loads(payload)
except (json.JSONDecodeError, ValueError):
@@ -53,6 +61,22 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
msg_id = msg.get("id")
command = msg.get("command", "?")
# ── user-agent + version check ────────────────────────────────────────────
ua = msg.get("user_agent") or ""
if not _UA_PATTERN.match(ua):
_send_error(msg_id, "forbidden: client required")
_log(addr, command, None, "DENIED", f"bad user-agent: {ua!r}")
return
client_ver = "0"
try:
client_ver = ua.split("/", 1)[1]
if parse_version(client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
_send_error(msg_id, f"client version {client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
_log(addr, command, None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
return
except (IndexError, ValueError):
pass
# ── auth ──────────────────────────────────────────────────────────────────
if auth_keys is not None:
pub = msg.get("pubkey") or ""
@@ -70,11 +94,6 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
_send_error(msg_id, "unauthorized: invalid signature")
_log(addr, command, None, "DENIED", "bad signature")
return
elif server_token is not None:
if not hmac.compare_digest(msg.get("token") or "", server_token):
_send_error(msg_id, "unauthorized: invalid or missing token")
_log(addr, command, None, "DENIED", "bad token")
return
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
@@ -120,13 +139,12 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
resolved_profile = msg.get("_route") or profile
strip = {"token", "_route", "pubkey", "sig"}
if strip & msg.keys():
clean_payload = json.dumps({k: v for k, v in msg.items() if k not in strip}).encode()
clean_header = struct.pack("<I", len(clean_payload))
else:
clean_payload = payload
clean_header = header
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
clean_header = struct.pack("<I", len(clean_payload))
try:
sock_path = _resolve_socket(resolved_profile)
@@ -141,6 +159,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
with PipeClient(sock_path, family="AF_PIPE") as pipe:
pipe.send_bytes(clean_payload)
resp = pipe.recv_bytes()
resp = adapt_response(resp, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp)) + resp)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
@@ -149,7 +168,8 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
resp_header = _recv_exact(local, 4)
resp_len = struct.unpack("<I", resp_header)[0]
resp_payload = _recv_exact(local, resp_len)
client_sock.sendall(resp_header + resp_payload)
resp_payload = adapt_response(resp_payload, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp_payload)) + resp_payload)
resp_data = json.loads(resp_payload if not is_windows() else resp)
if resp_data.get("success", True):
@@ -160,7 +180,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", str(e))
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None, auth_keys_path:"Path|None") -> None:
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
if not _CONN_LIMIT.acquire(blocking=False):
client_sock.close()
return
@@ -174,24 +194,29 @@ def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, serv
else:
auth_keys = None
nonce = secrets.token_hex(32)
challenge = json.dumps({"type": "challenge", "nonce": nonce}).encode()
challenge = json.dumps({
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}).encode()
try:
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, server_token, auth_keys, auth_keys_path, nonce)
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce)
finally:
_CONN_LIMIT.release()
@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("--token", default=None, metavar="TOKEN", help="Auth token (auto-generated if omitted).")
@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). Enables pubkey auth.")
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
@click.pass_context
def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
@@ -203,13 +228,11 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
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}")
server_token = None
elif no_auth:
auth_keys_path = None
server_token = None
else:
auth_keys_path = None
server_token = token or secrets.token_urlsafe(32)
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
sys.exit(1)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -221,8 +244,9 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
sys.exit(1)
server.listen(16)
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]")
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
@@ -230,10 +254,6 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
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]")
elif server_token:
console.print(f" Token: [bold yellow]{server_token}[/bold yellow]")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} --token {server_token} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\", token=\"{server_token}\").tabs_list()[/dim]")
else:
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]")
@@ -244,7 +264,7 @@ def cmd_serve(ctx, host, port, token, no_auth, auth_keys_file):
try:
while True:
conn, addr = server.accept()
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token, auth_keys_path), daemon=True).start()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
finally:
+4 -4
View File
@@ -16,10 +16,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None, token=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -32,7 +32,7 @@ def _multi_browser_targets():
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"), key=key)
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
@@ -99,7 +99,7 @@ def session_list():
if targets:
sessions = []
for target in targets:
result = _handle_multi("session.list", profile=target.profile, remote=target.remote, token=target.token)
result = _handle_multi("session.list", profile=target.profile, remote=target.remote)
if result is None:
continue
sessions.extend({**session, "browser": target.display_name} for session in result)
+5 -5
View File
@@ -19,10 +19,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None, token=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -35,7 +35,7 @@ def _multi_browser_targets():
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"), key=key)
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
@@ -84,7 +84,7 @@ def tabs_list():
if targets:
tabs = []
for target in targets:
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote, token=target.token)
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote)
if result is None:
continue
tabs.extend({**tab, "browser": target.display_name} for tab in result)
@@ -171,7 +171,7 @@ def tabs_count(pattern):
total = 0
rows = 0
for target in targets:
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote, token=target.token)
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote)
if count is None:
continue
count = int(count or 0)
+4 -4
View File
@@ -17,10 +17,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None, remote=None, token=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -33,7 +33,7 @@ def _multi_browser_targets():
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"), key=key)
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
@@ -76,7 +76,7 @@ def windows_list():
if targets:
windows = []
for target in targets:
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote, token=target.token)
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote)
if result is None:
continue
windows.extend({**window, "browser": target.display_name} for window in result)
+49
View File
@@ -0,0 +1,49 @@
"""
Stripe-style version compatibility layer for browser-cli serve.
When a behaviour-breaking change ships in a new server version, add one entry
to _COMPAT below:
("X.Y.Z", request_fn, response_fn)
- ``request_fn(msg: dict) -> dict``
Upgrade an incoming client message from a client older than X.Y.Z to the
current format before forwarding it to the native host.
- ``response_fn(resp: bytes, command: str) -> bytes``
Downgrade a native-host response to the format a client older than X.Y.Z
expects before sending it back.
Either function may be ``None`` when only one direction needs adapting.
Entries must stay in ascending version order. ``adapt_request`` walks forward
(oldest change first); ``adapt_response`` walks backward (newest change first)
so the transformations compose correctly.
Current baseline: 0.9.1 no shims needed yet.
"""
from __future__ import annotations
from typing import Callable
from browser_cli.version_manager import parse_version
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
]
def adapt_request(msg: dict, client_version: str) -> dict:
"""Upgrade a client message to the current server format."""
cv = parse_version(client_version)
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
return msg
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
"""Downgrade a server response to the format the client expects."""
cv = parse_version(client_version)
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
return resp
+2
View File
@@ -156,6 +156,7 @@ def socket_server(sock_path: str, bound_sock: "socket.socket | None" = None):
path.unlink()
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(sock_path)
os.chmod(sock_path, 0o600)
sock.listen(16)
while True:
@@ -319,6 +320,7 @@ def main():
path.unlink()
bound_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
bound_sock.bind(sock_path)
os.chmod(sock_path, 0o600)
bound_sock.listen(16)
else:
bound_sock = None
+17
View File
@@ -0,0 +1,17 @@
from importlib.metadata import version as _pkg_version
PROTOCOL_MIN_CLIENT = "0.9.0"
def parse_version(v: str) -> tuple[int, ...]:
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError:
return (0,)
def get_installed_version() -> str:
try:
return _pkg_version("browser-cli")
except Exception:
return "0.0.0"