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.
- browser_cli/__init__.py +7 -0
- browser_cli/actions/__init__.py +5 -0
- browser_cli/actions/cli_specs.py +868 -0
- browser_cli/agent_scope/__init__.py +14 -0
- browser_cli/automation/__init__.py +11 -0
- browser_cli/automation/api/__init__.py +5 -0
- browser_cli/automation/api/server.py +321 -0
- browser_cli/automation/hooks.py +42 -0
- browser_cli/automation/loader.py +134 -0
- browser_cli/automation/models.py +127 -0
- browser_cli/automation/persistence/__init__.py +5 -0
- browser_cli/automation/persistence/store.py +682 -0
- browser_cli/automation/projections.py +279 -0
- browser_cli/automation/publisher.py +153 -0
- browser_cli/automation/scheduler/__init__.py +5 -0
- browser_cli/automation/scheduler/schedule.py +113 -0
- browser_cli/automation/service/__init__.py +8 -0
- browser_cli/automation/service/__main__.py +64 -0
- browser_cli/automation/service/client.py +232 -0
- browser_cli/automation/service/runtime.py +388 -0
- browser_cli/automation/toml.py +35 -0
- browser_cli/automation/web/__init__.py +5 -0
- browser_cli/automation/web/assets.py +377 -0
- browser_cli/browser/__init__.py +1 -0
- browser_cli/browser/models.py +44 -0
- browser_cli/browser/network_capture.py +392 -0
- browser_cli/browser/service.py +1519 -0
- browser_cli/browser/session.py +158 -0
- browser_cli/browser/snapshot.py +35 -0
- browser_cli/browser/stealth.py +450 -0
- browser_cli/cli/__init__.py +1 -0
- browser_cli/cli/error_hints.py +26 -0
- browser_cli/cli/main.py +298 -0
- browser_cli/commands/__init__.py +1 -0
- browser_cli/commands/action.py +25 -0
- browser_cli/commands/automation.py +304 -0
- browser_cli/commands/doctor.py +336 -0
- browser_cli/commands/install_skills.py +138 -0
- browser_cli/commands/paths.py +31 -0
- browser_cli/commands/read.py +33 -0
- browser_cli/commands/reload.py +51 -0
- browser_cli/commands/status.py +444 -0
- browser_cli/commands/task.py +71 -0
- browser_cli/constants.py +97 -0
- browser_cli/daemon/__init__.py +1 -0
- browser_cli/daemon/__main__.py +34 -0
- browser_cli/daemon/app.py +941 -0
- browser_cli/daemon/browser_service.py +998 -0
- browser_cli/daemon/client.py +280 -0
- browser_cli/daemon/models.py +63 -0
- browser_cli/daemon/runtime_presentation.py +167 -0
- browser_cli/daemon/server.py +103 -0
- browser_cli/daemon/state.py +15 -0
- browser_cli/daemon/transport.py +77 -0
- browser_cli/drivers/__init__.py +6 -0
- browser_cli/drivers/_extension/__init__.py +1 -0
- browser_cli/drivers/_extension/artifact_actions.py +154 -0
- browser_cli/drivers/_extension/dialog_actions.py +35 -0
- browser_cli/drivers/_extension/input_actions.py +63 -0
- browser_cli/drivers/_extension/locator_actions.py +100 -0
- browser_cli/drivers/_extension/observe_actions.py +285 -0
- browser_cli/drivers/_extension/page_actions.py +210 -0
- browser_cli/drivers/_extension/state_actions.py +134 -0
- browser_cli/drivers/base.py +391 -0
- browser_cli/drivers/extension_driver.py +33 -0
- browser_cli/drivers/models.py +21 -0
- browser_cli/drivers/playwright_driver.py +499 -0
- browser_cli/error_codes.py +27 -0
- browser_cli/errors.py +146 -0
- browser_cli/exit_codes.py +9 -0
- browser_cli/extension/__init__.py +32 -0
- browser_cli/extension/protocol.py +222 -0
- browser_cli/extension/session.py +393 -0
- browser_cli/network.py +147 -0
- browser_cli/outputs/__init__.py +1 -0
- browser_cli/outputs/json.py +10 -0
- browser_cli/outputs/render.py +7 -0
- browser_cli/packaged_skills/__init__.py +1 -0
- browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
- browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
- browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
- browser_cli/profiles/__init__.py +1 -0
- browser_cli/profiles/discovery.py +185 -0
- browser_cli/refs/__init__.py +17 -0
- browser_cli/refs/generator.py +386 -0
- browser_cli/refs/models.py +127 -0
- browser_cli/refs/registry.py +51 -0
- browser_cli/refs/resolver.py +214 -0
- browser_cli/tabs/__init__.py +5 -0
- browser_cli/tabs/registry.py +283 -0
- browser_cli/task_runtime/__init__.py +31 -0
- browser_cli/task_runtime/client.py +107 -0
- browser_cli/task_runtime/entrypoint.py +110 -0
- browser_cli/task_runtime/errors.py +17 -0
- browser_cli/task_runtime/flow.py +171 -0
- browser_cli/task_runtime/models.py +97 -0
- browser_cli/task_runtime/read.py +71 -0
- browser_cli/task_runtime/templates.py +58 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
- 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)
|