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
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:
+17
-182
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user