gdmcode 0.1.0__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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
src/server/bridge.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""asyncio WebSocket bridge between gdm CLI and Chrome extension.
|
|
2
|
+
|
|
3
|
+
The bridge is a separate process with its own event loop so it survives
|
|
4
|
+
AgentLoop restarts and keeps the Chrome extension persistently connected.
|
|
5
|
+
|
|
6
|
+
Security model:
|
|
7
|
+
- Binds to 127.0.0.1 only — never 0.0.0.0
|
|
8
|
+
- Bearer token required on first message (auth handshake)
|
|
9
|
+
- Origin validation for browser connections
|
|
10
|
+
- Token written to disk with chmod 600
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
config = BridgeConfig(port=9321, session_id="abc")
|
|
15
|
+
server = BridgeServer(config)
|
|
16
|
+
asyncio.run(server.run())
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import platform
|
|
24
|
+
import re
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
from collections import deque
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
__all__ = ["BridgeConfig", "BridgeServer", "TokenManager", "NONCE_TTL_SECS"]
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Constants
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
BRIDGE_DEFAULT_PORT: int = 9321
|
|
41
|
+
BRIDGE_DEFAULT_HOST: str = "127.0.0.1"
|
|
42
|
+
COMMAND_QUEUE_TTL_SECS: float = 30.0
|
|
43
|
+
MAX_MESSAGE_SIZE_BYTES: int = 10 * 1024 * 1024 # 10 MB (screenshots)
|
|
44
|
+
HEARTBEAT_INTERVAL_SECS: float = 20.0
|
|
45
|
+
TOKEN_FILE_NAME: str = "bridge.token"
|
|
46
|
+
HEALTH_CHECK_PATH: str = "/health"
|
|
47
|
+
NONCE_TTL_SECS: float = 60.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Configuration
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _default_config_dir() -> Path:
|
|
55
|
+
if platform.system() == "Windows":
|
|
56
|
+
base = Path.home() / "AppData" / "Roaming" / "gdm"
|
|
57
|
+
else:
|
|
58
|
+
base = Path.home() / ".config" / "gdm"
|
|
59
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
return base
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_token_path(project_root: Path | None = None) -> Path:
|
|
64
|
+
"""Resolve the canonical bridge token path.
|
|
65
|
+
|
|
66
|
+
Checks ``.context-memory/bridge.token`` relative to *project_root* (if
|
|
67
|
+
provided and the file exists), then falls back to the user-scoped config
|
|
68
|
+
directory (``~/.config/gdm/bridge.token`` on Linux/macOS).
|
|
69
|
+
"""
|
|
70
|
+
if project_root is not None:
|
|
71
|
+
override = project_root / ".context-memory" / TOKEN_FILE_NAME
|
|
72
|
+
if override.exists():
|
|
73
|
+
return override
|
|
74
|
+
return _default_config_dir() / TOKEN_FILE_NAME
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class BridgeConfig:
|
|
79
|
+
host: str = BRIDGE_DEFAULT_HOST
|
|
80
|
+
port: int = BRIDGE_DEFAULT_PORT
|
|
81
|
+
token_dir: Path = field(default_factory=_default_config_dir)
|
|
82
|
+
session_id: str = ""
|
|
83
|
+
db_path: Path | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Token management
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class TokenManager:
|
|
92
|
+
"""Manages bearer token generation, storage, and validation."""
|
|
93
|
+
token_dir: Path
|
|
94
|
+
|
|
95
|
+
def generate(self) -> str:
|
|
96
|
+
token = secrets.token_urlsafe(32)
|
|
97
|
+
token_path = self.token_dir / TOKEN_FILE_NAME
|
|
98
|
+
token_path.write_text(token, encoding="utf-8")
|
|
99
|
+
try:
|
|
100
|
+
token_path.chmod(0o600)
|
|
101
|
+
except NotImplementedError:
|
|
102
|
+
pass # Windows — chmod is a no-op
|
|
103
|
+
log.info("Bridge token written to %s", token_path)
|
|
104
|
+
return token
|
|
105
|
+
|
|
106
|
+
def load(self) -> str | None:
|
|
107
|
+
token_path = self.token_dir / TOKEN_FILE_NAME
|
|
108
|
+
if not token_path.exists():
|
|
109
|
+
return None
|
|
110
|
+
return token_path.read_text(encoding="utf-8").strip()
|
|
111
|
+
|
|
112
|
+
def validate(self, candidate: str) -> bool:
|
|
113
|
+
stored = self.load()
|
|
114
|
+
if stored is None:
|
|
115
|
+
return False
|
|
116
|
+
return secrets.compare_digest(candidate, stored)
|
|
117
|
+
|
|
118
|
+
def revoke(self) -> None:
|
|
119
|
+
(self.token_dir / TOKEN_FILE_NAME).unlink(missing_ok=True)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Client registry
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class ConnectedClient:
|
|
128
|
+
ws: Any # WebSocketServerProtocol
|
|
129
|
+
client_type: str = "unknown"
|
|
130
|
+
authenticated: bool = False
|
|
131
|
+
connected_at: float = field(default_factory=time.monotonic)
|
|
132
|
+
last_heartbeat: float = field(default_factory=time.monotonic)
|
|
133
|
+
tab_id: int | None = None
|
|
134
|
+
origin: str = ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ClientRegistry:
|
|
138
|
+
def __init__(self) -> None:
|
|
139
|
+
self._clients: dict[int, ConnectedClient] = {}
|
|
140
|
+
|
|
141
|
+
def add(self, ws: Any) -> ConnectedClient:
|
|
142
|
+
client = ConnectedClient(ws=ws)
|
|
143
|
+
self._clients[id(ws)] = client
|
|
144
|
+
return client
|
|
145
|
+
|
|
146
|
+
def remove(self, ws: Any) -> None:
|
|
147
|
+
self._clients.pop(id(ws), None)
|
|
148
|
+
|
|
149
|
+
def get(self, ws: Any) -> ConnectedClient | None:
|
|
150
|
+
return self._clients.get(id(ws))
|
|
151
|
+
|
|
152
|
+
def get_cli_clients(self) -> list[ConnectedClient]:
|
|
153
|
+
return [c for c in self._clients.values()
|
|
154
|
+
if c.client_type == "cli" and c.authenticated]
|
|
155
|
+
|
|
156
|
+
def get_extension_clients(self) -> list[ConnectedClient]:
|
|
157
|
+
return [c for c in self._clients.values()
|
|
158
|
+
if c.client_type == "extension" and c.authenticated]
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def count(self) -> int:
|
|
162
|
+
return len(self._clients)
|
|
163
|
+
|
|
164
|
+
def summary(self) -> dict[str, int]:
|
|
165
|
+
cli = len(self.get_cli_clients())
|
|
166
|
+
ext = len(self.get_extension_clients())
|
|
167
|
+
return {"cli": cli, "extension": ext, "total": self.count}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Command queue
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class PendingCommand:
|
|
176
|
+
id: str
|
|
177
|
+
payload: dict[str, Any]
|
|
178
|
+
queued_at: float = field(default_factory=time.monotonic)
|
|
179
|
+
source_ws: Any | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class CommandQueue:
|
|
183
|
+
"""Buffers CLI→extension commands when no extension is connected."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, ttl: float = COMMAND_QUEUE_TTL_SECS) -> None:
|
|
186
|
+
self._queue: deque[PendingCommand] = deque()
|
|
187
|
+
self._ttl = ttl
|
|
188
|
+
|
|
189
|
+
def enqueue(self, cmd: PendingCommand) -> None:
|
|
190
|
+
self._expire_stale()
|
|
191
|
+
self._queue.append(cmd)
|
|
192
|
+
log.debug("Command %s queued (%d pending)", cmd.id, len(self._queue))
|
|
193
|
+
|
|
194
|
+
def drain(self) -> list[PendingCommand]:
|
|
195
|
+
self._expire_stale()
|
|
196
|
+
commands = list(self._queue)
|
|
197
|
+
self._queue.clear()
|
|
198
|
+
return commands
|
|
199
|
+
|
|
200
|
+
def _expire_stale(self) -> None:
|
|
201
|
+
now = time.monotonic()
|
|
202
|
+
while self._queue and (now - self._queue[0].queued_at) > self._ttl:
|
|
203
|
+
expired = self._queue.popleft()
|
|
204
|
+
log.warning("Command %s expired after %.1fs", expired.id, self._ttl)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def size(self) -> int:
|
|
208
|
+
return len(self._queue)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Main server
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
_LOCALHOST_ORIGIN_RE = re.compile(
|
|
216
|
+
r"https?://(localhost|127\.0\.0\.1)(:\d+)?$"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class BridgeServer:
|
|
221
|
+
"""asyncio WebSocket bridge between gdm CLI and Chrome extension.
|
|
222
|
+
|
|
223
|
+
Handles:
|
|
224
|
+
- Bearer-token authentication on first message
|
|
225
|
+
- Command forwarding: CLI → extension (with queue when disconnected)
|
|
226
|
+
- Response forwarding: extension → CLI
|
|
227
|
+
- Status broadcast: extension → all CLI clients
|
|
228
|
+
- /health HTTP endpoint (no auth required)
|
|
229
|
+
- Origin validation for browser-side connections
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(self, config: BridgeConfig) -> None:
|
|
233
|
+
self._config = config
|
|
234
|
+
self._token_mgr = TokenManager(token_dir=config.token_dir)
|
|
235
|
+
self._clients = ClientRegistry()
|
|
236
|
+
self._command_queue = CommandQueue()
|
|
237
|
+
self._pending_responses: dict[str, PendingCommand] = {}
|
|
238
|
+
self._audit_log: list[dict[str, Any]] = []
|
|
239
|
+
self._nonce_cache: dict[str, float] = {}
|
|
240
|
+
|
|
241
|
+
async def run(self) -> None:
|
|
242
|
+
"""Start the server. Blocks until shutdown."""
|
|
243
|
+
try:
|
|
244
|
+
import websockets
|
|
245
|
+
from websockets.server import serve
|
|
246
|
+
except ImportError as exc:
|
|
247
|
+
raise RuntimeError(
|
|
248
|
+
"websockets package required: pip install websockets"
|
|
249
|
+
) from exc
|
|
250
|
+
|
|
251
|
+
self._token_mgr.generate()
|
|
252
|
+
log.info(
|
|
253
|
+
"Bridge server starting on %s:%d",
|
|
254
|
+
self._config.host, self._config.port,
|
|
255
|
+
)
|
|
256
|
+
async with serve(
|
|
257
|
+
self._handle_client,
|
|
258
|
+
self._config.host,
|
|
259
|
+
self._config.port,
|
|
260
|
+
max_size=MAX_MESSAGE_SIZE_BYTES,
|
|
261
|
+
process_request=self._handle_http,
|
|
262
|
+
):
|
|
263
|
+
log.info("Bridge ready — waiting for connections")
|
|
264
|
+
await asyncio.Future() # run forever
|
|
265
|
+
|
|
266
|
+
async def _handle_http(
|
|
267
|
+
self, path: str, headers: Any
|
|
268
|
+
) -> tuple[int, list[tuple[str, str]], bytes] | None:
|
|
269
|
+
if path == HEALTH_CHECK_PATH:
|
|
270
|
+
body = json.dumps({
|
|
271
|
+
"status": "ok",
|
|
272
|
+
"clients": self._clients.summary(),
|
|
273
|
+
"queue_depth": self._command_queue.size,
|
|
274
|
+
}).encode()
|
|
275
|
+
return (200, [("Content-Type", "application/json")], body)
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
async def _handle_client(self, ws: Any) -> None:
|
|
279
|
+
from src.server.protocol_version import (
|
|
280
|
+
is_compatible,
|
|
281
|
+
make_hello_message,
|
|
282
|
+
make_version_mismatch_error,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
client = self._clients.add(ws)
|
|
286
|
+
origin = getattr(ws, "request_headers", {}).get("Origin", "")
|
|
287
|
+
if origin and not self._is_allowed_origin(origin):
|
|
288
|
+
await ws.close(4003, f"Origin {origin} not allowed")
|
|
289
|
+
self._clients.remove(ws)
|
|
290
|
+
return
|
|
291
|
+
client.origin = origin
|
|
292
|
+
|
|
293
|
+
# Send protocol hello (protocol-001) — must arrive before auth handshake
|
|
294
|
+
hello = make_hello_message(server_name="gdm-bridge")
|
|
295
|
+
await ws.send(json.dumps(hello))
|
|
296
|
+
|
|
297
|
+
# Wait for hello_ack (5 second timeout)
|
|
298
|
+
_first_msg: dict[str, Any] | None = None
|
|
299
|
+
try:
|
|
300
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
301
|
+
ack = json.loads(raw)
|
|
302
|
+
if ack.get("type") == "hello_ack":
|
|
303
|
+
client_ver = ack.get("protocol_version", "0.0")
|
|
304
|
+
compatible, reason = is_compatible(client_ver)
|
|
305
|
+
if not compatible:
|
|
306
|
+
await ws.send(json.dumps(make_version_mismatch_error(client_ver)))
|
|
307
|
+
await ws.close(code=4000, reason=reason)
|
|
308
|
+
self._clients.remove(ws)
|
|
309
|
+
return
|
|
310
|
+
elif reason:
|
|
311
|
+
log.warning("Protocol minor version warning: %s", reason)
|
|
312
|
+
else:
|
|
313
|
+
# Not hello_ack — backward compat: treat as first regular message
|
|
314
|
+
_first_msg = ack
|
|
315
|
+
except asyncio.TimeoutError:
|
|
316
|
+
log.warning("Client did not send hello_ack within 5s — proceeding without version check")
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
if _first_msg is not None:
|
|
320
|
+
await self._route_message(client, _first_msg)
|
|
321
|
+
async for raw_msg in ws:
|
|
322
|
+
try:
|
|
323
|
+
msg = json.loads(raw_msg)
|
|
324
|
+
except json.JSONDecodeError:
|
|
325
|
+
log.warning("Malformed JSON from %s — ignored", client.client_type)
|
|
326
|
+
continue
|
|
327
|
+
await self._route_message(client, msg)
|
|
328
|
+
except Exception: # noqa: BLE001 — websockets.ConnectionClosed + others
|
|
329
|
+
log.debug("Client %s disconnected", client.client_type)
|
|
330
|
+
finally:
|
|
331
|
+
self._clients.remove(ws)
|
|
332
|
+
|
|
333
|
+
async def _route_message(self, client: ConnectedClient, msg: dict[str, Any]) -> None:
|
|
334
|
+
msg_type = msg.get("type", "")
|
|
335
|
+
|
|
336
|
+
if not client.authenticated:
|
|
337
|
+
if msg_type != "auth":
|
|
338
|
+
await client.ws.close(4001, "First message must be auth")
|
|
339
|
+
return
|
|
340
|
+
if not self._token_mgr.validate(msg.get("token", "")):
|
|
341
|
+
await client.ws.close(4002, "Invalid token")
|
|
342
|
+
return
|
|
343
|
+
client.authenticated = True
|
|
344
|
+
client.client_type = msg.get("client_type", "cli")
|
|
345
|
+
await client.ws.send(json.dumps({"type": "auth_ok"}))
|
|
346
|
+
# Flush queued commands to newly connected extension
|
|
347
|
+
if client.client_type == "extension":
|
|
348
|
+
for cmd in self._command_queue.drain():
|
|
349
|
+
await client.ws.send(json.dumps(cmd.payload))
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
self._audit(msg_type, msg)
|
|
353
|
+
match msg_type:
|
|
354
|
+
case "heartbeat":
|
|
355
|
+
client.last_heartbeat = time.monotonic()
|
|
356
|
+
|
|
357
|
+
case "command":
|
|
358
|
+
nonce = msg.get("nonce", "")
|
|
359
|
+
if nonce and not self._check_and_record_nonce(nonce):
|
|
360
|
+
self._audit("replay_rejected", msg)
|
|
361
|
+
await client.ws.send(json.dumps({
|
|
362
|
+
"type": "error",
|
|
363
|
+
"code": "replay_detected",
|
|
364
|
+
"id": msg.get("id", ""),
|
|
365
|
+
"message": "Replayed nonce rejected",
|
|
366
|
+
}))
|
|
367
|
+
return
|
|
368
|
+
extensions = self._clients.get_extension_clients()
|
|
369
|
+
cmd = PendingCommand(id=msg.get("id", ""), payload=msg, source_ws=client.ws)
|
|
370
|
+
if extensions:
|
|
371
|
+
await extensions[0].ws.send(json.dumps(msg))
|
|
372
|
+
self._pending_responses[msg.get("id", "")] = cmd
|
|
373
|
+
else:
|
|
374
|
+
self._command_queue.enqueue(cmd)
|
|
375
|
+
|
|
376
|
+
case "response":
|
|
377
|
+
cmd_id = msg.get("id", "")
|
|
378
|
+
pending = self._pending_responses.pop(cmd_id, None)
|
|
379
|
+
if pending and pending.source_ws:
|
|
380
|
+
await pending.source_ws.send(json.dumps(msg))
|
|
381
|
+
|
|
382
|
+
case "status":
|
|
383
|
+
for cli in self._clients.get_cli_clients():
|
|
384
|
+
await cli.ws.send(json.dumps(msg))
|
|
385
|
+
|
|
386
|
+
def _check_and_record_nonce(self, nonce: str) -> bool:
|
|
387
|
+
"""Record nonce; return True if fresh, False if replayed within NONCE_TTL_SECS."""
|
|
388
|
+
now = time.monotonic()
|
|
389
|
+
stale = [n for n, ts in self._nonce_cache.items() if (now - ts) > NONCE_TTL_SECS]
|
|
390
|
+
for n in stale:
|
|
391
|
+
del self._nonce_cache[n]
|
|
392
|
+
if nonce in self._nonce_cache:
|
|
393
|
+
return False
|
|
394
|
+
self._nonce_cache[nonce] = now
|
|
395
|
+
return True
|
|
396
|
+
|
|
397
|
+
def _is_allowed_origin(self, origin: str) -> bool:
|
|
398
|
+
if origin.startswith("chrome-extension://"):
|
|
399
|
+
return True
|
|
400
|
+
if origin in ("http://localhost", "http://127.0.0.1"):
|
|
401
|
+
return True
|
|
402
|
+
return bool(_LOCALHOST_ORIGIN_RE.match(origin))
|
|
403
|
+
|
|
404
|
+
def _audit(self, msg_type: str, msg: dict[str, Any]) -> None:
|
|
405
|
+
self._audit_log.append({
|
|
406
|
+
"timestamp": time.time(),
|
|
407
|
+
"type": msg_type,
|
|
408
|
+
"msg_id": msg.get("id", ""),
|
|
409
|
+
"summary": json.dumps(msg)[:500],
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
async def shutdown(self) -> None:
|
|
413
|
+
"""Graceful shutdown: notify pending commands, close all clients."""
|
|
414
|
+
for cmd in self._command_queue.drain():
|
|
415
|
+
if cmd.source_ws:
|
|
416
|
+
try:
|
|
417
|
+
await cmd.source_ws.send(json.dumps({
|
|
418
|
+
"type": "response", "id": cmd.id,
|
|
419
|
+
"ok": False, "error": "Bridge shutting down",
|
|
420
|
+
}))
|
|
421
|
+
except Exception: # noqa: BLE001
|
|
422
|
+
pass
|
|
423
|
+
for client in list(self._clients._clients.values()):
|
|
424
|
+
try:
|
|
425
|
+
await client.ws.close(1001, "Bridge shutting down")
|
|
426
|
+
except Exception: # noqa: BLE001
|
|
427
|
+
pass
|
src/server/bridge_cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Entry point for launching the bridge server as a subprocess.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
python -m src.server.bridge_cli --port 9321
|
|
6
|
+
gdm browser start
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from src.server.bridge import BRIDGE_DEFAULT_PORT, BridgeConfig, BridgeServer
|
|
19
|
+
|
|
20
|
+
__all__ = ["main", "start_bridge", "stop_bridge", "bridge_status"]
|
|
21
|
+
|
|
22
|
+
_bridge_process: subprocess.Popen | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
p = argparse.ArgumentParser(
|
|
27
|
+
prog="gdm-bridge",
|
|
28
|
+
description="Start the gdm WebSocket bridge server",
|
|
29
|
+
)
|
|
30
|
+
p.add_argument("--port", type=int, default=9321, help="Listen port (default: 9321)")
|
|
31
|
+
p.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
|
|
32
|
+
p.add_argument("--session-id", default="", help="Associate with a gdm session ID")
|
|
33
|
+
p.add_argument("--token-dir", type=Path, default=None, help="Directory to store auth token")
|
|
34
|
+
p.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
|
|
35
|
+
return p
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> None:
|
|
39
|
+
parser = _build_parser()
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
logging.basicConfig(
|
|
43
|
+
level=getattr(logging, args.log_level),
|
|
44
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
cfg = BridgeConfig(
|
|
48
|
+
host=args.host,
|
|
49
|
+
port=args.port,
|
|
50
|
+
session_id=args.session_id,
|
|
51
|
+
)
|
|
52
|
+
if args.token_dir:
|
|
53
|
+
cfg.token_dir = args.token_dir
|
|
54
|
+
|
|
55
|
+
server = BridgeServer(cfg)
|
|
56
|
+
try:
|
|
57
|
+
asyncio.run(server.run())
|
|
58
|
+
except KeyboardInterrupt:
|
|
59
|
+
pass
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def start_bridge(port: int = BRIDGE_DEFAULT_PORT, token_dir: Path | None = None) -> None:
|
|
68
|
+
"""Start the bridge server in a background subprocess (no-op if already running)."""
|
|
69
|
+
global _bridge_process
|
|
70
|
+
if _bridge_process is not None and _bridge_process.poll() is None:
|
|
71
|
+
return
|
|
72
|
+
cmd = [sys.executable, "-m", "src.server.bridge_cli", "--port", str(port)]
|
|
73
|
+
if token_dir is not None:
|
|
74
|
+
cmd += ["--token-dir", str(token_dir)]
|
|
75
|
+
_bridge_process = subprocess.Popen(
|
|
76
|
+
cmd,
|
|
77
|
+
stdout=subprocess.DEVNULL,
|
|
78
|
+
stderr=subprocess.DEVNULL,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def stop_bridge() -> None:
|
|
83
|
+
"""Gracefully stop the bridge server subprocess."""
|
|
84
|
+
global _bridge_process
|
|
85
|
+
if _bridge_process is None:
|
|
86
|
+
return
|
|
87
|
+
_bridge_process.terminate()
|
|
88
|
+
try:
|
|
89
|
+
_bridge_process.wait(timeout=5.0)
|
|
90
|
+
except subprocess.TimeoutExpired:
|
|
91
|
+
_bridge_process.kill()
|
|
92
|
+
_bridge_process = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def bridge_status() -> dict[str, Any]:
|
|
96
|
+
"""Return running status and port of the bridge server."""
|
|
97
|
+
global _bridge_process
|
|
98
|
+
running = _bridge_process is not None and _bridge_process.poll() is None
|
|
99
|
+
return {
|
|
100
|
+
"running": running,
|
|
101
|
+
"port": BRIDGE_DEFAULT_PORT,
|
|
102
|
+
"pid": _bridge_process.pid if running else None,
|
|
103
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Synchronous CLI-side WebSocket client for communicating with the bridge server.
|
|
2
|
+
|
|
3
|
+
Runs an asyncio event loop in a background thread, exposing a blocking
|
|
4
|
+
send_command() for use from the synchronous AgentLoop.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
client = BridgeClient(url="ws://127.0.0.1:9321", token="abc123")
|
|
9
|
+
client.connect()
|
|
10
|
+
response = client.send_command(action="click", params={"selector": "#btn"})
|
|
11
|
+
client.disconnect()
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import concurrent.futures
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import threading
|
|
20
|
+
import uuid
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
__all__ = ["BridgeClient"]
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BridgeClient:
|
|
29
|
+
"""Synchronous WebSocket client wrapping an asyncio WS in a background thread."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, url: str, token: str) -> None:
|
|
32
|
+
self._url = url
|
|
33
|
+
self._token = token
|
|
34
|
+
self._ws: Any = None
|
|
35
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
36
|
+
self._thread: threading.Thread | None = None
|
|
37
|
+
self._connected = threading.Event()
|
|
38
|
+
self._disconnect_flag = threading.Event()
|
|
39
|
+
self._pending: dict[str, concurrent.futures.Future[dict[str, Any]]] = {}
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
# ── Public interface ───────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_connected(self) -> bool:
|
|
46
|
+
return self._connected.is_set() and not self._disconnect_flag.is_set()
|
|
47
|
+
|
|
48
|
+
def connect(self, timeout: float = 10.0) -> None:
|
|
49
|
+
"""Start the background event loop and connect to the bridge."""
|
|
50
|
+
self._loop = asyncio.new_event_loop()
|
|
51
|
+
self._disconnect_flag.clear()
|
|
52
|
+
self._thread = threading.Thread(
|
|
53
|
+
target=self._run_loop,
|
|
54
|
+
daemon=True,
|
|
55
|
+
name="BridgeClient-io",
|
|
56
|
+
)
|
|
57
|
+
self._thread.start()
|
|
58
|
+
if not self._connected.wait(timeout=timeout):
|
|
59
|
+
raise ConnectionError(
|
|
60
|
+
f"Failed to connect to bridge at {self._url} within {timeout}s"
|
|
61
|
+
)
|
|
62
|
+
log.debug("BridgeClient connected to %s", self._url)
|
|
63
|
+
|
|
64
|
+
def disconnect(self) -> None:
|
|
65
|
+
"""Close the WebSocket and stop the background event loop."""
|
|
66
|
+
self._disconnect_flag.set()
|
|
67
|
+
self._connected.clear()
|
|
68
|
+
if self._loop and self._ws:
|
|
69
|
+
asyncio.run_coroutine_threadsafe(self._ws.close(), self._loop)
|
|
70
|
+
if self._thread and self._thread.is_alive():
|
|
71
|
+
self._thread.join(timeout=5)
|
|
72
|
+
|
|
73
|
+
def send_command(
|
|
74
|
+
self,
|
|
75
|
+
action: str,
|
|
76
|
+
params: dict[str, Any],
|
|
77
|
+
timeout: float = 10.0,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Send a command and block until response is received.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
TimeoutError: No response within `timeout` seconds.
|
|
83
|
+
ConnectionError: Bridge is not connected.
|
|
84
|
+
"""
|
|
85
|
+
if not self.is_connected:
|
|
86
|
+
raise ConnectionError("Not connected to bridge")
|
|
87
|
+
|
|
88
|
+
cmd_id = uuid.uuid4().hex[:12]
|
|
89
|
+
msg = {"type": "command", "id": cmd_id, "action": action, **params}
|
|
90
|
+
|
|
91
|
+
future: concurrent.futures.Future[dict[str, Any]] = concurrent.futures.Future()
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._pending[cmd_id] = future
|
|
94
|
+
|
|
95
|
+
asyncio.run_coroutine_threadsafe(
|
|
96
|
+
self._ws.send(json.dumps(msg)), self._loop # type: ignore[union-attr]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
return future.result(timeout=timeout)
|
|
101
|
+
except concurrent.futures.TimeoutError as exc:
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._pending.pop(cmd_id, None)
|
|
104
|
+
raise TimeoutError(f"Command {cmd_id} timed out after {timeout}s") from exc
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._pending.pop(cmd_id, None)
|
|
108
|
+
raise ConnectionError(f"Command failed: {exc}") from exc
|
|
109
|
+
|
|
110
|
+
# ── Background loop ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _run_loop(self) -> None:
|
|
113
|
+
asyncio.set_event_loop(self._loop)
|
|
114
|
+
try:
|
|
115
|
+
self._loop.run_until_complete(self._main()) # type: ignore[union-attr]
|
|
116
|
+
except Exception:
|
|
117
|
+
log.debug("BridgeClient loop exited")
|
|
118
|
+
finally:
|
|
119
|
+
self._connected.clear()
|
|
120
|
+
|
|
121
|
+
async def _main(self) -> None:
|
|
122
|
+
try:
|
|
123
|
+
import websockets
|
|
124
|
+
except ImportError as exc:
|
|
125
|
+
raise RuntimeError("websockets package required: pip install websockets") from exc
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
async with websockets.connect(self._url) as ws:
|
|
129
|
+
self._ws = ws
|
|
130
|
+
await ws.send(json.dumps({
|
|
131
|
+
"type": "auth",
|
|
132
|
+
"token": self._token,
|
|
133
|
+
"client_type": "cli",
|
|
134
|
+
}))
|
|
135
|
+
auth_reply = json.loads(await ws.recv())
|
|
136
|
+
if auth_reply.get("type") != "auth_ok":
|
|
137
|
+
raise ConnectionError(f"Auth failed: {auth_reply}")
|
|
138
|
+
self._connected.set()
|
|
139
|
+
log.debug("BridgeClient authenticated")
|
|
140
|
+
|
|
141
|
+
async for raw in ws:
|
|
142
|
+
if self._disconnect_flag.is_set():
|
|
143
|
+
break
|
|
144
|
+
try:
|
|
145
|
+
msg = json.loads(raw)
|
|
146
|
+
except json.JSONDecodeError:
|
|
147
|
+
continue
|
|
148
|
+
self._dispatch_response(msg)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
log.debug("BridgeClient WS error: %s", exc)
|
|
151
|
+
self._connected.clear()
|
|
152
|
+
self._fail_all_pending(str(exc))
|
|
153
|
+
|
|
154
|
+
def _dispatch_response(self, msg: dict[str, Any]) -> None:
|
|
155
|
+
msg_id = msg.get("id")
|
|
156
|
+
if not msg_id:
|
|
157
|
+
return
|
|
158
|
+
with self._lock:
|
|
159
|
+
future = self._pending.pop(msg_id, None)
|
|
160
|
+
if future and not future.done():
|
|
161
|
+
future.set_result(msg)
|
|
162
|
+
|
|
163
|
+
def _fail_all_pending(self, error: str) -> None:
|
|
164
|
+
with self._lock:
|
|
165
|
+
pending = list(self._pending.items())
|
|
166
|
+
self._pending.clear()
|
|
167
|
+
exc = ConnectionError(error)
|
|
168
|
+
for _cmd_id, future in pending:
|
|
169
|
+
if not future.done():
|
|
170
|
+
future.set_exception(exc)
|