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"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.9.1",
"version": "0.9.2",
"description": "Control your browser from the terminal via browser-cli",
"permissions": [
"tabs",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "browser-cli"
version = "0.9.1"
version = "0.9.2"
description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10"
dependencies = [
+46 -47
View File
@@ -65,10 +65,9 @@ class TestBrowserCLIInit:
assert b._browser == "chrome"
def test_remote_options_stored(self):
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
b = BrowserCLI(browser="work", remote="host:8765", key=None)
assert b._browser == "work"
assert b._remote == "host:8765"
assert b._token == "secret"
# ── Internal factories ────────────────────────────────────────────────────────
@@ -129,7 +128,7 @@ class TestNavigation:
mock_send.assert_called_once_with(
"navigate.open",
{"url": "https://example.com", "background": False, "window": None, "group": None},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_open_background(self, b, mock_send):
@@ -143,38 +142,38 @@ class TestNavigation:
def test_reload(self, b, mock_send):
b.reload(tab_id=5)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
def test_hard_reload(self, b, mock_send):
b.hard_reload(tab_id=7)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, key=None)
def test_back(self, b, mock_send):
b.back(tab_id=3)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, key=None)
def test_forward(self, b, mock_send):
b.forward(tab_id=3)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, key=None)
def test_focus_url(self, b, mock_send):
b.focus_url("github.com")
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, key=None)
def test_navigate_tab(self, b, mock_send):
b.navigate_tab(5, "https://example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, token=None, key=None
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, key=None
)
def test_profile_forwarded(self, b_profile, mock_send):
b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, key=None)
def test_remote_forwarded(self, mock_send):
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
b = BrowserCLI(browser="work", remote="host:8765", key=None)
b.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", token="secret", key=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", key=None)
# ── Search ────────────────────────────────────────────────────────────────────
@@ -207,12 +206,12 @@ class TestExtract:
result = b.extract_markdown()
assert result == "# Title"
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None)
def test_extract_markdown_selector(self, b, mock_send):
b.extract_markdown("article")
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
# ── Tabs ──────────────────────────────────────────────────────────────────────
@@ -247,7 +246,7 @@ class TestTabs:
mock_send.assert_called_once_with(
"tabs.close",
{"tabId": 10, "inactive": False, "duplicates": False},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_tabs_move(self, b, mock_send):
@@ -255,19 +254,19 @@ class TestTabs:
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_tabs_active(self, b, mock_send):
b.tabs_active(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
def test_window_active_tab(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.window_active_tab(1)
assert isinstance(tab, Tab)
assert tab.id == 10
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, key=None)
def test_window_active_tab_missing_raises(self, b, mock_send):
mock_send.return_value = None
@@ -319,17 +318,17 @@ class TestTabs:
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="default"),
call("tabs.list", {}, profile="work"),
call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None, key=None),
call("tabs.close", {"tabId": 11}, profile="work", remote=None, key=None),
]
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", token="secret", key=None)
b = BrowserCLI(remote="host:8765", key=None)
with patch(
"browser_cli.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
):
mock_send.side_effect = [[TAB_DATA], None]
tabs = b.tabs_list()
@@ -337,8 +336,8 @@ class TestTabs:
assert [tab.browser for tab in tabs] == ["host:work"]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="host:8765", token="secret", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", token="secret", key=None),
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
]
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
@@ -381,7 +380,7 @@ class TestTabs:
def test_tabs_sort(self, b, mock_send):
b.tabs_sort(by="title")
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, key=None)
def test_tabs_merge_windows(self, b, mock_send):
mock_send.return_value = {"moved": 4}
@@ -414,7 +413,7 @@ class TestGroups:
mock_send.return_value = [TAB_DATA]
tabs = b.group_tabs(42)
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
def test_group_count(self, b, mock_send):
mock_send.return_value = 7
@@ -442,17 +441,17 @@ class TestGroups:
assert mock_send.call_args_list == [
call("group.list", {}, profile="default"),
call("group.list", {}, profile="work"),
call("group.close", {"groupId": 99}, profile="work", remote=None, token=None, key=None),
call("group.close", {"groupId": 99}, profile="work", remote=None, key=None),
]
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", token="secret", key=None)
b = BrowserCLI(remote="host:8765", key=None)
with patch(
"browser_cli.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
):
mock_send.side_effect = [[GROUP_DATA], None]
groups = b.group_list()
@@ -460,8 +459,8 @@ class TestGroups:
assert [group.browser for group in groups] == ["host:work"]
assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="host:8765", token="secret", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", token="secret", key=None),
call("group.list", {}, profile="work", remote="host:8765", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
@@ -484,7 +483,7 @@ class TestGroups:
def test_group_close(self, b, mock_send):
b.group_close(42)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
def test_group_create_dict_response(self, b, mock_send):
mock_send.return_value = GROUP_DATA
@@ -504,7 +503,7 @@ class TestGroups:
tab_id = b.group_add_tab(42, "https://example.com")
assert tab_id == 55
mock_send.assert_called_once_with(
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, token=None, key=None
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, key=None
)
def test_group_add_tab_non_dict_response(self, b, mock_send):
@@ -514,7 +513,7 @@ class TestGroups:
def test_group_move_forward(self, b, mock_send):
b.group_move(42, forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None, key=None
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
)
@@ -544,7 +543,7 @@ class TestWindows:
result = b.windows_open()
assert result == {"id": 5}
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, key=None)
def test_windows_open_with_url(self, b, mock_send):
mock_send.return_value = {"id": 9}
@@ -552,7 +551,7 @@ class TestWindows:
result = b.windows_open("https://example.com")
assert result == {"id": 9}
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
class TestSession:
@@ -562,7 +561,7 @@ class TestSession:
result = b.session_list()
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, key=None)
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
with patch(
@@ -597,26 +596,26 @@ class TestTabModel:
def test_close(self, tab, mock_send):
tab.close()
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, key=None)
def test_activate(self, tab, mock_send):
tab.activate()
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
def test_reload(self, tab, mock_send):
tab.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, key=None)
def test_hard_reload(self, tab, mock_send):
tab.hard_reload()
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, key=None)
def test_move_forward(self, tab, mock_send):
tab.move(forward=True)
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_move_to_group(self, tab, mock_send):
@@ -626,12 +625,12 @@ class TestTabModel:
def test_html(self, tab, mock_send):
mock_send.return_value = "<html/>"
assert tab.html() == "<html/>"
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, key=None)
def test_open(self, tab, mock_send):
tab.open("https://new.example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, token=None, key=None
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
)
def test_open_background_changes_same_tab(self, tab, mock_send):
@@ -639,7 +638,7 @@ class TestTabModel:
mock_send.assert_called_once_with(
"navigate.to",
{"tabId": 10, "url": "https://new.example.com"},
profile=None, remote=None, token=None, key=None,
profile=None, remote=None, key=None,
)
def test_unbound_raises(self):
@@ -657,18 +656,18 @@ class TestGroupModel:
def test_close(self, group, mock_send):
group.close()
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
def test_tabs(self, group, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = group.tabs()
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None, key=None)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
def test_move_forward(self, group, mock_send):
group.move(forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None, key=None
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
)
def test_move_backward(self, group, mock_send):
+10 -24
View File
@@ -168,19 +168,16 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
assert "0.8.2" in result.output
def test_clients_remote_uses_remote_endpoint_without_local_registry():
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "clients.list"
assert profile is None
assert remote == "127.0.0.1:8765"
assert token == "test"
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command, patch(
"browser_cli.cli.save_remote_token"
):
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--token", "test", "clients"])
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
assert result.exit_code == 0
send_command.assert_called_once()
@@ -194,7 +191,7 @@ def test_clients_remote_respects_global_browser_route():
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
assert result.exit_code == 1
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", token=None, key=None)
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
def test_clients_browser_alias_resolves_to_remote():
@@ -207,15 +204,13 @@ def test_clients_browser_alias_resolves_to_remote():
display_name="192.168.188.104:automatisation",
socket_path="",
remote="192.168.188.104:8765",
token="tok",
)
all_remote_targets = [resolved_target]
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "clients.list"
assert profile == "automatisation"
assert remote == "192.168.188.104:8765"
assert token == "tok"
return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
with patch.dict(os.environ, {}, clear=True), patch(
@@ -287,17 +282,17 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.commands.tabs.remote_browser_targets",
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765", token="secret")],
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
), patch(
"browser_cli.commands.tabs.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
) as send_command, patch("browser_cli.cli.save_remote_token"):
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "--token", "secret", "tabs", "list"])
) as send_command:
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
assert result.exit_code == 0
assert "remote-host:work" in result.output
assert "Remote" in result.output
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", token="secret")
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765")
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
@@ -634,14 +629,6 @@ def test_convert_html_to_markdown_indents_multiline_list_items():
" Local LLMs / API Modelle / Spezialmodelle"
) in markdown
def test_remote_token_is_saved_when_passed_on_cli():
endpoint = "browser-host.example:8765"
with patch("browser_cli.cli.save_remote_token") as save_remote_token:
result = CliRunner().invoke(main, ["--remote", endpoint, "--token", "secret", "completion", "bash", "--script"])
assert result.exit_code == 0
save_remote_token.assert_called_once_with(endpoint, "secret")
def test_tabs_list_multi_browser_queries_remote_target():
endpoint = "browser-host.example:8765"
@@ -650,7 +637,6 @@ def test_tabs_list_multi_browser_queries_remote_target():
"browser-host.example:work",
"",
remote=endpoint,
token="secret",
)
with patch("browser_cli.commands.tabs.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
@@ -660,5 +646,5 @@ def test_tabs_list_multi_browser_queries_remote_target():
result = CliRunner().invoke(main, ["tabs", "list"])
assert result.exit_code == 0
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint, token="secret")
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint)
assert "browser-host.example:work" in result.output
+18 -39
View File
@@ -10,10 +10,8 @@ from browser_cli.client import (
active_browser_targets,
display_browser_name,
key_for_remote,
save_remote_token,
send_command,
remote_target_for_alias,
token_for_remote,
)
from browser_cli.platform import endpoint_for_alias
@@ -94,29 +92,15 @@ def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
def test_save_remote_token_persists_per_endpoint(monkeypatch, tmp_path):
remotes_path = tmp_path / "remotes.json"
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
endpoint = "browser-host.example:8765"
save_remote_token(endpoint, "secret-token")
assert token_for_remote(endpoint) == "secret-token"
assert json.loads(remotes_path.read_text(encoding="utf-8")) == {
endpoint: {"token": "secret-token"}
}
def test_send_command_auto_routes_single_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
sent = {}
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, token=None, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)],
lambda endpoint, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
@@ -125,20 +109,19 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
assert send_command("tabs.list", remote="host:8765", token="secret", key=None) == "ok"
assert send_command("tabs.list", remote="host:8765", key=None) == "ok"
assert sent["_route"] == "work"
assert sent["token"] == "secret"
assert "token" not in sent
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
sent = {}
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
)
def fake_send_remote(endpoint, msg, private_key=None):
@@ -151,13 +134,13 @@ def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
assert send_command("tabs.list") == []
assert sent["endpoint"] == "host:8765"
assert sent["_route"] == "work"
assert sent["token"] == "secret"
assert "token" not in sent
def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
)
target = remote_target_for_alias("host:8765:work")
@@ -172,7 +155,7 @@ def test_remote_target_for_alias_accepts_host_when_only_one_remote_target(monkey
remote_endpoint = f"{remote_host}:8765"
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
)
target = remote_target_for_alias(remote_host)
@@ -186,13 +169,12 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
remote_host = "browser-host.example"
remote_endpoint = f"{remote_host}:8765"
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
monkeypatch.setenv("BROWSER_CLI_PROFILE", remote_host)
sent = {}
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint, token="secret")],
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
@@ -205,15 +187,15 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
assert send_command("tabs.list") == []
assert sent["endpoint"] == remote_endpoint
assert sent["_route"] == "work"
assert sent["token"] == "secret"
assert "token" not in sent
def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets(monkeypatch):
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [
BrowserTarget("main", "host:main", "", remote="host:8765", token="secret"),
BrowserTarget("work", "host:work", "", remote="host:8765", token="secret"),
BrowserTarget("main", "host:main", "", remote="host:8765"),
BrowserTarget("work", "host:work", "", remote="host:8765"),
],
)
@@ -224,27 +206,26 @@ def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, token=None, key=None: [
BrowserTarget("main", "host:main", "", remote=endpoint, token=token),
BrowserTarget("furry", "host:furry", "", remote=endpoint, token=token),
lambda endpoint, key=None: [
BrowserTarget("main", "host:main", "", remote=endpoint),
BrowserTarget("furry", "host:furry", "", remote=endpoint),
],
)
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
send_command("tabs.list", remote="host:8765", token="secret")
send_command("tabs.list", remote="host:8765")
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
remotes_path = tmp_path / "remotes.json"
endpoint = "browser-host.example:8765"
remotes_path.write_text(json.dumps({endpoint: {"token": "secret-token"}}), encoding="utf-8")
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
def fake_send_command(command, args=None, profile=None, remote=None, token=None, key=None):
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "browser-cli.targets"
assert remote == endpoint
assert token == "secret-token"
return [{"profile": "work", "displayName": "work"}]
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
@@ -255,7 +236,6 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
assert targets[0].profile == "work"
assert targets[0].display_name == "browser-host.example:work"
assert targets[0].remote == endpoint
assert targets[0].token == "secret-token"
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
@@ -268,7 +248,6 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
from pathlib import Path as _Path
@@ -281,7 +260,7 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
monkeypatch.setattr("browser_cli.client._load_private_key", fake_load_private_key)
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, token=None, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
lambda endpoint, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.9.1"
version = "0.9.2"
source = { editable = "." }
dependencies = [
{ name = "click" },