refactor(api): namespaced SDK + dedicated transport layer
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s

Restructure the Python API and internals around composable namespaces and
a standalone transport/endpoint layer. Bump to 0.12.0.

Python API:
- Replace flat methods (b.tabs_list(), b.group_list()) with namespaces:
  b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage,
  b.cookies, b.session, b.perf, b.extension.
- Shrink browser_cli/__init__.py to a thin composition root; move all
  behaviour into browser_cli/sdk/ (one module per namespace + factories,
  base, routing).

Internals:
- Add browser_cli/transport.py and remote_transport.py to isolate IPC from
  command logic; client.py now delegates instead of owning transport.
- Add browser_cli/endpoints.py for endpoint resolution and
  browser_cli/errors.py for shared error types.
- Extract markdown rendering into browser_cli/markdown.py (out of extract).
- Add USER_AGENT to version_manager.

Tooling & tests:
- Add justfile with common dev tasks.
- Update CLI commands and demo to the namespaced API.
- Rework tests for the new layout; add test_transport.py and
  test_refactor_boundaries.py to lock in module boundaries.

BREAKING CHANGE: flat API methods are removed in favour of namespaces
(e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
This commit is contained in:
2026-06-11 13:58:41 +02:00
parent 0813ae2de9
commit fd5447cbb9
52 changed files with 3344 additions and 2348 deletions
+17 -182
View File
@@ -10,10 +10,8 @@ Profile selection order:
"""
import json
import os
import re
import socket
import struct
import sys
import uuid
from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
@@ -21,57 +19,26 @@ from pathlib import Path
from typing import Any
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
from browser_cli.registry import load_registry
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
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"
# Re-exported for backward compatibility — these used to live here and are still
# referenced as ``browser_cli.client.<name>`` by callers, serve.py, and tests.
from browser_cli.errors import BrowserNotConnected # noqa: F401
from browser_cli.endpoints import ( # noqa: F401
_DEFAULT_REMOTE_PORT,
_looks_like_domain,
_normalize_endpoint,
_remote_display_name,
_resolve_connect_endpoint,
display_browser_name,
)
from browser_cli.remote_transport import _recv_all, _recv_exact, _send_remote # noqa: F401
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"
_DEFAULT_REMOTE_PORT = 443
def _looks_like_domain(host: str) -> bool:
"""True if host looks like a domain name rather than an IP address or localhost."""
if host in {"localhost", "127.0.0.1", "::1"}:
return False
if re.match(r'^\d{1,3}(\.\d{1,3}){3}$', host):
return False
return '.' in host and any(c.isalpha() for c in host)
def _normalize_endpoint(endpoint: str) -> str:
"""Strip :443 from domain-like endpoints so they are stored without the default port."""
if not endpoint:
return endpoint
host, sep, port = endpoint.rpartition(":")
if sep and port == "443" and _looks_like_domain(host):
return host
return endpoint
def _resolve_connect_endpoint(endpoint: str) -> str:
"""Return host:port for TCP connection; domain without port defaults to :443."""
_, sep, _ = endpoint.rpartition(":")
if not sep:
if _looks_like_domain(endpoint):
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
raise BrowserNotConnected(
f"Invalid remote endpoint '{endpoint}': expected host:port"
)
return endpoint
class BrowserNotConnected(Exception):
"""Raised when the native host socket is not available."""
@dataclass(frozen=True)
class BrowserTarget:
profile: str
@@ -98,12 +65,6 @@ def _active_endpoints(reg: dict) -> dict:
return dict(reg)
return {k: v for k, v in reg.items() if _is_reachable_unix_endpoint(v)}
def display_browser_name(profile_name: str, sock_path: str) -> str:
if profile_name != "default":
return profile_name
return Path(sock_path).stem or profile_name
def _load_remotes() -> dict[str, dict[str, str]]:
if not REMOTE_REGISTRY_PATH.exists():
return {}
@@ -116,13 +77,10 @@ def _load_remotes() -> dict[str, dict[str, str]]:
# normalize keys so old entries stored as "domain:443" match current lookups
return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
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."""
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and ("/" in s or Path(s).suffix in {".pem", ".key"}))
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
if not endpoint or not key_spec:
@@ -138,7 +96,6 @@ def save_remote_key(endpoint: str, key_spec: str) -> None:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True))
def key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
return None
@@ -152,16 +109,6 @@ def key_for_remote(endpoint: str | None) -> str | None:
return None
return key_str
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
host, sep, port = endpoint.rpartition(":")
if sep and (port == "8765" or (port == "443" and _looks_like_domain(host))):
display_endpoint = host
else:
display_endpoint = endpoint # normalized domain (no port) or non-default port
return f"{display_endpoint}:{display_name or profile_name}"
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, key=key)
@@ -179,7 +126,6 @@ def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
)
return targets
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for endpoint in _load_remotes():
@@ -189,7 +135,6 @@ def _remote_browser_targets(key=None) -> list[BrowserTarget]:
continue
return targets
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
if not alias:
@@ -228,7 +173,6 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
)
return None
def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
if REGISTRY_PATH.exists():
@@ -241,7 +185,6 @@ def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[Br
targets.extend(_remote_browser_targets(key=key))
return targets
def _is_active_local_profile(profile: str | None) -> bool:
"""Return True when profile names a reachable local browser endpoint."""
if not profile:
@@ -257,7 +200,6 @@ def _is_active_local_profile(profile: str | None) -> bool:
return False
return False
def _resolve_socket(profile: str | None = None) -> str:
"""Return the socket path for the given profile (or auto-detect)."""
target = profile or os.environ.get("BROWSER_CLI_PROFILE")
@@ -292,7 +234,6 @@ def _resolve_socket(profile: str | None = None) -> str:
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
)
def _load_private_key(key_path: "Path | str | None" = None):
"""Load an Ed25519 signing key.
@@ -320,96 +261,6 @@ def _load_private_key(key_path: "Path | str | None" = None):
except Exception:
return None
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
port = int(port_str)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
try:
raw_sock.connect((host, port))
if port == 443:
import ssl
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
with sock:
# receive challenge
challenge_raw = _recv_all(sock)
if challenge_raw is None:
raise BrowserNotConnected(f"No challenge received from {endpoint}")
try:
challenge = json.loads(challenge_raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else 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
pq_shared_secret = None
if nonce_hex and private_key is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
nonce = bytes.fromhex(nonce_hex)
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
sys.stderr.write(
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
if pq_shared_secret is not None:
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
msg = {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": public_key_hex(private_key),
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
else:
sys.stderr.write(
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
payload = json.dumps(msg).encode("utf-8")
framed = struct.pack("<I", len(payload)) + payload
sock.sendall(framed)
response = _recv_all(sock)
if response is not None and pq_shared_secret is not None:
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as e:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
return response
def _auto_route_remote(endpoint: str, key=None) -> str | None:
targets = remote_browser_targets(endpoint, key=key)
if len(targets) == 1:
@@ -423,7 +274,6 @@ def _auto_route_remote(endpoint: str, key=None) -> str | None:
)
return None
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")
@@ -443,7 +293,9 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
"args": args or {},
}
if remote_endpoint:
from browser_cli import transport
msg["user_agent"] = _USER_AGENT
msg["accept_encoding"] = transport.client_accept_encoding()
# 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)
@@ -493,25 +345,8 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
if response is None:
raise ConnectionError("Connection closed before full response received")
result = json.loads(response)
from browser_cli import transport
result = transport.decode_response(response)
if not result.get("success", True):
raise RuntimeError(result.get("error", "unknown error from browser"))
return result.get("data")
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)
def _recv_exact(sock: socket.socket, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("Socket closed before full message received")
buf += chunk
return buf