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:
2026-06-13 23:31:24 +02:00
parent fd5447cbb9
commit 076914e5b7
88 changed files with 7491 additions and 5228 deletions
+137 -97
View File
@@ -2,126 +2,166 @@
browser_cli — Python SDK for controlling your running browser.
Usage:
from browser_cli import BrowserCLI
b = BrowserCLI()
from browser_cli import BrowserCLI
b = BrowserCLI()
tabs = b.tabs.list() # list[Tab]
tabs[0].close()
tabs[0].move(forward=True)
tabs = b.tabs.list() # list[Tab]
tabs[0].close()
tabs[0].move(forward=True)
groups = b.groups.list() # list[Group]
groups[0].tabs()
groups[0].add_tab("https://example.com")
groups = b.groups.list() # list[Group]
groups[0].tabs()
groups[0].add_tab("https://example.com")
b.nav.open("https://example.com")
b.dom.click("#submit")
b.session.save("work")
b.nav.open("https://example.com")
b.dom.click("#submit")
b.session.save("work")
# When multiple browser instances are active, pass the alias:
b = BrowserCLI(browser="brave")
# When multiple browser instances are active, pass the alias:
b = BrowserCLI(browser="brave")
Commands are grouped into namespaces on the client:
b.nav navigation (open, reload, back, forward, focus, search)
b.tabs tabs (list, open, close, move, status, mute, sort, ...)
b.groups tab groups (list, create, add_tab, move, close)
b.windows browser windows (list, open, close, rename)
b.dom page elements (query, click, type, wait_for, eval, ...)
b.extract content extraction (links, images, text, json, markdown)
b.page page info
b.storage localStorage / sessionStorage
b.cookies cookies (list, get, set)
b.session sessions (save, load, list, diff, ...)
b.perf performance profile + background jobs
b.extension control the extension itself
b.nav navigation (open, reload, back, forward, focus, search)
b.tabs tabs (list, open, close, move, status, mute, sort, ...)
b.groups tab groups (list, create, add_tab, move, close)
b.windows browser windows (list, open, close, rename)
b.dom page elements (query, click, type, wait_for, eval, ...)
b.extract content extraction (links, images, text, json, markdown)
b.page page info
b.storage localStorage / sessionStorage
b.cookies cookies (list, get, set)
b.session sessions (save, load, list, diff, ...)
b.perf performance profile + background jobs
b.extension control the extension itself
b.decorators workflow decorators for scripts
"""
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from collections.abc import Callable
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async
from browser_cli.errors import BrowserNotConnected
from browser_cli.models import BrowserCounts, Group, Tab
from browser_cli.sdk import (
CookiesNS,
DomNS,
ExtensionNS,
ExtractNS,
GroupsNS,
NavigationNS,
PageNS,
PerfNS,
SessionNS,
StorageNS,
TabsNS,
WindowsNS,
CookiesNS,
DecoratorsNS,
DomNS,
ExtensionNS,
ExtractNS,
GroupsNS,
NAMESPACE_SPECS,
NavigationNS,
PageNS,
PerfNS,
SessionNS,
StorageNS,
TabsNS,
WindowsNS,
)
from browser_cli.sdk.factories import FactoryMixin
from browser_cli.sdk.routing import RoutingMixin
__all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
from browser_cli.async_sdk import AsyncBrowserCLI
__all__ = ["BrowserCLI", "AsyncBrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
class BrowserCLI(FactoryMixin, RoutingMixin):
"""Client for a running browser, with commands grouped into namespaces.
"""Client for a running browser, with commands grouped into namespaces.
The client itself holds the connection target (browser/remote/key) and the
shared machinery; the actual commands live on namespace accessors such as
:attr:`tabs`, :attr:`dom`, and :attr:`session`. Object construction
(``Tab``/``Group``) comes from :class:`~browser_cli.sdk.factories.FactoryMixin`
and multi-browser fan-out from :class:`~browser_cli.sdk.routing.RoutingMixin`.
The client itself holds the connection target (browser/remote/key) and the
shared machinery; the actual commands live on namespace accessors such as
:attr:`tabs`, :attr:`dom`, and :attr:`session`. Object construction
(``Tab``/``Group``) comes from :class:`~browser_cli.sdk.factories.FactoryMixin`
and multi-browser fan-out from :class:`~browser_cli.sdk.routing.RoutingMixin`.
"""
_browser: str | None
_remote: str | None
_key: str | None
_command_sender: Callable
nav: NavigationNS
tabs: TabsNS
groups: GroupsNS
windows: WindowsNS
dom: DomNS
extract: ExtractNS
page: PageNS
storage: StorageNS
cookies: CookiesNS
session: SessionNS
perf: PerfNS
extension: ExtensionNS
decorators: DecoratorsNS
def __init__(
self,
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
*,
_command_sender=None,
):
"""
Args:
browser: Profile alias to target. Required when multiple browser
instances are active. Equivalent to ``--browser`` on the CLI.
remote: Connect to a remote browser exposed via ``browser-cli serve``.
Format: ``"host:port"`` (e.g. ``"browser-host.example:8765"``).
Can be combined with ``browser`` to route to a specific
remote profile.
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._key = key if key else None
self._command_sender = _command_sender or send_command
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
instances are active. Equivalent to ``--browser`` on the CLI.
remote: Connect to a remote browser exposed via ``browser-cli serve``.
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
Can be combined with ``browser`` to route to a specific
remote profile.
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._key = key if key else None
for name, namespace_type in NAMESPACE_SPECS:
setattr(self, name, namespace_type(self))
self.decorators = DecoratorsNS(self)
# Command namespaces.
self.nav = NavigationNS(self)
self.tabs = TabsNS(self)
self.groups = GroupsNS(self)
self.windows = WindowsNS(self)
self.dom = DomNS(self)
self.extract = ExtractNS(self)
self.page = PageNS(self)
self.storage = StorageNS(self)
self.cookies = CookiesNS(self)
self.session = SessionNS(self)
self.perf = PerfNS(self)
self.extension = ExtensionNS(self)
@property
def browser(self) -> str | None:
"""Target browser/profile alias, equivalent to ``--browser``."""
return self._browser
@property
def browser(self) -> str | None:
"""Target browser/profile alias, equivalent to ``--browser``."""
return self._browser
@property
def remote(self) -> str | None:
"""Remote endpoint used by this client, if any."""
return self._remote
@property
def remote(self) -> str | None:
"""Remote endpoint used by this client, if any."""
return self._remote
@property
def key(self) -> str | None:
"""Ed25519 key spec used for remote auth, if explicitly configured."""
return self._key
@property
def key(self) -> str | None:
"""Ed25519 key spec used for remote auth, if explicitly configured."""
return self._key
def dispatch(self, command: str, args: dict | None = None):
"""Dispatch one browser command using this client's target settings."""
return self._command_sender(command, args, profile=self._browser, remote=self._remote, key=self._key)
def _cmd(self, command: str, args: dict | None = None):
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
def require_tab(self, data, error: str):
"""Convert a tab-like command response into a bound Tab."""
return self.require_tab_response(data, error)
def command(self, command: str, args: dict | None = None):
"""Send a raw browser-cli command and return its response.
_FIELD_MISSING = object()
This is the SDK escape hatch for commands that do not have a dedicated
namespace method yet.
"""
return self._cmd(command, args or {})
def field(self, result, key, default=None, *, fallback=_FIELD_MISSING):
"""Read a named field from command output."""
if fallback is self._FIELD_MISSING:
return self._field(result, key, default)
return self._field(result, key, default, fallback=fallback)
def clients(self) -> list[dict]:
"""Return the active browser clients known to this connection."""
return self._cmd("clients.list", {})
def _cmd(self, command: str, args: dict | None = None):
return self.dispatch(command, args)
def command(self, command: str, args: dict | None = None):
"""Send a raw browser-cli command and return its response.
This is the SDK escape hatch for commands that do not have a dedicated
namespace method yet.
"""
return self._cmd(command, args or {})
def clients(self) -> list[dict]:
"""Return the active browser clients known to this connection."""
return self._cmd("clients.list", {})
+235
View File
@@ -0,0 +1,235 @@
"""Async browser-cli Python SDK.
The async SDK intentionally reuses the synchronous SDK namespaces instead of
copying every command method. Each async namespace is a thin adapter that runs
the corresponding sync SDK method in a worker thread, while a private command
sender dispatches commands through ``send_command_async``. That keeps command
strings, argument shapes, result mapping, and bound model creation in one place:
``browser_cli.sdk``.
"""
from __future__ import annotations
import asyncio
import functools
from collections.abc import Callable
from typing import TypeVar
from browser_cli.models import Group, Tab
from browser_cli.sdk import NAMESPACE_NAMES
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
F = TypeVar("F", bound=Callable)
class AsyncNamespaceAdapter:
"""Async wrapper around one synchronous SDK namespace."""
def __init__(self, sync_namespace):
self._sync = sync_namespace
def __getattr__(self, name: str):
value = getattr(self._sync, name)
if not callable(value):
return value
@functools.wraps(value)
async def wrapper(*args, **kwargs):
return await asyncio.to_thread(value, *args, **kwargs)
return wrapper
class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
"""Async workflow decorators for :class:`AsyncBrowserCLI`.
The public decorator methods are inherited from ``WorkflowDecoratorsMixin``;
only the execution strategy differs: every wrapper is async and awaits both
browser calls and async user functions.
"""
def __init__(self, client: "AsyncBrowserCLI"):
self._c = client
@staticmethod
async def _maybe_await(value):
if hasattr(value, "__await__"):
return await value
return value
def _value_decorator(
self,
func: F | None,
get_value: Callable,
*,
keyword: str | None | object = "tab",
cleanup: Callable | None = None,
):
def decorator(fn: F) -> F:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
value = await get_value()
try:
extra_args = ()
if keyword is not _NO_INJECT:
extra_args, kwargs = self._inject(kwargs, keyword, value)
return await self._maybe_await(fn(*extra_args, *args, **kwargs))
finally:
if cleanup is not None:
await self._maybe_await(cleanup(value))
return wrapper # type: ignore[return-value]
return decorator(func) if func is not None else decorator
def new_tab(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
close: bool = False,
keyword: str | None = "tab",
):
def open_tab():
return self._c.tabs.open(
url,
wait=wait,
timeout=timeout,
background=background,
window=window,
group=group,
)
async def close_tab(tab):
await self._c.tabs.close(tab.id)
return self._value_decorator(None, open_tab, keyword=keyword, cleanup=close_tab if close else None)
def performance_profile(self, profile: str, *, restore: bool = True):
def decorator(fn: F) -> F:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
previous = (await self._c.perf.status()).get("performanceProfile") if restore else None
await self._c.perf.set_profile(profile)
try:
return await self._maybe_await(fn(*args, **kwargs))
finally:
if previous:
await self._c.perf.set_profile(previous)
return wrapper # type: ignore[return-value]
return decorator
def retry(
self,
*,
times: int = 3,
delay: float = 0.0,
exceptions: tuple[type[BaseException], ...] = (Exception,),
):
attempts = max(1, times)
def decorator(fn: F) -> F:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
last_error = None
for attempt in range(attempts):
try:
return await self._maybe_await(fn(*args, **kwargs))
except exceptions as exc:
last_error = exc
if attempt == attempts - 1:
raise
if delay > 0:
await asyncio.sleep(delay)
raise last_error # type: ignore[misc]
return wrapper # type: ignore[return-value]
return decorator
class AsyncBrowserCLI:
"""Async client for a running browser.
Namespace methods are awaitable mirrors of :class:`browser_cli.BrowserCLI`.
"""
_NAMESPACES = NAMESPACE_NAMES
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
from browser_cli import BrowserCLI
self._browser = browser
self._remote = remote
self._key = key if key else None
self._sync = BrowserCLI(browser=browser, remote=remote, key=key, _command_sender=self._blocking_async_cmd)
for name in self._NAMESPACES:
setattr(self, name, AsyncNamespaceAdapter(getattr(self._sync, name)))
self.decorators = AsyncDecoratorsNS(self)
@property
def browser(self) -> str | None:
return self._browser
@property
def remote(self) -> str | None:
return self._remote
@property
def key(self) -> str | None:
return self._key
def _blocking_async_cmd(
self,
command: str,
args: dict | None = None,
*,
profile: str | None = None,
remote: str | None = None,
key: str | None = None,
):
"""Run the native async transport from a worker thread.
Async namespace methods execute sync SDK logic in ``asyncio.to_thread``.
Inside that worker thread, the sync SDK's injected command sender lands
here and uses the async transport implementation without blocking the
caller's event loop.
"""
return asyncio.run(self._cmd(command, args, profile=profile, remote=remote, key=key))
async def _cmd(
self,
command: str,
args: dict | None = None,
*,
profile: str | None = None,
remote: str | None = None,
key: str | None = None,
):
from browser_cli.client import send_command_async
return await send_command_async(
command,
args,
profile=self._browser if profile is None else profile,
remote=self._remote if remote is None else remote,
key=self._key if key is None else key,
)
async def command(self, command: str, args: dict | None = None):
return await self._cmd(command, args or {})
async def clients(self) -> list[dict]:
return await self._cmd("clients.list", {})
def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab:
return self._sync.tab_from(
data,
browser_profile=browser_profile,
browser_name=browser_name,
browser_remote=browser_remote,
)
def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group:
return self._sync.group_from(
data,
browser_profile=browser_profile,
browser_name=browser_name,
browser_remote=browser_remote,
)
+183 -210
View File
@@ -14,277 +14,250 @@ from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
)
_CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli"
DEFAULT_KEY_PATH = _CONFIG_DIR / "client.key.pem"
DEFAULT_AUTHORIZED_KEYS_PATH = _CONFIG_DIR / "authorized_keys"
# ── SSH agent protocol constants ───────────────────────────────────────────────
_SSH_AGENTC_REQUEST_IDENTITIES = 11
_SSH_AGENT_IDENTITIES_ANSWER = 12
_SSH_AGENTC_SIGN_REQUEST = 13
_SSH_AGENT_SIGN_RESPONSE = 14
from browser_cli.constants import (
DEFAULT_AUTHORIZED_KEYS_PATH,
DEFAULT_KEY_PATH,
PQ_KEX_ALG,
PQ_TRANSPORT_ALG,
SSH_AGENT_IDENTITIES_ANSWER,
SSH_AGENT_SIGN_RESPONSE,
SSH_AGENTC_REQUEST_IDENTITIES,
SSH_AGENTC_SIGN_REQUEST,
)
def _pack_str(s: bytes) -> bytes:
return struct.pack(">I", len(s)) + s
return struct.pack(">I", len(s)) + s
def _unpack_str(data: bytes, off: int) -> tuple[bytes, int]:
n = struct.unpack_from(">I", data, off)[0]
return data[off + 4 : off + 4 + n], off + 4 + n
n = struct.unpack_from(">I", data, off)[0]
return data[off + 4 : off + 4 + n], off + 4 + n
def _agent_roundtrip(msg: bytes) -> bytes:
sock_path = os.environ.get("SSH_AUTH_SOCK")
if not sock_path:
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(10)
sock.connect(sock_path)
sock.sendall(struct.pack(">I", len(msg)) + msg)
raw_len = b""
while len(raw_len) < 4:
chunk = sock.recv(4 - len(raw_len))
if not chunk:
raise RuntimeError("SSH agent closed connection")
raw_len += chunk
n = struct.unpack(">I", raw_len)[0]
resp = b""
while len(resp) < n:
chunk = sock.recv(n - len(resp))
if not chunk:
raise RuntimeError("SSH agent closed connection mid-response")
resp += chunk
return resp
sock_path = os.environ.get("SSH_AUTH_SOCK")
if not sock_path:
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(10)
sock.connect(sock_path)
sock.sendall(struct.pack(">I", len(msg)) + msg)
raw_len = b""
while len(raw_len) < 4:
chunk = sock.recv(4 - len(raw_len))
if not chunk:
raise RuntimeError("SSH agent closed connection")
raw_len += chunk
n = struct.unpack(">I", raw_len)[0]
resp = b""
while len(resp) < n:
chunk = sock.recv(n - len(resp))
if not chunk:
raise RuntimeError("SSH agent closed connection mid-response")
resp += chunk
return resp
# ── AgentKey ───────────────────────────────────────────────────────────────────
@dataclass
class AgentKey:
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
blob: bytes
comment: str
@property
def pubkey_bytes(self) -> bytes:
_algo, off = _unpack_str(self.blob, 0)
key_bytes, _ = _unpack_str(self.blob, off)
return key_bytes
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
blob: bytes
comment: str
@property
def pubkey_bytes(self) -> bytes:
_algo, off = _unpack_str(self.blob, 0)
key_bytes, _ = _unpack_str(self.blob, off)
return key_bytes
# ── Agent helpers ──────────────────────────────────────────────────────────────
def agent_list_keys() -> list[AgentKey]:
"""Return all Ed25519 keys currently held by the SSH agent."""
resp = _agent_roundtrip(bytes([_SSH_AGENTC_REQUEST_IDENTITIES]))
if resp[0] != _SSH_AGENT_IDENTITIES_ANSWER:
raise RuntimeError(f"Unexpected agent response: {resp[0]}")
n_keys = struct.unpack_from(">I", resp, 1)[0]
keys: list[AgentKey] = []
off = 5
for _ in range(n_keys):
blob, off = _unpack_str(resp, off)
comment, off = _unpack_str(resp, off)
algo, _ = _unpack_str(blob, 0)
if algo == b"ssh-ed25519":
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
return keys
"""Return all Ed25519 keys currently held by the SSH agent."""
resp = _agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
if resp[0] != SSH_AGENT_IDENTITIES_ANSWER:
raise RuntimeError(f"Unexpected agent response: {resp[0]}")
n_keys = struct.unpack_from(">I", resp, 1)[0]
keys: list[AgentKey] = []
off = 5
for _ in range(n_keys):
blob, off = _unpack_str(resp, off)
comment, off = _unpack_str(resp, off)
algo, _ = _unpack_str(blob, 0)
if algo == b"ssh-ed25519":
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
return keys
def agent_find_key(selector: str | None = None) -> AgentKey | None:
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
try:
keys = agent_list_keys()
except Exception:
return None
for key in keys:
if key.comment == "(none)":
continue
if selector is None or selector in key.comment:
return key
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
try:
keys = agent_list_keys()
except Exception:
return None
for key in keys:
if key.comment == "(none)":
continue
if selector is None or selector in key.comment:
return key
return None
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
msg = (
bytes([_SSH_AGENTC_SIGN_REQUEST])
+ _pack_str(key.blob)
+ _pack_str(data)
+ struct.pack(">I", 0)
)
resp = _agent_roundtrip(msg)
if resp[0] != _SSH_AGENT_SIGN_RESPONSE:
raise RuntimeError(f"SSH agent refused to sign (response code {resp[0]})")
sig_blob, _ = _unpack_str(resp, 1)
_algo, soff = _unpack_str(sig_blob, 0)
raw_sig, _ = _unpack_str(sig_blob, soff)
if len(raw_sig) != 64:
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
return raw_sig
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
msg = (
bytes([SSH_AGENTC_SIGN_REQUEST])
+ _pack_str(key.blob)
+ _pack_str(data)
+ struct.pack(">I", 0)
)
resp = _agent_roundtrip(msg)
if resp[0] != SSH_AGENT_SIGN_RESPONSE:
raise RuntimeError(f"SSH agent refused to sign (response code {resp[0]})")
sig_blob, _ = _unpack_str(resp, 1)
_algo, soff = _unpack_str(sig_blob, 0)
raw_sig, _ = _unpack_str(sig_blob, soff)
if len(raw_sig) != 64:
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
return raw_sig
# ── File-based key helpers ─────────────────────────────────────────────────────
def generate_keypair() -> tuple[bytes, str]:
"""Return (private_key_pem_bytes, public_key_hex)."""
priv = Ed25519PrivateKey.generate()
pem = priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
pub_hex = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
return pem, pub_hex
"""Return (private_key_pem_bytes, public_key_hex)."""
priv = Ed25519PrivateKey.generate()
pem = priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
pub_hex = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
return pem, pub_hex
def load_private_key(path: Path) -> Ed25519PrivateKey:
return load_pem_private_key(path.read_bytes(), password=None)
return load_pem_private_key(path.read_bytes(), password=None)
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
if isinstance(key, AgentKey):
return key.pubkey_bytes.hex()
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
if isinstance(key, AgentKey):
return key.pubkey_bytes.hex()
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
# ── Canonical payload + sign/verify ───────────────────────────────────────────
def canonical_payload(msg: dict) -> bytes:
"""Deterministic JSON encoding of msg without auth protocol fields."""
return json.dumps(
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
"""Deterministic JSON encoding of msg without auth protocol fields."""
return json.dumps(
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def _auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
if pq_shared_secret is not None:
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
return data
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
if pq_shared_secret is not None:
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
return data
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
data = _auth_message(nonce, msg, pq_shared_secret)
if isinstance(key, AgentKey):
return agent_sign_raw(key, data)
return key.sign(data)
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
data = _auth_message(nonce, msg, pq_shared_secret)
if isinstance(key, AgentKey):
return agent_sign_raw(key, data)
return key.sign(data)
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
try:
pub_bytes = bytes.fromhex(pub_hex)
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
return True
except (InvalidSignature, ValueError):
return False
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
try:
pub_bytes = bytes.fromhex(pub_hex)
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
return True
except (InvalidSignature, ValueError):
return False
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
PQ_KEX_ALG = "ML-KEM-768"
PQ_TRANSPORT_ALG = "ML-KEM-768+ChaCha20Poly1305"
def pq_kex_server_keypair():
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
Returns ``None`` when the installed cryptography/OpenSSL backend does not
support ML-KEM yet. The serve/client protocol treats this as graceful
downgrade instead of breaking local installs on older OpenSSL builds.
"""
try:
from cryptography.hazmat.primitives.asymmetric import mlkem
priv = mlkem.MLKEM768PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
return priv, pub
except Exception:
return None
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
Returns ``None`` when the installed cryptography/OpenSSL backend does not
support ML-KEM yet. The serve/client protocol treats this as graceful
downgrade instead of breaking local installs on older OpenSSL builds.
"""
try:
from cryptography.hazmat.primitives.asymmetric import mlkem
priv = mlkem.MLKEM768PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
return priv, pub
except Exception:
return None
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
from cryptography.hazmat.primitives.asymmetric import mlkem
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
shared_secret, ciphertext = pub.encapsulate()
return ciphertext.hex(), shared_secret
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
from cryptography.hazmat.primitives.asymmetric import mlkem
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
shared_secret, ciphertext = pub.encapsulate()
return ciphertext.hex(), shared_secret
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
).derive(shared_secret)
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
).derive(shared_secret)
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
nonce = secrets.token_bytes(12)
key = _pq_transport_key(shared_secret, direction)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
nonce = secrets.token_bytes(12)
key = _pq_transport_key(shared_secret, direction)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
"""Decrypt an app-layer frame produced by pq_encrypt()."""
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
raise ValueError("unsupported encrypted transport envelope")
key = _pq_transport_key(shared_secret, direction)
return ChaCha20Poly1305(key).decrypt(
bytes.fromhex(str(envelope["nonce"])),
bytes.fromhex(str(envelope["ciphertext"])),
None,
)
"""Decrypt an app-layer frame produced by pq_encrypt()."""
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
raise ValueError("unsupported encrypted transport envelope")
key = _pq_transport_key(shared_secret, direction)
return ChaCha20Poly1305(key).decrypt(
bytes.fromhex(str(envelope["nonce"])),
bytes.fromhex(str(envelope["ciphertext"])),
None,
)
def new_nonce() -> str:
return secrets.token_hex(32)
return secrets.token_hex(32)
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
if not path.exists():
return []
result = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
pubkey = parts[0]
name = parts[1].strip() if len(parts) > 1 else ""
result.append((pubkey, name))
return result
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
if not path.exists():
return []
result = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
pubkey = parts[0]
name = parts[1].strip() if len(parts) > 1 else ""
result.append((pubkey, name))
return result
def load_authorized_keys(path: Path) -> list[str]:
return [pk for pk, _ in load_authorized_keys_with_names(path)]
return [pk for pk, _ in load_authorized_keys_with_names(path)]
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
"""Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True)
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
if pub_hex in existing:
return False
line = (f"{pub_hex} {name}".rstrip()) + "\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
return True
"""Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True)
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
if pub_hex in existing:
return False
line = (f"{pub_hex} {name}".rstrip()) + "\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
return True
+64 -531
View File
@@ -3,9 +3,7 @@
browser-cli — Control your running browser from the terminal.
"""
import click
import sys
import os
import json
import shutil
import re
from importlib.metadata import PackageNotFoundError, version as package_version
@@ -26,332 +24,90 @@ from browser_cli.commands.cookies import cookies_group
from browser_cli.commands.perf import perf_group
from browser_cli.commands.extension import extension_group
from browser_cli.commands.serve import cmd_serve
from browser_cli.client import (
send_command,
BrowserNotConnected,
REGISTRY_PATH,
active_browser_targets,
display_browser_name,
remote_target_for_alias,
remote_browser_targets,
)
from browser_cli.platform import install_base_dir, is_windows
from browser_cli.registry import load_registry
from browser_cli.commands.link_serve import cmd_link_serve
from browser_cli.commands.auth import auth_group
from browser_cli.commands.clients import clients_group
from browser_cli.commands.completion import cmd_completion
from browser_cli.commands.install import cmd_install
console = Console()
# Click's Group.shell_complete hardcodes no limit for get_short_help_str (defaults to 45 chars);
# patch to use a wider limit so zsh completion descriptions aren't truncated.
def _patched_group_shell_complete(self, ctx, incomplete):
from click.shell_completion import CompletionItem
results = [
CompletionItem(name, help=command.get_short_help_str(limit=shutil.get_terminal_size().columns))
for name, command in self.commands.items()
if not command.hidden and name.startswith(incomplete)
]
results.extend(click.Command.shell_complete(self, ctx, incomplete))
return results
from click.shell_completion import CompletionItem
results = [
CompletionItem(name, help=command.get_short_help_str(limit=shutil.get_terminal_size().columns))
for name, command in self.commands.items()
if not command.hidden and name.startswith(incomplete)
]
results.extend(click.Command.shell_complete(self, ctx, incomplete))
return results
click.Group.shell_complete = _patched_group_shell_complete
NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
NATIVE_HOST_DIRS = {
"chrome": {
"linux": [Path.home() / ".config/google-chrome/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Google/Chrome/NativeMessagingHosts"],
},
"chromium": {
"linux": [Path.home() / ".config/chromium/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Chromium/NativeMessagingHosts"],
},
"brave": {
"linux": [Path.home() / ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts"],
},
"edge": {
"linux": [Path.home() / ".config/microsoft-edge/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Microsoft Edge/NativeMessagingHosts"],
},
"vivaldi": {
"linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"],
},
}
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"chrome": [r"Software\Google\Chrome\NativeMessagingHosts"],
"chromium": [r"Software\Chromium\NativeMessagingHosts"],
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
}
def _rename_target_profile(target_browser: str | None) -> str | None:
if target_browser:
return target_browser
active = active_browser_targets()
if len(active) == 1:
return active[0].profile
return None
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
target_profile = _rename_target_profile(target_browser)
profiles: dict[str, str] = load_registry(REGISTRY_PATH)
if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists")
def _native_host_exe() -> Path:
base = install_base_dir()
if is_windows():
return base / "libexec" / "browser-cli-native-host.cmd"
return base / "libexec" / "browser-cli-native-host"
def _write_native_host_exe(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if is_windows():
path.write_text(
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native_host import main; main()" %*\r\n',
encoding="utf-8",
)
else:
path.write_text(
f'#!{sys.executable}\nfrom browser_cli.native_host import main\nmain()\n'
)
path.chmod(path.stat().st_mode | 0o111)
def _windows_registry_views():
import winreg
return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)]
def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]:
import winreg
installed = []
for key_path in WINDOWS_NATIVE_HOST_REGISTRY_KEYS[browser]:
full_key = f"{key_path}\\{NATIVE_HOST_NAME}"
for view in _windows_registry_views():
try:
access = winreg.KEY_WRITE | view
key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, full_key, 0, access)
with key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(manifest_path))
installed.append(f"HKCU\\{full_key}")
except OSError as e:
console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]")
return installed
def _project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
content = pyproject_path.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if match:
return match.group(1)
except OSError:
pass
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
content = pyproject_path.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if match:
return match.group(1)
except OSError:
pass
try:
return package_version("browser-cli")
except PackageNotFoundError:
return "unknown"
try:
return package_version("browser-cli")
except PackageNotFoundError:
return "unknown"
def _print_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
click.echo(_project_version())
ctx.exit()
if not value or ctx.resilient_parsing:
return
click.echo(_project_version())
ctx.exit()
@click.group()
@click.option(
"-V", "--version",
is_flag=True,
is_eager=True,
expose_value=False,
callback=_print_version,
help="Show the browser-cli version and exit.",
"-V", "--version",
is_flag=True,
is_eager=True,
expose_value=False,
callback=_print_version,
help="Show the browser-cli version and exit.",
)
@click.option(
"--browser", default=None, metavar="ALIAS",
help="Browser profile alias to target (required when multiple browsers are active).",
"--browser", default=None, metavar="ALIAS",
help="Browser profile alias to target (required when multiple browsers are active).",
)
@click.option(
"--remote", default=None, metavar="HOST[:PORT]",
help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.",
"--remote", default=None, metavar="HOST[:PORT]",
help="Connect to a remote browser exposed via 'browser-cli serve'. Domains default to port 443.",
)
@click.option(
"--key", default=None, metavar="PATH",
help="Ed25519 private key PEM for pubkey auth with a remote serve instance.",
"--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, key):
"""Control your running browser from the terminal via a Chrome extension."""
ctx.ensure_object(dict)
ctx.obj["browser"] = browser
ctx.obj["browser_explicit"] = browser is not None
if browser:
os.environ["BROWSER_CLI_PROFILE"] = browser
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
ctx.obj["remote"] = remote
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 key:
os.environ["BROWSER_CLI_KEY"] = key
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
# ── auth ──────────────────────────────────────────────────────────────────────
@click.group("auth")
def auth_group():
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
@auth_group.command("keygen")
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
@click.option("--force", is_flag=True, help="Overwrite existing key.")
def cmd_auth_keygen(output, force):
"""Generate an Ed25519 keypair for pubkey auth."""
from browser_cli.auth import DEFAULT_KEY_PATH, generate_keypair
key_path = Path(output) if output else DEFAULT_KEY_PATH
if key_path.exists() and not force:
console.print(f"[red]Key already exists:[/red] {key_path} (use --force to overwrite)")
sys.exit(1)
pem, pub_hex = generate_keypair()
key_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(pem)
console.print(f"[green]✓[/green] Private key: {key_path}")
console.print(f"\nPublic key:\n [bold cyan]{pub_hex}[/bold cyan]")
console.print(f"\nOn the serve host, trust this key:")
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
@auth_group.command("trust")
@click.argument("pubkey")
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
def cmd_auth_trust(ctx, pubkey, name, keys_file):
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
if len(pubkey) != 64:
console.print("[red]Invalid public key:[/red] expected 64 hex characters (Ed25519 raw public key)")
sys.exit(1)
try:
bytes.fromhex(pubkey)
except ValueError:
console.print("[red]Invalid public key:[/red] not valid hex")
sys.exit(1)
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.trust",
args={"pubkey": pubkey, "name": name},
remote=remote,
key=(ctx.obj or {}).get("key"),
)
added = (result or {}).get("added", False)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
else:
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
added = add_authorized_key(path, pubkey, name)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
console.print(f" File: {path}")
console.print(f"\nStart the server with:")
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
else:
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
@auth_group.command("show")
@click.option("--key", "key_src", default=None, metavar="PATH|agent[:<selector>]",
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.")
def cmd_auth_show(key_src):
"""Print the Ed25519 public key that browser-cli will use for auth."""
from browser_cli.auth import DEFAULT_KEY_PATH, agent_find_key, load_private_key, public_key_hex
src = key_src or os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
if src == "agent" or src.startswith("agent:"):
selector = src[6:] or None
key = agent_find_key(selector)
if key is None:
console.print("[red]No Ed25519 key found in SSH agent.[/red]")
console.print(" Make sure gpg-agent / ssh-agent is running and the key is loaded.")
sys.exit(1)
console.print(f"[dim]source:[/dim] agent ({key.comment})")
console.print(public_key_hex(key))
return
path = Path(src)
if not path.exists():
console.print(f"[red]No key found at {path}[/red]")
console.print(" Run: [dim]browser-cli auth keygen[/dim]")
console.print(" Or use: [dim]browser-cli auth show --key agent[/dim]")
sys.exit(1)
try:
priv = load_private_key(path)
console.print(f"[dim]source:[/dim] {path}")
console.print(public_key_hex(priv))
except Exception as e:
console.print(f"[red]Failed to load key:[/red] {e}")
sys.exit(1)
@auth_group.command("keys")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
def cmd_auth_keys(ctx, keys_file):
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
from rich.table import Table
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.keys",
remote=remote,
key=(ctx.obj or {}).get("key"),
)
entries = result or []
source_label = remote
else:
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
source_label = str(path)
if not entries:
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Public Key")
for entry in entries:
name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", ""))
console.print(table)
main.add_command(auth_group)
"""Control your running browser from the terminal via a Chrome extension."""
ctx.ensure_object(dict)
ctx.obj["browser"] = browser
ctx.obj["browser_explicit"] = browser is not None
if browser:
os.environ["BROWSER_CLI_PROFILE"] = browser
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
ctx.obj["remote"] = remote
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 key:
os.environ["BROWSER_CLI_KEY"] = key
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
# ── Sub-command groups ─────────────────────────────────────────────────────────
main.add_command(auth_group)
main.add_command(nav_group)
main.add_command(tabs_group)
main.add_command(group_group)
@@ -366,241 +122,18 @@ main.add_command(cookies_group)
main.add_command(perf_group)
main.add_command(extension_group)
main.add_command(cmd_serve)
# ── clients ────────────────────────────────────────────────────────────────────
def _append_clients(into, label, *, profile=None, remote=None, key=None):
"""Query clients.list for one target and append each, tagged with *label*."""
result = send_command("clients.list", profile=profile, remote=remote, key=key)
for c in (result or []):
c["profile"] = label
into.append(c)
@click.group("clients", invoke_without_command=True)
@click.pass_context
def clients_group(ctx):
"""Inspect and manage connected browser clients."""
if ctx.invoked_subcommand is not None:
return
all_clients = []
browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
key = (ctx.obj or {}).get("key")
if not remote and browser_alias:
# --browser <host> without --remote: resolve host alias to a remote endpoint,
# then show ALL clients from that remote (not just the one resolved profile).
resolved = remote_target_for_alias(browser_alias)
if resolved:
try:
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:
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
except (BrowserNotConnected, RuntimeError):
continue
elif remote:
try:
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)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
else:
profiles: dict[str, str] = {}
if REGISTRY_PATH.exists():
profiles = load_registry(REGISTRY_PATH)
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
try:
_append_clients(all_clients, display_profile, profile=profile_name)
except (BrowserNotConnected, RuntimeError):
# Socket registered but browser no longer connected
all_clients.append({
"profile": display_profile,
"name": "",
"version": "",
"extensionVersion": "disconnected",
})
for target in active_browser_targets():
if target.remote is None:
continue
try:
_append_clients(all_clients, target.display_name, profile=target.profile, remote=target.remote)
except (BrowserNotConnected, RuntimeError):
continue
if not all_clients:
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
sys.exit(1)
from rich.table import Table
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile")
table.add_column("Browser")
table.add_column("Version")
table.add_column("Extension Version")
for c in all_clients:
table.add_row(
c.get("profile", ""),
c.get("name", ""),
c.get("version", ""),
c.get("extensionVersion", ""),
)
console.print(table)
main.add_command(cmd_link_serve)
main.add_command(clients_group)
@clients_group.command("rename")
@click.option(
"--browser", "target_browser", default=None, metavar="ALIAS",
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
)
@click.argument("alias")
def cmd_clients_rename(target_browser, alias):
"""Set the profile alias used to identify this browser instance."""
try:
_ensure_unique_browser_alias(alias, target_browser)
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
console.print(f"[green]Profile renamed to '{alias}'[/green]")
# ── install ────────────────────────────────────────────────────────────────────
@main.command("install")
@click.argument("browser", type=click.Choice(["chrome", "chromium", "brave", "edge", "vivaldi"]), default="chrome")
def cmd_install(browser):
"""Register the native messaging host and print extension load instructions."""
host_exe = _native_host_exe()
_write_native_host_exe(host_exe)
# Load extension
ext_urls = {
"chrome": "chrome://extensions",
"chromium": "chrome://extensions",
"brave": "brave://extensions",
"edge": "edge://extensions",
"vivaldi": "vivaldi://extensions",
}
ext_url = ext_urls[browser]
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent / 'extension'}[/cyan]")
console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
extension_id = EXTENSION_ID
# Build native messaging manifest
manifest = {
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(host_exe),
"type": "stdio",
"allowed_origins": [f"chrome-extension://{extension_id}/"],
}
installed = []
if is_windows():
manifest_dir = host_exe.parent
manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
installed = _register_windows_native_host(browser, manifest_path)
else:
platform = "darwin" if sys.platform == "darwin" else "linux"
dirs = NATIVE_HOST_DIRS[browser][platform]
for d in dirs:
try:
d.mkdir(parents=True, exist_ok=True)
manifest_path = d / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2))
installed.append(manifest_path)
except Exception as e:
console.print(f"[yellow]Could not write to {d}: {e}[/yellow]")
if not installed:
console.print("[red]Failed to install native host manifest[/red]")
sys.exit(1)
for p in installed:
if is_windows():
console.print(f"[green]✓[/green] Registered native host: {p}")
else:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
main.add_command(cmd_completion)
main.add_command(cmd_install)
# ── native-host (hidden, called by Chrome via native messaging) ────────────────
@main.command("native-host", hidden=True)
def cmd_native_host():
"""Native messaging host — called by Chrome, not for direct use."""
from browser_cli.native_host import main as _main
_main()
# ── completion ─────────────────────────────────────────────────────────────────
@main.command("completion")
@click.argument("shell", type=click.Choice(["zsh", "bash", "fish"]))
@click.option("--script", is_flag=True, help="Output the raw completion script instead of instructions")
def cmd_completion(shell, script):
"""Print shell completion setup instructions (or output the script with --script)."""
if script:
from click.shell_completion import BashComplete, ZshComplete, FishComplete
cls = {"zsh": ZshComplete, "bash": BashComplete, "fish": FishComplete}[shell]
comp = cls(main, {}, "browser-cli", "_BROWSER_CLI_COMPLETE")
click.echo(comp.source())
return
exe = sys.executable.replace("/python", "/browser-cli").replace("/python3", "/browser-cli")
if not Path(exe).exists():
exe = "browser-cli"
env_var = "_BROWSER_CLI_COMPLETE"
if shell == "zsh":
console.print("[bold]Quickest setup — generate the file once:[/bold]")
console.print()
console.print(f" [cyan]uv run browser-cli completion zsh --script > ~/.zfunc/_browser-cli[/cyan]")
console.print()
console.print(" Then add these lines to [bold]~/.zshrc[/bold] (before any compinit call):")
console.print(" [cyan]fpath=(~/.zfunc $fpath)[/cyan]")
console.print(" [cyan]autoload -Uz compinit && compinit[/cyan]")
console.print()
console.print(" Reload: [cyan]exec zsh[/cyan]")
console.print()
console.print("[bold]Alternative — eval on every shell start (simpler but slower):[/bold]")
console.print(f' [cyan]eval "$({env_var}=zsh_source {exe})"[/cyan]')
elif shell == "bash":
console.print("[bold]Quickest setup — generate the file once:[/bold]")
console.print()
console.print(f" [cyan]uv run browser-cli completion bash --script > ~/.bash_completion.d/browser-cli[/cyan]")
console.print()
console.print(" Reload: [cyan]source ~/.bashrc[/cyan]")
console.print()
console.print("[bold]Alternative — eval on every shell start:[/bold]")
console.print(f' [cyan]eval "$({env_var}=bash_source {exe})"[/cyan]')
elif shell == "fish":
console.print("[bold]Setup:[/bold]")
console.print()
console.print(f" [cyan]uv run browser-cli completion fish --script > ~/.config/fish/completions/browser-cli.fish[/cyan]")
"""Native messaging host — called by Chrome, not for direct use."""
from browser_cli.native.host import main as _main
_main()
if __name__ == "__main__":
main()
main()
-352
View File
@@ -1,352 +0,0 @@
"""
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 json
import os
import socket
import struct
import uuid
from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
from pathlib import Path
from typing import Any
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
from browser_cli.registry import load_registry
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
# 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"
@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 _load_remotes() -> dict[str, dict[str, str]]:
if not REMOTE_REGISTRY_PATH.exists():
return {}
try:
data = json.loads(REMOTE_REGISTRY_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
# 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:
return
if not _is_valid_key_spec(key_spec):
return # refuse to save serialized objects or other garbage
remotes = _load_remotes()
current = remotes.get(endpoint, {})
current["key"] = key_spec
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 key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
return None
cfg = _load_remotes().get(endpoint) or {}
key = cfg.get("key")
if not key:
return None
key_str = str(key)
# reject corrupted values (e.g. str(AgentKey(...)) saved by an older bug)
if not _is_valid_key_spec(key_str):
return None
return key_str
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)
targets: list[BrowserTarget] = []
for item in remote_targets 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(key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for endpoint in _load_remotes():
try:
targets.extend(remote_browser_targets(endpoint, key=key))
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) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
if REGISTRY_PATH.exists():
reg = load_registry(REGISTRY_PATH)
targets.extend(
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_endpoints(reg).items()
)
if include_remotes:
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:
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)
# Auto-detect: error when multiple browser instances are active
try:
active = active_browser_targets(include_remotes=False)
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."
)
def _load_private_key(key_path: "Path | str | None" = None):
"""Load an Ed25519 signing key.
Sources (in priority order):
1. Explicit key_path / --key flag
2. BROWSER_CLI_KEY environment variable
3. Default PEM file (~/.config/browser-cli/client.key.pem)
Pass "agent" or "agent:<selector>" to use a key from the SSH agent
(works with YubiKey via gpg-agent, TPM, or regular ssh-agent).
"""
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 # "agent:cardno:..." → "cardno:..."
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
return load_private_key(path)
except Exception:
return None
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) -> 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")
if remote_endpoint:
remote_endpoint = _normalize_endpoint(remote_endpoint)
remote_alias_target = None
if not remote_endpoint and requested_profile and not _is_active_local_profile(requested_profile):
remote_alias_target = remote_target_for_alias(requested_profile)
if remote_alias_target:
remote_endpoint = remote_alias_target.remote
requested_profile = remote_alias_target.profile
msg = {
"id": str(uuid.uuid4()),
"command": command,
"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)
# persist explicit key spec so future calls don't need --key
if key is not None:
save_remote_key(remote_endpoint, str(key))
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, key=key_spec)
if route_profile:
msg["_route"] = route_profile
else:
private_key = None
try:
if remote_endpoint:
response = _send_remote(remote_endpoint, msg, private_key)
elif is_windows():
payload = json.dumps(msg).encode("utf-8")
sock_path = _resolve_socket(profile)
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
response = conn.recv_bytes()
else:
payload = json.dumps(msg).encode("utf-8")
sock_path = _resolve_socket(profile)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(struct.pack("<I", len(payload)) + payload)
response = _recv_all(sock)
except (FileNotFoundError, ConnectionRefusedError, OSError):
if remote_endpoint:
raise BrowserNotConnected(
f"Cannot connect to remote browser at {remote_endpoint}.\n"
"Make sure browser-cli serve is running on the remote host."
)
profile_hint = f" (profile: {profile})" if profile else ""
raise 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"
)
if response is None:
raise ConnectionError("Connection closed before full response received")
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")
+47
View File
@@ -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",
]
+63
View File
@@ -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
+200
View File
@@ -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)
+45
View File
@@ -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."
)
+95
View File
@@ -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."
)
+1 -2
View File
@@ -13,11 +13,10 @@ from rich.table import Table
from browser_cli import BrowserCLI, BrowserCounts
from browser_cli.client import BrowserNotConnected
from browser_cli.constants import GENTLE_MODES
_console = Console()
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
# Reusable ``--tab`` option: select a tab by ID (default: the active tab).
tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab ID (default: active tab)")
+157
View File
@@ -0,0 +1,157 @@
"""Click commands for browser-cli remote authentication keys."""
from __future__ import annotations
import os
import sys
from pathlib import Path
import click
from rich.console import Console
console = Console()
@click.group("auth")
def auth_group():
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
@auth_group.command("keygen")
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
@click.option("--force", is_flag=True, help="Overwrite existing key.")
def cmd_auth_keygen(output, force):
"""Generate an Ed25519 keypair for pubkey auth."""
from browser_cli.auth import DEFAULT_KEY_PATH, generate_keypair
key_path = Path(output) if output else DEFAULT_KEY_PATH
if key_path.exists() and not force:
console.print(f"[red]Key already exists:[/red] {key_path} (use --force to overwrite)")
sys.exit(1)
pem, pub_hex = generate_keypair()
key_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(pem)
console.print(f"[green]✓[/green] Private key: {key_path}")
console.print(f"\nPublic key:\n [bold cyan]{pub_hex}[/bold cyan]")
console.print("\nOn the serve host, trust this key:")
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
@auth_group.command("trust")
@click.argument("pubkey")
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
def cmd_auth_trust(ctx, pubkey, name, keys_file):
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
if len(pubkey) != 64:
console.print("[red]Invalid public key:[/red] expected 64 hex characters (Ed25519 raw public key)")
sys.exit(1)
try:
bytes.fromhex(pubkey)
except ValueError:
console.print("[red]Invalid public key:[/red] not valid hex")
sys.exit(1)
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.trust",
args={"pubkey": pubkey, "name": name},
remote=remote,
key=(ctx.obj or {}).get("key"),
)
added = (result or {}).get("added", False)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
else:
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
added = add_authorized_key(path, pubkey, name)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
console.print(f" File: {path}")
console.print("\nStart the server with:")
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
else:
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
@auth_group.command("show")
@click.option(
"--key",
"key_src",
default=None,
metavar="PATH|agent[:<selector>]",
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.",
)
def cmd_auth_show(key_src):
"""Print the Ed25519 public key that browser-cli will use for auth."""
from browser_cli.auth import DEFAULT_KEY_PATH, agent_find_key, load_private_key, public_key_hex
src = key_src or os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
if src == "agent" or src.startswith("agent:"):
selector = src[6:] or None
key = agent_find_key(selector)
if key is None:
console.print("[red]No Ed25519 key found in SSH agent.[/red]")
console.print(" Make sure gpg-agent / ssh-agent is running and the key is loaded.")
sys.exit(1)
console.print(f"[dim]source:[/dim] agent ({key.comment})")
console.print(public_key_hex(key))
return
path = Path(src)
if not path.exists():
console.print(f"[red]No key found at {path}[/red]")
console.print(" Run: [dim]browser-cli auth keygen[/dim]")
console.print(" Or use: [dim]browser-cli auth show --key agent[/dim]")
sys.exit(1)
try:
priv = load_private_key(path)
console.print(f"[dim]source:[/dim] {path}")
console.print(public_key_hex(priv))
except Exception as e:
console.print(f"[red]Failed to load key:[/red] {e}")
sys.exit(1)
@auth_group.command("keys")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
def cmd_auth_keys(ctx, keys_file):
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
from rich.table import Table
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.keys",
remote=remote,
key=(ctx.obj or {}).get("key"),
)
entries = result or []
source_label = remote
else:
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
source_label = str(path)
if not entries:
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Public Key")
for entry in entries:
name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", ""))
console.print(table)
+170
View File
@@ -0,0 +1,170 @@
"""Click commands for inspecting connected browser clients."""
from __future__ import annotations
import os
import sys
import click
from rich.console import Console
from browser_cli.client import (
BrowserNotConnected,
REGISTRY_PATH,
active_browser_targets,
display_browser_name,
remote_browser_targets,
remote_target_for_alias,
send_command,
)
from browser_cli.registry import load_registry
console = Console()
def _rename_target_profile(target_browser: str | None) -> str | None:
if target_browser:
return target_browser
active = active_browser_targets()
if len(active) == 1:
return active[0].profile
return None
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
target_profile = _rename_target_profile(target_browser)
profiles: dict[str, str] = load_registry(REGISTRY_PATH)
if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists")
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False):
"""Query clients.list for one target and append each, tagged with *label*."""
if quiet_remote_warning:
result = send_command(
"clients.list",
profile=profile,
remote=remote,
key=key,
suppress_pq_warning=True,
)
else:
result = send_command("clients.list", profile=profile, remote=remote, key=key)
for c in (result or []):
c["profile"] = label
into.append(c)
@click.group("clients", invoke_without_command=True)
@click.pass_context
def clients_group(ctx):
"""Inspect and manage connected browser clients."""
if ctx.invoked_subcommand is not None:
return
all_clients = []
browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
key = (ctx.obj or {}).get("key")
if not remote and browser_alias:
_collect_remote_alias_clients(all_clients, browser_alias, key)
elif remote:
_collect_explicit_remote_clients(all_clients, browser_alias, remote, key)
else:
_collect_local_and_saved_remote_clients(all_clients)
if not all_clients:
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
sys.exit(1)
_print_clients(all_clients)
def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) -> None:
resolved = remote_target_for_alias(browser_alias)
if not resolved:
return
try:
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:
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key)
except (BrowserNotConnected, RuntimeError):
continue
def _collect_explicit_remote_clients(all_clients: list, browser_alias: str | None, remote: str, key) -> None:
try:
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)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
profiles: dict[str, str] = load_registry(REGISTRY_PATH) if REGISTRY_PATH.exists() else {}
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
try:
_append_clients(all_clients, display_profile, profile=profile_name)
except (BrowserNotConnected, RuntimeError):
all_clients.append({
"profile": display_profile,
"name": "",
"version": "",
"extensionVersion": "disconnected",
})
targets = active_browser_targets(suppress_pq_warning=True)
for target in targets:
if target.remote is None:
continue
try:
_append_clients(
all_clients,
target.display_name,
profile=target.profile,
remote=target.remote,
quiet_remote_warning=True,
)
except (BrowserNotConnected, RuntimeError):
continue
def _print_clients(all_clients: list) -> None:
from rich.table import Table
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile")
table.add_column("Browser")
table.add_column("Version")
table.add_column("Extension Version")
for c in all_clients:
table.add_row(
c.get("profile", ""),
c.get("name", ""),
c.get("version", ""),
c.get("extensionVersion", ""),
)
console.print(table)
@clients_group.command("rename")
@click.option(
"--browser",
"target_browser",
default=None,
metavar="ALIAS",
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
)
@click.argument("alias")
def cmd_clients_rename(target_browser, alias):
"""Set the profile alias used to identify this browser instance."""
try:
_ensure_unique_browser_alias(alias, target_browser)
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
console.print(f"[green]Profile renamed to '{alias}'[/green]")
+56
View File
@@ -0,0 +1,56 @@
"""Shell completion command."""
from __future__ import annotations
import sys
from pathlib import Path
import click
from rich.console import Console
console = Console()
@click.command("completion")
@click.argument("shell", type=click.Choice(["zsh", "bash", "fish"]))
@click.option("--script", is_flag=True, help="Output the raw completion script instead of instructions")
@click.pass_context
def cmd_completion(ctx, shell, script):
"""Print shell completion setup instructions (or output the script with --script)."""
if script:
from click.shell_completion import BashComplete, FishComplete, ZshComplete
cls = {"zsh": ZshComplete, "bash": BashComplete, "fish": FishComplete}[shell]
comp = cls(ctx.find_root().command, {}, "browser-cli", "_BROWSER_CLI_COMPLETE")
click.echo(comp.source())
return
exe = sys.executable.replace("/python", "/browser-cli").replace("/python3", "/browser-cli")
if not Path(exe).exists():
exe = "browser-cli"
env_var = "_BROWSER_CLI_COMPLETE"
if shell == "zsh":
console.print("[bold]Quickest setup — generate the file once:[/bold]")
console.print()
console.print(" [cyan]uv run browser-cli completion zsh --script > ~/.zfunc/_browser-cli[/cyan]")
console.print()
console.print(" Then add these lines to [bold]~/.zshrc[/bold] (before any compinit call):")
console.print(" [cyan]fpath=(~/.zfunc $fpath)[/cyan]")
console.print(" [cyan]autoload -Uz compinit && compinit[/cyan]")
console.print()
console.print(" Reload: [cyan]exec zsh[/cyan]")
console.print()
console.print("[bold]Alternative — eval on every shell start (simpler but slower):[/bold]")
console.print(f' [cyan]eval "$({env_var}=zsh_source {exe})"[/cyan]')
elif shell == "bash":
console.print("[bold]Quickest setup — generate the file once:[/bold]")
console.print()
console.print(" [cyan]uv run browser-cli completion bash --script > ~/.bash_completion.d/browser-cli[/cyan]")
console.print()
console.print(" Reload: [cyan]source ~/.bashrc[/cyan]")
console.print()
console.print("[bold]Alternative — eval on every shell start:[/bold]")
console.print(f' [cyan]eval "$({env_var}=bash_source {exe})"[/cyan]')
elif shell == "fish":
console.print("[bold]Setup:[/bold]")
console.print()
console.print(" [cyan]uv run browser-cli completion fish --script > ~/.config/fish/completions/browser-cli.fish[/cyan]")
-3
View File
@@ -2,9 +2,6 @@ import json
import click
from browser_cli.commands import client_from_ctx, handle_errors
# Re-exported for backward compatibility: the HTML→Markdown engine now lives in
# browser_cli.markdown and is applied by the SDK (ExtractNS.markdown).
from browser_cli.markdown import _clean_markdown_output, _convert_html_to_markdown # noqa: F401
from rich.console import Console
from rich.table import Table
+118
View File
@@ -0,0 +1,118 @@
"""Native Messaging host installation command."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import click
from rich.console import Console
from browser_cli.constants import (
EXTENSION_ID,
NATIVE_HOST_DIRS,
NATIVE_HOST_NAME,
SUPPORTED_BROWSERS,
WINDOWS_NATIVE_HOST_REGISTRY_KEYS,
)
from browser_cli.platform import install_base_dir, is_windows
console = Console()
def native_host_exe() -> Path:
base = install_base_dir()
if is_windows():
return base / "libexec" / "browser-cli-native-host.cmd"
return base / "libexec" / "browser-cli-native-host"
def write_native_host_exe(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if is_windows():
path.write_text(
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native.host import main; main()" %*\r\n',
encoding="utf-8",
)
else:
path.write_text(f'#!{sys.executable}\nfrom browser_cli.native.host import main\nmain()\n')
path.chmod(path.stat().st_mode | 0o111)
def _windows_registry_views():
import winreg
return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)]
def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]:
import winreg
installed = []
for key_path in WINDOWS_NATIVE_HOST_REGISTRY_KEYS[browser]:
full_key = f"{key_path}\\{NATIVE_HOST_NAME}"
for view in _windows_registry_views():
try:
access = winreg.KEY_WRITE | view
key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, full_key, 0, access)
with key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(manifest_path))
installed.append(f"HKCU\\{full_key}")
except OSError as e:
console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]")
return installed
@click.command("install")
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
def cmd_install(browser):
"""Register the native messaging host and print extension load instructions."""
host_exe = native_host_exe()
write_native_host_exe(host_exe)
ext_url = {
"chrome": "chrome://extensions",
"chromium": "chrome://extensions",
"brave": "brave://extensions",
"edge": "edge://extensions",
"vivaldi": "vivaldi://extensions",
}[browser]
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
manifest = {
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(host_exe),
"type": "stdio",
"allowed_origins": [f"chrome-extension://{EXTENSION_ID}/"],
}
installed = _install_manifest(browser, host_exe, manifest)
if not installed:
console.print("[red]Failed to install native host manifest[/red]")
sys.exit(1)
for p in installed:
label = "Registered native host" if is_windows() else "Wrote native host manifest"
console.print(f"[green]✓[/green] {label}: {p}")
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
if is_windows():
manifest_dir = host_exe.parent
manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return _register_windows_native_host(browser, manifest_path)
platform = "darwin" if sys.platform == "darwin" else "linux"
installed = []
for directory in NATIVE_HOST_DIRS[browser][platform]:
try:
directory.mkdir(parents=True, exist_ok=True)
manifest_path = directory / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2))
installed.append(manifest_path)
except Exception as e:
console.print(f"[yellow]Could not write to {directory}: {e}[/yellow]")
return installed
+95 -316
View File
@@ -1,338 +1,117 @@
import re, threading, secrets, socket, struct, click, json, sys
from datetime import datetime
"""Click command for exposing a browser over TCP."""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from rich.console import Console
import click
from browser_cli import transport
from browser_cli.client import _recv_exact, _recv_all
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, MAX_MSG_BYTES, parse_version, get_installed_version
_UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
console = Console()
def _framed_send(sock: socket.socket, data: bytes) -> None:
sock.sendall(struct.pack("<I", len(data)) + data)
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
ts = datetime.now().strftime("%H:%M:%S")
addr_str = f"{addr[0]}:{addr[1]}"
profile_str = f"[dim]{profile}[/dim] " if profile else ""
if error:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
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, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None, compress:bool=True) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
response_secret = None
accept_encoding = None # set once the (decrypted) request is parsed; None → plain JSON
def _send_payload(data: bytes) -> None:
if response_secret is not None:
from browser_cli.auth import pq_encrypt
data = json.dumps({"encrypted": pq_encrypt(response_secret, "response", data)}).encode()
_framed_send(client_sock, data)
def _send_error(msg_id, msg:str) -> None:
# errors stay plain JSON: tiny, and safe for any client
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
_send_payload(err)
except OSError:
pass
def _send_ok(msg_id, payload, command=None) -> None:
obj = {"id": msg_id, "success": True, "data": payload}
try:
_send_payload(transport.encode_response(obj, accept_encoding if compress else None, command))
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):
_send_error(None, "invalid JSON")
_log(addr, "?", None, "ERROR", "invalid JSON")
return
# ── user-agent + version check ────────────────────────────────────────────
msg_id = msg.get("id")
ua = msg.get("user_agent") or ""
if not _UA_PATTERN.match(ua):
_send_error(msg_id, "forbidden: client required")
_log(addr, msg.get("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, msg.get("command", "?"), None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
return
except (IndexError, ValueError):
pass
msg = adapt_auth(msg, client_ver)
command = msg.get("command", "?")
# ── auth ──────────────────────────────────────────────────────────────────
if auth_keys is not None:
pub = msg.get("pubkey") or ""
sig = msg.get("sig") or ""
if not pub or not sig:
_send_error(msg_id, "unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
_log(addr, command, None, "DENIED", "missing pubkey/sig")
return
if pub not in auth_keys:
_send_error(msg_id, "unauthorized: untrusted public key")
_log(addr, command, None, "DENIED", "untrusted key")
return
pq_shared_secret = None
transport_encrypted = False
if pq_private_key is not None:
kex = msg.get("pq_kex") or {}
pq_required = parse_version(client_ver) >= parse_version("0.9.5")
if not isinstance(kex, dict) or kex.get("alg") != "ML-KEM-768" or not kex.get("ciphertext"):
if pq_required:
_send_error(msg_id, "unauthorized: post-quantum key exchange required")
_log(addr, command, None, "DENIED", "missing pq kex")
return
else:
try:
from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
pq_shared_secret = pq_kex_server_decapsulate(pq_private_key, str(kex["ciphertext"]))
if "encrypted" in msg:
decrypted_msg = json.loads(pq_decrypt(pq_shared_secret, "request", msg["encrypted"]))
if not isinstance(decrypted_msg, dict):
raise ValueError("encrypted request is not a JSON object")
decrypted_msg["pubkey"] = pub
decrypted_msg["sig"] = sig
decrypted_msg["pq_kex"] = kex
msg = adapt_auth(decrypted_msg, client_ver)
msg_id = msg.get("id", msg_id)
command = msg.get("command", "?")
transport_encrypted = True
elif pq_required:
_send_error(msg_id, "unauthorized: post-quantum encrypted transport required")
_log(addr, command, None, "DENIED", "missing pq transport")
return
except Exception:
_send_error(msg_id, "unauthorized: invalid post-quantum encrypted transport")
_log(addr, command, None, "DENIED", "bad pq transport")
return
from browser_cli.auth import verify
if not verify(pub, bytes.fromhex(nonce), msg, sig, pq_shared_secret):
_send_error(msg_id, "unauthorized: invalid signature")
_log(addr, command, None, "DENIED", "bad signature")
return
response_secret = pq_shared_secret if transport_encrypted else None
# client advertises what response encodings it can decode (signed, then stripped)
accept_encoding = msg.get("accept_encoding")
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
targets = [
{"profile": target.profile, "displayName": target.display_name}
for target in active_browser_targets(include_remotes=False)
]
_send_ok(msg_id, targets, command)
_log(addr, command, None, "OK")
return
if command == "browser-cli.auth.keys":
if auth_keys_path is None:
_send_error(msg_id, "no authorized keys file configured on this server")
_log(addr, command, None, "ERROR", "no authorized keys file")
return
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
_send_ok(msg_id, entries, command)
_log(addr, command, None, "OK")
return
if command == "browser-cli.auth.trust":
if auth_keys_path is None:
_send_error(msg_id, "no authorized keys file configured on this server")
_log(addr, command, None, "ERROR", "no authorized keys file")
return
from browser_cli.auth import add_authorized_key
args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "")
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
_send_error(msg_id, "invalid pubkey: expected 64 lowercase hex characters")
_log(addr, command, None, "ERROR", "invalid pubkey")
return
added = add_authorized_key(auth_keys_path, pubkey, name)
_send_ok(msg_id, {"added": added}, command)
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
return
resolved_profile = msg.get("_route") or profile
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
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)
except BrowserNotConnected as e:
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", "browser not connected")
return
try:
if is_windows():
from multiprocessing.connection import Client as PipeClient
with PipeClient(sock_path, family="AF_PIPE") as pipe:
pipe.send_bytes(clean_payload)
resp_payload = pipe.recv_bytes()
resp_payload = adapt_response(resp_payload, command, client_ver)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
local.connect(sock_path)
local.sendall(clean_header + clean_payload)
resp_payload = _recv_all(local)
resp_payload = adapt_response(resp_payload, command, client_ver)
# parse once: drives both the access log and (re-)encoding for the client
resp_data = json.loads(resp_payload)
if compress:
_send_payload(transport.encode_response(resp_data, accept_encoding, command))
else:
_send_payload(resp_payload)
if resp_data.get("success", True):
_log(addr, command, resolved_profile, "OK")
else:
_log(addr, command, resolved_profile, "ERROR", resp_data.get("error", ""))
except (OSError, json.JSONDecodeError) as e:
_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, auth_keys_path:"Path|None", compress:bool=True) -> None:
if not _CONN_LIMIT.acquire(blocking=False):
client_sock.close()
return
client_sock.settimeout(30)
try:
with client_sock:
# reload on every connection so auth trust --remote takes effect immediately
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
auth_keys: list[str] | None = load_authorized_keys(auth_keys_path)
else:
auth_keys = None
nonce = secrets.token_hex(32)
pq_private_key = None
challenge_msg = {
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}
if auth_keys_path is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
pq_keypair = pq_kex_server_keypair()
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
challenge = json.dumps(challenge_msg).encode()
try:
_framed_send(client_sock, challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
finally:
_CONN_LIMIT.release()
from browser_cli.serve.runtime import (
_async_framed_send,
_async_handle_client,
_async_recv_all,
_handle_client,
_serve_async,
console,
)
from browser_cli.version_manager import get_installed_version
__all__ = [
"_async_framed_send",
"_async_handle_client",
"_async_recv_all",
"_handle_client",
"_serve_async",
"cmd_serve",
]
@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("--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). Required unless --no-auth.")
@click.option("--no-compress", "no_compress", is_flag=True, default=False,
help="Disable response compression / msgpack even for clients that support it.")
@click.option(
"--authorized-keys",
"auth_keys_file",
default=None,
metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
)
@click.option(
"--no-compress",
"no_compress",
is_flag=True,
default=False,
help="Disable response compression / msgpack even for clients that support it.",
)
@click.pass_context
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
if host in ("0.0.0.0", "::"):
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
if host in ("0.0.0.0", "::"):
console.print(
"[yellow]Warning:[/yellow] Binding to all interfaces — "
"anyone who can reach this port controls your browser."
)
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
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}")
elif no_auth:
auth_keys_path = None
else:
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
sys.exit(1)
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
if auth_keys_path is False:
sys.exit(1)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server.bind((host, port))
except OSError as e:
server.close()
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
server.listen(16)
_print_startup(host, port, profile, auth_keys_path, compress)
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] [dim]v{current_ver}[/dim]")
try:
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
except OSError as e:
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
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}")
return auth_keys_path
if no_auth:
return None
console.print(
"[red]Error:[/red] --authorized-keys FILE is required. "
"Use --no-auth to explicitly disable auth (dangerous)."
)
return False
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
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] [dim]v{current_ver}[/dim]")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
else:
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path))
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]")
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]")
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
_print_encoding_status(compress)
console.print("Ctrl-C to stop.\n")
if compress:
def _print_encoding_status(compress: bool) -> None:
if not compress:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
return
codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization())
console.print(f" Encode: [green]on[/green] [dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]")
else:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
console.print("Ctrl-C to stop.\n")
try:
while True:
conn, addr = server.accept()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path, compress), daemon=True).start()
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
finally:
server.close()
console.print(
" Encode: [green]on[/green] "
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
)
+94
View File
@@ -0,0 +1,94 @@
"""Project-wide constants for browser-cli.
Only static values live here. Runtime-derived state (e.g. installed version,
open sockets, locks) stays in the owning module.
"""
from __future__ import annotations
import os
from pathlib import Path
APP_NAME = "browser-cli"
RUNTIME_DIRNAME = ".browser_cli"
DEFAULT_ALIAS = "default"
NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
SUPPORTED_BROWSERS = ["chrome", "chromium", "brave", "edge", "vivaldi"]
PROTOCOL_MIN_CLIENT = "0.9.0"
MAX_MSG_BYTES = 32 * 1024 * 1024
DEFAULT_REMOTE_PORT = 443
DEFAULT_PAGE_SIZE = 100
DEFAULT_TRANSPORT_THRESHOLD = 512
NO_ROUTE_COMMANDS = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
GENTLE_MODES = ["auto", "normal", "gentle", "ultra"]
PAGEABLE_COMMANDS = {
"tabs.list",
"tabs.filter",
"tabs.query",
"group.list",
"group.tabs",
"group.query",
"windows.list",
"dom.query",
"dom.text",
"dom.attr",
"extract.links",
"extract.images",
"extract.json",
"cookies.list",
"session.list",
}
NATIVE_HOST_DIRS = {
"chrome": {
"linux": [Path.home() / ".config/google-chrome/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Google/Chrome/NativeMessagingHosts"],
},
"chromium": {
"linux": [Path.home() / ".config/chromium/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Chromium/NativeMessagingHosts"],
},
"brave": {
"linux": [Path.home() / ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts"],
},
"edge": {
"linux": [Path.home() / ".config/microsoft-edge/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Microsoft Edge/NativeMessagingHosts"],
},
"vivaldi": {
"linux": [Path.home() / ".config/vivaldi/NativeMessagingHosts"],
"darwin": [Path.home() / "Library/Application Support/Vivaldi/NativeMessagingHosts"],
},
}
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"chrome": [r"Software\Google\Chrome\NativeMessagingHosts"],
"chromium": [r"Software\Chromium\NativeMessagingHosts"],
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
}
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / APP_NAME
DEFAULT_KEY_PATH = CONFIG_DIR / "client.key.pem"
DEFAULT_AUTHORIZED_KEYS_PATH = CONFIG_DIR / "authorized_keys"
SSH_AGENTC_REQUEST_IDENTITIES = 11
SSH_AGENT_IDENTITIES_ANSWER = 12
SSH_AGENTC_SIGN_REQUEST = 13
SSH_AGENT_SIGN_RESPONSE = 14
PQ_KEX_ALG = "ML-KEM-768"
PQ_TRANSPORT_ALG = "ML-KEM-768+ChaCha20Poly1305"
SER_JSON = 0
SER_MSGPACK = 1
COMP_NONE = 0
COMP_ZLIB = 1
COMP_GZIP = 2
COMP_ZSTD = 3
+3 -5
View File
@@ -2,17 +2,15 @@
Pure helpers (no sockets, no I/O) for turning user-facing ``host[:port]``
strings into the canonical forms the rest of the client uses, and back into the
short forms shown to humans. Re-exported from :mod:`browser_cli.client` for
backward compatibility.
short forms shown to humans.
"""
from __future__ import annotations
import re
from browser_cli.constants import DEFAULT_REMOTE_PORT
from browser_cli.errors import BrowserNotConnected
_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"}:
@@ -35,7 +33,7 @@ def _resolve_connect_endpoint(endpoint: str) -> str:
_, sep, _ = endpoint.rpartition(":")
if not sep:
if _looks_like_domain(endpoint):
return f"{endpoint}:{_DEFAULT_REMOTE_PORT}"
return f"{endpoint}:{DEFAULT_REMOTE_PORT}"
raise BrowserNotConnected(
f"Invalid remote endpoint '{endpoint}': expected host:port"
)
+2 -3
View File
@@ -1,8 +1,7 @@
"""Shared exception types for the browser-cli client stack.
Kept dependency-free so the transport/endpoint modules and ``client`` itself can
import it without creating an import cycle. ``BrowserNotConnected`` is re-exported
from :mod:`browser_cli.client` for backward compatibility.
Kept dependency-free so the transport, endpoint, and client modules can import
it without creating an import cycle.
"""
from __future__ import annotations
+83
View File
@@ -0,0 +1,83 @@
"""Length-prefixed byte framing used by browser-cli transports.
Frame format is shared by local IPC and remote TCP: 4-byte little-endian payload
length followed by raw payload bytes. Native Messaging stdio has the same length
prefix but JSON helpers stay in ``browser_cli.native.host`` because they operate
on file streams, not sockets/asyncio streams.
"""
from __future__ import annotations
import asyncio
import struct
from typing import Protocol
from browser_cli.version_manager import MAX_MSG_BYTES
class RecvSocket(Protocol):
def recv(self, n: int) -> bytes: ...
class SendSocket(Protocol):
def sendall(self, data: bytes) -> None: ...
def frame(data: bytes) -> bytes:
"""Return *data* with the browser-cli 4-byte little-endian length prefix."""
return struct.pack("<I", len(data)) + data
def _message_length(raw_len: bytes, *, label: str) -> int:
msg_len = struct.unpack("<I", raw_len)[0]
if msg_len > MAX_MSG_BYTES:
raise ConnectionError(f"{label} too large ({msg_len} bytes)")
return msg_len
def recv_exact(sock: RecvSocket, n: int, *, allow_eof: bool = False) -> bytes | None:
"""Read exactly *n* bytes from a blocking socket-like object.
Returns ``None`` on EOF when ``allow_eof=True``; otherwise raises
``ConnectionError``.
"""
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
if allow_eof:
return None
raise ConnectionError("Socket closed before full message received")
buf += chunk
return buf
def recv_frame(sock: RecvSocket, *, allow_eof: bool = False, label: str = "Message") -> bytes | None:
"""Read one framed payload from a blocking socket-like object."""
raw_len = recv_exact(sock, 4, allow_eof=allow_eof)
if raw_len is None:
return None
return recv_exact(sock, _message_length(raw_len, label=label), allow_eof=allow_eof)
def send_frame(sock: SendSocket, data: bytes) -> None:
"""Send one framed payload through a blocking socket-like object."""
sock.sendall(frame(data))
async def async_recv_exact(reader: asyncio.StreamReader, n: int, *, allow_eof: bool = False) -> bytes | None:
"""Read exactly *n* bytes from an asyncio StreamReader."""
try:
return await reader.readexactly(n)
except asyncio.IncompleteReadError as exc:
if allow_eof:
return None
raise ConnectionError("Socket closed before full message received") from exc
async def async_recv_frame(
reader: asyncio.StreamReader,
*,
allow_eof: bool = False,
label: str = "Message",
) -> bytes | None:
"""Read one framed payload from an asyncio StreamReader."""
raw_len = await async_recv_exact(reader, 4, allow_eof=allow_eof)
if raw_len is None:
return None
return await async_recv_exact(reader, _message_length(raw_len, label=label), allow_eof=allow_eof)
async def async_send_frame(writer: asyncio.StreamWriter, data: bytes) -> None:
"""Send one framed payload through an asyncio StreamWriter."""
writer.write(frame(data))
await writer.drain()
+64
View File
@@ -0,0 +1,64 @@
"""Local IPC transport for browser-cli client commands."""
from __future__ import annotations
import asyncio
import socket
from collections.abc import Callable
from multiprocessing.connection import Client as PipeClient
from browser_cli.framing import async_recv_exact as framing_async_recv_exact
from browser_cli.framing import async_recv_frame, async_send_frame, frame
from browser_cli.platform import is_windows
from browser_cli.remote.transport import _recv_all
async def async_recv_exact(reader: asyncio.StreamReader, n: int) -> bytes | None:
try:
return await framing_async_recv_exact(reader, n, allow_eof=True)
except ConnectionError:
return None
async def async_recv_all(reader: asyncio.StreamReader) -> bytes | None:
try:
return await async_recv_frame(reader, allow_eof=True)
except ConnectionError:
return None
async def async_send_all(writer: asyncio.StreamWriter, data: bytes) -> None:
await async_send_frame(writer, data)
def send_local_sync(profile: str | None, payload: bytes, resolve_socket: Callable[[str | None], str]) -> bytes | None:
sock_path = resolve_socket(profile)
if is_windows():
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
return conn.recv_bytes()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(frame(payload))
return _recv_all(sock)
async def send_local_async(profile: str | None, payload: bytes, resolve_socket: Callable[[str | None], str]) -> bytes | None:
sock_path = await asyncio.to_thread(resolve_socket, profile)
if is_windows():
return await _send_windows_pipe_async(sock_path, payload)
return await _send_local_unix_async(sock_path, payload)
async def _send_local_unix_async(sock_path: str, payload: bytes) -> bytes | None:
reader, writer = await asyncio.open_unix_connection(sock_path)
try:
await async_send_all(writer, payload)
return await async_recv_all(reader)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
async def _send_windows_pipe_async(sock_path: str, payload: bytes) -> bytes:
def _roundtrip():
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
return conn.recv_bytes()
return await asyncio.to_thread(_roundtrip)
-413
View File
@@ -1,413 +0,0 @@
"""HTML → Markdown conversion and Markdown clean-up.
Pure, presentation-agnostic text transforms shared by the SDK
(:meth:`browser_cli.sdk.dom.ExtractNS.markdown`) and the ``extract markdown``
CLI command. No Click/Rich/IPC dependencies — just an HTML tree walker plus a
set of repair passes for the markdown the page (or a markdown editor like
Obsidian/CodeMirror) hands back.
"""
from __future__ import annotations
import re
from html.parser import HTMLParser
_FENCE_RE = re.compile(r"```(?:[^\n`]*)\n.*?\n```", re.DOTALL)
_ESCAPED_MARKDOWN_RE = re.compile(r"\\([_-])")
_TABLE_SEPARATOR_RE = re.compile(r"^\|(?:\s*:?-{3,}:?\s*\|)+\s*$")
class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag
self.attrs = attrs or {}
self.text = text
self.children = []
class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.root = _HtmlNode(tag="document")
self._stack = [self.root]
def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS:
self._stack.append(node)
def handle_startendtag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
def handle_endtag(self, tag):
lowered = tag.lower()
for index in range(len(self._stack) - 1, 0, -1):
if self._stack[index].tag == lowered:
del self._stack[index:]
break
def handle_data(self, data):
if data:
self._stack[-1].children.append(_HtmlNode(text=data))
def _normalize_text(value):
return re.sub(r"\s+", " ", value or "").strip()
def _normalize_inline(value):
value = value.replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return value.strip()
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
def _iter_descendants(node):
for child in getattr(node, "children", []):
yield child
yield from _iter_descendants(child)
def _has_class(node, class_name):
classes = (node.attrs.get("class") or "").split()
return class_name in classes
def _is_code_block_node(node):
if not node or not node.tag:
return False
if node.attrs.get("data-is-code-block-view") == "true":
return True
return node.tag == "pre"
def _inline_text(node):
if node.text is not None:
return _escape_markdown(node.text)
if not node.tag:
return ""
tag = node.tag
if tag == "br":
return "\n"
if tag == "img":
src = node.attrs.get("src") or ""
alt = _normalize_text(node.attrs.get("alt") or "")
if not src:
return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or ""
return f"[{text or href}]({href})" if href else text
if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"`{text.replace('`', r'\\`')}`" if text else ""
if tag in {"strong", "b"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"**{text}**" if text else ""
if tag in {"em", "i"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"*{text}*" if text else ""
chunks = []
for child in node.children:
rendered = _inline_text(child)
if rendered:
chunks.append(rendered)
if child.tag in {"p", "div", "table", "ul", "ol", "pre"}:
chunks.append("\n")
return "".join(chunks)
def _text_block(node):
return _collapse_blank_lines(_normalize_inline("".join(_inline_text(child) for child in node.children)))
def _inner_text_preserve(node):
if node.text is not None:
return node.text
if not node.tag:
return ""
if node.tag == "br":
return ""
return "".join(_inner_text_preserve(child) for child in node.children)
def _table_to_markdown(node):
rows = []
for descendant in _iter_descendants(node):
if descendant.tag != "tr":
continue
row = []
for cell in descendant.children:
if cell.tag in {"td", "th"}:
row.append(_escape_table_cell(_text_block(cell)))
if row:
rows.append(row)
if not rows:
return ""
widths = max(len(row) for row in rows)
normalized_rows = [row + [""] * (widths - len(row)) for row in rows]
headers = normalized_rows[0]
body_rows = normalized_rows[1:]
first_row_blank = all(not cell.strip() for cell in headers)
if first_row_blank and len(normalized_rows) > 1:
headers = normalized_rows[1]
body_rows = normalized_rows[2:]
has_thead = any(child.tag == "thead" for child in node.children)
first_row = next((child for child in _iter_descendants(node) if child.tag == "tr"), None)
first_row_has_th = bool(first_row and any(child.tag == "th" for child in first_row.children))
if not (has_thead or first_row_has_th or first_row_blank):
headers = [""] * widths
body_rows = normalized_rows
separator = ["---"] * widths
lines = [
f"| {' | '.join(headers)} |",
f"| {' | '.join(separator)} |",
]
lines.extend(f"| {' | '.join(row)} |" for row in body_rows)
return "\n".join(lines)
def _list_to_markdown(node, depth=0):
ordered = node.tag == "ol"
items = []
index = 1
for child in node.children:
if child.tag != "li":
continue
marker = f"{index}. " if ordered else "- "
index += 1
content = []
nested = []
for item_child in child.children:
if item_child.tag in {"ul", "ol"}:
nested.append(_list_to_markdown(item_child, depth + 1))
else:
content.append(_inline_text(item_child))
line = _collapse_blank_lines(_normalize_inline("".join(content)))
indent = " " * depth
if line:
line_parts = line.splitlines()
items.append(f"{indent}{marker}{line_parts[0]}")
continuation_indent = f"{indent}{' ' * len(marker)}"
items.extend(f"{continuation_indent}{part}" for part in line_parts[1:])
items.extend(block for block in nested if block)
return "\n".join(items)
def _code_block_to_markdown(node):
if node.tag == "pre":
text = _inner_text_preserve(node).rstrip("\n")
return f"```\n{text}\n```" if text else ""
lines = []
for descendant in _iter_descendants(node):
if descendant.tag and _has_class(descendant, "cm-line"):
lines.append(_inner_text_preserve(descendant))
code = "\n".join(lines).rstrip("\n")
return f"```\n{code}\n```" if code else ""
def _block_to_markdown(node):
if node.text is not None:
return _normalize_text(node.text)
if not node.tag:
return ""
if _is_code_block_node(node):
return _code_block_to_markdown(node)
if node.tag == "table":
return _table_to_markdown(node)
if node.tag in {"ul", "ol"}:
return _list_to_markdown(node)
if re.fullmatch(r"h[1-6]", node.tag):
text = _text_block(node)
return f"{'#' * int(node.tag[1])} {text}" if text else ""
if node.tag in {"p", "figcaption"}:
return _text_block(node)
if node.tag == "blockquote":
content = _collapse_blank_lines("\n\n".join(filter(None, (_block_to_markdown(child) for child in node.children))))
return "\n".join(f"> {line}" if line else ">" for line in content.splitlines()) if content else ""
if node.tag == "hr":
return "---"
if node.tag == "img":
return _inline_text(node)
child_blocks = [block for block in (_block_to_markdown(child) for child in node.children) if block]
if child_blocks:
return _collapse_blank_lines("\n\n".join(child_blocks))
return _text_block(node)
def _parse_table_row(line):
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return None
return [cell.strip() for cell in stripped.strip("|").split("|")]
def _repair_table_headers(lines):
repaired = []
index = 0
while index < len(lines):
if (
index + 2 < len(lines)
and _parse_table_row(lines[index]) is not None
and _TABLE_SEPARATOR_RE.match(lines[index + 1].strip())
and _parse_table_row(lines[index + 2]) is not None
):
first = _parse_table_row(lines[index])
third = _parse_table_row(lines[index + 2])
if first and all(not cell for cell in first) and any(cell for cell in third):
repaired.append(lines[index + 2].strip())
repaired.append(lines[index + 1].strip())
index += 3
continue
repaired.append(lines[index].strip())
index += 1
return repaired
def _repair_list_continuations(lines):
repaired = []
previous_was_list_item = False
previous_continuation_indent = ""
for line in lines:
stripped = line.strip()
list_match = re.match(r"^(\s*)([-*+]|\d+\.)\s+.+$", stripped)
is_markdown_block_start = (
not stripped
or stripped.startswith(("```", "#", ">", "|"))
or _TABLE_SEPARATOR_RE.match(stripped)
or re.match(r"^(\s*)([-*+]|\d+\.)\s+", stripped)
)
if previous_was_list_item and stripped and not is_markdown_block_start:
repaired.append(f"{previous_continuation_indent}{stripped}")
previous_was_list_item = False
continue
repaired.append(stripped)
if list_match:
marker = list_match.group(2)
base_indent = list_match.group(1)
previous_continuation_indent = f"{base_indent}{' ' * (len(marker) + 1)}"
previous_was_list_item = True
else:
previous_was_list_item = False
return repaired
def _repair_flattened_diagram(text):
if "\n" in text:
return text
if sum(text.count(char) for char in "│▼├└") < 2:
return text
text = re.sub(r"\s{2,}([│▼])", r"\n \1", text)
text = re.sub(r"([│▼])\s{2,}", r"\1\n", text)
text = re.sub(r"([│▼])(?=[^\s\n│▼├└])", r"\1\n", text)
text = re.sub(r"(?<=[^\s\n])([├└])", r"\n\1", text)
text = re.sub(r"([^\s\n])(\()", r"\1\n\2", text)
return "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
def _convert_dash_lists_to_branches(lines):
converted = []
index = 0
while index < len(lines):
match = re.match(r"^(\s*)-\s+(.*)$", lines[index])
if not match:
converted.append(lines[index])
index += 1
continue
indent = match.group(1)
items = []
while index < len(lines):
next_match = re.match(rf"^{re.escape(indent)}-\s+(.*)$", lines[index])
if not next_match:
break
items.append(next_match.group(1))
index += 1
for item_index, item in enumerate(items):
branch = "" if item_index == len(items) - 1 else ""
converted.append(f"{indent}{branch} {item}")
return converted
def _clean_code_block(code):
lines = [line.rstrip() for line in code.splitlines()]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
flattened = _repair_flattened_diagram("\n".join(lines))
lines = flattened.splitlines() if flattened else []
lines = [
f" {line.strip()}"
if line.strip() in {"", ""} and not re.match(r"^\s+[│▼]\s*$", line)
else line
for line in lines
]
lines = _convert_dash_lists_to_branches(lines)
return "\n".join(lines)
def _clean_markdown_output(markdown):
if not markdown:
return ""
pieces = []
last_index = 0
for match in _FENCE_RE.finditer(markdown):
prose = markdown[last_index:match.start()]
if prose:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", prose)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
fence = match.group(0)
header, _, tail = fence.partition("\n")
body, _, _ = tail.rpartition("\n")
cleaned_body = _clean_code_block(body)
pieces.append(f"{header}\n{cleaned_body}\n```" if cleaned_body else f"{header}\n```")
last_index = match.end()
trailing = markdown[last_index:]
if trailing:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", trailing)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
return "\n\n".join(piece for piece in pieces if piece)
def _convert_html_to_markdown(html):
parser = _HtmlTreeBuilder()
parser.feed(html or "")
markdown = _block_to_markdown(parser.root)
return _clean_markdown_output(markdown)
def render_markdown(raw: str | None) -> str:
"""Normalize *raw* extractor output into clean Markdown.
If the payload looks like HTML (first non-space char is ``<``) it is run
through the HTML→Markdown converter; otherwise it is treated as Markdown and
only the clean-up/repair passes are applied.
"""
raw = raw or ""
if raw.lstrip().startswith("<"):
return _convert_html_to_markdown(raw)
return _clean_markdown_output(raw)
+8
View File
@@ -0,0 +1,8 @@
"""Markdown rendering and HTML-to-Markdown conversion helpers."""
from browser_cli.markdown.render import (
_clean_markdown_output,
_convert_html_to_markdown,
render_markdown,
)
__all__ = ["_clean_markdown_output", "_convert_html_to_markdown", "render_markdown"]
+259
View File
@@ -0,0 +1,259 @@
"""HTML tree walking for browser-cli Markdown rendering."""
from __future__ import annotations
import re
from html.parser import HTMLParser
def _normalize_text(value):
return re.sub(r"\s+", " ", value or "").strip()
def _normalize_inline(value):
value = value.replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return value.strip()
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag
self.attrs = attrs or {}
self.text = text
self.children = []
class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.root = _HtmlNode(tag="document")
self._stack = [self.root]
def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS:
self._stack.append(node)
def handle_startendtag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
def handle_endtag(self, tag):
lowered = tag.lower()
for index in range(len(self._stack) - 1, 0, -1):
if self._stack[index].tag == lowered:
del self._stack[index:]
break
def handle_data(self, data):
if data:
self._stack[-1].children.append(_HtmlNode(text=data))
def _normalize_text(value):
return re.sub(r"\s+", " ", value or "").strip()
def _normalize_inline(value):
value = value.replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return value.strip()
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
def _iter_descendants(node):
for child in getattr(node, "children", []):
yield child
yield from _iter_descendants(child)
def _has_class(node, class_name):
classes = (node.attrs.get("class") or "").split()
return class_name in classes
def _is_code_block_node(node):
if not node or not node.tag:
return False
if node.attrs.get("data-is-code-block-view") == "true":
return True
return node.tag == "pre"
def _inline_text(node):
if node.text is not None:
return _escape_markdown(node.text)
if not node.tag:
return ""
tag = node.tag
if tag == "br":
return "\n"
if tag == "img":
src = node.attrs.get("src") or ""
alt = _normalize_text(node.attrs.get("alt") or "")
if not src:
return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or ""
return f"[{text or href}]({href})" if href else text
if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"`{text.replace('`', r'\\`')}`" if text else ""
if tag in {"strong", "b"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"**{text}**" if text else ""
if tag in {"em", "i"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"*{text}*" if text else ""
chunks = []
for child in node.children:
rendered = _inline_text(child)
if rendered:
chunks.append(rendered)
if child.tag in {"p", "div", "table", "ul", "ol", "pre"}:
chunks.append("\n")
return "".join(chunks)
def _text_block(node):
return _collapse_blank_lines(_normalize_inline("".join(_inline_text(child) for child in node.children)))
def _inner_text_preserve(node):
if node.text is not None:
return node.text
if not node.tag:
return ""
if node.tag == "br":
return ""
return "".join(_inner_text_preserve(child) for child in node.children)
def _table_to_markdown(node):
rows = []
for descendant in _iter_descendants(node):
if descendant.tag != "tr":
continue
row = []
for cell in descendant.children:
if cell.tag in {"td", "th"}:
row.append(_escape_table_cell(_text_block(cell)))
if row:
rows.append(row)
if not rows:
return ""
widths = max(len(row) for row in rows)
normalized_rows = [row + [""] * (widths - len(row)) for row in rows]
headers = normalized_rows[0]
body_rows = normalized_rows[1:]
first_row_blank = all(not cell.strip() for cell in headers)
if first_row_blank and len(normalized_rows) > 1:
headers = normalized_rows[1]
body_rows = normalized_rows[2:]
has_thead = any(child.tag == "thead" for child in node.children)
first_row = next((child for child in _iter_descendants(node) if child.tag == "tr"), None)
first_row_has_th = bool(first_row and any(child.tag == "th" for child in first_row.children))
if not (has_thead or first_row_has_th or first_row_blank):
headers = [""] * widths
body_rows = normalized_rows
separator = ["---"] * widths
lines = [
f"| {' | '.join(headers)} |",
f"| {' | '.join(separator)} |",
]
lines.extend(f"| {' | '.join(row)} |" for row in body_rows)
return "\n".join(lines)
def _list_to_markdown(node, depth=0):
ordered = node.tag == "ol"
items = []
index = 1
for child in node.children:
if child.tag != "li":
continue
marker = f"{index}. " if ordered else "- "
index += 1
content = []
nested = []
for item_child in child.children:
if item_child.tag in {"ul", "ol"}:
nested.append(_list_to_markdown(item_child, depth + 1))
else:
content.append(_inline_text(item_child))
line = _collapse_blank_lines(_normalize_inline("".join(content)))
indent = " " * depth
if line:
line_parts = line.splitlines()
items.append(f"{indent}{marker}{line_parts[0]}")
continuation_indent = f"{indent}{' ' * len(marker)}"
items.extend(f"{continuation_indent}{part}" for part in line_parts[1:])
items.extend(block for block in nested if block)
return "\n".join(items)
def _code_block_to_markdown(node):
if node.tag == "pre":
text = _inner_text_preserve(node).rstrip("\n")
return f"```\n{text}\n```" if text else ""
lines = []
for descendant in _iter_descendants(node):
if descendant.tag and _has_class(descendant, "cm-line"):
lines.append(_inner_text_preserve(descendant))
code = "\n".join(lines).rstrip("\n")
return f"```\n{code}\n```" if code else ""
def _block_to_markdown(node):
if node.text is not None:
return _normalize_text(node.text)
if not node.tag:
return ""
if _is_code_block_node(node):
return _code_block_to_markdown(node)
if node.tag == "table":
return _table_to_markdown(node)
if node.tag in {"ul", "ol"}:
return _list_to_markdown(node)
if re.fullmatch(r"h[1-6]", node.tag):
text = _text_block(node)
return f"{'#' * int(node.tag[1])} {text}" if text else ""
if node.tag in {"p", "figcaption"}:
return _text_block(node)
if node.tag == "blockquote":
content = _collapse_blank_lines("\n\n".join(filter(None, (_block_to_markdown(child) for child in node.children))))
return "\n".join(f"> {line}" if line else ">" for line in content.splitlines()) if content else ""
if node.tag == "hr":
return "---"
if node.tag == "img":
return _inline_text(node)
child_blocks = [block for block in (_block_to_markdown(child) for child in node.children) if block]
if child_blocks:
return _collapse_blank_lines("\n\n".join(child_blocks))
return _text_block(node)
def convert_html_to_markdown(html, clean_markdown_output):
parser = _HtmlTreeBuilder()
parser.feed(html or "")
markdown = _block_to_markdown(parser.root)
return clean_markdown_output(markdown)
+188
View File
@@ -0,0 +1,188 @@
"""HTML → Markdown conversion and Markdown clean-up.
Pure, presentation-agnostic text transforms shared by the SDK
(:meth:`browser_cli.sdk.dom.ExtractNS.markdown`) and the ``extract markdown``
CLI command. No Click/Rich/IPC dependencies just an HTML tree walker plus a
set of repair passes for the markdown the page (or a markdown editor like
Obsidian/CodeMirror) hands back.
"""
from __future__ import annotations
import re
from browser_cli.markdown.html import convert_html_to_markdown
_FENCE_RE = re.compile(r"```(?:[^\n`]*)\n.*?\n```", re.DOTALL)
_ESCAPED_MARKDOWN_RE = re.compile(r"\\([_-])")
_TABLE_SEPARATOR_RE = re.compile(r"^\|(?:\s*:?-{3,}:?\s*\|)+\s*$")
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _parse_table_row(line):
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return None
return [cell.strip() for cell in stripped.strip("|").split("|")]
def _repair_table_headers(lines):
repaired = []
index = 0
while index < len(lines):
if (
index + 2 < len(lines)
and _parse_table_row(lines[index]) is not None
and _TABLE_SEPARATOR_RE.match(lines[index + 1].strip())
and _parse_table_row(lines[index + 2]) is not None
):
first = _parse_table_row(lines[index])
third = _parse_table_row(lines[index + 2])
if first and all(not cell for cell in first) and any(cell for cell in third):
repaired.append(lines[index + 2].strip())
repaired.append(lines[index + 1].strip())
index += 3
continue
repaired.append(lines[index].strip())
index += 1
return repaired
def _repair_list_continuations(lines):
repaired = []
previous_was_list_item = False
previous_continuation_indent = ""
for line in lines:
stripped = line.strip()
list_match = re.match(r"^(\s*)([-*+]|\d+\.)\s+.+$", stripped)
is_markdown_block_start = (
not stripped
or stripped.startswith(("```", "#", ">", "|"))
or _TABLE_SEPARATOR_RE.match(stripped)
or re.match(r"^(\s*)([-*+]|\d+\.)\s+", stripped)
)
if previous_was_list_item and stripped and not is_markdown_block_start:
repaired.append(f"{previous_continuation_indent}{stripped}")
previous_was_list_item = False
continue
repaired.append(stripped)
if list_match:
marker = list_match.group(2)
base_indent = list_match.group(1)
previous_continuation_indent = f"{base_indent}{' ' * (len(marker) + 1)}"
previous_was_list_item = True
else:
previous_was_list_item = False
return repaired
def _repair_flattened_diagram(text):
if "\n" in text:
return text
if sum(text.count(char) for char in "│▼├└") < 2:
return text
text = re.sub(r"\s{2,}([│▼])", r"\n \1", text)
text = re.sub(r"([│▼])\s{2,}", r"\1\n", text)
text = re.sub(r"([│▼])(?=[^\s\n│▼├└])", r"\1\n", text)
text = re.sub(r"(?<=[^\s\n])([├└])", r"\n\1", text)
text = re.sub(r"([^\s\n])(\()", r"\1\n\2", text)
return "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
def _convert_dash_lists_to_branches(lines):
converted = []
index = 0
while index < len(lines):
match = re.match(r"^(\s*)-\s+(.*)$", lines[index])
if not match:
converted.append(lines[index])
index += 1
continue
indent = match.group(1)
items = []
while index < len(lines):
next_match = re.match(rf"^{re.escape(indent)}-\s+(.*)$", lines[index])
if not next_match:
break
items.append(next_match.group(1))
index += 1
for item_index, item in enumerate(items):
branch = "" if item_index == len(items) - 1 else ""
converted.append(f"{indent}{branch} {item}")
return converted
def _clean_code_block(code):
lines = [line.rstrip() for line in code.splitlines()]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
flattened = _repair_flattened_diagram("\n".join(lines))
lines = flattened.splitlines() if flattened else []
lines = [
f" {line.strip()}"
if line.strip() in {"", ""} and not re.match(r"^\s+[│▼]\s*$", line)
else line
for line in lines
]
lines = _convert_dash_lists_to_branches(lines)
return "\n".join(lines)
def _clean_markdown_output(markdown):
if not markdown:
return ""
pieces = []
last_index = 0
for match in _FENCE_RE.finditer(markdown):
prose = markdown[last_index:match.start()]
if prose:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", prose)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
fence = match.group(0)
header, _, tail = fence.partition("\n")
body, _, _ = tail.rpartition("\n")
cleaned_body = _clean_code_block(body)
pieces.append(f"{header}\n{cleaned_body}\n```" if cleaned_body else f"{header}\n```")
last_index = match.end()
trailing = markdown[last_index:]
if trailing:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", trailing)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
return "\n\n".join(piece for piece in pieces if piece)
def _convert_html_to_markdown(html):
return convert_html_to_markdown(html, _clean_markdown_output)
def render_markdown(raw: str | None) -> str:
"""Normalize *raw* extractor output into clean Markdown.
If the payload looks like HTML (first non-space char is ``<``) it is run
through the HTMLMarkdown converter; otherwise it is treated as Markdown and
only the clean-up/repair passes are applied.
"""
raw = raw or ""
if raw.lstrip().startswith("<"):
return _convert_html_to_markdown(raw)
return _clean_markdown_output(raw)
+131 -120
View File
@@ -4,168 +4,179 @@ Typed dataclasses returned by the BrowserCLI Python API.
Each object is bound to a BrowserCLI instance so you can call actions
directly on it:
tabs = b.tabs.list()
tabs[0].close()
tabs[0].move(forward=True)
tabs = b.tabs.list()
tabs[0].close()
tabs[0].move(forward=True)
groups = b.groups.list()
groups[0].tabs()
groups[0].add_tab("https://example.com")
groups = b.groups.list()
groups[0].tabs()
groups[0].add_tab("https://example.com")
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from typing import Any, Protocol
if TYPE_CHECKING:
from browser_cli import BrowserCLI
class BoundBrowser(Protocol):
tabs: Any
groups: Any
nav: Any
def dispatch(self, command: str, args: dict | None = None): ...
# ── BrowserCounts ───────────────────────────────────────────────────────────
@dataclass(frozen=True)
class BrowserCounts:
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int
by_browser: dict[str, int]
"""Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int
by_browser: dict[str, int]
# ── Tab ───────────────────────────────────────────────────────────────────────
@dataclass
class Tab:
"""A browser tab."""
id: int
window_id: int
active: bool
muted: bool = False
title: str = ""
url: str = ""
group_id: int | None = None
browser: str | None = None
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
"""A browser tab."""
id: int
window_id: int
active: bool
muted: bool = False
title: str = ""
url: str = ""
group_id: int | None = None
browser: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BrowserCLI:
if self._browser is None:
raise RuntimeError("Tab is not bound to a BrowserCLI instance")
return self._browser
def _b(self) -> BoundBrowser:
if self._browser is None:
raise RuntimeError("Tab is not bound to a BrowserCLI instance")
return self._browser
def close(self) -> None:
"""Close this tab."""
self._b()._cmd("tabs.close", {"tabId": self.id})
def _command(self, name: str, args: dict | None = None):
browser = self._b()
return browser.dispatch(name, args)
def activate(self) -> None:
"""Switch browser focus to this tab."""
self._b()._cmd("tabs.active", {"tabId": self.id})
def close(self) -> None:
"""Close this tab."""
self._command("tabs.close", {"tabId": self.id})
def mute(self) -> None:
"""Mute this tab."""
self._b()._cmd("tabs.mute", {"tabId": self.id})
def activate(self) -> None:
"""Switch browser focus to this tab."""
self._command("tabs.active", {"tabId": self.id})
def unmute(self) -> None:
"""Unmute this tab."""
self._b()._cmd("tabs.unmute", {"tabId": self.id})
def mute(self) -> None:
"""Mute this tab."""
self._command("tabs.mute", {"tabId": self.id})
def reload(self) -> None:
"""Reload this tab."""
self._b()._cmd("navigate.reload", {"tabId": self.id})
def unmute(self) -> None:
"""Unmute this tab."""
self._command("tabs.unmute", {"tabId": self.id})
def hard_reload(self) -> None:
"""Hard-reload this tab (bypass cache)."""
self._b()._cmd("navigate.hard_reload", {"tabId": self.id})
def reload(self) -> None:
"""Reload this tab."""
self._command("navigate.reload", {"tabId": self.id})
def move(
self, *,
forward: bool = False,
backward: bool = False,
group_id: int | None = None,
window_id: int | None = None,
index: int | None = None,
) -> None:
"""Move this tab.
def hard_reload(self) -> None:
"""Hard-reload this tab (bypass cache)."""
self._command("navigate.hard_reload", {"tabId": self.id})
Args:
forward: Move one position to the right within the window.
backward: Move one position to the left within the window.
group_id: Move into the tab group with this ID.
window_id: Move to the window with this ID.
index: Absolute position index in the target window.
"""
self._b()._cmd("tabs.move", {
"tabId": self.id,
"forward": forward,
"backward": backward,
"groupId": group_id,
"windowId": window_id,
"index": index,
})
def move(
self, *,
forward: bool = False,
backward: bool = False,
group_id: int | None = None,
window_id: int | None = None,
index: int | None = None,
) -> None:
"""Move this tab.
def html(self) -> str:
"""Return the full HTML source of this tab."""
return self._b()._cmd("tabs.html", {"tabId": self.id})
Args:
forward: Move one position to the right within the window.
backward: Move one position to the left within the window.
group_id: Move into the tab group with this ID.
window_id: Move to the window with this ID.
index: Absolute position index in the target window.
"""
self._command("tabs.move", {
"tabId": self.id,
"forward": forward,
"backward": backward,
"groupId": group_id,
"windowId": window_id,
"index": index,
})
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
"""Capture this tab's visible area. Returns a base64 data URL."""
return self._b().tabs.screenshot(self.id, format=format, quality=quality)
def html(self) -> str:
"""Return the full HTML source of this tab."""
return self._command("tabs.html", {"tabId": self.id})
def pin(self) -> None:
"""Pin this tab."""
self._b()._cmd("tabs.pin", {"tabId": self.id})
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
"""Capture this tab's visible area. Returns a base64 data URL."""
return self._b().tabs.screenshot(self.id, format=format, quality=quality)
def unpin(self) -> None:
"""Unpin this tab."""
self._b()._cmd("tabs.unpin", {"tabId": self.id})
def pin(self) -> None:
"""Pin this tab."""
self._command("tabs.pin", {"tabId": self.id})
def refresh(self) -> Tab:
"""Return a fresh snapshot of this tab."""
return self._b().tabs.status(self.id)
def unpin(self) -> None:
"""Unpin this tab."""
self._command("tabs.unpin", {"tabId": self.id})
def wait_for_load(self, *, timeout: float = 30.0, ready_state: str = "complete") -> Tab:
"""Wait until this tab reaches the requested readyState."""
return self._b().tabs.wait_for_load(self.id, timeout=timeout, ready_state=ready_state)
def refresh(self) -> Tab:
"""Return a fresh snapshot of this tab."""
return self._b().tabs.status(self.id)
def watch_url(self, pattern: str, *, timeout: float = 30.0) -> Tab:
"""Wait until this tab's URL matches regex *pattern*."""
return self._b().tabs.watch_url(pattern, tab_id=self.id, timeout=timeout)
def wait_for_load(self, *, timeout: float = 30.0, ready_state: str = "complete") -> Tab:
"""Wait until this tab reaches the requested readyState."""
return self._b().tabs.wait_for_load(self.id, timeout=timeout, ready_state=ready_state)
def open(self, url: str, *, background: bool = False) -> None:
"""Navigate this tab to *url* in place."""
self._b().nav.to(self.id, url)
def watch_url(self, pattern: str, *, timeout: float = 30.0) -> Tab:
"""Wait until this tab's URL matches regex *pattern*."""
return self._b().tabs.watch_url(pattern, tab_id=self.id, timeout=timeout)
def open(self, url: str, *, background: bool = False) -> None:
"""Navigate this tab to *url* in place."""
self._b().nav.to(self.id, url)
# ── Group ─────────────────────────────────────────────────────────────────────
@dataclass
class Group:
"""A browser tab group."""
id: int
title: str
color: str
collapsed: bool
tab_count: int
browser: str | None = None
_browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False)
"""A browser tab group."""
id: int
title: str
color: str
collapsed: bool
tab_count: int
browser: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BrowserCLI:
if self._browser is None:
raise RuntimeError("Group is not bound to a BrowserCLI instance")
return self._browser
def _b(self) -> BoundBrowser:
if self._browser is None:
raise RuntimeError("Group is not bound to a BrowserCLI instance")
return self._browser
def close(self) -> None:
"""Ungroup (and close) this tab group."""
self._b()._cmd("group.close", {"groupId": self.id})
def _command(self, name: str, args: dict | None = None):
browser = self._b()
return browser.dispatch(name, args)
def tabs(self) -> list[Tab]:
"""Return all tabs inside this group."""
return self._b().groups.tabs(self.id)
def close(self) -> None:
"""Ungroup (and close) this tab group."""
self._command("group.close", {"groupId": self.id})
def move(self, *, forward: bool = False, backward: bool = False) -> None:
"""Move this group forward or backward among groups."""
self._b()._cmd("group.move", {
"group": str(self.id),
"forward": forward,
"backward": backward,
})
def tabs(self) -> list[Tab]:
"""Return all tabs inside this group."""
return self._b().groups.tabs(self.id)
def add_tab(self, url: str | None = None) -> int | None:
"""Open a new tab inside this group. Returns the new tab ID."""
return self._b().groups.add_tab(self.id, url)
def move(self, *, forward: bool = False, backward: bool = False) -> None:
"""Move this group forward or backward among groups."""
self._command("group.move", {
"group": str(self.id),
"forward": forward,
"backward": backward,
})
def add_tab(self, url: str | None = None) -> int | None:
"""Open a new tab inside this group. Returns the new tab ID."""
return self._b().groups.add_tab(self.id, url)
+1
View File
@@ -0,0 +1 @@
"""Native messaging host internals."""
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
Native Messaging Host for browser-cli.
Chrome launches this process when extension calls connectNative().
It relays messages between extension (stdin/stdout Native Messaging protocol)
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
"""
import json
import math
import os
import queue
import socket
import sys
import threading
import uuid
from pathlib import Path
from browser_cli.native import local_server, protocol
from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, PAGEABLE_COMMANDS
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path, runtime_dir
from browser_cli.registry import update_registry
SOCKET_PATH: str = "" # set after hello handshake
PENDING: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock()
WRITE_LOCK = threading.Lock()
REGISTRY_PATH = registry_path()
PAGE_SIZE = int(os.environ.get("BROWSER_CLI_PAGE_SIZE", str(DEFAULT_PAGE_SIZE)))
# --- Registry helpers ---
def _registry_add(alias: str, sock_path: str) -> None:
try:
update_registry(alias, sock_path, REGISTRY_PATH)
except Exception:
pass
def _registry_remove(alias: str) -> None:
try:
update_registry(alias, None, REGISTRY_PATH)
except Exception:
pass
def _socket_path_for(alias: str) -> str:
return endpoint_for_alias(alias)
def _resolve_profile_alias(first_msg: dict | None) -> str:
"""Return a unique alias when the extension did not provide one."""
if first_msg and first_msg.get("type") == "hello":
alias = first_msg.get("alias")
if alias and alias != DEFAULT_ALIAS:
return alias
return str(uuid.uuid4())
# --- Thread A: read messages from extension (stdin) ---
def stdin_reader(alias: str):
stdin = sys.stdin.buffer
while True:
msg = protocol.read_native_message(stdin)
if msg is None:
# Extension disconnected — clean up and exit
_cleanup(alias)
os._exit(0)
# Profile alias handshake
if msg.get("type") == "hello":
continue # already handled during startup
if msg.get("type") == "bye":
_cleanup(alias)
os._exit(0)
msg_id = msg.get("id")
if msg_id:
with PENDING_LOCK:
q = PENDING.get(msg_id)
if q:
q.put(msg)
# --- Thread B: accept CLI socket connections ---
def _json_response(result: dict) -> bytes:
return json.dumps(result).encode("utf-8")
def _error_response(exc: Exception) -> bytes:
return _json_response({"success": False, "error": str(exc)})
def _decode_cli_command(data: bytes) -> dict:
cmd = json.loads(data)
if "id" not in cmd:
cmd["id"] = str(uuid.uuid4())
return cmd
def _handle_cli_payload(data: bytes) -> bytes:
return _json_response(_handle_browser_command(_decode_cli_command(data)))
def _handle_browser_command(cmd: dict) -> dict:
command = cmd.get("command")
if command in PAGEABLE_COMMANDS:
return _collect_paged_browser_command(cmd)
return _send_browser_command(cmd)
def _send_browser_command(cmd: dict, timeout: int = 30) -> dict:
msg_id = cmd.get("id") or str(uuid.uuid4())
cmd["id"] = msg_id
response_queue: queue.Queue = queue.Queue()
with PENDING_LOCK:
PENDING[msg_id] = response_queue
try:
with WRITE_LOCK:
protocol.write_native_message(sys.stdout.buffer, cmd)
try:
return response_queue.get(timeout=timeout)
except queue.Empty:
return {"id": msg_id, "success": False, "error": "timeout waiting for browser response"}
finally:
with PENDING_LOCK:
PENDING.pop(msg_id, None)
def _collect_paged_browser_command(cmd: dict) -> dict:
original_id = cmd.get("id") or str(uuid.uuid4())
offset = 0
items = []
total = None
max_pages = math.ceil(10_000 / PAGE_SIZE)
pages_fetched = 0
while True:
if pages_fetched >= max_pages:
return {"id": original_id, "success": False, "error": f"paging loop exceeded {max_pages} pages — extension bug?"}
pages_fetched += 1
page_cmd = dict(cmd)
page_cmd["id"] = str(uuid.uuid4())
page_args = dict(cmd.get("args") or {})
page_args["__page"] = {"offset": offset, "limit": PAGE_SIZE}
page_cmd["args"] = page_args
result = _send_browser_command(page_cmd)
result["id"] = original_id
if not result.get("success", True):
return result
data = result.get("data")
if not isinstance(data, dict) or data.get("__browserCliPage") is not True:
return result
page_items = data.get("items") or []
if not isinstance(page_items, list):
return {"id": original_id, "success": False, "error": "invalid paged response from browser"}
items.extend(page_items)
total = data.get("total", total)
next_offset = data.get("nextOffset")
if next_offset is None:
break
offset = int(next_offset)
return {"id": original_id, "success": True, "data": items, "pageSize": PAGE_SIZE, "total": total}
# --- Socket helpers (length-prefixed framing) ---
def _cleanup(alias: str):
try:
if not is_windows():
Path(_socket_path_for(alias)).unlink(missing_ok=True)
except Exception:
pass
_registry_remove(alias)
def main():
stdin = sys.stdin.buffer
# Wait for the hello handshake to learn the profile alias
first_msg = protocol.read_native_message(stdin)
if first_msg and first_msg.get("type") == "hello":
alias = _resolve_profile_alias(first_msg)
else:
# No hello — use a generated alias; first_msg is dropped (no response path).
alias = str(uuid.uuid4())
runtime_dir().mkdir(mode=0o700, exist_ok=True)
sock_path = _socket_path_for(alias)
if not is_windows():
path = Path(sock_path)
if path.exists():
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
_registry_add(alias, sock_path)
t = threading.Thread(
target=local_server.socket_server,
args=(sock_path, _handle_cli_payload, _error_response),
kwargs={"bound_sock": bound_sock},
daemon=True,
)
t.start()
stdin_reader(alias)
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
"""Local IPC server loops used by the native messaging host."""
import asyncio
import os
import socket
import threading
from collections.abc import Callable
from multiprocessing.connection import Listener
from pathlib import Path
from browser_cli import framing, local_transport
from browser_cli.platform import is_windows
PayloadHandler = Callable[[bytes], bytes]
ErrorHandler = Callable[[Exception], bytes]
async def async_socket_server(
sock_path: str,
handle_payload: PayloadHandler,
error_response: ErrorHandler,
*,
bound_sock: socket.socket | None = None,
) -> None:
sock = bound_sock
if sock is None:
path = Path(sock_path)
if path.exists():
path.unlink()
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(sock_path)
os.chmod(sock_path, 0o600)
sock.listen(16)
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
await async_handle_cli_connection(reader, writer, handle_payload, error_response)
server = await asyncio.start_unix_server(handle, sock=sock)
async with server:
await server.serve_forever()
def socket_server(
sock_path: str,
handle_payload: PayloadHandler,
error_response: ErrorHandler,
*,
bound_sock: socket.socket | None = None,
) -> None:
if is_windows():
windows_pipe_server(sock_path, handle_payload, error_response)
return
asyncio.run(async_socket_server(sock_path, handle_payload, error_response, bound_sock=bound_sock))
def windows_pipe_server(sock_path: str, handle_payload: PayloadHandler, error_response: ErrorHandler) -> None:
while True:
listener = None
try:
listener = Listener(sock_path, family="AF_PIPE")
conn = listener.accept()
except OSError:
if listener is not None:
try:
listener.close()
except Exception:
pass
break
threading.Thread(target=handle_cli_connection, args=(conn, handle_payload, error_response, listener), daemon=True).start()
async def async_handle_cli_connection(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
handle_payload: PayloadHandler,
error_response: ErrorHandler,
) -> None:
try:
data = await local_transport.async_recv_all(reader)
if not data:
return
response = await asyncio.to_thread(handle_payload, data)
await local_transport.async_send_all(writer, response)
except Exception as exc:
try:
await local_transport.async_send_all(writer, error_response(exc))
except Exception:
pass
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
def send_cli_response(conn, response: bytes) -> None:
if is_windows():
conn.send_bytes(response)
else:
framing.send_frame(conn, response)
def handle_cli_connection(conn, handle_payload: PayloadHandler, error_response: ErrorHandler, listener=None) -> None:
try:
data = conn.recv_bytes() if is_windows() else framing.recv_frame(conn, allow_eof=True)
if not data:
return
send_cli_response(conn, handle_payload(data))
except Exception as exc:
try:
send_cli_response(conn, error_response(exc))
except Exception:
pass
finally:
conn.close()
if listener is not None:
listener.close()
+30
View File
@@ -0,0 +1,30 @@
"""Chrome Native Messaging stdio protocol helpers."""
from __future__ import annotations
import json
import struct
def read_exact_stream(stream, n: int) -> bytes | None:
buf = b""
while len(buf) < n:
chunk = stream.read(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def read_native_message(stream) -> dict | None:
raw_len = read_exact_stream(stream, 4)
if raw_len is None:
return None
msg_len = struct.unpack("<I", raw_len)[0]
data = read_exact_stream(stream, msg_len)
if data is None:
return None
return json.loads(data.decode("utf-8"))
def write_native_message(stream, msg: dict) -> None:
data = json.dumps(msg).encode("utf-8")
stream.write(struct.pack("<I", len(data)))
stream.write(data)
stream.flush()
-340
View File
@@ -1,340 +0,0 @@
#!/usr/bin/env python3
"""
Native Messaging Host for browser-cli.
Chrome launches this process when extension calls connectNative().
It relays messages between extension (stdin/stdout Native Messaging protocol)
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
"""
import json
import math
import os
import queue
import socket
import struct
import sys
import threading
import uuid
from multiprocessing.connection import Listener
from pathlib import Path
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
from browser_cli.registry import update_registry
SOCKET_PATH: str = "" # set after hello handshake
PENDING: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock()
WRITE_LOCK = threading.Lock()
REGISTRY_PATH = registry_path()
PAGE_SIZE = int(os.environ.get("BROWSER_CLI_PAGE_SIZE", "100"))
PAGEABLE_COMMANDS = {
"tabs.list",
"tabs.filter",
"tabs.query",
"group.list",
"group.tabs",
"group.query",
"windows.list",
"dom.query",
"dom.text",
"dom.attr",
"extract.links",
"extract.images",
"extract.json",
"cookies.list",
"session.list",
}
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
def _read_exact_stream(stream, n: int) -> bytes | None:
buf = b""
while len(buf) < n:
chunk = stream.read(n - len(buf))
if not chunk:
return None # real EOF
buf += chunk
return buf
def read_native_message(stream) -> dict | None:
raw_len = _read_exact_stream(stream, 4)
if raw_len is None:
return None
msg_len = struct.unpack("<I", raw_len)[0]
data = _read_exact_stream(stream, msg_len)
if data is None:
return None
return json.loads(data.decode("utf-8"))
def write_native_message(stream, msg: dict) -> None:
data = json.dumps(msg).encode("utf-8")
stream.write(struct.pack("<I", len(data)))
stream.write(data)
stream.flush()
# --- Registry helpers ---
def _registry_add(alias: str, sock_path: str) -> None:
try:
update_registry(alias, sock_path, REGISTRY_PATH)
except Exception:
pass
def _registry_remove(alias: str) -> None:
try:
update_registry(alias, None, REGISTRY_PATH)
except Exception:
pass
def _socket_path_for(alias: str) -> str:
return endpoint_for_alias(alias)
def _resolve_profile_alias(first_msg: dict | None) -> str:
"""Return a unique alias when the extension did not provide one."""
if first_msg and first_msg.get("type") == "hello":
alias = first_msg.get("alias")
if alias and alias != DEFAULT_ALIAS:
return alias
return str(uuid.uuid4())
# --- Thread A: read messages from extension (stdin) ---
def stdin_reader(alias: str):
stdin = sys.stdin.buffer
while True:
msg = read_native_message(stdin)
if msg is None:
# Extension disconnected — clean up and exit
_cleanup(alias)
os._exit(0)
# Profile alias handshake
if msg.get("type") == "hello":
continue # already handled during startup
if msg.get("type") == "bye":
_cleanup(alias)
os._exit(0)
msg_id = msg.get("id")
if msg_id:
with PENDING_LOCK:
q = PENDING.get(msg_id)
if q:
q.put(msg)
# --- Thread B: accept CLI socket connections ---
def socket_server(sock_path: str, bound_sock: "socket.socket | None" = None):
if is_windows():
while True:
listener = None
try:
listener = Listener(sock_path, family="AF_PIPE")
conn = listener.accept()
except OSError:
if listener is not None:
try:
listener.close()
except Exception:
pass
break
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
return
sock = bound_sock
if sock is None:
path = Path(sock_path)
if path.exists():
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:
try:
conn, _ = sock.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn, None), daemon=True).start()
def handle_cli_connection(conn, listener=None) -> None:
try:
data = conn.recv_bytes() if is_windows() else _recv_all(conn)
if not data:
return
cmd = json.loads(data)
if "id" not in cmd:
cmd["id"] = str(uuid.uuid4())
result = _handle_browser_command(cmd)
response = json.dumps(result).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception as exc:
try:
response = json.dumps({"success": False, "error": str(exc)}).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception:
pass
finally:
conn.close()
if listener is not None:
listener.close()
def _handle_browser_command(cmd: dict) -> dict:
command = cmd.get("command")
if command in PAGEABLE_COMMANDS:
return _collect_paged_browser_command(cmd)
return _send_browser_command(cmd)
def _send_browser_command(cmd: dict, timeout: int = 30) -> dict:
msg_id = cmd.get("id") or str(uuid.uuid4())
cmd["id"] = msg_id
response_queue: queue.Queue = queue.Queue()
with PENDING_LOCK:
PENDING[msg_id] = response_queue
try:
with WRITE_LOCK:
write_native_message(sys.stdout.buffer, cmd)
try:
return response_queue.get(timeout=timeout)
except queue.Empty:
return {"id": msg_id, "success": False, "error": "timeout waiting for browser response"}
finally:
with PENDING_LOCK:
PENDING.pop(msg_id, None)
def _collect_paged_browser_command(cmd: dict) -> dict:
original_id = cmd.get("id") or str(uuid.uuid4())
offset = 0
items = []
total = None
max_pages = math.ceil(10_000 / PAGE_SIZE)
pages_fetched = 0
while True:
if pages_fetched >= max_pages:
return {"id": original_id, "success": False, "error": f"paging loop exceeded {max_pages} pages — extension bug?"}
pages_fetched += 1
page_cmd = dict(cmd)
page_cmd["id"] = str(uuid.uuid4())
page_args = dict(cmd.get("args") or {})
page_args["__page"] = {"offset": offset, "limit": PAGE_SIZE}
page_cmd["args"] = page_args
result = _send_browser_command(page_cmd)
result["id"] = original_id
if not result.get("success", True):
return result
data = result.get("data")
if not isinstance(data, dict) or data.get("__browserCliPage") is not True:
return result
page_items = data.get("items") or []
if not isinstance(page_items, list):
return {"id": original_id, "success": False, "error": "invalid paged response from browser"}
items.extend(page_items)
total = data.get("total", total)
next_offset = data.get("nextOffset")
if next_offset is None:
break
offset = int(next_offset)
return {"id": original_id, "success": True, "data": items, "pageSize": PAGE_SIZE, "total": total}
# --- Socket helpers (length-prefixed framing) ---
def _send_all(conn: socket.socket, data: bytes) -> None:
framed = struct.pack("<I", len(data)) + data
conn.sendall(framed)
def _recv_all(conn: socket.socket) -> bytes | None:
raw_len = _recv_exact(conn, 4)
if raw_len is None:
return None
msg_len = struct.unpack("<I", raw_len)[0]
if msg_len > _MAX_MSG_BYTES:
return None
return _recv_exact(conn, msg_len)
def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
buf = b""
while len(buf) < n:
chunk = conn.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def _cleanup(alias: str):
try:
if not is_windows():
Path(_socket_path_for(alias)).unlink(missing_ok=True)
except Exception:
pass
_registry_remove(alias)
def main():
stdin = sys.stdin.buffer
# Wait for the hello handshake to learn the profile alias
first_msg = read_native_message(stdin)
if first_msg and first_msg.get("type") == "hello":
alias = _resolve_profile_alias(first_msg)
else:
# No hello — use a generated alias; first_msg is dropped (no response path).
alias = str(uuid.uuid4())
runtime_dir().mkdir(mode=0o700, exist_ok=True)
sock_path = _socket_path_for(alias)
if not is_windows():
path = Path(sock_path)
if path.exists():
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
_registry_add(alias, sock_path)
t = threading.Thread(target=socket_server, args=(sock_path,), kwargs={"bound_sock": bound_sock}, daemon=True)
t.start()
stdin_reader(alias)
if __name__ == "__main__":
main()
+1 -3
View File
@@ -2,9 +2,7 @@ import os
import sys
from pathlib import Path
APP_NAME = "browser-cli"
RUNTIME_DIRNAME = ".browser_cli"
DEFAULT_ALIAS = "default"
from browser_cli.constants import APP_NAME, DEFAULT_ALIAS, RUNTIME_DIRNAME
def is_windows() -> bool:
return sys.platform.startswith("win")
+1
View File
@@ -0,0 +1 @@
"""Client-side remote browser transport and registry."""
+53
View File
@@ -0,0 +1,53 @@
"""Persistence for remembered remote browser endpoints and key specs."""
from __future__ import annotations
import json
import os
from pathlib import Path
from browser_cli.constants import CONFIG_DIR
from browser_cli.endpoints import _normalize_endpoint
REMOTE_REGISTRY_PATH = CONFIG_DIR / "remotes.json"
def load_remotes() -> dict[str, dict[str, str]]:
if not REMOTE_REGISTRY_PATH.exists():
return {}
try:
data = json.loads(REMOTE_REGISTRY_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(data, dict):
return {}
# 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(value: str) -> bool:
"""Return True for 'agent', 'agent:<selector>', or a plausible key file path."""
return value == "agent" or value.startswith("agent:") or (
not value.startswith("<") and ("/" in value or Path(value).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 or not is_valid_key_spec(key_spec):
return
remotes = load_remotes()
current = remotes.get(endpoint, {})
current["key"] = key_spec
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 key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
return None
cfg = load_remotes().get(endpoint) or {}
key = cfg.get("key")
if not key:
return None
key_str = str(key)
# Reject corrupted values (e.g. str(AgentKey(...)) saved by an older bug).
return key_str if is_valid_key_spec(key_str) else None
+230
View File
@@ -0,0 +1,230 @@
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
Owns the wire mechanics of the remote leg: open a socket (TLS on :443),
complete the signed challenge/response handshake with an optional post-quantum
key exchange, frame the request, and read the framed (possibly encrypted)
response. The higher-level "which endpoint / which profile / which key"
decisions stay in :mod:`browser_cli.client.core`.
"""
from __future__ import annotations
import asyncio
import json
import socket
import sys
from collections.abc import Callable
from contextlib import contextmanager
from typing import TypeVar
from browser_cli.errors import BrowserNotConnected
from browser_cli.endpoints import _resolve_connect_endpoint
from browser_cli.framing import async_recv_exact, async_recv_frame, async_send_frame, frame, recv_exact, recv_frame
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
T = TypeVar("T")
_AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
_PQ_WARNING = (
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
def _recv_exact(sock: socket.socket, n: int) -> bytes:
return recv_exact(sock, n) or b""
def _recv_all(sock: socket.socket) -> bytes:
return recv_frame(sock, label="Response") or b""
async def _async_recv_exact(reader: asyncio.StreamReader, n: int) -> bytes:
return await async_recv_exact(reader, n) or b""
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
return await async_recv_frame(reader, label="Response") or b""
def _split_endpoint(endpoint: str) -> tuple[str, int]:
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
return host, int(port_str)
@contextmanager
def _open_socket(endpoint: str):
host, port = _split_endpoint(endpoint)
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
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
with sock:
yield sock
async def _open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
host, port = _split_endpoint(endpoint)
ssl_ctx = None
if port == 443:
import ssl
ssl_ctx = ssl.create_default_context()
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
def _parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
try:
challenge = json.loads(raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
return challenge, nonce_hex
except (json.JSONDecodeError, AttributeError):
return None, None
def _check_min_client_version(challenge: dict | None) -> None:
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if not min_ver:
return
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
def _clean_message(msg: dict) -> dict:
return {k: v for k, v in msg.items() if k not in _AUTH_FIELDS}
def _get_pq_public_key(challenge: dict | None) -> str | None:
if not isinstance(challenge, dict):
return None
from browser_cli.auth import PQ_KEX_ALG
kex = challenge.get("pq_kex")
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
return str(kex["public_key"])
return None
def _signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, public_key_hex, sign
nonce = bytes.fromhex(nonce_hex)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
pubkey = public_key_hex(private_key)
if pq_shared_secret is None:
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
return {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": pubkey,
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
def _warn_no_pq(enabled: bool) -> None:
if enabled:
sys.stderr.write(_PQ_WARNING)
def _build_auth_message(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
encapsulate: Callable[[str], tuple[str, bytes]],
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
if not nonce_hex or private_key is None:
_warn_no_pq(warn_no_pq)
return msg, None
clean_msg = _clean_message(msg)
pq_shared_secret = None
pq_public_key = _get_pq_public_key(challenge)
if pq_public_key:
from browser_cli.auth import PQ_KEX_ALG
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
_warn_no_pq(warn_no_pq)
return _signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
async def _build_auth_message_async(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
def encapsulate(public_key: str) -> tuple[str, bytes]:
from browser_cli.auth import pq_kex_client_encapsulate
return pq_kex_client_encapsulate(public_key)
return await asyncio.to_thread(
_build_auth_message,
msg,
challenge,
nonce_hex,
private_key,
encapsulate,
warn_no_pq=warn_no_pq,
)
def _decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
if response is None or pq_shared_secret is None:
return response
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 _with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
if challenge_raw is None:
raise BrowserNotConnected("No challenge received from remote endpoint")
challenge, nonce_hex = _parse_challenge(challenge_raw)
_check_min_client_version(challenge)
return build_auth(msg, challenge, nonce_hex, private_key)
def _should_warn_no_pq(msg: dict) -> bool:
return not bool(msg.pop("_suppress_pq_warning", False))
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
reader, writer = await _open_async_connection(endpoint)
try:
challenge_raw = await _async_recv_all(reader)
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
return await _build_auth_message_async(sync_msg, challenge, nonce_hex, key, warn_no_pq=warn)
payload_msg, pq_shared_secret = await _with_challenge(challenge_raw, msg, private_key, build_auth)
await async_send_frame(writer, json.dumps(payload_msg).encode("utf-8"))
return _decode_pq_response(await _async_recv_all(reader), pq_shared_secret)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
from browser_cli.auth import pq_kex_client_encapsulate
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
with _open_socket(endpoint) as sock:
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
-123
View File
@@ -1,123 +0,0 @@
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
Owns the wire mechanics of the remote leg: open a socket (TLS on :443),
complete the signed challenge/response handshake with an optional post-quantum
key exchange, frame the request, and read the framed (possibly encrypted)
response. The higher-level "which endpoint / which profile / which key"
decisions stay in :mod:`browser_cli.client`, which re-exports these for
backward compatibility.
"""
from __future__ import annotations
import json
import socket
import struct
import sys
from browser_cli.errors import BrowserNotConnected
from browser_cli.endpoints import _resolve_connect_endpoint
from browser_cli.version_manager import MAX_MSG_BYTES as _MAX_MSG_BYTES
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
_PQ_WARNING = (
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
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
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 _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(_PQ_WARNING)
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(_PQ_WARNING)
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
+20
View File
@@ -5,6 +5,7 @@ client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups
in the browser extension.
"""
from browser_cli.sdk.browser_data import CookiesNS, StorageNS
from browser_cli.sdk.decorators import DecoratorsNS
from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS
from browser_cli.sdk.extension import ExtensionNS
from browser_cli.sdk.groups import GroupsNS
@@ -14,6 +15,22 @@ from browser_cli.sdk.session import SessionNS
from browser_cli.sdk.tabs import TabsNS
from browser_cli.sdk.windows import WindowsNS
NAMESPACE_SPECS = (
("nav", NavigationNS),
("tabs", TabsNS),
("groups", GroupsNS),
("windows", WindowsNS),
("dom", DomNS),
("extract", ExtractNS),
("page", PageNS),
("storage", StorageNS),
("cookies", CookiesNS),
("session", SessionNS),
("perf", PerfNS),
("extension", ExtensionNS),
)
NAMESPACE_NAMES = tuple(name for name, _ in NAMESPACE_SPECS)
__all__ = [
"NavigationNS",
"TabsNS",
@@ -27,4 +44,7 @@ __all__ = [
"SessionNS",
"PerfNS",
"ExtensionNS",
"DecoratorsNS",
"NAMESPACE_SPECS",
"NAMESPACE_NAMES",
]
+106 -9
View File
@@ -1,19 +1,116 @@
"""Base class for SDK command namespaces.
"""Base helpers for SDK command namespaces.
Each namespace (``b.tabs``, ``b.dom``, ...) is a thin object bound to its
:class:`~browser_cli.BrowserCLI` client. Namespaces hold no state of their own;
they delegate to the client's shared infrastructure (``_cmd``, the multi-browser
helpers, and the ``Tab``/``Group`` factories).
they delegate to the client's shared infrastructure (command dispatch, the
multi-browser helpers, and the ``Tab``/``Group`` factories).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from collections.abc import Callable
from functools import wraps
from typing import Any, TypeVar
if TYPE_CHECKING:
from browser_cli import BrowserCLI
F = TypeVar("F", bound=Callable)
_MISSING = object()
def _clone_default(value):
if isinstance(value, (dict, list, set)):
return value.copy()
return value
def sdk_command(
name: str,
args: Callable | None = None,
*,
default=_MISSING,
field: str | None = None,
fallback=_MISSING,
mapper: Callable | None = None,
return_result: bool = True,
):
"""Decorate a namespace method as a browser wire command.
This keeps the public method signature/docstring on the normal Python
method, while moving repetitive command-dispatch plumbing into one place.
The wrapped function body is intentionally unused; it exists only for
signature, docs, and type hints.
"""
def decorator(func: F) -> F:
@wraps(func)
def wrapper(self, *method_args, **method_kwargs):
payload = args(self, *method_args, **method_kwargs) if args is not None else {}
result = self.command(name, payload)
if not return_result:
return None
if mapper is not None:
return mapper(self, result, *method_args, **method_kwargs)
if field is not None:
if fallback is _MISSING:
return self.field(result, field, default)
return self.field(result, field, default, fallback=fallback)
if default is not _MISSING and not result:
return _clone_default(default)
return result
wrapper._browser_cli_command = name # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
return decorator
class Namespace:
"""A group of related SDK methods, bound to a BrowserCLI client."""
"""A group of related SDK methods, bound to a BrowserCLI client."""
def __init__(self, client: "BrowserCLI"):
self._c = client
def __init__(self, client: Any):
self._c = client
def command(self, name: str, args: dict | None = None):
"""Dispatch a browser command through the owning client."""
return self._c.dispatch(name, args)
def tab_from(self, data: dict):
"""Build a bound Tab from a raw command response dict."""
return self._c.tab_from(data)
def group_from(self, data: dict):
"""Build a bound Group from a raw command response dict."""
return self._c.group_from(data)
def tab_from_target(self, data: dict, target):
"""Build a bound Tab for a multi-browser target."""
return self._c.tab_from_target(data, target)
def group_from_target(self, data: dict, target):
"""Build a bound Group for a multi-browser target."""
return self._c.group_from_target(data, target)
def tag_browser(self, item: dict, target):
"""Annotate a raw dict with its browser in multi-browser mode."""
return self._c.tag_browser(item, target)
def multi_list(self, name: str, args: dict | None, mapper: Callable):
"""Run a list command with multi-browser fan-out support."""
return self._c.multi_list(name, args, mapper)
def multi_count(self, name: str, args: dict | None = None):
"""Run a count command with multi-browser fan-out support."""
return self._c.multi_count(name, args)
def apply_tab_filter(self, filter_fn: Callable):
"""Apply a Python-side tab filter using client semantics."""
return self._c.apply_tab_filter(filter_fn)
def toggle_tab(self, name: str, tab_id: int | None):
"""Run a tab toggle command and return the affected tab ID."""
return self._c.toggle_tab(name, tab_id)
def require_tab(self, data, error: str):
"""Convert a tab-like response into a bound Tab or raise a clean error."""
return self._c.require_tab(data, error)
def field(self, result, key, default=None, *, fallback=_MISSING):
"""Read a field from command output using the client's SDK semantics."""
if fallback is _MISSING:
return self._c.field(result, key, default)
return self._c.field(result, key, default, fallback=fallback)
+73 -54
View File
@@ -1,66 +1,85 @@
"""Storage and cookies namespaces: ``b.storage.*``, ``b.cookies.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.base import Namespace, sdk_command
class StorageNS(Namespace):
"""Read and write localStorage / sessionStorage."""
"""Read and write localStorage / sessionStorage."""
def get(
self,
key: str | None = None,
*,
type: str = "local",
tab_id: int | None = None,
) -> str | dict | None:
"""Get a localStorage/sessionStorage entry (or all entries if key omitted)."""
return self._c._cmd("storage.get", {"key": key, "type": type, "tabId": tab_id})
@sdk_command("storage.get", lambda self, key=None, *, type="local", tab_id=None: {
"key": key,
"type": type,
"tabId": tab_id,
})
def get(
self,
key: str | None = None,
*,
type: str = "local",
tab_id: int | None = None,
) -> str | dict | None:
"""Get a localStorage/sessionStorage entry (or all entries if key omitted)."""
def set(
self,
key: str,
value: str,
*,
type: str = "local",
tab_id: int | None = None,
) -> None:
"""Set a localStorage/sessionStorage entry."""
self._c._cmd("storage.set", {"key": key, "value": value, "type": type, "tabId": tab_id})
@sdk_command("storage.set", lambda self, key, value, *, type="local", tab_id=None: {
"key": key,
"value": value,
"type": type,
"tabId": tab_id,
})
def set(
self,
key: str,
value: str,
*,
type: str = "local",
tab_id: int | None = None,
) -> None:
"""Set a localStorage/sessionStorage entry."""
class CookiesNS(Namespace):
"""List, get, and set cookies."""
"""List, get, and set cookies."""
def list(
self,
*,
url: str | None = None,
domain: str | None = None,
name: str | None = None,
) -> list[dict]:
"""List cookies, optionally filtered by url, domain, or name."""
return self._c._cmd("cookies.list", {"url": url, "domain": domain, "name": name}) or []
@sdk_command("cookies.list", lambda self, *, url=None, domain=None, name=None: {
"url": url,
"domain": domain,
"name": name,
}, default=[])
def list(
self,
*,
url: str | None = None,
domain: str | None = None,
name: str | None = None,
) -> list[dict]:
"""List cookies, optionally filtered by url, domain, or name."""
def get(self, url: str, name: str) -> dict | None:
"""Get a single cookie by url and name."""
return self._c._cmd("cookies.get", {"url": url, "name": name})
@sdk_command("cookies.get", lambda self, url, name: {"url": url, "name": name})
def get(self, url: str, name: str) -> dict | None:
"""Get a single cookie by url and name."""
def set(
self,
url: str,
name: str,
value: str,
*,
domain: str | None = None,
path: str | None = None,
secure: bool | None = None,
http_only: bool | None = None,
expiration_date: float | None = None,
same_site: str | None = None,
) -> dict:
"""Set a cookie. Returns the created cookie dict."""
return self._c._cmd("cookies.set", {
"url": url, "name": name, "value": value,
"domain": domain, "path": path,
"secure": secure, "httpOnly": http_only,
"expirationDate": expiration_date, "sameSite": same_site,
})
@sdk_command("cookies.set", lambda self, url, name, value, *, domain=None, path=None, secure=None,
http_only=None, expiration_date=None, same_site=None: {
"url": url,
"name": name,
"value": value,
"domain": domain,
"path": path,
"secure": secure,
"httpOnly": http_only,
"expirationDate": expiration_date,
"sameSite": same_site,
})
def set(
self,
url: str,
name: str,
value: str,
*,
domain: str | None = None,
path: str | None = None,
secure: bool | None = None,
http_only: bool | None = None,
expiration_date: float | None = None,
same_site: str | None = None,
) -> dict:
"""Set a cookie. Returns the created cookie dict."""
+107
View File
@@ -0,0 +1,107 @@
"""Synchronous workflow decorator namespace for the Python SDK."""
from __future__ import annotations
import asyncio
import functools
import inspect
from collections.abc import Callable
from typing import TypeVar
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
F = TypeVar("F", bound=Callable)
class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
"""Workflow decorators bound to a :class:`browser_cli.BrowserCLI` client.
The normal SDK is synchronous, but these decorators also work on ``async def``
functions: browser operations run via ``asyncio.to_thread`` so the event loop
is not blocked while waiting for the browser response.
"""
def _run(self, func: Callable, *args, **kwargs):
if inspect.iscoroutinefunction(func):
raise TypeError("sync BrowserCLI decorators cannot call async browser methods")
return func(*args, **kwargs)
def _call_wrapped(self, func: Callable, *args, **kwargs):
if inspect.iscoroutinefunction(func):
async def run_async():
return await func(*args, **kwargs)
return run_async()
return func(*args, **kwargs)
def _value_decorator(
self,
func: F | None,
get_value: Callable,
*,
keyword: str | None | object = "tab",
cleanup: Callable | None = None,
):
def decorator(fn: F) -> F:
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args, **kwargs):
value = await asyncio.to_thread(get_value)
try:
extra_args = ()
if keyword is not _NO_INJECT:
extra_args, kwargs = self._inject(kwargs, keyword, value)
return await fn(*extra_args, *args, **kwargs)
finally:
if cleanup is not None:
await asyncio.to_thread(cleanup, value)
return async_wrapper # type: ignore[return-value]
return WorkflowDecoratorsMixin._value_decorator(
self, fn, get_value, keyword=keyword, cleanup=cleanup
)
return decorator(func) if func is not None else decorator
def performance_profile(self, profile: str, *, restore: bool = True):
def decorator(fn: F) -> F:
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args, **kwargs):
previous = None
if restore:
previous = (await asyncio.to_thread(self._c.perf.status)).get("performanceProfile")
await asyncio.to_thread(self._c.perf.set_profile, profile)
try:
return await fn(*args, **kwargs)
finally:
if previous:
await asyncio.to_thread(self._c.perf.set_profile, previous)
return async_wrapper # type: ignore[return-value]
return WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn)
return decorator
def retry(
self,
*,
times: int = 3,
delay: float = 0.0,
exceptions: tuple[type[BaseException], ...] = (Exception,),
):
attempts = max(1, times)
def decorator(fn: F) -> F:
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args, **kwargs):
last_error = None
for attempt in range(attempts):
try:
return await fn(*args, **kwargs)
except exceptions as exc:
last_error = exc
if attempt == attempts - 1:
raise
if delay > 0:
await asyncio.sleep(delay)
raise last_error # type: ignore[misc]
return async_wrapper # type: ignore[return-value]
return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn)
return decorator
+132 -113
View File
@@ -1,150 +1,169 @@
"""DOM, content-extraction, and page-info namespaces: ``b.dom.*``, ``b.extract.*``, ``b.page.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.base import Namespace, sdk_command
def _selector_args(self, selector):
return {"selector": selector}
def _selector_value_args(self, selector, value):
return {"selector": selector, "value": value}
def _extract_markdown(self, result, *args, **kwargs) -> str:
from browser_cli.markdown import render_markdown
return render_markdown(result)
class DomNS(Namespace):
"""Query and drive page elements in the active (or specified) tab."""
"""Query and drive page elements in the active (or specified) tab."""
def query(self, selector: str) -> list[dict]:
return self._c._cmd("dom.query", {"selector": selector}) or []
@sdk_command("dom.query", _selector_args, default=[])
def query(self, selector: str) -> list[dict]:
"""Return elements matching a CSS selector."""
def click(self, selector: str) -> None:
self._c._cmd("dom.click", {"selector": selector})
@sdk_command("dom.click", _selector_args, return_result=False)
def click(self, selector: str) -> None:
"""Click the first element matching a CSS selector."""
def type(self, selector: str, text: str) -> None:
self._c._cmd("dom.type", {"selector": selector, "text": text})
@sdk_command("dom.type", lambda self, selector, text: {"selector": selector, "text": text}, return_result=False)
def type(self, selector: str, text: str) -> None:
"""Type text into the first matching element."""
def attr(self, selector: str, attr: str) -> list[str]:
return self._c._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
@sdk_command("dom.attr", lambda self, selector, attr: {"selector": selector, "attr": attr}, default=[])
def attr(self, selector: str, attr: str) -> list[str]:
"""Return an attribute from all matching elements."""
def text(self, selector: str) -> list[str]:
return self._c._cmd("dom.text", {"selector": selector}) or []
@sdk_command("dom.text", _selector_args, default=[])
def text(self, selector: str) -> list[str]:
"""Return text from all matching elements."""
def exists(self, selector: str) -> bool:
return self._c._cmd("dom.exists", {"selector": selector}) or False
@sdk_command("dom.exists", _selector_args, default=False)
def exists(self, selector: str) -> bool:
"""Return whether a selector exists."""
def scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
"""Scroll to a CSS selector or to pixel coordinates."""
self._c._cmd("dom.scroll", {"selector": selector, "x": x, "y": y})
@sdk_command("dom.scroll", lambda self, selector=None, *, x=None, y=None: {"selector": selector, "x": x, "y": y}, return_result=False)
def scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
"""Scroll to a CSS selector or to pixel coordinates."""
def select(self, selector: str, value: str) -> None:
"""Set the value of a <select> element."""
self._c._cmd("dom.select", {"selector": selector, "value": value})
@sdk_command("dom.select", _selector_value_args, return_result=False)
def select(self, selector: str, value: str) -> None:
"""Set the value of a <select> element."""
def eval(self, code: str, tab_id: int | None = None):
"""Evaluate JavaScript in the page's main world and return the result."""
return self._c._cmd("dom.eval", {"code": code, "tabId": tab_id})
@sdk_command("dom.eval", lambda self, code, tab_id=None: {"code": code, "tabId": tab_id})
def eval(self, code: str, tab_id: int | None = None):
"""Evaluate JavaScript in the page's main world and return the result."""
def key(self, key: str, selector: str | None = None) -> None:
"""Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
self._c._cmd("dom.key", {"key": key, "selector": selector})
@sdk_command("dom.key", lambda self, key, selector=None: {"key": key, "selector": selector}, return_result=False)
def key(self, key: str, selector: str | None = None) -> None:
"""Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
def hover(self, selector: str) -> None:
"""Dispatch mouseover/mouseenter on an element."""
self._c._cmd("dom.hover", {"selector": selector})
@sdk_command("dom.hover", _selector_args, return_result=False)
def hover(self, selector: str) -> None:
"""Dispatch mouseover/mouseenter on an element."""
def check(self, selector: str) -> None:
"""Check a checkbox."""
self._c._cmd("dom.check", {"selector": selector})
@sdk_command("dom.check", _selector_args, return_result=False)
def check(self, selector: str) -> None:
"""Check a checkbox."""
def uncheck(self, selector: str) -> None:
"""Uncheck a checkbox."""
self._c._cmd("dom.uncheck", {"selector": selector})
@sdk_command("dom.uncheck", _selector_args, return_result=False)
def uncheck(self, selector: str) -> None:
"""Uncheck a checkbox."""
def clear(self, selector: str) -> None:
"""Clear the value of an input element."""
self._c._cmd("dom.clear", {"selector": selector})
@sdk_command("dom.clear", _selector_args, return_result=False)
def clear(self, selector: str) -> None:
"""Clear the value of an input element."""
def focus(self, selector: str) -> None:
"""Focus an element."""
self._c._cmd("dom.focus", {"selector": selector})
@sdk_command("dom.focus", _selector_args, return_result=False)
def focus(self, selector: str) -> None:
"""Focus an element."""
def submit(self, selector: str) -> None:
"""Submit the form containing the matched element."""
self._c._cmd("dom.submit", {"selector": selector})
@sdk_command("dom.submit", _selector_args, return_result=False)
def submit(self, selector: str) -> None:
"""Submit the form containing the matched element."""
def poll(
self,
selector: str,
pattern: str,
*,
attr: str | None = None,
timeout: float = 30.0,
interval: float = 0.5,
tab_id: int | None = None,
) -> dict:
"""Poll selector's text/value until it matches regex pattern.
def poll(
self,
selector: str,
pattern: str,
*,
attr: str | None = None,
timeout: float = 30.0,
interval: float = 0.5,
tab_id: int | None = None,
) -> dict:
"""Poll selector's text/value until it matches regex pattern.
Returns ``{"selector": ..., "value": ..., "pattern": ...}`` when matched.
"""
return self._c._cmd("dom.poll", {
"selector": selector,
"pattern": pattern,
"attr": attr,
"timeout": int(timeout * 1000),
"interval": int(interval * 1000),
"tabId": tab_id,
})
Returns ``{"selector": ..., "value": ..., "pattern": ...}`` when matched.
"""
return self.command("dom.poll", {
"selector": selector,
"pattern": pattern,
"attr": attr,
"timeout": int(timeout * 1000),
"interval": int(interval * 1000),
"tabId": tab_id,
})
def wait_for(
self,
selector: str,
*,
timeout: float = 10.0,
visible: bool = False,
hidden: bool = False,
tab_id: int | None = None,
) -> dict:
"""Wait until a CSS selector appears (or disappears) in the DOM.
def wait_for(
self,
selector: str,
*,
timeout: float = 10.0,
visible: bool = False,
hidden: bool = False,
tab_id: int | None = None,
) -> dict:
"""Wait until a CSS selector appears (or disappears) in the DOM.
Args:
selector: CSS selector to watch.
timeout: Max seconds to wait before raising ``RuntimeError``.
visible: Wait until the element has non-zero dimensions.
hidden: Wait until the element is absent or has ``offsetParent == null``.
tab_id: Tab to watch. Defaults to the active tab.
"""
return self._c._cmd("dom.wait_for", {
"selector": selector,
"timeout": int(timeout * 1000),
"visible": visible,
"hidden": hidden,
"tabId": tab_id,
})
Args:
selector: CSS selector to watch.
timeout: Max seconds to wait before raising ``RuntimeError``.
visible: Wait until the element has non-zero dimensions.
hidden: Wait until the element is absent or has ``offsetParent == null``.
tab_id: Tab to watch. Defaults to the active tab.
"""
return self.command("dom.wait_for", {
"selector": selector,
"timeout": int(timeout * 1000),
"visible": visible,
"hidden": hidden,
"tabId": tab_id,
})
class ExtractNS(Namespace):
"""Extract structured content from the active tab."""
"""Extract structured content from the active tab."""
def links(self) -> list[dict]:
return self._c._cmd("extract.links", {}) or []
@sdk_command("extract.links", default=[])
def links(self) -> list[dict]:
"""Return links from the active tab."""
def images(self) -> list[dict]:
return self._c._cmd("extract.images", {}) or []
@sdk_command("extract.images", default=[])
def images(self) -> list[dict]:
"""Return images from the active tab."""
def text(self) -> str:
return self._c._cmd("extract.text", {}) or ""
@sdk_command("extract.text", default="")
def text(self) -> str:
"""Return plain text from the active tab."""
def json(self, selector: str):
return self._c._cmd("extract.json", {"selector": selector})
@sdk_command("extract.json", lambda self, selector: {"selector": selector})
def json(self, selector: str):
"""Extract JSON-like structured data from a selector."""
def html(self) -> str:
"""Return the full HTML source of the active tab."""
return self._c._cmd("extract.html", {}) or ""
@sdk_command("extract.html", default="")
def html(self) -> str:
"""Return the full HTML source of the active tab."""
def markdown(self, selector: str | None = None) -> str:
"""Extract the page's main content as clean Markdown.
@sdk_command("extract.markdown", lambda self, selector=None: {"selector": selector}, mapper=_extract_markdown)
def markdown(self, selector: str | None = None) -> str:
"""Extract the page's main content as clean Markdown.
The extractor may return either Markdown or raw HTML; both are
normalized to Markdown here so SDK and CLI callers get identical output.
"""
from browser_cli.markdown import render_markdown
return render_markdown(self._c._cmd("extract.markdown", {"selector": selector}))
The extractor may return either Markdown or raw HTML; both are
normalized to Markdown here so SDK and CLI callers get identical output.
"""
class PageNS(Namespace):
"""Inspect the active page."""
"""Inspect the active page."""
def info(self) -> dict:
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
return self._c._cmd("page.info", {}) or {}
@sdk_command("page.info", default={})
def info(self) -> dict:
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
+9 -9
View File
@@ -1,16 +1,16 @@
"""Extension-control namespace: ``b.extension.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.base import Namespace, sdk_command
class ExtensionNS(Namespace):
"""Control the browser-cli extension itself."""
"""Control the browser-cli extension itself."""
def reload(self) -> None:
"""Reload the browser-cli extension service worker.
@sdk_command("extension.reload")
def reload(self) -> None:
"""Reload the browser-cli extension service worker.
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
immediately. The extension restarts ~200 ms later and reconnects via the
keepalive alarm within ~25 seconds.
"""
self._c._cmd("extension.reload", {})
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
immediately. The extension restarts ~200 ms later and reconnects via the
keepalive alarm within ~25 seconds.
"""
+22 -13
View File
@@ -7,8 +7,13 @@ client targeting the browser it came from, so ``tab.close()`` routes correctly.
"""
from __future__ import annotations
from typing import Any, Protocol, cast
from browser_cli.models import Group, Tab
class _FactoryClient(Protocol):
_key: str | None
class FactoryMixin:
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
@@ -16,7 +21,7 @@ class FactoryMixin:
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
"""
def _make_tab(
def tab_from(
self,
data: dict,
*,
@@ -34,20 +39,22 @@ class FactoryMixin:
group_id=data.get("groupId") or None,
browser=browser_name,
)
tab._browser = self if browser_profile is None else type(self)(
client = cast(_FactoryClient, self)
tab._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=self._key,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return tab
def _require_tab(self, data, error: str) -> Tab:
def require_tab_response(self, data, error: str) -> Tab:
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(error)
return self._make_tab(data)
return self.tab_from(data)
def _make_group(
def group_from(
self,
data: dict,
*,
@@ -63,25 +70,27 @@ class FactoryMixin:
tab_count=data.get("tabCount", 0),
browser=browser_name,
)
group._browser = self if browser_profile is None else type(self)(
client = cast(_FactoryClient, self)
group._browser = self if browser_profile is None else cast(Any, type(self))(
browser=browser_profile,
remote=browser_remote,
key=self._key,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return group
def _make_tab_for(self, data: dict, target) -> Tab:
def tab_from_target(self, data: dict, target) -> Tab:
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self._make_tab(
return self.tab_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None,
)
def _make_group_for(self, data: dict, target) -> Group:
def group_from_target(self, data: dict, target) -> Group:
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self._make_group(
return self.group_from(
data,
browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None,
@@ -89,6 +98,6 @@ class FactoryMixin:
)
@staticmethod
def _tag_browser(item: dict, target) -> dict:
def tag_browser(item: dict, target) -> dict:
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
return item if target is None else {**item, "browser": target.display_name}
+35 -40
View File
@@ -1,56 +1,51 @@
"""Tab groups namespace: ``b.groups.*``."""
from __future__ import annotations
from typing import TYPE_CHECKING
from browser_cli.models import Group, Tab
from browser_cli.models import BrowserCounts, Group, Tab
from browser_cli.sdk.base import Namespace
if TYPE_CHECKING:
from browser_cli import BrowserCounts
class GroupsNS(Namespace):
"""List, create, query, and modify tab groups."""
"""List, create, query, and modify tab groups."""
def list(self) -> list[Group]:
"""Return all tab groups.
def list(self) -> list[Group]:
"""Return all tab groups.
When multiple browsers are active and no browser was specified, each Group
includes ``group.browser`` naming its source browser.
"""
return self._c._multi_list("group.list", {}, self._c._make_group_for)
When multiple browsers are active and no browser was specified, each Group
includes ``group.browser`` naming its source browser.
"""
return self.multi_list("group.list", {}, self.group_from_target)
def count(self) -> "int | BrowserCounts":
"""Return the number of tab groups.
def count(self) -> "int | BrowserCounts":
"""Return the number of tab groups.
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
return self._c._multi_count("group.count", {})
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
return self.multi_count("group.count", {})
def query(self, search: str) -> list[Group]:
"""Search groups by name."""
return [self._c._make_group(g) for g in (self._c._cmd("group.query", {"search": search}) or [])]
def query(self, search: str) -> list[Group]:
"""Search groups by name."""
return [self.group_from(g) for g in (self.command("group.query", {"search": search}) or [])]
def create(self, name: str) -> Group:
"""Create a new tab group with *name*. Returns the created Group."""
data = self._c._cmd("group.open", {"name": name})
if isinstance(data, dict):
return self._c._make_group(data)
return Group(id=data, title=name, color="", collapsed=False, tab_count=0)
def create(self, name: str) -> Group:
"""Create a new tab group with *name*. Returns the created Group."""
data = self.command("group.open", {"name": name})
if isinstance(data, dict):
return self.group_from(data)
return Group(id=data, title=name, color="", collapsed=False, tab_count=0)
def tabs(self, group_id: int) -> list[Tab]:
"""Return all tabs inside a group."""
return [self._c._make_tab(t) for t in (self._c._cmd("group.tabs", {"groupId": group_id}) or [])]
def tabs(self, group_id: int) -> list[Tab]:
"""Return all tabs inside a group."""
return [self.tab_from(t) for t in (self.command("group.tabs", {"groupId": group_id}) or [])]
def add_tab(self, group: str | int, url: str | None = None) -> int | None:
"""Open a new tab (optionally at URL) inside a group. Returns the new tab ID."""
result = self._c._cmd("group.add_tab", {"group": str(group), "url": url})
return self._c._field(result, "tabId", fallback=result)
def add_tab(self, group: str | int, url: str | None = None) -> int | None:
"""Open a new tab (optionally at URL) inside a group. Returns the new tab ID."""
result = self.command("group.add_tab", {"group": str(group), "url": url})
return self.field(result, "tabId", fallback=result)
def move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> dict | None:
"""Move a tab group forward or backward. Returns the raw move result."""
return self._c._cmd("group.move", {"group": str(group), "forward": forward, "backward": backward})
def move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> dict | None:
"""Move a tab group forward or backward. Returns the raw move result."""
return self.command("group.move", {"group": str(group), "forward": forward, "backward": backward})
def close(self, group_id: int, *, gentle_mode: str = "auto") -> None:
"""Ungroup (and close) a tab group by ID."""
self._c._cmd("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
def close(self, group_id: int, *, gentle_mode: str = "auto") -> None:
"""Ungroup (and close) a tab group by ID."""
self.command("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
+58 -48
View File
@@ -2,62 +2,72 @@
from __future__ import annotations
from browser_cli.models import Tab
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.base import Namespace, sdk_command
def _open_args(self, url, *, background=False, window=None, group=None):
return {"url": url, "background": background, "window": window, "group": group}
def _tab_args(self, tab_id=None):
return {"tabId": tab_id}
class NavigationNS(Namespace):
"""Open URLs, navigate history, and focus tabs."""
"""Open URLs, navigate history, and focus tabs."""
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
"""Open *url* in a new tab."""
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
@sdk_command("navigate.open", _open_args)
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
"""Open *url* in a new tab."""
def open_wait(
self,
url: str,
*,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> Tab:
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
return self._c._require_tab(
self._c._cmd("navigate.open_wait", {
"url": url, "timeout": int(timeout * 1000),
"background": background, "window": window, "group": group,
}),
"navigate.open_wait returned unexpected data",
)
def open_wait(
self,
url: str,
*,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> Tab:
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
return self.require_tab(
self.command("navigate.open_wait", {
"url": url, "timeout": int(timeout * 1000),
"background": background, "window": window, "group": group,
}),
"navigate.open_wait returned unexpected data",
)
def reload(self, tab_id: int | None = None) -> None:
self._c._cmd("navigate.reload", {"tabId": tab_id})
@sdk_command("navigate.reload", _tab_args)
def reload(self, tab_id: int | None = None) -> None:
"""Reload the active tab or a specific tab."""
def hard_reload(self, tab_id: int | None = None) -> None:
self._c._cmd("navigate.hard_reload", {"tabId": tab_id})
@sdk_command("navigate.hard_reload", _tab_args)
def hard_reload(self, tab_id: int | None = None) -> None:
"""Hard-reload the active tab or a specific tab."""
def back(self, tab_id: int | None = None) -> None:
self._c._cmd("navigate.back", {"tabId": tab_id})
@sdk_command("navigate.back", _tab_args)
def back(self, tab_id: int | None = None) -> None:
"""Navigate back in the active tab or a specific tab."""
def forward(self, tab_id: int | None = None) -> None:
self._c._cmd("navigate.forward", {"tabId": tab_id})
@sdk_command("navigate.forward", _tab_args)
def forward(self, tab_id: int | None = None) -> None:
"""Navigate forward in the active tab or a specific tab."""
def focus(self, pattern: str) -> dict | None:
"""Focus the first tab whose URL matches *pattern*. Returns the matched tab info, if any."""
return self._c._cmd("navigate.focus", {"pattern": pattern})
@sdk_command("navigate.focus", lambda self, pattern: {"pattern": pattern})
def focus(self, pattern: str) -> dict | None:
"""Focus the first tab whose URL matches *pattern*. Returns the matched tab info, if any."""
def to(self, tab_id: int, url: str) -> None:
"""Navigate a specific tab to *url* in place."""
self._c._cmd("navigate.to", {"tabId": tab_id, "url": url})
@sdk_command("navigate.to", lambda self, tab_id, url: {"tabId": tab_id, "url": url})
def to(self, tab_id: int, url: str) -> None:
"""Navigate a specific tab to *url* in place."""
def search(
self, engine: str, query: str, *,
background: bool = False, window: str | None = None, group: str | None = None,
) -> None:
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
from urllib.parse import quote_plus
from browser_cli.commands.search import ENGINES
template = ENGINES.get(engine)
if template is None:
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
url = template.format(query=quote_plus(query))
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
def search(
self, engine: str, query: str, *,
background: bool = False, window: str | None = None, group: str | None = None,
) -> None:
"""Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
from urllib.parse import quote_plus
from browser_cli.commands.search import ENGINES
template = ENGINES.get(engine)
if template is None:
raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
url = template.format(query=quote_plus(query))
self.command("navigate.open", {"url": url, "background": background, "window": window, "group": group})
+14 -10
View File
@@ -1,19 +1,23 @@
"""Performance and background-jobs namespace: ``b.perf.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.base import Namespace, sdk_command
class PerfNS(Namespace):
"""Inspect the performance profile and manage background jobs."""
"""Inspect the performance profile and manage background jobs."""
def status(self) -> dict:
return self._c._cmd("perf.status", {}) or {}
@sdk_command("perf.status", default={})
def status(self) -> dict:
"""Return current performance profile, throttle info, and running jobs."""
def set_profile(self, profile: str) -> dict:
return self._c._cmd("perf.set_profile", {"profile": profile}) or {}
@sdk_command("perf.set_profile", lambda self, profile: {"profile": profile}, default={})
def set_profile(self, profile: str) -> dict:
"""Set the global extension performance profile."""
def job_status(self, job_id: str) -> dict:
return self._c._cmd("jobs.status", {"jobId": job_id}) or {}
@sdk_command("jobs.status", lambda self, job_id: {"jobId": job_id}, default={})
def job_status(self, job_id: str) -> dict:
"""Return status/progress for a background job."""
def job_cancel(self, job_id: str) -> dict:
return self._c._cmd("jobs.cancel", {"jobId": job_id}) or {}
@sdk_command("jobs.cancel", lambda self, job_id: {"jobId": job_id}, default={})
def job_cancel(self, job_id: str) -> dict:
"""Request cancellation for a background job."""
+118 -92
View File
@@ -7,117 +7,143 @@ helpers; single-browser mode falls straight through to ``_cmd``.
"""
from __future__ import annotations
import importlib
import sys
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Protocol, cast
import browser_cli as _pkg
from browser_cli.client import BrowserNotConnected
from browser_cli.client import BrowserTarget
from browser_cli.errors import BrowserNotConnected
from browser_cli.models import BrowserCounts, Tab
if TYPE_CHECKING:
from browser_cli.sdk.tabs import TabsNS
class _RoutingClient(Protocol):
_browser: str | None
_remote: str | None
_key: str | None
tabs: "TabsNS"
def dispatch(self, command: str, args: dict | None = None): ...
# send_command / active_browser_targets / remote_browser_targets are resolved
# through the ``browser_cli`` package namespace (``_pkg``) at call time, not bound
# here at import, so tests patching ``browser_cli.send_command`` still take effect.
# through the ``browser_cli`` package namespace at call time, not bound here at
# import, so tests patching ``browser_cli.send_command`` still take effect
# without a module-level import cycle back into browser_cli.__init__.
_UNSET = object()
def _browser_cli_package():
return sys.modules.get("browser_cli") or importlib.import_module("browser_cli")
class RoutingMixin:
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
Relies on the client exposing ``_browser``/``_remote``/``_key``, ``_cmd``,
and a ``tabs`` namespace.
"""
Relies on the client exposing ``_browser``/``_remote``/``_key``, ``_cmd``,
and a ``tabs`` namespace.
"""
def _multi_browser_targets(self):
if self._browser is not None:
return []
if self._remote:
targets = _pkg.remote_browser_targets(self._remote, key=self._key)
@property
def _client(self) -> _RoutingClient:
return cast(_RoutingClient, cast(object, self))
def _multi_browser_targets(self) -> list[BrowserTarget]:
client = self._client
package = _browser_cli_package()
if client._browser is not None:
return []
if client._remote:
targets = package.remote_browser_targets(client._remote, key=client._key)
else:
targets = package.active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
targets = self._multi_browser_targets()
for target in targets:
try:
if target.remote:
data = _browser_cli_package().send_command(
command, args, profile=target.profile, remote=target.remote, key=self._client._key
)
else:
targets = _pkg.active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
data = _browser_cli_package().send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
continue
results.append((target, data))
if results:
return results
if targets:
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."
)
return []
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
targets = self._multi_browser_targets()
for target in targets:
try:
if target.remote:
data = _pkg.send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
else:
data = _pkg.send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
continue
results.append((target, data))
if results:
return results
if targets:
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."
)
return []
@staticmethod
def _field(result, key, default=None, *, fallback=_UNSET):
"""Pull *key* out of a dict response, with a non-dict fallback.
@staticmethod
def _field(result, key, default=None, *, fallback=_UNSET):
"""Pull *key* out of a dict response, with a non-dict fallback.
Returns ``result[key]`` (or *default*) when *result* is a dict. When it
is not a dict, returns *fallback* if given, else *default*.
"""
if isinstance(result, dict):
return result.get(key, default)
return default if fallback is _UNSET else fallback
Returns ``result[key]`` (or *default*) when *result* is a dict. When it
is not a dict, returns *fallback* if given, else *default*.
"""
if isinstance(result, dict):
return result.get(key, default)
return default if fallback is _UNSET else fallback
def toggle_tab(self, command: str, tab_id: int | None) -> int:
"""Run a tab toggle command (mute/pin/...) and return the target tab ID."""
result = self._client.dispatch(command, {"tabId": tab_id})
return self._field(result, "tabId", tab_id, fallback=int(tab_id or 0))
def _toggle_tab(self, command: str, tab_id: int | None) -> int:
"""Run a tab toggle command (mute/pin/...) and return the target tab ID."""
result = self._cmd(command, {"tabId": tab_id})
return self._field(result, "tabId", tab_id, fallback=int(tab_id or 0))
def multi_count(self, command: str, args: dict | None = None) -> "int | BrowserCounts":
"""Count command that aggregates into :class:`BrowserCounts` in multi-browser mode."""
multi_results = self._collect_multi_browser(command, args or {})
if not multi_results:
return self._client.dispatch(command, args or {})
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
def _multi_count(self, command: str, args: dict | None = None) -> "int | BrowserCounts":
"""Count command that aggregates into :class:`BrowserCounts` in multi-browser mode."""
multi_results = self._collect_multi_browser(command, args or {})
if not multi_results:
return self._cmd(command, args or {})
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
def multi_list(self, command: str, args: dict | None, mapper):
"""List command, flattening per-browser results in multi-browser mode.
def _multi_list(self, command: str, args: dict | None, mapper):
"""List command, flattening per-browser results in multi-browser mode.
*mapper* is ``(item, target) -> mapped`` where ``target`` is the source
:class:`BrowserTarget` in multi mode, or ``None`` in single-browser mode.
"""
multi_results = self._collect_multi_browser(command, args or {})
if multi_results:
return [
mapper(item, target)
for target, items in multi_results
for item in (items or [])
]
return [mapper(item, None) for item in (self._client.dispatch(command, args or {}) or [])]
*mapper* is ``(item, target) -> mapped`` where ``target`` is the source
:class:`BrowserTarget` in multi mode, or ``None`` in single-browser mode.
"""
multi_results = self._collect_multi_browser(command, args or {})
if multi_results:
return [
mapper(item, target)
for target, items in multi_results
for item in (items or [])
]
return [mapper(item, None) for item in (self._cmd(command, args or {}) or [])]
def apply_tab_filter(self, filter_fn: Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
tabs = self._client.tabs.list()
def _apply_tab_filter(self, filter_fn: Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
tabs = self.tabs.list()
try:
transformed = filter_fn(tabs)
except (AttributeError, TypeError):
return [tab for tab in tabs if filter_fn(tab)]
try:
transformed = filter_fn(tabs)
except (AttributeError, TypeError):
return [tab for tab in tabs if filter_fn(tab)]
if isinstance(transformed, list):
return transformed
if isinstance(transformed, tuple):
return list(transformed)
if isinstance(transformed, set):
return list(transformed)
if transformed is tabs:
return tabs
if isinstance(transformed, bool):
return [tab for tab in tabs if filter_fn(tab)]
if isinstance(transformed, list):
return transformed
if isinstance(transformed, tuple):
return list(transformed)
if isinstance(transformed, set):
return list(transformed)
if transformed is tabs:
return tabs
if isinstance(transformed, bool):
return [tab for tab in tabs if filter_fn(tab)]
try:
return list(transformed)
except TypeError:
return [tab for tab in tabs if filter_fn(tab)]
try:
return list(transformed)
except TypeError:
return [tab for tab in tabs if filter_fn(tab)]
+51 -50
View File
@@ -1,63 +1,64 @@
"""Session namespace: ``b.session.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.base import Namespace, sdk_command
def _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs) -> dict:
return {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
}
class SessionNS(Namespace):
"""Save, restore, list, and diff browser sessions."""
"""Save, restore, list, and diff browser sessions."""
def save(self, name: str) -> dict:
"""Save all current tabs as session *name*. Returns the save result (incl. tab count)."""
return self._c._cmd("session.save", {"name": name}) or {}
@sdk_command("session.save", lambda self, name: {"name": name}, default={})
def save(self, name: str) -> dict:
"""Save all current tabs as session *name*. Returns the save result (incl. tab count)."""
@staticmethod
def _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs) -> dict:
return {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
}
def load(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
"""Restore session *name*. Returns the load result (incl. tabs opened)."""
return self.command("session.load", _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)) or {}
def load(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
"""Restore session *name*. Returns the load result (incl. tabs opened)."""
args = self._load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
return self._c._cmd("session.load", args) or {}
def load_background(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
"""Restore session *name* as a background job. Returns the job descriptor."""
args = _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
return self.command("session.load", {**args, "__background": True}) or {}
def load_background(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
"""Restore session *name* as a background job. Returns the job descriptor."""
args = self._load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
return self._c._cmd("session.load", {**args, "__background": True}) or {}
@sdk_command("session.diff", lambda self, name_a, name_b: {"nameA": name_a, "nameB": name_b}, default={})
def diff(self, name_a: str, name_b: str) -> dict:
"""Diff two saved sessions."""
def diff(self, name_a: str, name_b: str) -> dict:
return self._c._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
def list(self) -> list[dict]:
"""Return saved sessions.
def list(self) -> list[dict]:
"""Return saved sessions.
In implicit multi-browser mode each session dict includes a ``browser`` key.
"""
return self.multi_list("session.list", {}, self.tag_browser)
In implicit multi-browser mode each session dict includes a ``browser`` key.
"""
return self._c._multi_list("session.list", {}, self._c._tag_browser)
@sdk_command("session.remove", lambda self, name: {"name": name}, return_result=False)
def remove(self, name: str) -> None:
"""Remove a saved session."""
def remove(self, name: str) -> None:
self._c._cmd("session.remove", {"name": name})
def auto_save(self, enabled: bool) -> None:
self._c._cmd("session.auto_save", {"enabled": enabled})
@sdk_command("session.auto_save", lambda self, enabled: {"enabled": enabled}, return_result=False)
def auto_save(self, enabled: bool) -> None:
"""Enable or disable automatic session saves."""
+171 -175
View File
@@ -2,214 +2,210 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING
from browser_cli.models import Tab
from browser_cli.models import BrowserCounts, Tab
from browser_cli.sdk.base import Namespace
if TYPE_CHECKING:
from browser_cli import BrowserCounts
class TabsNS(Namespace):
"""List, open, close, move, and inspect browser tabs."""
"""List, open, close, move, and inspect browser tabs."""
def list(self) -> list[Tab]:
"""Return all open tabs across all windows.
def list(self) -> list[Tab]:
"""Return all open tabs across all windows.
When multiple browsers are active and no browser was specified, each Tab
includes ``tab.browser`` naming its source browser.
"""
return self._c._multi_list("tabs.list", {}, self._c._make_tab_for)
When multiple browsers are active and no browser was specified, each Tab
includes ``tab.browser`` naming its source browser.
"""
return self.multi_list("tabs.list", {}, self.tab_from_target)
def open(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> Tab:
"""Open *url* in a new tab and return a bound :class:`Tab`.
def open(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> Tab:
"""Open *url* in a new tab and return a bound :class:`Tab`.
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
"""
if wait:
return self._c.nav.open_wait(url, timeout=timeout, background=background, window=window, group=group)
return self._c._require_tab(
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group}),
"navigate.open returned unexpected data",
)
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
"""
if wait:
return self._c.nav.open_wait(url, timeout=timeout, background=background, window=window, group=group)
return self.require_tab(
self.command("navigate.open", {"url": url, "background": background, "window": window, "group": group}),
"navigate.open returned unexpected data",
)
def get(self, tab_id: int) -> Tab:
"""Return a specific tab by ID."""
return self.status(tab_id)
def get(self, tab_id: int) -> Tab:
"""Return a specific tab by ID."""
return self.status(tab_id)
def active(self) -> Tab:
"""Return the active tab."""
return self.status()
def active(self) -> Tab:
"""Return the active tab."""
return self.status()
def query(self, search: str) -> list[Tab]:
"""Search tabs by URL or title."""
return [self._c._make_tab(t) for t in (self._c._cmd("tabs.query", {"search": search}) or [])]
def query(self, search: str) -> list[Tab]:
"""Search tabs by URL or title."""
return [self.tab_from(t) for t in (self.command("tabs.query", {"search": search}) or [])]
def first(self, search: str) -> Tab | None:
"""Return the first tab matching *search*, or ``None``."""
matches = self.query(search)
return matches[0] if matches else None
def first(self, search: str) -> Tab | None:
"""Return the first tab matching *search*, or ``None``."""
matches = self.query(search)
return matches[0] if matches else None
def close(
self,
tab_id: int | None = None,
*,
tab_ids: Iterable[int | Tab] | None = None,
inactive: bool = False,
duplicates: bool = False,
gentle_mode: str = "auto",
) -> int:
"""Close tab(s). Returns the number of tabs closed.
def close(
self,
tab_id: int | None = None,
*,
tab_ids: Iterable[int | Tab] | None = None,
inactive: bool = False,
duplicates: bool = False,
gentle_mode: str = "auto",
) -> int:
"""Close tab(s). Returns the number of tabs closed.
Pass ``tab_ids`` to close many tabs in a single round-trip. Accepts tab
IDs or :class:`Tab` objects. ``gentle_mode`` (auto/normal/gentle/ultra)
controls throttling of large close operations.
"""
ids = None
if tab_ids is not None:
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
result = self._c._cmd("tabs.close", {
"tabId": tab_id,
"tabIds": ids,
"inactive": inactive,
"duplicates": duplicates,
"gentleMode": gentle_mode,
})
return self._c._field(result, "closed", 1)
Pass ``tab_ids`` to close many tabs in a single round-trip. Accepts tab
IDs or :class:`Tab` objects. ``gentle_mode`` (auto/normal/gentle/ultra)
controls throttling of large close operations.
"""
ids = None
if tab_ids is not None:
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
result = self.command("tabs.close", {
"tabId": tab_id,
"tabIds": ids,
"inactive": inactive,
"duplicates": duplicates,
"gentleMode": gentle_mode,
})
return self.field(result, "closed", 1)
def close_inactive(self) -> int:
"""Close all inactive tabs. Returns count closed."""
return self._c._field(self._c._cmd("tabs.close", {"inactive": True}), "closed", 0)
def close_inactive(self) -> int:
"""Close all inactive tabs. Returns count closed."""
return self.field(self.command("tabs.close", {"inactive": True}), "closed", 0)
def close_duplicates(self) -> int:
"""Close duplicate tabs. Returns count closed."""
return self._c._field(self._c._cmd("tabs.close", {"duplicates": True}), "closed", 0)
def close_duplicates(self) -> int:
"""Close duplicate tabs. Returns count closed."""
return self.field(self.command("tabs.close", {"duplicates": True}), "closed", 0)
def move(
self, tab_id: int, *,
forward: bool = False, backward: bool = False,
group_id: int | None = None, window_id: int | None = None, index: int | None = None,
) -> None:
self._c._cmd("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index,
})
def move(
self, tab_id: int, *,
forward: bool = False, backward: bool = False,
group_id: int | None = None, window_id: int | None = None, index: int | None = None,
) -> None:
self.command("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index,
})
def activate(self, tab_id: int) -> None:
"""Switch browser focus to a tab by ID."""
self._c._cmd("tabs.active", {"tabId": tab_id})
def activate(self, tab_id: int) -> None:
"""Switch browser focus to a tab by ID."""
self.command("tabs.active", {"tabId": tab_id})
def status(self, tab_id: int | None = None) -> Tab:
"""Return status for the active tab or a specific tab."""
return self._c._require_tab(self._c._cmd("tabs.status", {"tabId": tab_id}), "No tab status returned")
def status(self, tab_id: int | None = None) -> Tab:
"""Return status for the active tab or a specific tab."""
return self.require_tab(self.command("tabs.status", {"tabId": tab_id}), "No tab status returned")
def mute(self, tab_id: int | None = None) -> int:
"""Mute the active tab or a specific tab. Returns the target tab ID."""
return self._c._toggle_tab("tabs.mute", tab_id)
def mute(self, tab_id: int | None = None) -> int:
"""Mute the active tab or a specific tab. Returns the target tab ID."""
return self.toggle_tab("tabs.mute", tab_id)
def unmute(self, tab_id: int | None = None) -> int:
"""Unmute the active tab or a specific tab. Returns the target tab ID."""
return self._c._toggle_tab("tabs.unmute", tab_id)
def unmute(self, tab_id: int | None = None) -> int:
"""Unmute the active tab or a specific tab. Returns the target tab ID."""
return self.toggle_tab("tabs.unmute", tab_id)
def pin(self, tab_id: int | None = None) -> int:
"""Pin the active tab or a specific tab. Returns the target tab ID."""
return self._c._toggle_tab("tabs.pin", tab_id)
def pin(self, tab_id: int | None = None) -> int:
"""Pin the active tab or a specific tab. Returns the target tab ID."""
return self.toggle_tab("tabs.pin", tab_id)
def unpin(self, tab_id: int | None = None) -> int:
"""Unpin the active tab or a specific tab. Returns the target tab ID."""
return self._c._toggle_tab("tabs.unpin", tab_id)
def unpin(self, tab_id: int | None = None) -> int:
"""Unpin the active tab or a specific tab. Returns the target tab ID."""
return self.toggle_tab("tabs.unpin", tab_id)
def watch_url(self, pattern: str, *, tab_id: int | None = None, timeout: float = 30.0) -> Tab:
"""Block until the tab URL matches regex pattern. Returns the Tab."""
return self._c._require_tab(
self._c._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)}),
"tabs.watch_url returned unexpected data",
)
def watch_url(self, pattern: str, *, tab_id: int | None = None, timeout: float = 30.0) -> Tab:
"""Block until the tab URL matches regex pattern. Returns the Tab."""
return self.require_tab(
self.command("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)}),
"tabs.watch_url returned unexpected data",
)
def wait_for_load(
self,
tab_id: int | None = None,
*,
timeout: float = 30.0,
ready_state: str = "complete",
) -> Tab:
"""Block until the tab finishes loading. Returns the Tab when ready.
def wait_for_load(
self,
tab_id: int | None = None,
*,
timeout: float = 30.0,
ready_state: str = "complete",
) -> Tab:
"""Block until the tab finishes loading. Returns the Tab when ready.
Args:
tab_id: Tab to watch. Defaults to the active tab.
timeout: Max seconds to wait before raising ``RuntimeError``.
ready_state: ``"complete"`` (default) or ``"interactive"``.
"""
return self._c._require_tab(
self._c._cmd("navigate.wait", {
"tabId": tab_id,
"timeout": int(timeout * 1000),
"readyState": ready_state,
}),
"navigate.wait returned unexpected data",
)
Args:
tab_id: Tab to watch. Defaults to the active tab.
timeout: Max seconds to wait before raising ``RuntimeError``.
ready_state: ``"complete"`` (default) or ``"interactive"``.
"""
return self.require_tab(
self.command("navigate.wait", {
"tabId": tab_id,
"timeout": int(timeout * 1000),
"readyState": ready_state,
}),
"navigate.wait returned unexpected data",
)
def screenshot(
self,
tab_id: int | None = None,
*,
format: str = "png",
quality: int | None = None,
) -> str:
"""Capture the visible area of a tab. Returns a base64 data URL.
def screenshot(
self,
tab_id: int | None = None,
*,
format: str = "png",
quality: int | None = None,
) -> str:
"""Capture the visible area of a tab. Returns a base64 data URL.
Args:
tab_id: Tab to capture. Defaults to the active tab.
format: ``"png"`` (default) or ``"jpeg"``.
quality: JPEG quality 0-100 (ignored for PNG).
"""
result = self._c._cmd("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
return self._c._field(result, "dataUrl", "", fallback=str(result))
Args:
tab_id: Tab to capture. Defaults to the active tab.
format: ``"png"`` (default) or ``"jpeg"``.
quality: JPEG quality 0-100 (ignored for PNG).
"""
result = self.command("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
return self.field(result, "dataUrl", "", fallback=str(result))
def active_in_window(self, window_id: int) -> Tab:
"""Return active tab for a specific browser window."""
return self._c._require_tab(
self._c._cmd("tabs.active_in_window", {"windowId": window_id}),
f"No active tab found for window {window_id}",
)
def active_in_window(self, window_id: int) -> Tab:
"""Return active tab for a specific browser window."""
return self.require_tab(
self.command("tabs.active_in_window", {"windowId": window_id}),
f"No active tab found for window {window_id}",
)
def filter(
self,
pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]],
) -> list[Tab]:
"""Return tabs filtered by URL pattern or a Python callable."""
if isinstance(pattern_or_filter, str):
return [self._c._make_tab(t) for t in (self._c._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])]
return self._c._apply_tab_filter(pattern_or_filter)
def filter(
self,
pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]],
) -> list[Tab]:
"""Return tabs filtered by URL pattern or a Python callable."""
if isinstance(pattern_or_filter, str):
return [self.tab_from(t) for t in (self.command("tabs.filter", {"pattern": pattern_or_filter}) or [])]
return self.apply_tab_filter(pattern_or_filter)
def count(self, pattern: str | None = None) -> "int | BrowserCounts":
"""Count open tabs, optionally filtered by URL pattern.
def count(self, pattern: str | None = None) -> "int | BrowserCounts":
"""Count open tabs, optionally filtered by URL pattern.
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
return self._c._multi_count("tabs.count", {"pattern": pattern})
Returns ``BrowserCounts`` in implicit multi-browser mode.
"""
return self.multi_count("tabs.count", {"pattern": pattern})
def html(self, tab_id: int | None = None) -> str:
"""Return the full HTML source of the active (or specified) tab."""
return self._c._cmd("tabs.html", {"tabId": tab_id}) or ""
def html(self, tab_id: int | None = None) -> str:
"""Return the full HTML source of the active (or specified) tab."""
return self.command("tabs.html", {"tabId": tab_id}) or ""
def dedupe(self, *, gentle_mode: str = "auto") -> int:
"""Close duplicate tabs (keep the first occurrence of each URL). Returns count closed."""
return self._c._field(self._c._cmd("tabs.dedupe", {"gentleMode": gentle_mode}), "closed", 0)
def dedupe(self, *, gentle_mode: str = "auto") -> int:
"""Close duplicate tabs (keep the first occurrence of each URL). Returns count closed."""
return self.field(self.command("tabs.dedupe", {"gentleMode": gentle_mode}), "closed", 0)
def sort(self, by: str = "domain", *, gentle_mode: str = "auto") -> None:
"""Sort tabs within each window. *by* is one of 'domain', 'title', 'time'."""
self._c._cmd("tabs.sort", {"by": by, "gentleMode": gentle_mode})
def sort(self, by: str = "domain", *, gentle_mode: str = "auto") -> None:
"""Sort tabs within each window. *by* is one of 'domain', 'title', 'time'."""
self.command("tabs.sort", {"by": by, "gentleMode": gentle_mode})
def merge_windows(self, *, gentle_mode: str = "auto") -> int:
"""Move all tabs into the focused window. Returns count moved."""
return self._c._field(self._c._cmd("tabs.merge_windows", {"gentleMode": gentle_mode}), "moved", 0)
def merge_windows(self, *, gentle_mode: str = "auto") -> int:
"""Move all tabs into the focused window. Returns count moved."""
return self.field(self.command("tabs.merge_windows", {"gentleMode": gentle_mode}), "moved", 0)
+16 -14
View File
@@ -1,24 +1,26 @@
"""Windows namespace: ``b.windows.*``."""
from __future__ import annotations
from browser_cli.sdk.base import Namespace
from browser_cli.sdk.base import Namespace, sdk_command
class WindowsNS(Namespace):
"""List, open, close, and rename browser windows."""
"""List, open, close, and rename browser windows."""
def list(self) -> list[dict]:
"""Return browser windows.
def list(self) -> list[dict]:
"""Return browser windows.
In implicit multi-browser mode each window dict includes a ``browser`` key.
"""
return self._c._multi_list("windows.list", {}, self._c._tag_browser)
In implicit multi-browser mode each window dict includes a ``browser`` key.
"""
return self.multi_list("windows.list", {}, self.tag_browser)
def open(self, url: str | None = None) -> dict:
"""Open a new browser window, optionally on a URL."""
return self._c._cmd("windows.open", {"url": url}) or {}
@sdk_command("windows.open", lambda self, url=None: {"url": url}, default={})
def open(self, url: str | None = None) -> dict:
"""Open a new browser window, optionally on a URL."""
def close(self, window_id: int) -> None:
self._c._cmd("windows.close", {"windowId": window_id})
@sdk_command("windows.close", lambda self, window_id: {"windowId": window_id}, return_result=False)
def close(self, window_id: int) -> None:
"""Close a browser window by ID."""
def rename(self, window_id: int, name: str) -> None:
self._c._cmd("windows.rename", {"windowId": window_id, "name": name})
@sdk_command("windows.rename", lambda self, window_id, name: {"windowId": window_id, "name": name}, return_result=False)
def rename(self, window_id: int, name: str) -> None:
"""Rename a browser window."""
+198
View File
@@ -0,0 +1,198 @@
"""Shared workflow decorator implementation for sync and async SDK clients."""
from __future__ import annotations
import functools
import time
from collections.abc import Callable
from typing import TypeVar
F = TypeVar("F", bound=Callable)
_NO_INJECT = object()
class WorkflowDecoratorsMixin:
"""Shared implementation for sync and async workflow decorators.
Subclasses only define *how* browser calls, user functions, and sleeps are
executed. The actual decorators live once here, so sync and async SDKs stay
in lockstep.
"""
_c: object
@staticmethod
def _inject(kwargs: dict, keyword: str | None, value):
if keyword is not None:
kwargs[keyword] = value
return (), kwargs
return (value,), kwargs
def _run(self, func: Callable, *args, **kwargs):
return func(*args, **kwargs)
def _call_wrapped(self, func: Callable, *args, **kwargs):
return func(*args, **kwargs)
def _sleep(self, delay: float) -> None:
time.sleep(delay)
def _value_decorator(
self,
func: F | None,
get_value: Callable,
*,
keyword: str | None | object = "tab",
cleanup: Callable | None = None,
):
"""Build a decorator around a browser-side value lookup.
``get_value`` always runs before the wrapped function. If ``keyword`` is
``_NO_INJECT`` the value is only used by ``cleanup`` and is not passed to
the wrapped function. ``keyword=None`` injects it positionally.
"""
def decorator(fn: F) -> F:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
value = self._run(get_value)
try:
extra_args = ()
if keyword is not _NO_INJECT:
extra_args, kwargs = self._inject(kwargs, keyword, value)
return self._call_wrapped(fn, *extra_args, *args, **kwargs)
finally:
if cleanup is not None:
self._run(cleanup, value)
return wrapper # type: ignore[return-value]
return decorator(func) if func is not None else decorator
def active_tab(self, func: F | None = None, *, keyword: str | None = "tab"):
"""Decorate a function so it receives the current active tab.
By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to
pass it as the first positional argument instead.
"""
return self._value_decorator(func, self._c.tabs.active, keyword=keyword) # type: ignore[attr-defined]
def new_tab(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
close: bool = False,
keyword: str | None = "tab",
):
"""Open *url* for the wrapped function and inject the created tab.
Set ``close=True`` to close the tab in a ``finally`` block after the
wrapped function returns or raises.
"""
def open_tab():
return self._c.tabs.open( # type: ignore[attr-defined]
url,
wait=wait,
timeout=timeout,
background=background,
window=window,
group=group,
)
def close_tab(tab):
tab.close()
return self._value_decorator(None, open_tab, keyword=keyword, cleanup=close_tab if close else None)
def wait_for_selector(
self,
selector: str,
*,
timeout: float = 10.0,
visible: bool = False,
hidden: bool = False,
tab_id: int | None = None,
keyword: str | None = None,
):
"""Wait for a selector before calling the wrapped function.
Pass ``keyword="result"`` (or similar) to inject the wait result into
the wrapped function. By default the result is not injected.
"""
def wait():
return self._c.dom.wait_for( # type: ignore[attr-defined]
selector,
timeout=timeout,
visible=visible,
hidden=hidden,
tab_id=tab_id,
)
inject = keyword if keyword is not None else _NO_INJECT
return self._value_decorator(None, wait, keyword=inject)
def wait_for_url(
self,
pattern: str,
*,
tab_id: int | None = None,
timeout: float = 30.0,
keyword: str | None = "tab",
):
"""Wait until a tab URL matches *pattern* before calling the function."""
def wait():
return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) # type: ignore[attr-defined]
inject = keyword if keyword is not None else _NO_INJECT
return self._value_decorator(None, wait, keyword=inject)
def performance_profile(self, profile: str, *, restore: bool = True):
"""Temporarily set the extension performance profile around a function."""
def decorator(fn: F) -> F:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
previous = None
if restore:
previous = self._run(self._c.perf.status).get("performanceProfile") # type: ignore[attr-defined]
self._run(self._c.perf.set_profile, profile) # type: ignore[attr-defined]
try:
return self._call_wrapped(fn, *args, **kwargs)
finally:
if previous:
self._run(self._c.perf.set_profile, previous) # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
return decorator
def save_session_before(self, name: str):
"""Save the current browser session before running the function."""
return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT) # type: ignore[attr-defined]
def retry(
self,
*,
times: int = 3,
delay: float = 0.0,
exceptions: tuple[type[BaseException], ...] = (Exception,),
):
"""Retry the wrapped function when it raises one of *exceptions*."""
attempts = max(1, times)
def decorator(fn: F) -> F:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(attempts):
try:
return self._call_wrapped(fn, *args, **kwargs)
except exceptions as exc:
last_error = exc
if attempt == attempts - 1:
raise
if delay > 0:
self._sleep(delay)
raise last_error # type: ignore[misc]
return wrapper # type: ignore[return-value]
return decorator
View File
+107
View File
@@ -0,0 +1,107 @@
"""Client validation and authentication for ``browser-cli serve``."""
from __future__ import annotations
import json
import re
from typing import Literal
from browser_cli.compat import adapt_auth
from browser_cli.serve.logging import log_request
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, parse_version
_UA_PATTERN = re.compile(r"^browser-cli/\d")
AuthDecodeResult = tuple[bytes | None, bool] | tuple[Literal[False], Literal[False]]
class ServeAuthMixin:
addr: tuple
command: str
client_ver: str
msg_id: object
nonce: str
pq_private_key: object | None
auth_keys: list[str] | None
response_secret: bytes | None
async def send_error(self, msg: str, msg_id=None) -> None: ...
async def validate_client(self, msg: dict) -> bool:
self.msg_id = msg.get("id")
ua = msg.get("user_agent") or ""
if not _UA_PATTERN.match(ua):
await self.send_error("forbidden: client required")
log_request(self.addr, msg.get("command", "?"), None, "DENIED", f"bad user-agent: {ua!r}")
return False
try:
self.client_ver = ua.split("/", 1)[1]
if parse_version(self.client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
await self.send_error(f"client version {self.client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
log_request(self.addr, msg.get("command", "?"), None, "DENIED", f"client {self.client_ver} < min {PROTOCOL_MIN_CLIENT}")
return False
except (IndexError, ValueError):
pass
return True
async def authenticate(self, msg: dict) -> dict | None:
if self.auth_keys is None:
return msg
pub = msg.get("pubkey") or ""
sig = msg.get("sig") or ""
if not pub or not sig:
await self.send_error("unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
log_request(self.addr, self.command, None, "DENIED", "missing pubkey/sig")
return None
if pub not in self.auth_keys:
await self.send_error("unauthorized: untrusted public key")
log_request(self.addr, self.command, None, "DENIED", "untrusted key")
return None
pq_shared_secret, transport_encrypted = await self._decode_pq_transport(msg, pub, sig)
if pq_shared_secret is False:
return None
from browser_cli.auth import verify
if not verify(pub, bytes.fromhex(self.nonce), msg, sig, pq_shared_secret):
await self.send_error("unauthorized: invalid signature")
log_request(self.addr, self.command, None, "DENIED", "bad signature")
return None
self.response_secret = pq_shared_secret if transport_encrypted else None
return msg
async def _decode_pq_transport(self, msg: dict, pub: str, sig: str) -> AuthDecodeResult:
pq_shared_secret = None
transport_encrypted = False
if self.pq_private_key is None:
return pq_shared_secret, transport_encrypted
kex = msg.get("pq_kex") or {}
pq_required = parse_version(self.client_ver) >= parse_version("0.9.5")
if not isinstance(kex, dict) or kex.get("alg") != "ML-KEM-768" or not kex.get("ciphertext"):
if pq_required:
await self.send_error("unauthorized: post-quantum key exchange required")
log_request(self.addr, self.command, None, "DENIED", "missing pq kex")
return False, False
return pq_shared_secret, transport_encrypted
try:
from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
pq_shared_secret = pq_kex_server_decapsulate(self.pq_private_key, str(kex["ciphertext"]))
if "encrypted" in msg:
decrypted_msg = json.loads(pq_decrypt(pq_shared_secret, "request", msg["encrypted"]))
if not isinstance(decrypted_msg, dict):
raise ValueError("encrypted request is not a JSON object")
decrypted_msg.update({"pubkey": pub, "sig": sig, "pq_kex": kex})
msg.clear()
msg.update(adapt_auth(decrypted_msg, self.client_ver))
self.msg_id = msg.get("id", self.msg_id)
self.command = msg.get("command", "?")
transport_encrypted = True
elif pq_required:
await self.send_error("unauthorized: post-quantum encrypted transport required")
log_request(self.addr, self.command, None, "DENIED", "missing pq transport")
return False, False
except Exception:
await self.send_error("unauthorized: invalid post-quantum encrypted transport")
log_request(self.addr, self.command, None, "DENIED", "bad pq transport")
return False, False
return pq_shared_secret, transport_encrypted
+59
View File
@@ -0,0 +1,59 @@
"""Built-in control commands handled directly by ``browser-cli serve``."""
from __future__ import annotations
import re
from pathlib import Path
from browser_cli.serve.logging import log_request
class ServeControlMixin:
addr: tuple
command: str
auth_keys_path: Path | None
async def send_error(self, msg: str, msg_id=None) -> None: ...
async def send_ok(self, payload, command: str | None = None) -> None: ...
async def handle_control_command(self, msg: dict) -> bool:
if self.command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
targets = [
{"profile": target.profile, "displayName": target.display_name}
for target in active_browser_targets(include_remotes=False)
]
await self.send_ok(targets, self.command)
log_request(self.addr, self.command, None, "OK")
return True
if self.command == "browser-cli.auth.keys":
if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(self.auth_keys_path)]
await self.send_ok(entries, self.command)
log_request(self.addr, self.command, None, "OK")
return True
if self.command == "browser-cli.auth.trust":
return await self._handle_trust(msg)
return False
async def _handle_trust(self, msg: dict) -> bool:
if self.auth_keys_path is None:
await self.send_error("no authorized keys file configured on this server")
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
return True
from browser_cli.auth import add_authorized_key
args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "")
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey")
return True
added = add_authorized_key(self.auth_keys_path, pubkey, name)
await self.send_ok({"added": added}, self.command)
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED")
return True
+16
View File
@@ -0,0 +1,16 @@
"""Shared logging helpers for ``browser-cli serve``."""
from __future__ import annotations
from datetime import datetime
from rich.console import Console
console = Console()
def log_request(addr: tuple, command: str, profile: str | None, status: str, error: str | None = None) -> None:
ts = datetime.now().strftime("%H:%M:%S")
addr_str = f"{addr[0]}:{addr[1]}"
profile_str = f"[dim]{profile}[/dim] " if profile else ""
if error:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
+79
View File
@@ -0,0 +1,79 @@
"""Proxying from TCP clients to the local browser native-host socket."""
from __future__ import annotations
import asyncio
import json
from browser_cli import transport
from browser_cli.compat import adapt_request, adapt_response
from browser_cli.framing import async_recv_frame, async_send_frame
from browser_cli.serve.logging import log_request
_STRIP_PROTOCOL_FIELDS = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
class ServeProxyMixin:
addr: tuple
profile: str | None
client_ver: str
command: str
compress: bool
accept_encoding: dict | None
async def send_error(self, msg: str, msg_id=None) -> None: ...
async def send_payload(self, data: bytes) -> None: ...
async def forward_to_browser(self, msg: dict) -> None:
from browser_cli.client import BrowserNotConnected
from browser_cli.client.targets import resolve_socket
from browser_cli.platform import is_windows
resolved_profile = msg.get("_route") or self.profile
clean_msg = {k: v for k, v in msg.items() if k not in _STRIP_PROTOCOL_FIELDS}
clean_payload = json.dumps(adapt_request(clean_msg, self.client_ver)).encode()
try:
sock_path = resolve_socket(resolved_profile)
except BrowserNotConnected as e:
await self.send_error(str(e))
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected")
return
try:
if is_windows():
resp_payload = await self._windows_roundtrip(sock_path, clean_payload)
else:
resp_payload = await self._unix_roundtrip(sock_path, clean_payload)
await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile)
except (OSError, json.JSONDecodeError, ConnectionError) as e:
await self.send_error(str(e))
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e))
async def _windows_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
from multiprocessing.connection import Client as PipeClient
def _pipe_roundtrip():
with PipeClient(sock_path, family="AF_PIPE") as pipe:
pipe.send_bytes(payload)
return pipe.recv_bytes()
return await asyncio.to_thread(_pipe_roundtrip)
async def _unix_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
local_reader, local_writer = await asyncio.open_unix_connection(sock_path)
try:
await async_send_frame(local_writer, payload)
return await async_recv_frame(local_reader) or b""
finally:
local_writer.close()
await local_writer.wait_closed()
async def send_browser_response(self, resp_payload: bytes, resolved_profile: str | None) -> None:
resp_data = json.loads(resp_payload)
if self.compress:
await self.send_payload(transport.encode_response(resp_data, self.accept_encoding, self.command))
else:
await self.send_payload(resp_payload)
if resp_data.get("success", True):
log_request(self.addr, self.command, resolved_profile, "OK")
else:
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""))
+196
View File
@@ -0,0 +1,196 @@
"""Runtime implementation for ``browser-cli serve``.
The Click command lives in ``browser_cli.commands.serve``. This module owns the
connection lifecycle; auth, control commands and browser proxying live in small
mixins so each piece can be tested/refactored independently.
"""
from __future__ import annotations
import asyncio
import json
import secrets
import socket
from dataclasses import dataclass
from pathlib import Path
from browser_cli import transport
from browser_cli.compat import adapt_auth
from browser_cli.framing import async_recv_frame, async_send_frame
from browser_cli.serve.auth import ServeAuthMixin
from browser_cli.serve.control import ServeControlMixin
from browser_cli.serve.logging import console, log_request
from browser_cli.serve.proxy import ServeProxyMixin
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
await async_send_frame(writer, data)
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
return await async_recv_frame(reader) or b""
@dataclass
class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
reader: asyncio.StreamReader
writer: asyncio.StreamWriter
addr: tuple
profile: str | None
auth_keys: list[str] | None
auth_keys_path: Path | None
nonce: str
pq_private_key: object | None = None
compress: bool = True
response_secret: bytes | None = None
accept_encoding: dict | None = None
client_ver: str = "0"
msg_id: object = None
command: str = "?"
async def send_payload(self, data: bytes) -> None:
if self.response_secret is not None:
from browser_cli.auth import pq_encrypt
data = json.dumps({"encrypted": pq_encrypt(self.response_secret, "response", data)}).encode()
await _async_framed_send(self.writer, data)
async def send_error(self, msg: str, msg_id=None) -> None:
err = json.dumps({"id": self.msg_id if msg_id is None else msg_id, "success": False, "error": msg}).encode()
try:
await self.send_payload(err)
except OSError:
pass
async def send_ok(self, payload, command: str | None = None) -> None:
obj = {"id": self.msg_id, "success": True, "data": payload}
try:
await self.send_payload(transport.encode_response(obj, self.accept_encoding if self.compress else None, command))
except OSError:
pass
async def read_message(self) -> dict | None:
try:
payload = await _async_recv_all(self.reader)
except (ConnectionError, OSError) as exc:
if "too large" in str(exc):
await self.send_error(str(exc), msg_id=None)
return None
try:
msg = json.loads(payload)
except (json.JSONDecodeError, ValueError):
await self.send_error("invalid JSON", msg_id=None)
log_request(self.addr, "?", None, "ERROR", "invalid JSON")
return None
return msg if isinstance(msg, dict) else None
async def run(self) -> None:
msg = await self.read_message()
if msg is None or not await self.validate_client(msg):
return
msg = adapt_auth(msg, self.client_ver)
self.command = msg.get("command", "?")
msg = await self.authenticate(msg)
if msg is None:
return
self.accept_encoding = msg.get("accept_encoding")
if await self.handle_control_command(msg):
return
await self.forward_to_browser(msg)
async def _async_proxy_request(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
addr: tuple,
profile: str | None,
auth_keys: list[str] | None,
auth_keys_path: Path | None,
nonce: str,
pq_private_key=None,
compress: bool = True,
) -> None:
await ServeRequest(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress).run()
async def _async_handle_client(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
addr: tuple,
profile: str | None,
auth_keys_path: Path | None,
compress: bool = True,
conn_limit: asyncio.Semaphore | None = None,
) -> None:
if conn_limit is None:
conn_limit = asyncio.Semaphore(64)
if conn_limit.locked():
writer.close()
await writer.wait_closed()
return
await conn_limit.acquire()
try:
auth_keys = await _load_auth_keys(auth_keys_path)
nonce, pq_private_key, challenge_msg = await _build_challenge(auth_keys_path)
try:
await _async_framed_send(writer, json.dumps(challenge_msg).encode())
except OSError:
return
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
finally:
conn_limit.release()
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
async def _load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
if auth_keys_path is None:
return None
from browser_cli.auth import load_authorized_keys
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
async def _build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
nonce = secrets.token_hex(32)
pq_private_key = None
challenge_msg = {
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}
if auth_keys_path is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
pq_keypair = await asyncio.to_thread(pq_kex_server_keypair)
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
return nonce, pq_private_key, challenge_msg
def _handle_client(
client_sock: socket.socket,
addr: tuple,
profile: str | None,
auth_keys_path: Path | None,
compress: bool = True,
) -> None:
"""Run one accepted socket through the async serve pipeline."""
async def _run() -> None:
reader, writer = await asyncio.open_connection(sock=client_sock)
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress)
try:
asyncio.run(_run())
except OSError:
try:
client_sock.close()
except OSError:
pass
async def _serve_async(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
conn_limit = asyncio.Semaphore(64)
async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
peer = writer.get_extra_info("peername") or ("?", 0)
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit)
server = await asyncio.start_server(_client_connected, host, port, backlog=16)
async with server:
await server.serve_forever()
+125 -124
View File
@@ -4,11 +4,11 @@ The wire frame stays ``4-byte LE length + payload``. The payload is made
self-describing so old peers keep working unchanged:
* A payload that starts with ``{`` or ``[`` is plain JSON (the historical
format). Old clients and old servers only ever produce/consume this.
format). Old clients and old servers only ever produce/consume this.
* Any other leading byte is a 1-byte codec tag followed by the encoded body.
The tag's high nibble selects serialization, the low nibble compression::
The tag's high nibble selects serialization, the low nibble compression::
tag = (serialization << 4) | compression
tag = (serialization << 4) | compression
This is only ever emitted toward a peer that advertised support for it, so it
is fully backward compatible: clients announce what they can decode via the
@@ -30,100 +30,101 @@ import json
import re
import zlib
from browser_cli.constants import (
COMP_GZIP,
COMP_NONE,
COMP_ZLIB,
COMP_ZSTD,
DEFAULT_TRANSPORT_THRESHOLD,
SER_JSON,
SER_MSGPACK,
)
try: # optional: better ratio + speed than zlib/gzip
import zstandard as _zstd
import zstandard as _zstd
except Exception: # pragma: no cover - depends on optional extra
_zstd = None
_zstd = None
try: # optional: alternate serialization + raw binary for screenshots
import msgpack as _msgpack
import msgpack as _msgpack
except Exception: # pragma: no cover - depends on optional extra
_msgpack = None
_msgpack = None
# ── codec ids ────────────────────────────────────────────────────────────────
SER_JSON = 0
SER_MSGPACK = 1
COMP_NONE = 0
COMP_ZLIB = 1
COMP_GZIP = 2
COMP_ZSTD = 3
_SER_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
_SER_ID = {v: k for k, v in _SER_NAME.items()}
_COMP_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
_COMP_ID = {v: k for k, v in _COMP_NAME.items()}
# Don't compress payloads smaller than this — the header/CPU cost is not worth it.
DEFAULT_THRESHOLD = 512
# JSON top-level values always start with one of these bytes; a tag byte never does.
_JSON_FIRST_BYTES = frozenset(b"{[")
def msgpack_available() -> bool:
return _msgpack is not None
return _msgpack is not None
def zstd_available() -> bool:
return _zstd is not None
return _zstd is not None
def supported_serialization() -> list[str]:
"""Serializations this build can produce/consume, best first."""
return (["msgpack"] if _msgpack is not None else []) + ["json"]
"""Serializations this build can produce/consume, best first."""
return (["msgpack"] if _msgpack is not None else []) + ["json"]
def supported_compression() -> list[str]:
"""Compression codecs this build can produce/consume, best first."""
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
"""Compression codecs this build can produce/consume, best first."""
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
def client_accept_encoding() -> dict:
"""What the local client advertises it can decode (sent with each request)."""
return {"ser": supported_serialization(), "comp": supported_compression()}
"""What the local client advertises it can decode (sent with each request)."""
return {"ser": supported_serialization(), "comp": supported_compression()}
# ── compression primitives ────────────────────────────────────────────────────
def _compress(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.compress(data, 6)
if comp_id == COMP_GZIP:
return gzip.compress(data, compresslevel=6)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd compression requested but zstandard is not installed")
return _zstd.ZstdCompressor(level=10).compress(data)
raise ValueError(f"unknown compression id {comp_id}")
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.compress(data, 6)
if comp_id == COMP_GZIP:
return gzip.compress(data, compresslevel=6)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd compression requested but zstandard is not installed")
return _zstd.ZstdCompressor(level=10).compress(data)
raise ValueError(f"unknown compression id {comp_id}")
def _decompress(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.decompress(data)
if comp_id == COMP_GZIP:
return gzip.decompress(data)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd payload received but zstandard is not installed")
return _zstd.ZstdDecompressor().decompress(data)
raise ValueError(f"unknown compression id {comp_id}")
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.decompress(data)
if comp_id == COMP_GZIP:
return gzip.decompress(data)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd payload received but zstandard is not installed")
return _zstd.ZstdDecompressor().decompress(data)
raise ValueError(f"unknown compression id {comp_id}")
# ── codec negotiation ──────────────────────────────────────────────────────────
def _choose(accept: dict | None) -> tuple[int, int]:
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
accept = accept if isinstance(accept, dict) else {}
accept_ser = accept.get("ser") or ["json"]
accept_comp = accept.get("comp") or []
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
accept = accept if isinstance(accept, dict) else {}
accept_ser = accept.get("ser") or ["json"]
accept_comp = accept.get("comp") or []
ser = SER_JSON
if _msgpack is not None and "msgpack" in accept_ser:
ser = SER_MSGPACK
ser = SER_JSON
if _msgpack is not None and "msgpack" in accept_ser:
ser = SER_MSGPACK
comp = COMP_NONE
for name in supported_compression(): # server preference: zstd > gzip > zlib
if name in accept_comp:
comp = _COMP_ID[name]
break
return ser, comp
comp = COMP_NONE
for name in supported_compression(): # server preference: zstd > gzip > zlib
if name in accept_comp:
comp = _COMP_ID[name]
break
return ser, comp
# ── raw-binary hoisting (screenshots) ──────────────────────────────────────────
@@ -131,83 +132,83 @@ _DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
_B64_MARKER = "__b64__"
def _hoist_screenshot(obj, command: str | None):
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
"""
if command != "tabs.screenshot" or not isinstance(obj, dict):
return obj
data = obj.get("data")
if not isinstance(data, dict):
return obj
url = data.get("dataUrl")
if not isinstance(url, str):
return obj
m = _DATA_URL_RE.match(url)
if not m:
return obj
try:
raw = base64.b64decode(m.group(2))
except Exception:
return obj
new_data = dict(data)
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
return {**obj, "data": new_data}
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
"""
if command != "tabs.screenshot" or not isinstance(obj, dict):
return obj
data = obj.get("data")
if not isinstance(data, dict):
return obj
url = data.get("dataUrl")
if not isinstance(url, str):
return obj
m = _DATA_URL_RE.match(url)
if not m:
return obj
try:
raw = base64.b64decode(m.group(2))
except Exception:
return obj
new_data = dict(data)
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
return {**obj, "data": new_data}
def _unhoist_binary(obj):
"""Rebuild any hoisted data URL so callers see the original string again."""
if isinstance(obj, dict):
raw = obj.get("raw")
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
mime = obj.get("mime") or "application/octet-stream"
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
return {k: _unhoist_binary(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_unhoist_binary(v) for v in obj]
return obj
"""Rebuild any hoisted data URL so callers see the original string again."""
if isinstance(obj, dict):
raw = obj.get("raw")
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
mime = obj.get("mime") or "application/octet-stream"
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
return {k: _unhoist_binary(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_unhoist_binary(v) for v in obj]
return obj
# ── encode / decode ─────────────────────────────────────────────────────────────
def encode_response(obj, accept: dict | None = None, command: str | None = None,
threshold: int = DEFAULT_THRESHOLD) -> bytes:
"""Encode a response object for the chosen/accepted codec.
threshold: int = DEFAULT_TRANSPORT_THRESHOLD) -> bytes:
"""Encode a response object for the chosen/accepted codec.
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
what an old server would have sent.
"""
ser, comp = _choose(accept)
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
what an old server would have sent.
"""
ser, comp = _choose(accept)
if ser == SER_MSGPACK:
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
else:
body = json.dumps(obj).encode("utf-8")
if ser == SER_MSGPACK:
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
else:
body = json.dumps(obj).encode("utf-8")
if comp != COMP_NONE and len(body) >= threshold:
body = _compress(comp, body)
else:
comp = COMP_NONE
if comp != COMP_NONE and len(body) >= threshold:
body = _compress(comp, body)
else:
comp = COMP_NONE
if ser == SER_JSON and comp == COMP_NONE:
return body # plain JSON — historical wire format, no tag byte
if ser == SER_JSON and comp == COMP_NONE:
return body # plain JSON — historical wire format, no tag byte
return bytes([(ser << 4) | comp]) + body
return bytes([(ser << 4) | comp]) + body
def decode_response(raw: bytes | None):
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
if raw is None:
return None
if not raw:
raise ValueError("empty response payload")
if raw[0] in _JSON_FIRST_BYTES:
return json.loads(raw)
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
if raw is None:
return None
if not raw:
raise ValueError("empty response payload")
if raw[0] in _JSON_FIRST_BYTES:
return json.loads(raw)
tag = raw[0]
ser, comp = tag >> 4, tag & 0x0F
body = _decompress(comp, raw[1:])
if ser == SER_MSGPACK:
if _msgpack is None:
raise ValueError("msgpack payload received but msgpack is not installed")
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
if ser == SER_JSON:
return json.loads(body)
raise ValueError(f"unknown serialization id {ser}")
tag = raw[0]
ser, comp = tag >> 4, tag & 0x0F
body = _decompress(comp, raw[1:])
if ser == SER_MSGPACK:
if _msgpack is None:
raise ValueError("msgpack payload received but msgpack is not installed")
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
if ser == SER_JSON:
return json.loads(body)
raise ValueError(f"unknown serialization id {ser}")
+1 -2
View File
@@ -1,7 +1,6 @@
from importlib.metadata import version as _pkg_version
PROTOCOL_MIN_CLIENT = "0.9.0"
MAX_MSG_BYTES = 32 * 1024 * 1024
from browser_cli.constants import MAX_MSG_BYTES, PROTOCOL_MIN_CLIENT
def parse_version(v: str) -> tuple[int, ...]:
try: