refactor: reorganize client transport and extension internals
- Split client, native, remote, serve, markdown, and SDK internals into focused packages with direct imports. - Move local and remote transport framing/protocol helpers behind clearer module boundaries. - Break up the extension injected DOM logic into a separate content dispatch bundle and dedicated content modules. - Add explicit client handling for passive remote discovery without noisy PQ warnings. - Keep behavior covered with updated unit, integration, and extension tests.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
"""Client-side command routing and BrowserTarget helpers."""
|
||||
from browser_cli.client.targets import REGISTRY_PATH, is_active_local_profile, resolve_socket
|
||||
from browser_cli.client.core import (
|
||||
BrowserNotConnected,
|
||||
BrowserTarget,
|
||||
_remote_browser_targets,
|
||||
_send_remote,
|
||||
_send_remote_async,
|
||||
active_browser_targets,
|
||||
remote_browser_targets,
|
||||
remote_browser_targets_async,
|
||||
remote_target_for_alias,
|
||||
send_command,
|
||||
send_command_async,
|
||||
)
|
||||
from browser_cli.endpoints import (
|
||||
_looks_like_domain,
|
||||
_normalize_endpoint,
|
||||
_remote_display_name,
|
||||
_resolve_connect_endpoint,
|
||||
display_browser_name,
|
||||
)
|
||||
from browser_cli.remote.transport import _recv_all, _recv_exact
|
||||
|
||||
__all__ = [
|
||||
"BrowserNotConnected",
|
||||
"BrowserTarget",
|
||||
"REGISTRY_PATH",
|
||||
"is_active_local_profile",
|
||||
"_looks_like_domain",
|
||||
"_normalize_endpoint",
|
||||
"_recv_all",
|
||||
"_recv_exact",
|
||||
"_remote_browser_targets",
|
||||
"_remote_display_name",
|
||||
"_resolve_connect_endpoint",
|
||||
"resolve_socket",
|
||||
"_send_remote",
|
||||
"_send_remote_async",
|
||||
"active_browser_targets",
|
||||
"display_browser_name",
|
||||
"remote_browser_targets",
|
||||
"remote_browser_targets_async",
|
||||
"remote_target_for_alias",
|
||||
"send_command",
|
||||
"send_command_async",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Remote client auth/key preparation for browser command messages."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.remote import registry as remote_registry
|
||||
from browser_cli.constants import DEFAULT_KEY_PATH, NO_ROUTE_COMMANDS
|
||||
from browser_cli.version_manager import USER_AGENT
|
||||
|
||||
def load_private_key(key_path: Path | str | None = None):
|
||||
"""Load an Ed25519 signing key from file or SSH agent spec."""
|
||||
raw = str(key_path) if key_path is not None else os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
|
||||
|
||||
if raw == "agent" or raw.startswith("agent:"):
|
||||
selector = raw[6:] or None
|
||||
from browser_cli.auth import agent_find_key
|
||||
return agent_find_key(selector)
|
||||
|
||||
path = Path(raw)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
from browser_cli.auth import load_private_key as load_pem_key
|
||||
return load_pem_key(path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def add_remote_auth_fields(msg: dict, command: str, requested_profile: str | None, remote_endpoint: str, key, auto_router) -> object:
|
||||
"""Mutate *msg* with remote auth/routing fields and return the signing key."""
|
||||
from browser_cli import transport
|
||||
|
||||
msg["user_agent"] = USER_AGENT
|
||||
msg["accept_encoding"] = transport.client_accept_encoding()
|
||||
key_spec = key if key is not None else remote_registry.key_for_remote(remote_endpoint)
|
||||
private_key = load_private_key(key_spec)
|
||||
if key is not None:
|
||||
remote_registry.save_remote_key(remote_endpoint, str(key))
|
||||
|
||||
route_profile = requested_profile
|
||||
if not route_profile and command not in NO_ROUTE_COMMANDS:
|
||||
route_profile = auto_router(remote_endpoint, key=key_spec)
|
||||
if route_profile:
|
||||
msg["_route"] = route_profile
|
||||
return private_key
|
||||
|
||||
async def add_remote_auth_fields_async(msg: dict, command: str, requested_profile: str | None, remote_endpoint: str, key, auto_router) -> object:
|
||||
from browser_cli import transport
|
||||
|
||||
msg["user_agent"] = USER_AGENT
|
||||
msg["accept_encoding"] = transport.client_accept_encoding()
|
||||
key_spec = key if key is not None else await asyncio.to_thread(remote_registry.key_for_remote, remote_endpoint)
|
||||
private_key = await asyncio.to_thread(load_private_key, key_spec)
|
||||
if key is not None:
|
||||
await asyncio.to_thread(remote_registry.save_remote_key, remote_endpoint, str(key))
|
||||
|
||||
route_profile = requested_profile
|
||||
if not route_profile and command not in NO_ROUTE_COMMANDS:
|
||||
route_profile = await auto_router(remote_endpoint, key=key_spec)
|
||||
if route_profile:
|
||||
msg["_route"] = route_profile
|
||||
return private_key
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Local IPC client — sends commands to native host relay endpoint.
|
||||
Used by both CLI and public Python API.
|
||||
|
||||
Profile selection order:
|
||||
1. Explicit `profile` argument to send_command()
|
||||
2. BROWSER_CLI_PROFILE environment variable
|
||||
3. First entry in runtime registry
|
||||
4. Otherwise, no browser can be resolved automatically
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from browser_cli import local_transport
|
||||
from browser_cli.client import auth, messages, targets as target_discovery
|
||||
from browser_cli.client.targets import BrowserTarget
|
||||
from browser_cli.remote import registry as remote_registry
|
||||
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.endpoints import _remote_display_name
|
||||
from browser_cli.remote.transport import _send_remote, _send_remote_async
|
||||
|
||||
def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for item in items or []:
|
||||
profile = str(item.get("profile") or "default")
|
||||
display = str(item.get("displayName") or profile)
|
||||
targets.append(
|
||||
BrowserTarget(
|
||||
profile=profile,
|
||||
display_name=_remote_display_name(endpoint, profile, display),
|
||||
socket_path="",
|
||||
remote=endpoint,
|
||||
)
|
||||
)
|
||||
return targets
|
||||
|
||||
def remote_browser_targets(endpoint: str, key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
"""Return browser targets advertised by a single remote endpoint."""
|
||||
kwargs = {"suppress_pq_warning": True} if suppress_pq_warning else {}
|
||||
return _remote_target_items(
|
||||
endpoint,
|
||||
send_command("browser-cli.targets", remote=endpoint, key=key, **kwargs),
|
||||
)
|
||||
|
||||
def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
targets: list[BrowserTarget] = []
|
||||
for endpoint in remote_registry.load_remotes():
|
||||
try:
|
||||
targets.extend(remote_browser_targets(endpoint, key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
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:
|
||||
return None
|
||||
targets = _remote_browser_targets()
|
||||
for target in targets:
|
||||
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
|
||||
if alias in {target.display_name, endpoint_profile}:
|
||||
return target
|
||||
|
||||
endpoint_matches = []
|
||||
for target in targets:
|
||||
if not target.remote:
|
||||
continue
|
||||
remote_host, sep, _remote_port = target.remote.rpartition(":")
|
||||
if alias == target.remote or (sep and alias == remote_host):
|
||||
endpoint_matches.append(target)
|
||||
if len(endpoint_matches) == 1:
|
||||
return endpoint_matches[0]
|
||||
if len(endpoint_matches) > 1:
|
||||
aliases = [target.profile for target in endpoint_matches]
|
||||
endpoint = endpoint_matches[0].remote or alias
|
||||
examples = "\n".join(
|
||||
f" browser-cli --remote {endpoint} --browser {a} ..."
|
||||
for a in aliases
|
||||
)
|
||||
display_aliases = [target.display_name for target in endpoint_matches]
|
||||
shorthand_examples = "\n".join(
|
||||
f" browser-cli --browser {a} ..."
|
||||
for a in display_aliases
|
||||
)
|
||||
raise BrowserNotConnected(
|
||||
f"Multiple remote browser instances are active on {alias}: {', '.join(aliases)}\n"
|
||||
f"Use --browser <alias> with --remote to select one:\n{examples}\n"
|
||||
f"Or use the full remote browser alias:\n{shorthand_examples}"
|
||||
)
|
||||
return None
|
||||
|
||||
def active_browser_targets(*, include_remotes: bool = True, key=None, suppress_pq_warning: bool = False) -> list[BrowserTarget]:
|
||||
targets = target_discovery.active_local_browser_targets()
|
||||
if include_remotes:
|
||||
targets.extend(_remote_browser_targets(key=key, suppress_pq_warning=suppress_pq_warning))
|
||||
return targets
|
||||
|
||||
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:
|
||||
aliases = [target.profile for target in targets]
|
||||
examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
|
||||
raise BrowserNotConnected(
|
||||
f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
|
||||
f"Use --browser <alias> to select one:\n{examples}"
|
||||
)
|
||||
return None
|
||||
|
||||
def send_command(
|
||||
command: str,
|
||||
args: dict | None = None,
|
||||
profile: str | None = None,
|
||||
remote: str | None = None,
|
||||
key: "Path | None" = None,
|
||||
*,
|
||||
suppress_pq_warning: bool = False,
|
||||
) -> Any:
|
||||
"""Send a command to the browser and return the response data."""
|
||||
requested_profile, remote_endpoint = messages.requested_target(profile, remote)
|
||||
if not remote_endpoint and requested_profile and not target_discovery.is_active_local_profile(requested_profile):
|
||||
if remote_alias_target := remote_target_for_alias(requested_profile):
|
||||
remote_endpoint = remote_alias_target.remote
|
||||
requested_profile = remote_alias_target.profile
|
||||
|
||||
msg = messages.base_message(command, args)
|
||||
private_key = None
|
||||
if remote_endpoint:
|
||||
if suppress_pq_warning:
|
||||
msg["_suppress_pq_warning"] = True
|
||||
private_key = auth.add_remote_auth_fields(msg, command, requested_profile, remote_endpoint, key, _auto_route_remote)
|
||||
|
||||
try:
|
||||
payload = messages.encode_payload(msg)
|
||||
response = (
|
||||
_send_remote(remote_endpoint, msg, private_key)
|
||||
if remote_endpoint
|
||||
else local_transport.send_local_sync(profile, payload, target_discovery.resolve_socket)
|
||||
)
|
||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(profile)
|
||||
|
||||
return messages.decode_response(response)
|
||||
|
||||
async def remote_browser_targets_async(endpoint: str, key=None) -> list[BrowserTarget]:
|
||||
"""Async variant of :func:`remote_browser_targets`."""
|
||||
return _remote_target_items(
|
||||
endpoint,
|
||||
await send_command_async("browser-cli.targets", remote=endpoint, key=key),
|
||||
)
|
||||
|
||||
async def _auto_route_remote_async(endpoint: str, key=None) -> str | None:
|
||||
targets = await remote_browser_targets_async(endpoint, key=key)
|
||||
if len(targets) == 1:
|
||||
return targets[0].profile
|
||||
if len(targets) > 1:
|
||||
aliases = [target.profile for target in targets]
|
||||
examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
|
||||
raise BrowserNotConnected(
|
||||
f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
|
||||
f"Use --browser <alias> to select one:\n{examples}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def send_command_async(
|
||||
command: str,
|
||||
args: dict | None = None,
|
||||
profile: str | None = None,
|
||||
remote: str | None = None,
|
||||
key: "Path | None" = None,
|
||||
*,
|
||||
suppress_pq_warning: bool = False,
|
||||
) -> Any:
|
||||
"""Async variant of :func:`send_command` with native async socket/TCP paths."""
|
||||
requested_profile, remote_endpoint = messages.requested_target(profile, remote)
|
||||
if not remote_endpoint and requested_profile and not await asyncio.to_thread(target_discovery.is_active_local_profile, requested_profile):
|
||||
if remote_alias_target := await asyncio.to_thread(remote_target_for_alias, requested_profile):
|
||||
remote_endpoint = remote_alias_target.remote
|
||||
requested_profile = remote_alias_target.profile
|
||||
|
||||
msg = messages.base_message(command, args)
|
||||
private_key = None
|
||||
if remote_endpoint:
|
||||
if suppress_pq_warning:
|
||||
msg["_suppress_pq_warning"] = True
|
||||
private_key = await auth.add_remote_auth_fields_async(msg, command, requested_profile, remote_endpoint, key, _auto_route_remote_async)
|
||||
|
||||
try:
|
||||
payload = messages.encode_payload(msg)
|
||||
response = (
|
||||
await _send_remote_async(remote_endpoint, msg, private_key)
|
||||
if remote_endpoint
|
||||
else await local_transport.send_local_async(profile, payload, target_discovery.resolve_socket)
|
||||
)
|
||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||
raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(profile)
|
||||
|
||||
return messages.decode_response(response)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Command message/response helpers shared by sync and async clients."""
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.endpoints import _normalize_endpoint
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
|
||||
def base_message(command: str, args: dict | None) -> dict:
|
||||
return {"id": str(uuid.uuid4()), "command": command, "args": args or {}}
|
||||
|
||||
def requested_target(profile: str | None, remote: str | None) -> tuple[str | None, str | None]:
|
||||
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
||||
return requested_profile, _normalize_endpoint(remote_endpoint) if remote_endpoint else None
|
||||
|
||||
def encode_payload(msg: dict) -> bytes:
|
||||
return json.dumps(msg).encode("utf-8")
|
||||
|
||||
def decode_response(response: bytes | None) -> Any:
|
||||
if response is None:
|
||||
raise ConnectionError("Connection closed before full response received")
|
||||
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 local_connection_error(profile: str | None) -> BrowserNotConnected:
|
||||
profile_hint = f" (profile: {profile})" if profile else ""
|
||||
return BrowserNotConnected(
|
||||
f"Cannot connect to browser{profile_hint}.\n"
|
||||
"Make sure:\n"
|
||||
" 1. The browser-cli extension is installed and enabled\n"
|
||||
" 2. The native host is registered: uv run browser-cli install <browser>\n"
|
||||
" 3. Your browser is running\n"
|
||||
" Tip: use BROWSER_CLI_PROFILE=<name> to select a specific profile"
|
||||
)
|
||||
|
||||
def remote_connection_error(remote_endpoint: str) -> BrowserNotConnected:
|
||||
return BrowserNotConnected(
|
||||
f"Cannot connect to remote browser at {remote_endpoint}.\n"
|
||||
"Make sure browser-cli serve is running on the remote host."
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Browser target discovery and local socket resolution."""
|
||||
import os
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.endpoints import display_browser_name
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
||||
from browser_cli.registry import load_registry
|
||||
|
||||
REGISTRY_PATH = registry_path()
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserTarget:
|
||||
profile: str
|
||||
display_name: str
|
||||
socket_path: str
|
||||
remote: str | None = None
|
||||
|
||||
def is_reachable_unix_endpoint(endpoint: str) -> bool:
|
||||
"""Return True when a Unix socket path exists and accepts connections."""
|
||||
path = Path(endpoint)
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.2)
|
||||
sock.connect(endpoint)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def active_endpoints(reg: dict) -> dict:
|
||||
"""Return only entries whose endpoint appears reachable."""
|
||||
if is_windows():
|
||||
return dict(reg)
|
||||
return {k: v for k, v in reg.items() if is_reachable_unix_endpoint(v)}
|
||||
|
||||
def active_local_browser_targets() -> list[BrowserTarget]:
|
||||
if not REGISTRY_PATH.exists():
|
||||
return []
|
||||
reg = load_registry(REGISTRY_PATH)
|
||||
return [
|
||||
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
|
||||
for profile, sock_path in active_endpoints(reg).items()
|
||||
]
|
||||
|
||||
def is_active_local_profile(profile: str | None) -> bool:
|
||||
"""Return True when profile names a reachable local browser endpoint."""
|
||||
if not profile:
|
||||
return False
|
||||
if REGISTRY_PATH.exists():
|
||||
reg = load_registry(REGISTRY_PATH)
|
||||
if profile in active_endpoints(reg):
|
||||
return True
|
||||
if not is_windows():
|
||||
try:
|
||||
return Path(endpoint_for_alias(profile)).exists()
|
||||
except Exception:
|
||||
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")
|
||||
|
||||
if target:
|
||||
if REGISTRY_PATH.exists():
|
||||
reg = load_registry(REGISTRY_PATH)
|
||||
if target in reg:
|
||||
return reg[target]
|
||||
return endpoint_for_alias(target)
|
||||
|
||||
try:
|
||||
active = active_local_browser_targets()
|
||||
if len(active) > 1:
|
||||
aliases = [target.profile for target in active]
|
||||
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
|
||||
raise BrowserNotConnected(
|
||||
f"Multiple browser instances are active: {', '.join(aliases)}\n"
|
||||
f"Use --browser <alias> to select one:\n{examples}"
|
||||
)
|
||||
if active:
|
||||
return active[0].socket_path
|
||||
except BrowserNotConnected:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise BrowserNotConnected(
|
||||
"Cannot resolve a browser socket automatically.\n"
|
||||
"Make sure the browser is running with the browser-cli extension enabled,\n"
|
||||
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
|
||||
)
|
||||
Reference in New Issue
Block a user