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.
Files changed (85) hide show
  1. browser_cli/__init__.py +164 -0
  2. browser_cli/async_sdk.py +237 -0
  3. browser_cli/auth.py +263 -0
  4. browser_cli/cli.py +151 -0
  5. browser_cli/client/__init__.py +47 -0
  6. browser_cli/client/auth.py +63 -0
  7. browser_cli/client/core.py +200 -0
  8. browser_cli/client/messages.py +45 -0
  9. browser_cli/client/targets.py +95 -0
  10. browser_cli/command_security.py +119 -0
  11. browser_cli/commands/__init__.py +81 -0
  12. browser_cli/commands/auth.py +157 -0
  13. browser_cli/commands/clients.py +173 -0
  14. browser_cli/commands/completion.py +56 -0
  15. browser_cli/commands/doctor.py +90 -0
  16. browser_cli/commands/dom.py +191 -0
  17. browser_cli/commands/events.py +52 -0
  18. browser_cli/commands/extension.py +42 -0
  19. browser_cli/commands/extract.py +70 -0
  20. browser_cli/commands/groups.py +108 -0
  21. browser_cli/commands/install.py +121 -0
  22. browser_cli/commands/navigate.py +96 -0
  23. browser_cli/commands/page.py +26 -0
  24. browser_cli/commands/perf.py +47 -0
  25. browser_cli/commands/raw.py +23 -0
  26. browser_cli/commands/remote.py +68 -0
  27. browser_cli/commands/script.py +68 -0
  28. browser_cli/commands/search.py +79 -0
  29. browser_cli/commands/serve.py +117 -0
  30. browser_cli/commands/serve_http.py +115 -0
  31. browser_cli/commands/session.py +163 -0
  32. browser_cli/commands/storage.py +36 -0
  33. browser_cli/commands/tabs.py +252 -0
  34. browser_cli/commands/watch.py +60 -0
  35. browser_cli/commands/windows.py +87 -0
  36. browser_cli/commands/workspace.py +91 -0
  37. browser_cli/compat/__init__.py +4 -0
  38. browser_cli/compat/auth.py +44 -0
  39. browser_cli/compat/commands.py +43 -0
  40. browser_cli/constants.py +95 -0
  41. browser_cli/endpoints.py +55 -0
  42. browser_cli/errors.py +9 -0
  43. browser_cli/framing.py +83 -0
  44. browser_cli/local_transport.py +64 -0
  45. browser_cli/markdown/__init__.py +8 -0
  46. browser_cli/markdown/html.py +259 -0
  47. browser_cli/markdown/render.py +188 -0
  48. browser_cli/models.py +182 -0
  49. browser_cli/native/__init__.py +1 -0
  50. browser_cli/native/host.py +211 -0
  51. browser_cli/native/local_server.py +111 -0
  52. browser_cli/native/protocol.py +30 -0
  53. browser_cli/platform.py +34 -0
  54. browser_cli/registry.py +99 -0
  55. browser_cli/remote/__init__.py +1 -0
  56. browser_cli/remote/registry.py +53 -0
  57. browser_cli/remote/transport.py +230 -0
  58. browser_cli/sdk/__init__.py +48 -0
  59. browser_cli/sdk/base.py +116 -0
  60. browser_cli/sdk/browser_data.py +37 -0
  61. browser_cli/sdk/decorators.py +107 -0
  62. browser_cli/sdk/dom.py +169 -0
  63. browser_cli/sdk/extension.py +24 -0
  64. browser_cli/sdk/factories.py +103 -0
  65. browser_cli/sdk/groups.py +51 -0
  66. browser_cli/sdk/navigation.py +122 -0
  67. browser_cli/sdk/perf.py +23 -0
  68. browser_cli/sdk/routing.py +149 -0
  69. browser_cli/sdk/session.py +72 -0
  70. browser_cli/sdk/tabs.py +213 -0
  71. browser_cli/sdk/windows.py +26 -0
  72. browser_cli/sdk/workflow_decorators.py +200 -0
  73. browser_cli/serve/__init__.py +0 -0
  74. browser_cli/serve/auth.py +107 -0
  75. browser_cli/serve/control.py +59 -0
  76. browser_cli/serve/logging.py +16 -0
  77. browser_cli/serve/proxy.py +79 -0
  78. browser_cli/serve/runtime.py +196 -0
  79. browser_cli/transport.py +214 -0
  80. browser_cli/version_manager.py +17 -0
  81. real_browser_cli-0.14.2.dist-info/METADATA +87 -0
  82. real_browser_cli-0.14.2.dist-info/RECORD +85 -0
  83. real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
  84. real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
  85. real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
@@ -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", {})
@@ -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