browser-control-and-automation-cli 0.1.1.dev3__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 (104) hide show
  1. browser_cli/__init__.py +7 -0
  2. browser_cli/actions/__init__.py +5 -0
  3. browser_cli/actions/cli_specs.py +868 -0
  4. browser_cli/agent_scope/__init__.py +14 -0
  5. browser_cli/automation/__init__.py +11 -0
  6. browser_cli/automation/api/__init__.py +5 -0
  7. browser_cli/automation/api/server.py +321 -0
  8. browser_cli/automation/hooks.py +42 -0
  9. browser_cli/automation/loader.py +134 -0
  10. browser_cli/automation/models.py +127 -0
  11. browser_cli/automation/persistence/__init__.py +5 -0
  12. browser_cli/automation/persistence/store.py +682 -0
  13. browser_cli/automation/projections.py +279 -0
  14. browser_cli/automation/publisher.py +153 -0
  15. browser_cli/automation/scheduler/__init__.py +5 -0
  16. browser_cli/automation/scheduler/schedule.py +113 -0
  17. browser_cli/automation/service/__init__.py +8 -0
  18. browser_cli/automation/service/__main__.py +64 -0
  19. browser_cli/automation/service/client.py +232 -0
  20. browser_cli/automation/service/runtime.py +388 -0
  21. browser_cli/automation/toml.py +35 -0
  22. browser_cli/automation/web/__init__.py +5 -0
  23. browser_cli/automation/web/assets.py +377 -0
  24. browser_cli/browser/__init__.py +1 -0
  25. browser_cli/browser/models.py +44 -0
  26. browser_cli/browser/network_capture.py +392 -0
  27. browser_cli/browser/service.py +1519 -0
  28. browser_cli/browser/session.py +158 -0
  29. browser_cli/browser/snapshot.py +35 -0
  30. browser_cli/browser/stealth.py +450 -0
  31. browser_cli/cli/__init__.py +1 -0
  32. browser_cli/cli/error_hints.py +26 -0
  33. browser_cli/cli/main.py +298 -0
  34. browser_cli/commands/__init__.py +1 -0
  35. browser_cli/commands/action.py +25 -0
  36. browser_cli/commands/automation.py +304 -0
  37. browser_cli/commands/doctor.py +336 -0
  38. browser_cli/commands/install_skills.py +138 -0
  39. browser_cli/commands/paths.py +31 -0
  40. browser_cli/commands/read.py +33 -0
  41. browser_cli/commands/reload.py +51 -0
  42. browser_cli/commands/status.py +444 -0
  43. browser_cli/commands/task.py +71 -0
  44. browser_cli/constants.py +97 -0
  45. browser_cli/daemon/__init__.py +1 -0
  46. browser_cli/daemon/__main__.py +34 -0
  47. browser_cli/daemon/app.py +941 -0
  48. browser_cli/daemon/browser_service.py +998 -0
  49. browser_cli/daemon/client.py +280 -0
  50. browser_cli/daemon/models.py +63 -0
  51. browser_cli/daemon/runtime_presentation.py +167 -0
  52. browser_cli/daemon/server.py +103 -0
  53. browser_cli/daemon/state.py +15 -0
  54. browser_cli/daemon/transport.py +77 -0
  55. browser_cli/drivers/__init__.py +6 -0
  56. browser_cli/drivers/_extension/__init__.py +1 -0
  57. browser_cli/drivers/_extension/artifact_actions.py +154 -0
  58. browser_cli/drivers/_extension/dialog_actions.py +35 -0
  59. browser_cli/drivers/_extension/input_actions.py +63 -0
  60. browser_cli/drivers/_extension/locator_actions.py +100 -0
  61. browser_cli/drivers/_extension/observe_actions.py +285 -0
  62. browser_cli/drivers/_extension/page_actions.py +210 -0
  63. browser_cli/drivers/_extension/state_actions.py +134 -0
  64. browser_cli/drivers/base.py +391 -0
  65. browser_cli/drivers/extension_driver.py +33 -0
  66. browser_cli/drivers/models.py +21 -0
  67. browser_cli/drivers/playwright_driver.py +499 -0
  68. browser_cli/error_codes.py +27 -0
  69. browser_cli/errors.py +146 -0
  70. browser_cli/exit_codes.py +9 -0
  71. browser_cli/extension/__init__.py +32 -0
  72. browser_cli/extension/protocol.py +222 -0
  73. browser_cli/extension/session.py +393 -0
  74. browser_cli/network.py +147 -0
  75. browser_cli/outputs/__init__.py +1 -0
  76. browser_cli/outputs/json.py +10 -0
  77. browser_cli/outputs/render.py +7 -0
  78. browser_cli/packaged_skills/__init__.py +1 -0
  79. browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
  80. browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
  81. browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
  82. browser_cli/profiles/__init__.py +1 -0
  83. browser_cli/profiles/discovery.py +185 -0
  84. browser_cli/refs/__init__.py +17 -0
  85. browser_cli/refs/generator.py +386 -0
  86. browser_cli/refs/models.py +127 -0
  87. browser_cli/refs/registry.py +51 -0
  88. browser_cli/refs/resolver.py +214 -0
  89. browser_cli/tabs/__init__.py +5 -0
  90. browser_cli/tabs/registry.py +283 -0
  91. browser_cli/task_runtime/__init__.py +31 -0
  92. browser_cli/task_runtime/client.py +107 -0
  93. browser_cli/task_runtime/entrypoint.py +110 -0
  94. browser_cli/task_runtime/errors.py +17 -0
  95. browser_cli/task_runtime/flow.py +171 -0
  96. browser_cli/task_runtime/models.py +97 -0
  97. browser_cli/task_runtime/read.py +71 -0
  98. browser_cli/task_runtime/templates.py +58 -0
  99. browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
  100. browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
  101. browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
  102. browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
  103. browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
  104. browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,32 @@
