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,72 @@
1
+ """Session namespace: ``b.session.*``."""
2
+ from __future__ import annotations
3
+
4
+ from browser_cli.sdk.base import Namespace, sdk_command
5
+
6
+ def _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs) -> dict:
7
+ return {
8
+ "name": name,
9
+ "gentleMode": gentle_mode,
10
+ "discardBackgroundTabs": discard_background_tabs,
11
+ "lazy": lazy,
12
+ "eagerTabs": eager_tabs,
13
+ }
14
+
15
+ class SessionNS(Namespace):
16
+ """Save, restore, list, and diff browser sessions."""
17
+
18
+ @sdk_command("session.save", lambda self, name: {"name": name}, default={})
19
+ def save(self, name: str) -> dict:
20
+ """Save all current tabs as session *name*. Returns the save result (incl. tab count)."""
21
+
22
+ def load(
23
+ self,
24
+ name: str,
25
+ *,
26
+ gentle_mode: str = "auto",
27
+ discard_background_tabs: bool = False,
28
+ lazy: bool = False,
29
+ eager_tabs: int = 10,
30
+ ) -> dict:
31
+ """Restore session *name*. Returns the load result (incl. tabs opened)."""
32
+ return self.command("session.load", _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)) or {}
33
+
34
+ def load_background(
35
+ self,
36
+ name: str,
37
+ *,
38
+ gentle_mode: str = "auto",
39
+ discard_background_tabs: bool = False,
40
+ lazy: bool = False,
41
+ eager_tabs: int = 10,
42
+ ) -> dict:
43
+ """Restore session *name* as a background job. Returns the job descriptor."""
44
+ args = _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
45
+ return self.command("session.load", {**args, "__background": True}) or {}
46
+
47
+ @sdk_command("session.diff", lambda self, name_a, name_b: {"nameA": name_a, "nameB": name_b}, default={})
48
+ def diff(self, name_a: str, name_b: str) -> dict:
49
+ """Diff two saved sessions."""
50
+
51
+ @sdk_command("session.export", lambda self, name=None: {"name": name}, default={})
52
+ def export(self, name: str | None = None) -> dict:
53
+ """Export one saved session, or all sessions when *name* is omitted."""
54
+
55
+ @sdk_command("session.import", lambda self, name, session, overwrite=False: {"name": name, "session": session, "overwrite": overwrite}, default={})
56
+ def import_(self, name: str, session: dict, *, overwrite: bool = False) -> dict:
57
+ """Import a saved session payload under *name*."""
58
+
59
+ def list(self) -> list[dict]:
60
+ """Return saved sessions.
61
+
62
+ In implicit multi-browser mode each session dict includes a ``browser`` key.
63
+ """
64
+ return self.multi_list("session.list", {}, self.tag_browser)
65
+
66
+ @sdk_command("session.remove", lambda self, name: {"name": name}, return_result=False)
67
+ def remove(self, name: str) -> None:
68
+ """Remove a saved session."""
69
+
70
+ @sdk_command("session.auto_save", lambda self, enabled: {"enabled": enabled}, return_result=False)
71
+ def auto_save(self, enabled: bool) -> None:
72
+ """Enable or disable automatic session saves."""
@@ -0,0 +1,213 @@
1
+ """Tabs namespace: ``b.tabs.*``."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable, Iterable
5
+
6
+ from browser_cli.models import BrowserCounts, Tab
7
+ from browser_cli.sdk.base import Namespace
8
+
9
+ class TabsNS(Namespace):
10
+ """List, open, close, move, and inspect browser tabs."""
11
+
12
+ def list(self) -> list[Tab]:
13
+ """Return all open tabs across all windows.
14
+
15
+ When multiple browsers are active and no browser was specified, each Tab
16
+ includes ``tab.browser`` naming its source browser.
17
+ """
18
+ return self.multi_list("tabs.list", {}, self.tab_from_target)
19
+
20
+ def open(
21
+ self,
22
+ url: str,
23
+ *,
24
+ wait: bool = False,
25
+ timeout: float = 30.0,
26
+ background: bool = False,
27
+ focus: bool = False,
28
+ window: str | None = None,
29
+ group: str | None = None,
30
+ ) -> Tab:
31
+ """Open *url* in a new tab and return a bound :class:`Tab`.
32
+
33
+ Set ``wait=True`` to block until the page reaches ``readyState=complete``.
34
+ Pass ``focus=True`` to explicitly bring the created tab/window forward.
35
+ """
36
+ if wait:
37
+ return self._c.nav.open_wait(url, timeout=timeout, background=background, focus=focus, window=window, group=group)
38
+ return self.require_tab(
39
+ self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}),
40
+ "navigate.open returned unexpected data",
41
+ )
42
+
43
+ def get(self, tab_id: int) -> Tab:
44
+ """Return a specific tab by ID."""
45
+ return self.status(tab_id)
46
+
47
+ def active(self) -> Tab:
48
+ """Return the active tab."""
49
+ return self.status()
50
+
51
+ def query(self, search: str) -> list[Tab]:
52
+ """Search tabs by URL or title."""
53
+ return [self.tab_from(t) for t in (self.command("tabs.query", {"search": search}) or [])]
54
+
55
+ def first(self, search: str) -> Tab | None:
56
+ """Return the first tab matching *search*, or ``None``."""
57
+ matches = self.query(search)
58
+ return matches[0] if matches else None
59
+
60
+ def close(
61
+ self,
62
+ tab_id: int | None = None,
63
+ *,
64
+ tab_ids: Iterable[int | Tab] | None = None,
65
+ inactive: bool = False,
66
+ duplicates: bool = False,
67
+ gentle_mode: str = "auto",
68
+ ) -> int:
69
+ """Close tab(s). Returns the number of tabs closed.
70
+
71
+ Pass ``tab_ids`` to close many tabs in a single round-trip. Accepts tab
72
+ IDs or :class:`Tab` objects. ``gentle_mode`` (auto/normal/gentle/ultra)
73
+ controls throttling of large close operations.
74
+ """
75
+ ids = None
76
+ if tab_ids is not None:
77
+ ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
78
+ result = self.command("tabs.close", {
79
+ "tabId": tab_id,
80
+ "tabIds": ids,
81
+ "inactive": inactive,
82
+ "duplicates": duplicates,
83
+ "gentleMode": gentle_mode,
84
+ })
85
+ return self.field(result, "closed", 1)
86
+
87
+ def close_inactive(self) -> int:
88
+ """Close all inactive tabs. Returns count closed."""
89
+ return self.field(self.command("tabs.close", {"inactive": True}), "closed", 0)
90
+
91
+ def close_duplicates(self) -> int:
92
+ """Close duplicate tabs. Returns count closed."""
93
+ return self.field(self.command("tabs.close", {"duplicates": True}), "closed", 0)
94
+
95
+ def move(
96
+ self, tab_id: int, *,
97
+ forward: bool = False, backward: bool = False,
98
+ group_id: int | None = None, window_id: int | None = None, index: int | None = None,
99
+ ) -> None:
100
+ self.command("tabs.move", {
101
+ "tabId": tab_id, "forward": forward, "backward": backward,
102
+ "groupId": group_id, "windowId": window_id, "index": index,
103
+ })
104
+
105
+ def activate(self, tab_id: int) -> None:
106
+ """Switch browser focus to a tab by ID."""
107
+ self.command("tabs.active", {"tabId": tab_id})
108
+
109
+ def status(self, tab_id: int | None = None) -> Tab:
110
+ """Return status for the active tab or a specific tab."""
111
+ return self.require_tab(self.command("tabs.status", {"tabId": tab_id}), "No tab status returned")
112
+
113
+ def mute(self, tab_id: int | None = None) -> int:
114
+ """Mute the active tab or a specific tab. Returns the target tab ID."""
115
+ return self.toggle_tab("tabs.mute", tab_id)
116
+
117
+ def unmute(self, tab_id: int | None = None) -> int:
118
+ """Unmute the active tab or a specific tab. Returns the target tab ID."""
119
+ return self.toggle_tab("tabs.unmute", tab_id)
120
+
121
+ def pin(self, tab_id: int | None = None) -> int:
122
+ """Pin the active tab or a specific tab. Returns the target tab ID."""
123
+ return self.toggle_tab("tabs.pin", tab_id)
124
+
125
+ def unpin(self, tab_id: int | None = None) -> int:
126
+ """Unpin the active tab or a specific tab. Returns the target tab ID."""
127
+ return self.toggle_tab("tabs.unpin", tab_id)
128
+
129
+ def watch_url(self, pattern: str, *, tab_id: int | None = None, timeout: float = 30.0) -> Tab:
130
+ """Block until the tab URL matches regex pattern. Returns the Tab."""
131
+ return self.require_tab(
132
+ self.command("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)}),
133
+ "tabs.watch_url returned unexpected data",
134
+ )
135
+
136
+ def wait_for_load(
137
+ self,
138
+ tab_id: int | None = None,
139
+ *,
140
+ timeout: float = 30.0,
141
+ ready_state: str = "complete",
142
+ ) -> Tab:
143
+ """Block until the tab finishes loading. Returns the Tab when ready.
144
+
145
+ Args:
146
+ tab_id: Tab to watch. Defaults to the active tab.
147
+ timeout: Max seconds to wait before raising ``RuntimeError``.
148
+ ready_state: ``"complete"`` (default) or ``"interactive"``.
149
+ """
150
+ return self.require_tab(
151
+ self.command("navigate.wait", {
152
+ "tabId": tab_id,
153
+ "timeout": int(timeout * 1000),
154
+ "readyState": ready_state,
155
+ }),
156
+ "navigate.wait returned unexpected data",
157
+ )
158
+
159
+ def screenshot(
160
+ self,
161
+ tab_id: int | None = None,
162
+ *,
163
+ format: str = "png",
164
+ quality: int | None = None,
165
+ ) -> str:
166
+ """Capture the visible area of a tab. Returns a base64 data URL.
167
+
168
+ Args:
169
+ tab_id: Tab to capture. Defaults to the active tab.
170
+ format: ``"png"`` (default) or ``"jpeg"``.
171
+ quality: JPEG quality 0-100 (ignored for PNG).
172
+ """
173
+ result = self.command("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
174
+ return self.field(result, "dataUrl", "", fallback=str(result))
175
+
176
+ def active_in_window(self, window_id: int) -> Tab:
177
+ """Return active tab for a specific browser window."""
178
+ return self.require_tab(
179
+ self.command("tabs.active_in_window", {"windowId": window_id}),
180
+ f"No active tab found for window {window_id}",
181
+ )
182
+
183
+ def filter(
184
+ self,
185
+ pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]],
186
+ ) -> list[Tab]:
187
+ """Return tabs filtered by URL pattern or a Python callable."""
188
+ if isinstance(pattern_or_filter, str):
189
+ return [self.tab_from(t) for t in (self.command("tabs.filter", {"pattern": pattern_or_filter}) or [])]
190
+ return self.apply_tab_filter(pattern_or_filter)
191
+
192
+ def count(self, pattern: str | None = None) -> "int | BrowserCounts":
193
+ """Count open tabs, optionally filtered by URL pattern.
194
+
195
+ Returns ``BrowserCounts`` in implicit multi-browser mode.
196
+ """
197
+ return self.multi_count("tabs.count", {"pattern": pattern})
198
+
199
+ def html(self, tab_id: int | None = None) -> str:
200
+ """Return the full HTML source of the active (or specified) tab."""
201
+ return self.command("tabs.html", {"tabId": tab_id}) or ""
202
+
203
+ def dedupe(self, *, gentle_mode: str = "auto") -> int:
204
+ """Close duplicate tabs (keep the first occurrence of each URL). Returns count closed."""
205
+ return self.field(self.command("tabs.dedupe", {"gentleMode": gentle_mode}), "closed", 0)
206
+
207
+ def sort(self, by: str = "domain", *, gentle_mode: str = "auto") -> None:
208
+ """Sort tabs within each window. *by* is one of 'domain', 'title', 'time'."""
209
+ self.command("tabs.sort", {"by": by, "gentleMode": gentle_mode})
210
+
211
+ def merge_windows(self, *, gentle_mode: str = "auto") -> int:
212
+ """Move all tabs into the focused window. Returns count moved."""
213
+ return self.field(self.command("tabs.merge_windows", {"gentleMode": gentle_mode}), "moved", 0)
@@ -0,0 +1,26 @@
1
+ """Windows namespace: ``b.windows.*``."""
2
+ from __future__ import annotations
3
+
4
+ from browser_cli.sdk.base import Namespace, sdk_command
5
+
6
+ class WindowsNS(Namespace):
7
+ """List, open, close, and rename browser windows."""
8
+
9
+ def list(self) -> list[dict]:
10
+ """Return browser windows.
11
+
12
+ In implicit multi-browser mode each window dict includes a ``browser`` key.
13
+ """
14
+ return self.multi_list("windows.list", {}, self.tag_browser)
15
+
16
+ @sdk_command("windows.open", lambda self, url=None: {"url": url}, default={})
17
+ def open(self, url: str | None = None) -> dict:
18
+ """Open a new browser window, optionally on a URL."""
19
+
20
+ @sdk_command("windows.close", lambda self, window_id: {"windowId": window_id}, return_result=False)
21
+ def close(self, window_id: int) -> None:
22
+ """Close a browser window by ID."""
23
+
24
+ @sdk_command("windows.rename", lambda self, window_id, name: {"windowId": window_id, "name": name}, return_result=False)
25
+ def rename(self, window_id: int, name: str) -> None:
26
+ """Rename a browser window."""
@@ -0,0 +1,200 @@
1
+ """Shared workflow decorator implementation for sync and async SDK clients."""
2
+ from __future__ import annotations
3
+
4
+ import functools
5
+ import time
6
+ from collections.abc import Callable
7
+ from typing import TypeVar
8
+
9
+ F = TypeVar("F", bound=Callable)
10
+ _NO_INJECT = object()
11
+
12
+ class WorkflowDecoratorsMixin:
13
+ """Shared implementation for sync and async workflow decorators.
14
+
15
+ Subclasses only define *how* browser calls, user functions, and sleeps are
16
+ executed. The actual decorators live once here, so sync and async SDKs stay
17
+ in lockstep.
18
+ """
19
+
20
+ _c: object
21
+
22
+ @staticmethod
23
+ def _inject(kwargs: dict, keyword: str | None, value):
24
+ if keyword is not None:
25
+ kwargs[keyword] = value
26
+ return (), kwargs
27
+ return (value,), kwargs
28
+
29
+ def _run(self, func: Callable, *args, **kwargs):
30
+ return func(*args, **kwargs)
31
+
32
+ def _call_wrapped(self, func: Callable, *args, **kwargs):
33
+ return func(*args, **kwargs)
34
+
35
+ def _sleep(self, delay: float) -> None:
36
+ time.sleep(delay)
37
+
38
+ def _value_decorator(
39
+ self,
40
+ func: F | None,
41
+ get_value: Callable,
42
+ *,
43
+ keyword: str | None | object = "tab",
44
+ cleanup: Callable | None = None,
45
+ ):
46
+ """Build a decorator around a browser-side value lookup.
47
+
48
+ ``get_value`` always runs before the wrapped function. If ``keyword`` is
49
+ ``_NO_INJECT`` the value is only used by ``cleanup`` and is not passed to
50
+ the wrapped function. ``keyword=None`` injects it positionally.
51
+ """
52
+
53
+ def decorator(fn: F) -> F:
54
+ @functools.wraps(fn)
55
+ def wrapper(*args, **kwargs):
56
+ value = self._run(get_value)
57
+ try:
58
+ extra_args = ()
59
+ if keyword is not _NO_INJECT:
60
+ extra_args, kwargs = self._inject(kwargs, keyword, value)
61
+ return self._call_wrapped(fn, *extra_args, *args, **kwargs)
62
+ finally:
63
+ if cleanup is not None:
64
+ self._run(cleanup, value)
65
+ return wrapper # type: ignore[return-value]
66
+
67
+ return decorator(func) if func is not None else decorator
68
+
69
+ def active_tab(self, func: F | None = None, *, keyword: str | None = "tab"):
70
+ """Decorate a function so it receives the current active tab.
71
+
72
+ By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to
73
+ pass it as the first positional argument instead.
74
+ """
75
+ return self._value_decorator(func, self._c.tabs.active, keyword=keyword) # type: ignore[attr-defined]
76
+
77
+ def new_tab(
78
+ self,
79
+ url: str,
80
+ *,
81
+ wait: bool = False,
82
+ timeout: float = 30.0,
83
+ background: bool = False,
84
+ focus: bool = False,
85
+ window: str | None = None,
86
+ group: str | None = None,
87
+ close: bool = False,
88
+ keyword: str | None = "tab",
89
+ ):
90
+ """Open *url* for the wrapped function and inject the created tab.
91
+
92
+ Set ``close=True`` to close the tab in a ``finally`` block after the
93
+ wrapped function returns or raises.
94
+ """
95
+ def open_tab():
96
+ return self._c.tabs.open( # type: ignore[attr-defined]
97
+ url,
98
+ wait=wait,
99
+ timeout=timeout,
100
+ background=background,
101
+ focus=focus,
102
+ window=window,
103
+ group=group,
104
+ )
105
+
106
+ def close_tab(tab):
107
+ tab.close()
108
+
109
+ return self._value_decorator(None, open_tab, keyword=keyword, cleanup=close_tab if close else None)
110
+
111
+ def wait_for_selector(
112
+ self,
113
+ selector: str,
114
+ *,
115
+ timeout: float = 10.0,
116
+ visible: bool = False,
117
+ hidden: bool = False,
118
+ tab_id: int | None = None,
119
+ keyword: str | None = None,
120
+ ):
121
+ """Wait for a selector before calling the wrapped function.
122
+
123
+ Pass ``keyword="result"`` (or similar) to inject the wait result into
124
+ the wrapped function. By default the result is not injected.
125
+ """
126
+ def wait():
127
+ return self._c.dom.wait_for( # type: ignore[attr-defined]
128
+ selector,
129
+ timeout=timeout,
130
+ visible=visible,
131
+ hidden=hidden,
132
+ tab_id=tab_id,
133
+ )
134
+
135
+ inject = keyword if keyword is not None else _NO_INJECT
136
+ return self._value_decorator(None, wait, keyword=inject)
137
+
138
+ def wait_for_url(
139
+ self,
140
+ pattern: str,
141
+ *,
142
+ tab_id: int | None = None,
143
+ timeout: float = 30.0,
144
+ keyword: str | None = "tab",
145
+ ):
146
+ """Wait until a tab URL matches *pattern* before calling the function."""
147
+ def wait():
148
+ return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) # type: ignore[attr-defined]
149
+
150
+ inject = keyword if keyword is not None else _NO_INJECT
151
+ return self._value_decorator(None, wait, keyword=inject)
152
+
153
+ def performance_profile(self, profile: str, *, restore: bool = True):
154
+ """Temporarily set the extension performance profile around a function."""
155
+ def decorator(fn: F) -> F:
156
+ @functools.wraps(fn)
157
+ def wrapper(*args, **kwargs):
158
+ previous = None
159
+ if restore:
160
+ previous = self._run(self._c.perf.status).get("performanceProfile") # type: ignore[attr-defined]
161
+ self._run(self._c.perf.set_profile, profile) # type: ignore[attr-defined]
162
+ try:
163
+ return self._call_wrapped(fn, *args, **kwargs)
164
+ finally:
165
+ if previous:
166
+ self._run(self._c.perf.set_profile, previous) # type: ignore[attr-defined]
167
+ return wrapper # type: ignore[return-value]
168
+ return decorator
169
+
170
+ def save_session_before(self, name: str):
171
+ """Save the current browser session before running the function."""
172
+ return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT) # type: ignore[attr-defined]
173
+
174
+ def retry(
175
+ self,
176
+ *,
177
+ times: int = 3,
178
+ delay: float = 0.0,
179
+ exceptions: tuple[type[BaseException], ...] = (Exception,),
180
+ ):
181
+ """Retry the wrapped function when it raises one of *exceptions*."""
182
+ attempts = max(1, times)
183
+
184
+ def decorator(fn: F) -> F:
185
+ @functools.wraps(fn)
186
+ def wrapper(*args, **kwargs):
187
+ last_error = None
188
+ for attempt in range(attempts):
189
+ try:
190
+ return self._call_wrapped(fn, *args, **kwargs)
191
+ except exceptions as exc:
192
+ last_error = exc
193
+ if attempt == attempts - 1:
194
+ raise
195
+ if delay > 0:
196
+ self._sleep(delay)
197
+ raise last_error # type: ignore[misc]
198
+ return wrapper # type: ignore[return-value]
199
+ return decorator
200
+
File without changes
@@ -0,0 +1,107 @@
1
+ """Client validation and authentication for ``browser-cli serve``."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from typing import Literal
7
+
8
+ from browser_cli.compat import adapt_auth
9
+ from browser_cli.serve.logging import log_request
10
+ from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, parse_version
11
+
12
+ _UA_PATTERN = re.compile(r"^browser-cli/\d")
13
+ AuthDecodeResult = tuple[bytes | None, bool] | tuple[Literal[False], Literal[False]]
14
+
15
+ class ServeAuthMixin:
16
+ addr: tuple
17
+ command: str
18
+ client_ver: str
19
+ msg_id: object
20
+ nonce: str
21
+ pq_private_key: object | None
22
+ auth_keys: list[str] | None
23
+ response_secret: bytes | None
24
+
25
+ async def send_error(self, msg: str, msg_id=None) -> None: ...
26
+
27
+ async def validate_client(self, msg: dict) -> bool:
28
+ self.msg_id = msg.get("id")
29
+ ua = msg.get("user_agent") or ""
30
+ if not _UA_PATTERN.match(ua):
31
+ await self.send_error("forbidden: client required")
32
+ log_request(self.addr, msg.get("command", "?"), None, "DENIED", f"bad user-agent: {ua!r}")
33
+ return False
34
+ try:
35
+ self.client_ver = ua.split("/", 1)[1]
36
+ if parse_version(self.client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
37
+ await self.send_error(f"client version {self.client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
38
+ log_request(self.addr, msg.get("command", "?"), None, "DENIED", f"client {self.client_ver} < min {PROTOCOL_MIN_CLIENT}")
39
+ return False
40
+ except (IndexError, ValueError):
41
+ pass
42
+ return True
43
+
44
+ async def authenticate(self, msg: dict) -> dict | None:
45
+ if self.auth_keys is None:
46
+ return msg
47
+
48
+ pub = msg.get("pubkey") or ""
49
+ sig = msg.get("sig") or ""
50
+ if not pub or not sig:
51
+ await self.send_error("unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
52
+ log_request(self.addr, self.command, None, "DENIED", "missing pubkey/sig")
53
+ return None
54
+ if pub not in self.auth_keys:
55
+ await self.send_error("unauthorized: untrusted public key")
56
+ log_request(self.addr, self.command, None, "DENIED", "untrusted key")
57
+ return None
58
+
59
+ pq_shared_secret, transport_encrypted = await self._decode_pq_transport(msg, pub, sig)
60
+ if pq_shared_secret is False:
61
+ return None
62
+
63
+ from browser_cli.auth import verify
64
+ if not verify(pub, bytes.fromhex(self.nonce), msg, sig, pq_shared_secret):
65
+ await self.send_error("unauthorized: invalid signature")
66
+ log_request(self.addr, self.command, None, "DENIED", "bad signature")
67
+ return None
68
+ self.response_secret = pq_shared_secret if transport_encrypted else None
69
+ return msg
70
+
71
+ async def _decode_pq_transport(self, msg: dict, pub: str, sig: str) -> AuthDecodeResult:
72
+ pq_shared_secret = None
73
+ transport_encrypted = False
74
+ if self.pq_private_key is None:
75
+ return pq_shared_secret, transport_encrypted
76
+
77
+ kex = msg.get("pq_kex") or {}
78
+ pq_required = parse_version(self.client_ver) >= parse_version("0.9.5")
79
+ if not isinstance(kex, dict) or kex.get("alg") != "ML-KEM-768" or not kex.get("ciphertext"):
80
+ if pq_required:
81
+ await self.send_error("unauthorized: post-quantum key exchange required")
82
+ log_request(self.addr, self.command, None, "DENIED", "missing pq kex")
83
+ return False, False
84
+ return pq_shared_secret, transport_encrypted
85
+
86
+ try:
87
+ from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
88
+ pq_shared_secret = pq_kex_server_decapsulate(self.pq_private_key, str(kex["ciphertext"]))
89
+ if "encrypted" in msg:
90
+ decrypted_msg = json.loads(pq_decrypt(pq_shared_secret, "request", msg["encrypted"]))
91
+ if not isinstance(decrypted_msg, dict):
92
+ raise ValueError("encrypted request is not a JSON object")
93
+ decrypted_msg.update({"pubkey": pub, "sig": sig, "pq_kex": kex})
94
+ msg.clear()
95
+ msg.update(adapt_auth(decrypted_msg, self.client_ver))
96
+ self.msg_id = msg.get("id", self.msg_id)
97
+ self.command = msg.get("command", "?")
98
+ transport_encrypted = True
99
+ elif pq_required:
100
+ await self.send_error("unauthorized: post-quantum encrypted transport required")
101
+ log_request(self.addr, self.command, None, "DENIED", "missing pq transport")
102
+ return False, False
103
+ except Exception:
104
+ await self.send_error("unauthorized: invalid post-quantum encrypted transport")
105
+ log_request(self.addr, self.command, None, "DENIED", "bad pq transport")
106
+ return False, False
107
+ return pq_shared_secret, transport_encrypted
@@ -0,0 +1,59 @@
1
+ """Built-in control commands handled directly by ``browser-cli serve``."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from browser_cli.serve.logging import log_request
8
+
9
+ class ServeControlMixin:
10
+ addr: tuple
11
+ command: str
12
+ auth_keys_path: Path | None
13
+
14
+ async def send_error(self, msg: str, msg_id=None) -> None: ...
15
+ async def send_ok(self, payload, command: str | None = None) -> None: ...
16
+
17
+ async def handle_control_command(self, msg: dict) -> bool:
18
+ if self.command == "browser-cli.targets":
19
+ from browser_cli.client import active_browser_targets
20
+ targets = [
21
+ {"profile": target.profile, "displayName": target.display_name}
22
+ for target in active_browser_targets(include_remotes=False)
23
+ ]
24
+ await self.send_ok(targets, self.command)
25
+ log_request(self.addr, self.command, None, "OK")
26
+ return True
27
+
28
+ if self.command == "browser-cli.auth.keys":
29
+ if self.auth_keys_path is None:
30
+ await self.send_error("no authorized keys file configured on this server")
31
+ log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
32
+ return True
33
+ from browser_cli.auth import load_authorized_keys_with_names
34
+ entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(self.auth_keys_path)]
35
+ await self.send_ok(entries, self.command)
36
+ log_request(self.addr, self.command, None, "OK")
37
+ return True
38
+
39
+ if self.command == "browser-cli.auth.trust":
40
+ return await self._handle_trust(msg)
41
+ return False
42
+
43
+ async def _handle_trust(self, msg: dict) -> bool:
44
+ if self.auth_keys_path is None:
45
+ await self.send_error("no authorized keys file configured on this server")
46
+ log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
47
+ return True
48
+ from browser_cli.auth import add_authorized_key
49
+ args = msg.get("args") or {}
50
+ pubkey = str(args.get("pubkey") or "")
51
+ name = str(args.get("name") or "")
52
+ if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
53
+ await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
54
+ log_request(self.addr, self.command, None, "ERROR", "invalid pubkey")
55
+ return True
56
+ added = add_authorized_key(self.auth_keys_path, pubkey, name)
57
+ await self.send_ok({"added": added}, self.command)
58
+ log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED")
59
+ return True