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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. 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
@@ -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)