1
+ """Extension transport primitives."""
2
+
3
+ from .protocol import (
4
+ ALL_EXTENSION_CAPABILITIES,
5
+ ARTIFACT_CHUNK_SIZE,
6
+ CORE_EXTENSION_CAPABILITIES,
7
+ OPTIONAL_EXTENSION_CAPABILITIES,
8
+ REQUIRED_EXTENSION_CAPABILITIES,
9
+ ExtensionArtifactBegin,
10
+ ExtensionArtifactChunk,
11
+ ExtensionArtifactEnd,
12
+ ExtensionHello,
13
+ ExtensionRequest,
14
+ ExtensionResponse,
15
+ )
16
+ from .session import ExtensionHub, ExtensionSession
17
+
18
+ __all__ = [
19
+ "CORE_EXTENSION_CAPABILITIES",
20
+ "REQUIRED_EXTENSION_CAPABILITIES",
21
+ "OPTIONAL_EXTENSION_CAPABILITIES",
22
+ "ALL_EXTENSION_CAPABILITIES",
23
+ "ARTIFACT_CHUNK_SIZE",
24
+ "ExtensionHello",
25
+ "ExtensionRequest",
26
+ "ExtensionResponse",
27
+ "ExtensionArtifactBegin",
28
+ "ExtensionArtifactChunk",
29
+ "ExtensionArtifactEnd",
30
+ "ExtensionHub",
31
+ "ExtensionSession",
32
+ ]
@@ -0,0 +1,222 @@
1
+ """JSON protocol between daemon and Browser CLI extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ REQUIRED_EXTENSION_CAPABILITIES = frozenset(
9
+ {
10
+ "open",
11
+ "tabs",
12
+ "info",
13
+ "html",
14
+ "snapshot",
15
+ "reload",
16
+ "back",
17
+ "forward",
18
+ "resize",
19
+ "click",
20
+ "double-click",
21
+ "hover",
22
+ "focus",
23
+ "fill",
24
+ "fill-form",
25
+ "select",
26
+ "options",
27
+ "check",
28
+ "uncheck",
29
+ "scroll-to",
30
+ "drag",
31
+ "upload",
32
+ "type",
33
+ "press",
34
+ "key-down",
35
+ "key-up",
36
+ "scroll",
37
+ "mouse-click",
38
+ "mouse-move",
39
+ "mouse-drag",
40
+ "mouse-down",
41
+ "mouse-up",
42
+ "eval",
43
+ "eval-on",
44
+ "wait",
45
+ "wait-network",
46
+ "screenshot",
47
+ "pdf",
48
+ "trace-start",
49
+ "trace-chunk",
50
+ "trace-stop",
51
+ "video-start",
52
+ "video-stop",
53
+ "console-start",
54
+ "console",
55
+ "console-stop",
56
+ "network-wait",
57
+ "network-start",
58
+ "network",
59
+ "network-stop",
60
+ "dialog-setup",
61
+ "dialog",
62
+ "dialog-remove",
63
+ "cookies",
64
+ "cookie-set",
65
+ "cookies-clear",
66
+ "storage-save",
67
+ "storage-load",
68
+ "workspace-status",
69
+ "workspace-rebuild-binding",
70
+ "verify-text",
71
+ "verify-visible",
72
+ "verify-url",
73
+ "verify-title",
74
+ "verify-state",
75
+ "verify-value",
76
+ }
77
+ )
78
+ OPTIONAL_EXTENSION_CAPABILITIES = frozenset()
79
+ ALL_EXTENSION_CAPABILITIES = REQUIRED_EXTENSION_CAPABILITIES
80
+ CORE_EXTENSION_CAPABILITIES = REQUIRED_EXTENSION_CAPABILITIES
81
+ PROTOCOL_VERSION = "1"
82
+ ARTIFACT_CHUNK_SIZE = 256 * 1024
83
+
84
+
85
+ @dataclass(slots=True, frozen=True)
86
+ class ExtensionHello:
87
+ protocol_version: str
88
+ extension_version: str
89
+ browser_name: str
90
+ browser_version: str
91
+ capabilities: frozenset[str]
92
+ workspace_window_state: dict[str, Any] = field(default_factory=dict)
93
+ extension_instance_id: str = ""
94
+
95
+ @classmethod
96
+ def from_message(cls, payload: dict[str, Any]) -> ExtensionHello:
97
+ return cls(
98
+ protocol_version=str(payload.get("protocol_version") or ""),
99
+ extension_version=str(payload.get("extension_version") or ""),
100
+ browser_name=str(payload.get("browser_name") or ""),
101
+ browser_version=str(payload.get("browser_version") or ""),
102
+ capabilities=frozenset(str(item) for item in (payload.get("capabilities") or [])),
103
+ workspace_window_state=dict(payload.get("workspace_window_state") or {}),
104
+ extension_instance_id=str(payload.get("extension_instance_id") or ""),
105
+ )
106
+
107
+ def is_compatible(self) -> bool:
108
+ return self.protocol_version == PROTOCOL_VERSION
109
+
110
+ def has_required_capabilities(self) -> bool:
111
+ return REQUIRED_EXTENSION_CAPABILITIES.issubset(self.capabilities)
112
+
113
+ def missing_required_capabilities(self) -> list[str]:
114
+ return sorted(REQUIRED_EXTENSION_CAPABILITIES - self.capabilities)
115
+
116
+ def has_core_capabilities(self) -> bool:
117
+ return self.has_required_capabilities()
118
+
119
+
120
+ @dataclass(slots=True, frozen=True)
121
+ class ExtensionRequest:
122
+ id: str
123
+ action: str
124
+ payload: dict[str, Any] = field(default_factory=dict)
125
+
126
+ def to_message(self) -> dict[str, Any]:
127
+ return {
128
+ "type": "request",
129
+ "id": self.id,
130
+ "action": self.action,
131
+ "payload": self.payload,
132
+ }
133
+
134
+
135
+ @dataclass(slots=True, frozen=True)
136
+ class ExtensionResponse:
137
+ id: str
138
+ ok: bool
139
+ data: dict[str, Any] = field(default_factory=dict)
140
+ error_code: str | None = None
141
+ error_message: str | None = None
142
+
143
+ @classmethod
144
+ def from_message(cls, payload: dict[str, Any]) -> ExtensionResponse:
145
+ return cls(
146
+ id=str(payload.get("id") or ""),
147
+ ok=bool(payload.get("ok")),
148
+ data=dict(payload.get("data") or {}),
149
+ error_code=(
150
+ str(payload["error_code"]) if payload.get("error_code") is not None else None
151
+ ),
152
+ error_message=(
153
+ str(payload["error_message"]) if payload.get("error_message") is not None else None
154
+ ),
155
+ )
156
+
157
+
158
+ @dataclass(slots=True, frozen=True)
159
+ class ExtensionArtifactBegin:
160
+ request_id: str
161
+ artifact_id: str
162
+ artifact_kind: str
163
+ mime_type: str
164
+ encoding: str
165
+ filename: str | None = None
166
+ page_id: str | None = None
167
+ metadata: dict[str, Any] = field(default_factory=dict)
168
+
169
+ @classmethod
170
+ def from_message(cls, payload: dict[str, Any]) -> ExtensionArtifactBegin:
171
+ return cls(
172
+ request_id=str(payload.get("request_id") or ""),
173
+ artifact_id=str(payload.get("artifact_id") or ""),
174
+ artifact_kind=str(payload.get("artifact_kind") or ""),
175
+ mime_type=str(payload.get("mime_type") or "application/octet-stream"),
176
+ encoding=str(payload.get("encoding") or "base64"),
177
+ filename=(str(payload["filename"]) if payload.get("filename") is not None else None),
178
+ page_id=(str(payload["page_id"]) if payload.get("page_id") is not None else None),
179
+ metadata=dict(payload.get("metadata") or {}),
180
+ )
181
+
182
+
183
+ @dataclass(slots=True, frozen=True)
184
+ class ExtensionArtifactChunk:
185
+ request_id: str
186
+ artifact_id: str
187
+ artifact_kind: str
188
+ mime_type: str
189
+ encoding: str
190
+ index: int
191
+ chunk: str
192
+ final: bool = False
193
+
194
+ @classmethod
195
+ def from_message(cls, payload: dict[str, Any]) -> ExtensionArtifactChunk:
196
+ return cls(
197
+ request_id=str(payload.get("request_id") or ""),
198
+ artifact_id=str(payload.get("artifact_id") or ""),
199
+ artifact_kind=str(payload.get("artifact_kind") or ""),
200
+ mime_type=str(payload.get("mime_type") or "application/octet-stream"),
201
+ encoding=str(payload.get("encoding") or "base64"),
202
+ index=int(payload.get("index") or 0),
203
+ chunk=str(payload.get("chunk") or ""),
204
+ final=bool(payload.get("final")),
205
+ )
206
+
207
+
208
+ @dataclass(slots=True, frozen=True)
209
+ class ExtensionArtifactEnd:
210
+ request_id: str
211
+ artifact_id: str
212
+ size_bytes: int | None = None
213
+
214
+ @classmethod
215
+ def from_message(cls, payload: dict[str, Any]) -> ExtensionArtifactEnd:
216
+ return cls(
217
+ request_id=str(payload.get("request_id") or ""),
218
+ artifact_id=str(payload.get("artifact_id") or ""),
219
+ size_bytes=(
220
+ int(payload["size_bytes"]) if payload.get("size_bytes") is not None else None
221
+ ),
222
+ )
@@ -0,0 +1,393 @@
1
+ """Extension WebSocket session and hub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import contextlib
8
+ import inspect
9
+ import json
10
+ import logging
11
+ import uuid
12
+ from collections.abc import Awaitable, Callable
13
+ from dataclasses import dataclass, field
14
+ from http import HTTPStatus
15
+ from typing import Any
16
+
17
+ import websockets
18
+ from websockets.datastructures import Headers
19
+ from websockets.http11 import Request, Response
20
+ from websockets.protocol import State
21
+ from websockets.server import ServerConnection
22
+
23
+ from browser_cli.constants import get_app_paths
24
+ from browser_cli.errors import OperationFailedError
25
+
26
+ from .protocol import (
27
+ ExtensionArtifactBegin,
28
+ ExtensionArtifactChunk,
29
+ ExtensionArtifactEnd,
30
+ ExtensionHello,
31
+ ExtensionRequest,
32
+ ExtensionResponse,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class _ArtifactBuffer:
40
+ descriptor: ExtensionArtifactBegin
41
+ chunks: list[str] = field(default_factory=list)
42
+
43
+ def append(self, chunk: str) -> None:
44
+ self.chunks.append(chunk)
45
+
46
+ def to_payload(self) -> dict[str, Any]:
47
+ content = base64.b64decode("".join(self.chunks).encode("ascii"))
48
+ return {
49
+ "artifact_id": self.descriptor.artifact_id,
50
+ "artifact_kind": self.descriptor.artifact_kind,
51
+ "mime_type": self.descriptor.mime_type,
52
+ "encoding": self.descriptor.encoding,
53
+ "filename": self.descriptor.filename,
54
+ "page_id": self.descriptor.page_id,
55
+ "metadata": dict(self.descriptor.metadata),
56
+ "content": content,
57
+ }
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class ExtensionSession:
62
+ websocket: ServerConnection
63
+ hello: ExtensionHello
64
+ _pending: dict[str, asyncio.Future[ExtensionResponse]] = field(init=False, repr=False)
65
+ _artifact_events: dict[str, asyncio.Event] = field(init=False, repr=False)
66
+ _artifact_buffers: dict[tuple[str, str], _ArtifactBuffer] = field(init=False, repr=False)
67
+ _completed_artifacts: dict[str, list[dict[str, Any]]] = field(init=False, repr=False)
68
+ _lock: asyncio.Lock = field(init=False, repr=False)
69
+
70
+ def __post_init__(self) -> None:
71
+ self._pending = {}
72
+ self._artifact_events = {}
73
+ self._artifact_buffers = {}
74
+ self._completed_artifacts = {}
75
+ self._lock = asyncio.Lock()
76
+
77
+ @property
78
+ def available(self) -> bool:
79
+ closed = getattr(self.websocket, "closed", None)
80
+ if isinstance(closed, bool):
81
+ return not closed
82
+ state = getattr(self.websocket, "state", None)
83
+ return state not in {State.CLOSING, State.CLOSED}
84
+
85
+ async def send_request(
86
+ self, action: str, payload: dict[str, Any] | None = None
87
+ ) -> dict[str, Any]:
88
+ if not self.available:
89
+ raise OperationFailedError("Browser CLI extension session is not connected.")
90
+ request = ExtensionRequest(
91
+ id=uuid.uuid4().hex,
92
+ action=action,
93
+ payload=payload or {},
94
+ )
95
+ future: asyncio.Future[ExtensionResponse] = asyncio.get_running_loop().create_future()
96
+ event = asyncio.Event()
97
+ event.set()
98
+ async with self._lock:
99
+ self._pending[request.id] = future
100
+ self._artifact_events[request.id] = event
101
+ await self.websocket.send(json.dumps(request.to_message()))
102
+ try:
103
+ response = await asyncio.wait_for(future, timeout=30.0)
104
+ await asyncio.wait_for(event.wait(), timeout=30.0)
105
+ finally:
106
+ self._pending.pop(request.id, None)
107
+ self._artifact_events.pop(request.id, None)
108
+ if not response.ok:
109
+ raise OperationFailedError(
110
+ response.error_message or f"Extension request failed: {action}",
111
+ error_code=response.error_code or "EXTENSION_REQUEST_FAILED",
112
+ )
113
+ data = dict(response.data)
114
+ artifacts = self._completed_artifacts.pop(request.id, [])
115
+ if artifacts:
116
+ data["_artifacts"] = artifacts
117
+ return data
118
+
119
+ def resolve_response(self, response: ExtensionResponse) -> None:
120
+ future = self._pending.get(response.id)
121
+ if future is None or future.done():
122
+ return
123
+ future.set_result(response)
124
+
125
+ def fail_all(self, message: str) -> None:
126
+ for future in list(self._pending.values()):
127
+ if not future.done():
128
+ future.set_exception(
129
+ OperationFailedError(message, error_code="EXTENSION_DISCONNECTED")
130
+ )
131
+ self._pending.clear()
132
+ self._artifact_buffers.clear()
133
+ self._completed_artifacts.clear()
134
+ for event in list(self._artifact_events.values()):
135
+ event.set()
136
+ self._artifact_events.clear()
137
+
138
+ def begin_artifact(self, descriptor: ExtensionArtifactBegin) -> None:
139
+ key = (descriptor.request_id, descriptor.artifact_id)
140
+ self._artifact_buffers[key] = _ArtifactBuffer(descriptor=descriptor)
141
+ event = self._artifact_events.get(descriptor.request_id)
142
+ if event is not None:
143
+ event.clear()
144
+
145
+ def append_artifact_chunk(self, chunk: ExtensionArtifactChunk) -> None:
146
+ key = (chunk.request_id, chunk.artifact_id)
147
+ buffer = self._artifact_buffers.get(key)
148
+ if buffer is None:
149
+ return
150
+ buffer.append(chunk.chunk)
151
+
152
+ def complete_artifact(self, artifact_end: ExtensionArtifactEnd) -> None:
153
+ key = (artifact_end.request_id, artifact_end.artifact_id)
154
+ buffer = self._artifact_buffers.pop(key, None)
155
+ if buffer is None:
156
+ return
157
+ self._completed_artifacts.setdefault(artifact_end.request_id, []).append(
158
+ buffer.to_payload()
159
+ )
160
+ if not any(
161
+ request_id == artifact_end.request_id
162
+ for request_id, _artifact_id in self._artifact_buffers
163
+ ):
164
+ event = self._artifact_events.get(artifact_end.request_id)
165
+ if event is not None:
166
+ event.set()
167
+
168
+
169
+ class ExtensionHub:
170
+ PROBE_PATH = "/ext"
171
+ STATUS_PATH = "/ext/runtime-status"
172
+ REBUILD_PATH = "/ext/workspace-rebuild"
173
+
174
+ def __init__(
175
+ self,
176
+ *,
177
+ status_provider: Callable[[], Awaitable[dict[str, Any]] | dict[str, Any]] | None = None,
178
+ workspace_rebuild_handler: (
179
+ Callable[[], Awaitable[dict[str, Any]] | dict[str, Any]] | None
180
+ ) = None,
181
+ ) -> None:
182
+ self._server: websockets.server.Serve | None = None
183
+ self._session: ExtensionSession | None = None
184
+ self._session_ready = asyncio.Event()
185
+ self._started = False
186
+ self._lock = asyncio.Lock()
187
+ self._session_changed = asyncio.Event()
188
+ self._status_provider = status_provider
189
+ self._workspace_rebuild_handler = workspace_rebuild_handler
190
+
191
+ @property
192
+ def session(self) -> ExtensionSession | None:
193
+ session = self._session
194
+ if session is None or not session.available:
195
+ return None
196
+ return session
197
+
198
+ async def ensure_started(self) -> None:
199
+ async with self._lock:
200
+ if self._started:
201
+ return
202
+ app_paths = get_app_paths()
203
+ self._server = await websockets.serve(
204
+ self._handle_websocket,
205
+ host=app_paths.extension_host,
206
+ port=app_paths.extension_port,
207
+ ping_interval=None,
208
+ ping_timeout=None,
209
+ process_request=self._process_request,
210
+ )
211
+ self._started = True
212
+ logger.info(
213
+ "Extension WebSocket listener started on ws://%s:%s%s",
214
+ app_paths.extension_host,
215
+ app_paths.extension_port,
216
+ app_paths.extension_ws_path,
217
+ )
218
+
219
+ async def stop(self) -> None:
220
+ async with self._lock:
221
+ session = self._session
222
+ self._session = None
223
+ self._session_ready.clear()
224
+ if session is not None:
225
+ session.fail_all("Extension session closed.")
226
+ with contextlib.suppress(Exception):
227
+ await session.websocket.close()
228
+ if self._server is not None:
229
+ self._server.close()
230
+ await self._server.wait_closed()
231
+ self._server = None
232
+ self._started = False
233
+ self._session_changed.set()
234
+ logger.info("Extension WebSocket listener stopped")
235
+
236
+ async def wait_for_session(self, timeout_seconds: float) -> ExtensionSession | None:
237
+ try:
238
+ await asyncio.wait_for(self._session_ready.wait(), timeout=timeout_seconds)
239
+ except TimeoutError:
240
+ return None
241
+ return self.session
242
+
243
+ async def wait_for_change(self) -> None:
244
+ await self._session_changed.wait()
245
+ self._session_changed.clear()
246
+
247
+ def set_status_provider(
248
+ self, provider: Callable[[], Awaitable[dict[str, Any]] | dict[str, Any]] | None
249
+ ) -> None:
250
+ self._status_provider = provider
251
+
252
+ def set_workspace_rebuild_handler(
253
+ self, handler: Callable[[], Awaitable[dict[str, Any]] | dict[str, Any]] | None
254
+ ) -> None:
255
+ self._workspace_rebuild_handler = handler
256
+
257
+ async def get_runtime_status_payload(self) -> dict[str, Any]:
258
+ if self._status_provider is None:
259
+ return {"ok": False, "error": "status unavailable"}
260
+ payload = self._status_provider()
261
+ if inspect.isawaitable(payload):
262
+ payload = await payload
263
+ return dict(payload)
264
+
265
+ async def rebuild_workspace_payload(self) -> dict[str, Any]:
266
+ if self._workspace_rebuild_handler is None:
267
+ return {"ok": False, "error": "workspace rebuild unavailable"}
268
+ payload = self._workspace_rebuild_handler()
269
+ if inspect.isawaitable(payload):
270
+ payload = await payload
271
+ return dict(payload)
272
+
273
+ async def _handle_websocket(self, websocket: ServerConnection) -> None:
274
+ hello_payload = json.loads(await websocket.recv())
275
+ if hello_payload.get("type") != "hello":
276
+ await websocket.close(code=4400, reason="Expected hello")
277
+ return
278
+ hello = ExtensionHello.from_message(dict(hello_payload))
279
+ session = ExtensionSession(websocket=websocket, hello=hello)
280
+ self._session = session
281
+ self._session_ready.set()
282
+ self._session_changed.set()
283
+ logger.info(
284
+ "Extension connected: version=%s browser=%s capabilities=%d workspace=%s",
285
+ hello.extension_version,
286
+ hello.browser_version,
287
+ len(hello.capabilities),
288
+ hello.workspace_window_state,
289
+ )
290
+ try:
291
+ async for raw_message in websocket:
292
+ payload = json.loads(raw_message)
293
+ message_type = str(payload.get("type") or "")
294
+ if message_type == "response":
295
+ session.resolve_response(ExtensionResponse.from_message(dict(payload)))
296
+ elif message_type == "artifact-begin":
297
+ session.begin_artifact(ExtensionArtifactBegin.from_message(dict(payload)))
298
+ elif message_type == "artifact-chunk":
299
+ session.append_artifact_chunk(
300
+ ExtensionArtifactChunk.from_message(dict(payload))
301
+ )
302
+ elif message_type == "artifact-end":
303
+ session.complete_artifact(ExtensionArtifactEnd.from_message(dict(payload)))
304
+ elif message_type == "heartbeat":
305
+ await websocket.send(json.dumps({"type": "heartbeat-ack"}))
306
+ finally:
307
+ if self._session is session:
308
+ self._session = None
309
+ self._session_ready.clear()
310
+ session.fail_all("Extension disconnected.")
311
+ self._session_changed.set()
312
+ logger.warning(
313
+ "Extension disconnected: code=%s reason=%s",
314
+ getattr(websocket, "close_code", None),
315
+ getattr(websocket, "close_reason", None),
316
+ )
317
+
318
+ async def _process_request(
319
+ self, connection_or_path: ServerConnection | str, request_or_headers: Request | Headers
320
+ ) -> Response | None:
321
+ # Support both websockets 12/13 (old API: process_request(path, headers))
322
+ # and websockets 14+ (new API: process_request(connection, request))
323
+ if hasattr(request_or_headers, "headers"):
324
+ # websockets 14+ API: request_or_headers is a Request object
325
+ request = request_or_headers
326
+ path = request.path
327
+ headers = request.headers
328
+ else:
329
+ # websockets 12/13 API: request_or_headers is a Headers object
330
+ path = str(connection_or_path)
331
+ headers = request_or_headers
332
+
333
+ if self._is_websocket_upgrade(headers):
334
+ return None
335
+ if path == self.PROBE_PATH:
336
+ return self._build_response(
337
+ HTTPStatus.UPGRADE_REQUIRED,
338
+ b"Browser CLI extension endpoint expects a WebSocket upgrade.\n",
339
+ upgrade="websocket",
340
+ )
341
+ if path == self.STATUS_PATH:
342
+ payload = await self.get_runtime_status_payload()
343
+ body = json.dumps(payload).encode("utf-8")
344
+ return self._build_json_response(HTTPStatus.OK, body)
345
+ if path == self.REBUILD_PATH:
346
+ # websockets only parses GET upgrade requests on this listener, so
347
+ # popup control endpoints must remain body-less GET requests.
348
+ payload = await self.rebuild_workspace_payload()
349
+ body = json.dumps(payload).encode("utf-8")
350
+ return self._build_json_response(HTTPStatus.OK, body)
351
+ return self._build_response(HTTPStatus.NOT_FOUND, b"Not Found\n")
352
+
353
+ @staticmethod
354
+ def _is_websocket_upgrade(headers: Headers) -> bool:
355
+ upgrade = headers.get("Upgrade", "")
356
+ connection = headers.get("Connection", "")
357
+ return upgrade.lower() == "websocket" and "upgrade" in connection.lower()
358
+
359
+ @staticmethod
360
+ def _build_response(
361
+ status: HTTPStatus,
362
+ body: bytes,
363
+ *,
364
+ upgrade: str | None = None,
365
+ ) -> Response | tuple[int, Headers, bytes]:
366
+ headers = Headers()
367
+ headers["Content-Type"] = "text/plain; charset=utf-8"
368
+ headers["Content-Length"] = str(len(body))
369
+ if upgrade:
370
+ headers["Upgrade"] = upgrade
371
+ # websockets 12/13 expect tuple (status, headers, body)
372
+ # websockets 14+ accepts Response object
373
+ import websockets
374
+
375
+ version_tuple = tuple(map(int, websockets.__version__.split(".")[:2]))
376
+ if version_tuple >= (14, 0):
377
+ return Response(int(status), status.phrase, headers, body)
378
+ else:
379
+ return (int(status), headers, body)
380
+
381
+ @staticmethod
382
+ def _build_json_response(
383
+ status: HTTPStatus,
384
+ body: bytes,
385
+ ) -> Response | tuple[int, Headers, bytes]:
386
+ headers = Headers()
387
+ headers["Content-Type"] = "application/json; charset=utf-8"
388
+ headers["Content-Length"] = str(len(body))
389
+ version_tuple = tuple(map(int, websockets.__version__.split(".")[:2]))
390
+ if version_tuple >= (14, 0):
391
+ return Response(int(status), status.phrase, headers, body)
392
+ else:
393
+ return (int(status), headers, body)