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