real-browser-cli 0.14.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browser_cli/__init__.py +164 -0
- browser_cli/async_sdk.py +237 -0
- browser_cli/auth.py +263 -0
- browser_cli/cli.py +151 -0
- browser_cli/client/__init__.py +47 -0
- browser_cli/client/auth.py +63 -0
- browser_cli/client/core.py +200 -0
- browser_cli/client/messages.py +45 -0
- browser_cli/client/targets.py +95 -0
- browser_cli/command_security.py +119 -0
- browser_cli/commands/__init__.py +81 -0
- browser_cli/commands/auth.py +157 -0
- browser_cli/commands/clients.py +173 -0
- browser_cli/commands/completion.py +56 -0
- browser_cli/commands/doctor.py +90 -0
- browser_cli/commands/dom.py +191 -0
- browser_cli/commands/events.py +52 -0
- browser_cli/commands/extension.py +42 -0
- browser_cli/commands/extract.py +70 -0
- browser_cli/commands/groups.py +108 -0
- browser_cli/commands/install.py +121 -0
- browser_cli/commands/navigate.py +96 -0
- browser_cli/commands/page.py +26 -0
- browser_cli/commands/perf.py +47 -0
- browser_cli/commands/raw.py +23 -0
- browser_cli/commands/remote.py +68 -0
- browser_cli/commands/script.py +68 -0
- browser_cli/commands/search.py +79 -0
- browser_cli/commands/serve.py +117 -0
- browser_cli/commands/serve_http.py +115 -0
- browser_cli/commands/session.py +163 -0
- browser_cli/commands/storage.py +36 -0
- browser_cli/commands/tabs.py +252 -0
- browser_cli/commands/watch.py +60 -0
- browser_cli/commands/windows.py +87 -0
- browser_cli/commands/workspace.py +91 -0
- browser_cli/compat/__init__.py +4 -0
- browser_cli/compat/auth.py +44 -0
- browser_cli/compat/commands.py +43 -0
- browser_cli/constants.py +95 -0
- browser_cli/endpoints.py +55 -0
- browser_cli/errors.py +9 -0
- browser_cli/framing.py +83 -0
- browser_cli/local_transport.py +64 -0
- browser_cli/markdown/__init__.py +8 -0
- browser_cli/markdown/html.py +259 -0
- browser_cli/markdown/render.py +188 -0
- browser_cli/models.py +182 -0
- browser_cli/native/__init__.py +1 -0
- browser_cli/native/host.py +211 -0
- browser_cli/native/local_server.py +111 -0
- browser_cli/native/protocol.py +30 -0
- browser_cli/platform.py +34 -0
- browser_cli/registry.py +99 -0
- browser_cli/remote/__init__.py +1 -0
- browser_cli/remote/registry.py +53 -0
- browser_cli/remote/transport.py +230 -0
- browser_cli/sdk/__init__.py +48 -0
- browser_cli/sdk/base.py +116 -0
- browser_cli/sdk/browser_data.py +37 -0
- browser_cli/sdk/decorators.py +107 -0
- browser_cli/sdk/dom.py +169 -0
- browser_cli/sdk/extension.py +24 -0
- browser_cli/sdk/factories.py +103 -0
- browser_cli/sdk/groups.py +51 -0
- browser_cli/sdk/navigation.py +122 -0
- browser_cli/sdk/perf.py +23 -0
- browser_cli/sdk/routing.py +149 -0
- browser_cli/sdk/session.py +72 -0
- browser_cli/sdk/tabs.py +213 -0
- browser_cli/sdk/windows.py +26 -0
- browser_cli/sdk/workflow_decorators.py +200 -0
- browser_cli/serve/__init__.py +0 -0
- browser_cli/serve/auth.py +107 -0
- browser_cli/serve/control.py +59 -0
- browser_cli/serve/logging.py +16 -0
- browser_cli/serve/proxy.py +79 -0
- browser_cli/serve/runtime.py +196 -0
- browser_cli/transport.py +214 -0
- browser_cli/version_manager.py +17 -0
- real_browser_cli-0.14.2.dist-info/METADATA +87 -0
- real_browser_cli-0.14.2.dist-info/RECORD +85 -0
- real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
- real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
- real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
browser_cli/__init__.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
browser_cli — Python SDK for controlling your running browser.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from browser_cli import BrowserCLI
|
|
6
|
+
b = BrowserCLI()
|
|
7
|
+
|
|
8
|
+
tabs = b.tabs.list() # list[Tab]
|
|
9
|
+
tabs[0].close()
|
|
10
|
+
tabs[0].move(forward=True)
|
|
11
|
+
|
|
12
|
+
groups = b.groups.list() # list[Group]
|
|
13
|
+
groups[0].tabs()
|
|
14
|
+
groups[0].add_tab("https://example.com")
|
|
15
|
+
|
|
16
|
+
b.nav.open("https://example.com")
|
|
17
|
+
b.dom.click("#submit")
|
|
18
|
+
b.session.save("work")
|
|
19
|
+
|
|
20
|
+
# When multiple browser instances are active, pass the alias:
|
|
21
|
+
b = BrowserCLI(browser="brave")
|
|
22
|
+
|
|
23
|
+
Commands are grouped into namespaces on the client:
|
|
24
|
+
b.nav navigation (open, reload, back, forward, focus, search)
|
|
25
|
+
b.tabs tabs (list, open, close, move, status, mute, sort, ...)
|
|
26
|
+
b.groups tab groups (list, create, add_tab, move, close)
|
|
27
|
+
b.windows browser windows (list, open, close, rename)
|
|
28
|
+
b.dom page elements (query, click, type, wait_for, eval, ...)
|
|
29
|
+
b.extract content extraction (links, images, text, json, markdown)
|
|
30
|
+
b.page page info
|
|
31
|
+
b.storage localStorage / sessionStorage
|
|
32
|
+
b.session sessions (save, load, list, diff, ...)
|
|
33
|
+
b.perf performance profile + background jobs
|
|
34
|
+
b.extension control the extension itself
|
|
35
|
+
b.decorators workflow decorators for scripts
|
|
36
|
+
"""
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
|
|
39
|
+
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async
|
|
40
|
+
from browser_cli.errors import BrowserNotConnected
|
|
41
|
+
from browser_cli.models import BrowserCounts, Group, Tab
|
|
42
|
+
from browser_cli.sdk import (
|
|
43
|
+
DecoratorsNS,
|
|
44
|
+
DomNS,
|
|
45
|
+
ExtensionNS,
|
|
46
|
+
ExtractNS,
|
|
47
|
+
GroupsNS,
|
|
48
|
+
NAMESPACE_SPECS,
|
|
49
|
+
NavigationNS,
|
|
50
|
+
PageNS,
|
|
51
|
+
PerfNS,
|
|
52
|
+
SessionNS,
|
|
53
|
+
StorageNS,
|
|
54
|
+
TabsNS,
|
|
55
|
+
WindowsNS,
|
|
56
|
+
)
|
|
57
|
+
from browser_cli.sdk.factories import FactoryMixin
|
|
58
|
+
from browser_cli.sdk.routing import RoutingMixin
|
|
59
|
+
|
|
60
|
+
from browser_cli.async_sdk import AsyncBrowserCLI
|
|
61
|
+
|
|
62
|
+
__all__ = ["BrowserCLI", "AsyncBrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
|
|
63
|
+
|
|
64
|
+
class BrowserCLI(FactoryMixin, RoutingMixin):
|
|
65
|
+
"""Client for a running browser, with commands grouped into namespaces.
|
|
66
|
+
|
|
67
|
+
The client itself holds the connection target (browser/remote/key) and the
|
|
68
|
+
shared machinery; the actual commands live on namespace accessors such as
|
|
69
|
+
:attr:`tabs`, :attr:`dom`, and :attr:`session`. Object construction
|
|
70
|
+
(``Tab``/``Group``) comes from :class:`~browser_cli.sdk.factories.FactoryMixin`
|
|
71
|
+
and multi-browser fan-out from :class:`~browser_cli.sdk.routing.RoutingMixin`.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
_browser: str | None
|
|
75
|
+
_remote: str | None
|
|
76
|
+
_key: str | None
|
|
77
|
+
_command_sender: Callable
|
|
78
|
+
nav: NavigationNS
|
|
79
|
+
tabs: TabsNS
|
|
80
|
+
groups: GroupsNS
|
|
81
|
+
windows: WindowsNS
|
|
82
|
+
dom: DomNS
|
|
83
|
+
extract: ExtractNS
|
|
84
|
+
page: PageNS
|
|
85
|
+
storage: StorageNS
|
|
86
|
+
session: SessionNS
|
|
87
|
+
perf: PerfNS
|
|
88
|
+
extension: ExtensionNS
|
|
89
|
+
decorators: DecoratorsNS
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
browser: str | None = None,
|
|
94
|
+
remote: str | None = None,
|
|
95
|
+
key: str | None = None,
|
|
96
|
+
*,
|
|
97
|
+
_command_sender=None,
|
|
98
|
+
):
|
|
99
|
+
"""
|
|
100
|
+
Args:
|
|
101
|
+
browser: Profile alias to target. Required when multiple browser
|
|
102
|
+
instances are active. Equivalent to ``--browser`` on the CLI.
|
|
103
|
+
remote: Connect to a remote browser exposed via ``browser-cli serve``.
|
|
104
|
+
Format: ``"host:port"`` (e.g. ``"browser-host.example:8765"``).
|
|
105
|
+
Can be combined with ``browser`` to route to a specific
|
|
106
|
+
remote profile.
|
|
107
|
+
key: Path to Ed25519 private key PEM for pubkey auth, or ``"agent"``
|
|
108
|
+
to use a key from the SSH agent (YubiKey, gpg-agent, etc.).
|
|
109
|
+
Defaults to ``~/.config/browser-cli/client.key.pem`` if that file exists.
|
|
110
|
+
"""
|
|
111
|
+
self._browser = browser
|
|
112
|
+
self._remote = remote
|
|
113
|
+
self._key = key if key else None
|
|
114
|
+
self._command_sender = _command_sender or send_command
|
|
115
|
+
|
|
116
|
+
for name, namespace_type in NAMESPACE_SPECS:
|
|
117
|
+
setattr(self, name, namespace_type(self))
|
|
118
|
+
self.decorators = DecoratorsNS(self)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def browser(self) -> str | None:
|
|
122
|
+
"""Target browser/profile alias, equivalent to ``--browser``."""
|
|
123
|
+
return self._browser
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def remote(self) -> str | None:
|
|
127
|
+
"""Remote endpoint used by this client, if any."""
|
|
128
|
+
return self._remote
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def key(self) -> str | None:
|
|
132
|
+
"""Ed25519 key spec used for remote auth, if explicitly configured."""
|
|
133
|
+
return self._key
|
|
134
|
+
|
|
135
|
+
def dispatch(self, command: str, args: dict | None = None):
|
|
136
|
+
"""Dispatch one browser command using this client's target settings."""
|
|
137
|
+
return self._command_sender(command, args, profile=self._browser, remote=self._remote, key=self._key)
|
|
138
|
+
|
|
139
|
+
def require_tab(self, data, error: str):
|
|
140
|
+
"""Convert a tab-like command response into a bound Tab."""
|
|
141
|
+
return self.require_tab_response(data, error)
|
|
142
|
+
|
|
143
|
+
_FIELD_MISSING = object()
|
|
144
|
+
|
|
145
|
+
def field(self, result, key, default=None, *, fallback=_FIELD_MISSING):
|
|
146
|
+
"""Read a named field from command output."""
|
|
147
|
+
if fallback is self._FIELD_MISSING:
|
|
148
|
+
return self._field(result, key, default)
|
|
149
|
+
return self._field(result, key, default, fallback=fallback)
|
|
150
|
+
|
|
151
|
+
def _cmd(self, command: str, args: dict | None = None):
|
|
152
|
+
return self.dispatch(command, args)
|
|
153
|
+
|
|
154
|
+
def command(self, command: str, args: dict | None = None):
|
|
155
|
+
"""Send a raw browser-cli command and return its response.
|
|
156
|
+
|
|
157
|
+
This is the SDK escape hatch for commands that do not have a dedicated
|
|
158
|
+
namespace method yet.
|
|
159
|
+
"""
|
|
160
|
+
return self._cmd(command, args or {})
|
|
161
|
+
|
|
162
|
+
def clients(self) -> list[dict]:
|
|
163
|
+
"""Return the active browser clients known to this connection."""
|
|
164
|
+
return self._cmd("clients.list", {})
|
browser_cli/async_sdk.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Async browser-cli Python SDK.
|
|
2
|
+
|
|
3
|
+
The async SDK intentionally reuses the synchronous SDK namespaces instead of
|
|
4
|
+
copying every command method. Each async namespace is a thin adapter that runs
|
|
5
|
+
the corresponding sync SDK method in a worker thread, while a private command
|
|
6
|
+
sender dispatches commands through ``send_command_async``. That keeps command
|
|
7
|
+
strings, argument shapes, result mapping, and bound model creation in one place:
|
|
8
|
+
``browser_cli.sdk``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import functools
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import TypeVar
|
|
16
|
+
|
|
17
|
+
from browser_cli.models import Group, Tab
|
|
18
|
+
from browser_cli.sdk import NAMESPACE_NAMES
|
|
19
|
+
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
|
|
20
|
+
|
|
21
|
+
F = TypeVar("F", bound=Callable)
|
|
22
|
+
|
|
23
|
+
class AsyncNamespaceAdapter:
|
|
24
|
+
"""Async wrapper around one synchronous SDK namespace."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, sync_namespace):
|
|
27
|
+
self._sync = sync_namespace
|
|
28
|
+
|
|
29
|
+
def __getattr__(self, name: str):
|
|
30
|
+
value = getattr(self._sync, name)
|
|
31
|
+
if not callable(value):
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
@functools.wraps(value)
|
|
35
|
+
async def wrapper(*args, **kwargs):
|
|
36
|
+
return await asyncio.to_thread(value, *args, **kwargs)
|
|
37
|
+
|
|
38
|
+
return wrapper
|
|
39
|
+
|
|
40
|
+
class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
|
|
41
|
+
"""Async workflow decorators for :class:`AsyncBrowserCLI`.
|
|
42
|
+
|
|
43
|
+
The public decorator methods are inherited from ``WorkflowDecoratorsMixin``;
|
|
44
|
+
only the execution strategy differs: every wrapper is async and awaits both
|
|
45
|
+
browser calls and async user functions.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, client: "AsyncBrowserCLI"):
|
|
49
|
+
self._c = client
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
async def _maybe_await(value):
|
|
53
|
+
if hasattr(value, "__await__"):
|
|
54
|
+
return await value
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
def _value_decorator(
|
|
58
|
+
self,
|
|
59
|
+
func: F | None,
|
|
60
|
+
get_value: Callable,
|
|
61
|
+
*,
|
|
62
|
+
keyword: str | None | object = "tab",
|
|
63
|
+
cleanup: Callable | None = None,
|
|
64
|
+
):
|
|
65
|
+
def decorator(fn: F) -> F:
|
|
66
|
+
@functools.wraps(fn)
|
|
67
|
+
async def wrapper(*args, **kwargs):
|
|
68
|
+
value = await get_value()
|
|
69
|
+
try:
|
|
70
|
+
extra_args = ()
|
|
71
|
+
if keyword is not _NO_INJECT:
|
|
72
|
+
extra_args, kwargs = self._inject(kwargs, keyword, value)
|
|
73
|
+
return await self._maybe_await(fn(*extra_args, *args, **kwargs))
|
|
74
|
+
finally:
|
|
75
|
+
if cleanup is not None:
|
|
76
|
+
await self._maybe_await(cleanup(value))
|
|
77
|
+
return wrapper # type: ignore[return-value]
|
|
78
|
+
return decorator(func) if func is not None else decorator
|
|
79
|
+
|
|
80
|
+
def new_tab(
|
|
81
|
+
self,
|
|
82
|
+
url: str,
|
|
83
|
+
*,
|
|
84
|
+
wait: bool = False,
|
|
85
|
+
timeout: float = 30.0,
|
|
86
|
+
background: bool = False,
|
|
87
|
+
focus: bool = False,
|
|
88
|
+
window: str | None = None,
|
|
89
|
+
group: str | None = None,
|
|
90
|
+
close: bool = False,
|
|
91
|
+
keyword: str | None = "tab",
|
|
92
|
+
):
|
|
93
|
+
def open_tab():
|
|
94
|
+
return self._c.tabs.open(
|
|
95
|
+
url,
|
|
96
|
+
wait=wait,
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
background=background,
|
|
99
|
+
focus=focus,
|
|
100
|
+
window=window,
|
|
101
|
+
group=group,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def close_tab(tab):
|
|
105
|
+
await self._c.tabs.close(tab.id)
|
|
106
|
+
|
|
107
|
+
return self._value_decorator(None, open_tab, keyword=keyword, cleanup=close_tab if close else None)
|
|
108
|
+
|
|
109
|
+
def performance_profile(self, profile: str, *, restore: bool = True):
|
|
110
|
+
def decorator(fn: F) -> F:
|
|
111
|
+
@functools.wraps(fn)
|
|
112
|
+
async def wrapper(*args, **kwargs):
|
|
113
|
+
previous = (await self._c.perf.status()).get("performanceProfile") if restore else None
|
|
114
|
+
await self._c.perf.set_profile(profile)
|
|
115
|
+
try:
|
|
116
|
+
return await self._maybe_await(fn(*args, **kwargs))
|
|
117
|
+
finally:
|
|
118
|
+
if previous:
|
|
119
|
+
await self._c.perf.set_profile(previous)
|
|
120
|
+
return wrapper # type: ignore[return-value]
|
|
121
|
+
return decorator
|
|
122
|
+
|
|
123
|
+
def retry(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
times: int = 3,
|
|
127
|
+
delay: float = 0.0,
|
|
128
|
+
exceptions: tuple[type[BaseException], ...] = (Exception,),
|
|
129
|
+
):
|
|
130
|
+
attempts = max(1, times)
|
|
131
|
+
|
|
132
|
+
def decorator(fn: F) -> F:
|
|
133
|
+
@functools.wraps(fn)
|
|
134
|
+
async def wrapper(*args, **kwargs):
|
|
135
|
+
last_error = None
|
|
136
|
+
for attempt in range(attempts):
|
|
137
|
+
try:
|
|
138
|
+
return await self._maybe_await(fn(*args, **kwargs))
|
|
139
|
+
except exceptions as exc:
|
|
140
|
+
last_error = exc
|
|
141
|
+
if attempt == attempts - 1:
|
|
142
|
+
raise
|
|
143
|
+
if delay > 0:
|
|
144
|
+
await asyncio.sleep(delay)
|
|
145
|
+
raise last_error # type: ignore[misc]
|
|
146
|
+
return wrapper # type: ignore[return-value]
|
|
147
|
+
return decorator
|
|
148
|
+
|
|
149
|
+
class AsyncBrowserCLI:
|
|
150
|
+
"""Async client for a running browser.
|
|
151
|
+
|
|
152
|
+
Namespace methods are awaitable mirrors of :class:`browser_cli.BrowserCLI`.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
_NAMESPACES = NAMESPACE_NAMES
|
|
156
|
+
|
|
157
|
+
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
|
|
158
|
+
from browser_cli import BrowserCLI
|
|
159
|
+
|
|
160
|
+
self._browser = browser
|
|
161
|
+
self._remote = remote
|
|
162
|
+
self._key = key if key else None
|
|
163
|
+
self._sync = BrowserCLI(browser=browser, remote=remote, key=key, _command_sender=self._blocking_async_cmd)
|
|
164
|
+
|
|
165
|
+
for name in self._NAMESPACES:
|
|
166
|
+
setattr(self, name, AsyncNamespaceAdapter(getattr(self._sync, name)))
|
|
167
|
+
self.decorators = AsyncDecoratorsNS(self)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def browser(self) -> str | None:
|
|
171
|
+
return self._browser
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def remote(self) -> str | None:
|
|
175
|
+
return self._remote
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def key(self) -> str | None:
|
|
179
|
+
return self._key
|
|
180
|
+
|
|
181
|
+
def _blocking_async_cmd(
|
|
182
|
+
self,
|
|
183
|
+
command: str,
|
|
184
|
+
args: dict | None = None,
|
|
185
|
+
*,
|
|
186
|
+
profile: str | None = None,
|
|
187
|
+
remote: str | None = None,
|
|
188
|
+
key: str | None = None,
|
|
189
|
+
):
|
|
190
|
+
"""Run the native async transport from a worker thread.
|
|
191
|
+
|
|
192
|
+
Async namespace methods execute sync SDK logic in ``asyncio.to_thread``.
|
|
193
|
+
Inside that worker thread, the sync SDK's injected command sender lands
|
|
194
|
+
here and uses the async transport implementation without blocking the
|
|
195
|
+
caller's event loop.
|
|
196
|
+
"""
|
|
197
|
+
return asyncio.run(self._cmd(command, args, profile=profile, remote=remote, key=key))
|
|
198
|
+
|
|
199
|
+
async def _cmd(
|
|
200
|
+
self,
|
|
201
|
+
command: str,
|
|
202
|
+
args: dict | None = None,
|
|
203
|
+
*,
|
|
204
|
+
profile: str | None = None,
|
|
205
|
+
remote: str | None = None,
|
|
206
|
+
key: str | None = None,
|
|
207
|
+
):
|
|
208
|
+
from browser_cli.client import send_command_async
|
|
209
|
+
return await send_command_async(
|
|
210
|
+
command,
|
|
211
|
+
args,
|
|
212
|
+
profile=self._browser if profile is None else profile,
|
|
213
|
+
remote=self._remote if remote is None else remote,
|
|
214
|
+
key=self._key if key is None else key,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def command(self, command: str, args: dict | None = None):
|
|
218
|
+
return await self._cmd(command, args or {})
|
|
219
|
+
|
|
220
|
+
async def clients(self) -> list[dict]:
|
|
221
|
+
return await self._cmd("clients.list", {})
|
|
222
|
+
|
|
223
|
+
def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab:
|
|
224
|
+
return self._sync.tab_from(
|
|
225
|
+
data,
|
|
226
|
+
browser_profile=browser_profile,
|
|
227
|
+
browser_name=browser_name,
|
|
228
|
+
browser_remote=browser_remote,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group:
|
|
232
|
+
return self._sync.group_from(
|
|
233
|
+
data,
|
|
234
|
+
browser_profile=browser_profile,
|
|
235
|
+
browser_name=browser_name,
|
|
236
|
+
browser_remote=browser_remote,
|
|
237
|
+
)
|
browser_cli/auth.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Ed25519 keypair management, ML-KEM key exchange, and auth helpers."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import socket
|
|
7
|
+
import struct
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cryptography.exceptions import InvalidSignature
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
13
|
+
from cryptography.hazmat.primitives import hashes
|
|
14
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
15
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
16
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
17
|
+
Encoding,
|
|
18
|
+
NoEncryption,
|
|
19
|
+
PrivateFormat,
|
|
20
|
+
PublicFormat,
|
|
21
|
+
load_pem_private_key,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from browser_cli.constants import (
|
|
25
|
+
DEFAULT_AUTHORIZED_KEYS_PATH,
|
|
26
|
+
DEFAULT_KEY_PATH,
|
|
27
|
+
PQ_KEX_ALG,
|
|
28
|
+
PQ_TRANSPORT_ALG,
|
|
29
|
+
SSH_AGENT_IDENTITIES_ANSWER,
|
|
30
|
+
SSH_AGENT_SIGN_RESPONSE,
|
|
31
|
+
SSH_AGENTC_REQUEST_IDENTITIES,
|
|
32
|
+
SSH_AGENTC_SIGN_REQUEST,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _pack_str(s: bytes) -> bytes:
|
|
36
|
+
return struct.pack(">I", len(s)) + s
|
|
37
|
+
|
|
38
|
+
def _unpack_str(data: bytes, off: int) -> tuple[bytes, int]:
|
|
39
|
+
n = struct.unpack_from(">I", data, off)[0]
|
|
40
|
+
return data[off + 4 : off + 4 + n], off + 4 + n
|
|
41
|
+
|
|
42
|
+
def _agent_roundtrip(msg: bytes) -> bytes:
|
|
43
|
+
sock_path = os.environ.get("SSH_AUTH_SOCK")
|
|
44
|
+
if not sock_path:
|
|
45
|
+
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
|
|
46
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
47
|
+
sock.settimeout(10)
|
|
48
|
+
sock.connect(sock_path)
|
|
49
|
+
sock.sendall(struct.pack(">I", len(msg)) + msg)
|
|
50
|
+
raw_len = b""
|
|
51
|
+
while len(raw_len) < 4:
|
|
52
|
+
chunk = sock.recv(4 - len(raw_len))
|
|
53
|
+
if not chunk:
|
|
54
|
+
raise RuntimeError("SSH agent closed connection")
|
|
55
|
+
raw_len += chunk
|
|
56
|
+
n = struct.unpack(">I", raw_len)[0]
|
|
57
|
+
resp = b""
|
|
58
|
+
while len(resp) < n:
|
|
59
|
+
chunk = sock.recv(n - len(resp))
|
|
60
|
+
if not chunk:
|
|
61
|
+
raise RuntimeError("SSH agent closed connection mid-response")
|
|
62
|
+
resp += chunk
|
|
63
|
+
return resp
|
|
64
|
+
|
|
65
|
+
# ── AgentKey ───────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class AgentKey:
|
|
69
|
+
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
|
|
70
|
+
blob: bytes
|
|
71
|
+
comment: str
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def pubkey_bytes(self) -> bytes:
|
|
75
|
+
_algo, off = _unpack_str(self.blob, 0)
|
|
76
|
+
key_bytes, _ = _unpack_str(self.blob, off)
|
|
77
|
+
return key_bytes
|
|
78
|
+
|
|
79
|
+
# ── Agent helpers ──────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def agent_list_keys() -> list[AgentKey]:
|
|
82
|
+
"""Return all Ed25519 keys currently held by the SSH agent."""
|
|
83
|
+
resp = _agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
|
|
84
|
+
if resp[0] != SSH_AGENT_IDENTITIES_ANSWER:
|
|
85
|
+
raise RuntimeError(f"Unexpected agent response: {resp[0]}")
|
|
86
|
+
n_keys = struct.unpack_from(">I", resp, 1)[0]
|
|
87
|
+
keys: list[AgentKey] = []
|
|
88
|
+
off = 5
|
|
89
|
+
for _ in range(n_keys):
|
|
90
|
+
blob, off = _unpack_str(resp, off)
|
|
91
|
+
comment, off = _unpack_str(resp, off)
|
|
92
|
+
algo, _ = _unpack_str(blob, 0)
|
|
93
|
+
if algo == b"ssh-ed25519":
|
|
94
|
+
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
|
|
95
|
+
return keys
|
|
96
|
+
|
|
97
|
+
def agent_find_key(selector: str | None = None) -> AgentKey | None:
|
|
98
|
+
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
|
|
99
|
+
try:
|
|
100
|
+
keys = agent_list_keys()
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
for key in keys:
|
|
104
|
+
if key.comment == "(none)":
|
|
105
|
+
continue
|
|
106
|
+
if selector is None or selector in key.comment:
|
|
107
|
+
return key
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
|
|
111
|
+
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
|
|
112
|
+
msg = (
|
|
113
|
+
bytes([SSH_AGENTC_SIGN_REQUEST])
|
|
114
|
+
+ _pack_str(key.blob)
|
|
115
|
+
+ _pack_str(data)
|
|
116
|
+
+ struct.pack(">I", 0)
|
|
117
|
+
)
|
|
118
|
+
resp = _agent_roundtrip(msg)
|
|
119
|
+
if resp[0] != SSH_AGENT_SIGN_RESPONSE:
|
|
120
|
+
raise RuntimeError(f"SSH agent refused to sign (response code {resp[0]})")
|
|
121
|
+
sig_blob, _ = _unpack_str(resp, 1)
|
|
122
|
+
_algo, soff = _unpack_str(sig_blob, 0)
|
|
123
|
+
raw_sig, _ = _unpack_str(sig_blob, soff)
|
|
124
|
+
if len(raw_sig) != 64:
|
|
125
|
+
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
|
|
126
|
+
return raw_sig
|
|
127
|
+
|
|
128
|
+
# ── File-based key helpers ─────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def generate_keypair() -> tuple[bytes, str]:
|
|
131
|
+
"""Return (private_key_pem_bytes, public_key_hex)."""
|
|
132
|
+
priv = Ed25519PrivateKey.generate()
|
|
133
|
+
pem = priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
|
|
134
|
+
pub_hex = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
|
135
|
+
return pem, pub_hex
|
|
136
|
+
|
|
137
|
+
def load_private_key(path: Path) -> Ed25519PrivateKey:
|
|
138
|
+
return load_pem_private_key(path.read_bytes(), password=None)
|
|
139
|
+
|
|
140
|
+
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
|
|
141
|
+
if isinstance(key, AgentKey):
|
|
142
|
+
return key.pubkey_bytes.hex()
|
|
143
|
+
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
|
144
|
+
|
|
145
|
+
# ── Canonical payload + sign/verify ───────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def canonical_payload(msg: dict) -> bytes:
|
|
148
|
+
"""Deterministic JSON encoding of msg without auth protocol fields."""
|
|
149
|
+
return json.dumps(
|
|
150
|
+
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
|
|
151
|
+
sort_keys=True,
|
|
152
|
+
separators=(",", ":"),
|
|
153
|
+
).encode("utf-8")
|
|
154
|
+
|
|
155
|
+
def _auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
|
156
|
+
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
|
|
157
|
+
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
|
|
158
|
+
if pq_shared_secret is not None:
|
|
159
|
+
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
|
|
163
|
+
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
|
|
164
|
+
data = _auth_message(nonce, msg, pq_shared_secret)
|
|
165
|
+
if isinstance(key, AgentKey):
|
|
166
|
+
return agent_sign_raw(key, data)
|
|
167
|
+
return key.sign(data)
|
|
168
|
+
|
|
169
|
+
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
|
|
170
|
+
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
|
|
171
|
+
try:
|
|
172
|
+
pub_bytes = bytes.fromhex(pub_hex)
|
|
173
|
+
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
|
|
174
|
+
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
|
|
175
|
+
return True
|
|
176
|
+
except (InvalidSignature, ValueError):
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
|
|
180
|
+
|
|
181
|
+
def pq_kex_server_keypair():
|
|
182
|
+
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
|
|
183
|
+
|
|
184
|
+
Returns ``None`` when the installed cryptography/OpenSSL backend does not
|
|
185
|
+
support ML-KEM yet. The serve/client protocol treats this as graceful
|
|
186
|
+
downgrade instead of breaking local installs on older OpenSSL builds.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
from cryptography.hazmat.primitives.asymmetric import mlkem
|
|
190
|
+
priv = mlkem.MLKEM768PrivateKey.generate()
|
|
191
|
+
pub = priv.public_key().public_bytes_raw()
|
|
192
|
+
return priv, pub
|
|
193
|
+
except Exception:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
|
|
197
|
+
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
|
|
198
|
+
from cryptography.hazmat.primitives.asymmetric import mlkem
|
|
199
|
+
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
|
|
200
|
+
shared_secret, ciphertext = pub.encapsulate()
|
|
201
|
+
return ciphertext.hex(), shared_secret
|
|
202
|
+
|
|
203
|
+
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
|
|
204
|
+
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
|
|
205
|
+
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
|
|
206
|
+
|
|
207
|
+
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
|
|
208
|
+
return HKDF(
|
|
209
|
+
algorithm=hashes.SHA256(),
|
|
210
|
+
length=32,
|
|
211
|
+
salt=None,
|
|
212
|
+
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
|
|
213
|
+
).derive(shared_secret)
|
|
214
|
+
|
|
215
|
+
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
|
|
216
|
+
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
|
|
217
|
+
nonce = secrets.token_bytes(12)
|
|
218
|
+
key = _pq_transport_key(shared_secret, direction)
|
|
219
|
+
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
|
|
220
|
+
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
|
|
221
|
+
|
|
222
|
+
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
|
|
223
|
+
"""Decrypt an app-layer frame produced by pq_encrypt()."""
|
|
224
|
+
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
|
|
225
|
+
raise ValueError("unsupported encrypted transport envelope")
|
|
226
|
+
key = _pq_transport_key(shared_secret, direction)
|
|
227
|
+
return ChaCha20Poly1305(key).decrypt(
|
|
228
|
+
bytes.fromhex(str(envelope["nonce"])),
|
|
229
|
+
bytes.fromhex(str(envelope["ciphertext"])),
|
|
230
|
+
None,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def new_nonce() -> str:
|
|
234
|
+
return secrets.token_hex(32)
|
|
235
|
+
|
|
236
|
+
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
|
|
237
|
+
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
|
|
238
|
+
if not path.exists():
|
|
239
|
+
return []
|
|
240
|
+
result = []
|
|
241
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
242
|
+
line = line.strip()
|
|
243
|
+
if not line or line.startswith("#"):
|
|
244
|
+
continue
|
|
245
|
+
parts = line.split(None, 1)
|
|
246
|
+
pubkey = parts[0]
|
|
247
|
+
name = parts[1].strip() if len(parts) > 1 else ""
|
|
248
|
+
result.append((pubkey, name))
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
def load_authorized_keys(path: Path) -> list[str]:
|
|
252
|
+
return [pk for pk, _ in load_authorized_keys_with_names(path)]
|
|
253
|
+
|
|
254
|
+
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
|
|
255
|
+
"""Append pub_hex to authorized_keys. Returns False if already present."""
|
|
256
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
|
|
258
|
+
if pub_hex in existing:
|
|
259
|
+
return False
|
|
260
|
+
line = (f"{pub_hex} {name}".rstrip()) + "\n"
|
|
261
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
262
|
+
f.write(line)
|
|
263
|
+
return True
|