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/remote/server.py ADDED
@@ -0,0 +1,586 @@
1
+ """RemoteServer — HTTP + WebSocket LAN bridge for phone-based agent control."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import queue
10
+ import secrets
11
+ from typing import TYPE_CHECKING
12
+
13
+ from src.remote.command_filter import RemoteCommandFilter
14
+ from src.remote.models import InputMessage as RemoteInputMessage
15
+ from src.remote.permission_handler import RemotePermissionPromptHandler, _put_send_queue
16
+ from src.remote.token_manager import PairingTokenService
17
+ from src.remote.phone_ui import AuditLog, get_html as _get_phone_ui_html, CSP_HEADER as _CSP_HEADER
18
+
19
+ if TYPE_CHECKING:
20
+ pass
21
+
22
+ __all__ = ["RemoteServer"]
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ _WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
27
+
28
+ # WebSocket frame opcodes
29
+ _OP_CONTINUATION = 0x0
30
+ _OP_TEXT = 0x1
31
+ _OP_BINARY = 0x2
32
+ _OP_CLOSE = 0x8
33
+ _OP_PING = 0x9
34
+ _OP_PONG = 0xA
35
+
36
+ _HTML_PAGE = """\
37
+ <!DOCTYPE html>
38
+ <html lang="en">
39
+ <head><meta charset="utf-8"><title>gdm remote</title>
40
+ <meta name="viewport" content="width=device-width,initial-scale=1">
41
+ <style>body{font-family:monospace;padding:1em}
42
+ #status{color:#888;margin-bottom:1em}
43
+ #transcript{white-space:pre-wrap;border:1px solid #ccc;padding:.5em;min-height:4em}</style>
44
+ </head>
45
+ <body>
46
+ <h1>gdm remote</h1>
47
+ <div id="status">Disconnected</div>
48
+ <div id="transcript"></div>
49
+ <script>
50
+ const csrfCookie = document.cookie.split(';').map(c=>c.trim())
51
+ .find(c=>c.startsWith('csrf='));
52
+ const csrfToken = csrfCookie ? csrfCookie.split('=')[1] : '';
53
+ const ws = new WebSocket('ws://' + location.host + '/ws');
54
+ ws.onopen = () => { document.getElementById('status').textContent = 'Connected'; };
55
+ ws.onclose = () => { document.getElementById('status').textContent = 'Disconnected'; };
56
+ ws.onmessage = (e) => {
57
+ const msg = JSON.parse(e.data);
58
+ if (msg.type === 'transcript') {
59
+ document.getElementById('transcript').textContent += msg.text + '\\n';
60
+ }
61
+ };
62
+ </script>
63
+ </body></html>
64
+ """
65
+
66
+ _STATUS_TEXT = {
67
+ 200: "OK",
68
+ 201: "Created",
69
+ 400: "Bad Request",
70
+ 401: "Unauthorized",
71
+ 403: "Forbidden",
72
+ 404: "Not Found",
73
+ 500: "Internal Server Error",
74
+ }
75
+
76
+
77
+ def _ws_accept_key(client_key: str) -> str:
78
+ digest = hashlib.sha1((client_key + _WS_MAGIC).encode()).digest()
79
+ return base64.b64encode(digest).decode()
80
+
81
+
82
+ def _parse_cookies(header: str) -> dict[str, str]:
83
+ result: dict[str, str] = {}
84
+ for part in header.split(";"):
85
+ name, _, value = part.strip().partition("=")
86
+ if name:
87
+ result[name.strip()] = value.strip()
88
+ return result
89
+
90
+
91
+ async def _ws_read_frame(
92
+ reader: asyncio.StreamReader,
93
+ ) -> tuple[int, bytes]:
94
+ """Read one WebSocket frame; returns (opcode, unmasked_payload)."""
95
+ header = await reader.readexactly(2)
96
+ opcode = header[0] & 0x0F
97
+ masked = bool(header[1] & 0x80)
98
+ payload_len = header[1] & 0x7F
99
+
100
+ if payload_len == 126:
101
+ payload_len = int.from_bytes(await reader.readexactly(2), "big")
102
+ elif payload_len == 127:
103
+ payload_len = int.from_bytes(await reader.readexactly(8), "big")
104
+
105
+ mask_key = await reader.readexactly(4) if masked else b""
106
+ payload = bytearray(await reader.readexactly(payload_len))
107
+
108
+ if masked:
109
+ for i in range(len(payload)):
110
+ payload[i] ^= mask_key[i % 4]
111
+
112
+ return opcode, bytes(payload)
113
+
114
+
115
+ async def _ws_write_frame(
116
+ writer: asyncio.StreamWriter, opcode: int, payload: bytes
117
+ ) -> None:
118
+ """Write one WebSocket frame (server→client, always unmasked)."""
119
+ hdr = bytearray()
120
+ hdr.append(0x80 | opcode) # FIN=1
121
+ plen = len(payload)
122
+ if plen <= 125:
123
+ hdr.append(plen)
124
+ elif plen <= 65535:
125
+ hdr.append(126)
126
+ hdr.extend(plen.to_bytes(2, "big"))
127
+ else:
128
+ hdr.append(127)
129
+ hdr.extend(plen.to_bytes(8, "big"))
130
+ writer.write(bytes(hdr) + payload)
131
+ await writer.drain()
132
+
133
+
134
+ async def _try_queue_get(q: queue.Queue, timeout: float = 0.05):
135
+ """Non-blocking get from a threading.Queue in async context."""
136
+
137
+ def _get():
138
+ try:
139
+ return q.get(timeout=timeout)
140
+ except queue.Empty:
141
+ return None
142
+
143
+ loop = asyncio.get_running_loop()
144
+ return await loop.run_in_executor(None, _get)
145
+
146
+
147
+ class RemoteServer:
148
+ """HTTP + WebSocket LAN server.
149
+
150
+ HTTP routes
151
+ -----------
152
+ GET /health → {"status": "ok", "version": "1"}
153
+ GET / → minimal phone UI (HTML)
154
+ POST /pair → exchange pairing token, set session + CSRF cookies
155
+
156
+ WebSocket
157
+ ---------
158
+ GET /ws → authenticated, CSRF-protected WebSocket connection
159
+ """
160
+
161
+ def __init__(
162
+ self,
163
+ input_broker,
164
+ event_fanout,
165
+ permission_bridge,
166
+ port: int = 8765,
167
+ ) -> None:
168
+ self._input_broker = input_broker
169
+ self._event_fanout = event_fanout
170
+ self._permission_bridge = permission_bridge
171
+ self._port = port
172
+ self._token_manager = PairingTokenService()
173
+ self._command_filter = RemoteCommandFilter()
174
+ self._audit_log = AuditLog()
175
+ # session_cookie_value → csrf_token
176
+ self._sessions: dict[str, str] = {}
177
+ self._server: asyncio.Server | None = None
178
+
179
+ async def start(self) -> None:
180
+ self._server = await asyncio.start_server(
181
+ self._handle_connection, "0.0.0.0", self._port
182
+ )
183
+ log.info("RemoteServer listening on port %d", self._port)
184
+
185
+ async def stop(self) -> None:
186
+ if self._server:
187
+ self._server.close()
188
+ await self._server.wait_closed()
189
+ self._server = None
190
+ log.info("RemoteServer stopped")
191
+
192
+ # ------------------------------------------------------------------
193
+ # Connection entry-point
194
+ # ------------------------------------------------------------------
195
+
196
+ async def _handle_connection(
197
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
198
+ ) -> None:
199
+ try:
200
+ await self._dispatch(reader, writer)
201
+ except Exception:
202
+ log.debug("connection handler exception", exc_info=True)
203
+ finally:
204
+ try:
205
+ writer.close()
206
+ await writer.wait_closed()
207
+ except Exception:
208
+ pass
209
+
210
+ async def _dispatch(
211
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
212
+ ) -> None:
213
+ # Read request line
214
+ try:
215
+ raw_line = await asyncio.wait_for(reader.readline(), timeout=10.0)
216
+ except asyncio.TimeoutError:
217
+ return
218
+ if not raw_line:
219
+ return
220
+
221
+ parts = raw_line.decode("ascii", "surrogateescape").strip().split(" ", 2)
222
+ if len(parts) < 2:
223
+ return
224
+ method, path = parts[0].upper(), parts[1]
225
+
226
+ # Read headers
227
+ headers: dict[str, str] = {}
228
+ while True:
229
+ try:
230
+ line = await asyncio.wait_for(reader.readline(), timeout=5.0)
231
+ except asyncio.TimeoutError:
232
+ return
233
+ if line in (b"\r\n", b"\n", b""):
234
+ break
235
+ decoded = line.decode("ascii", "surrogateescape").strip()
236
+ if ":" in decoded:
237
+ name, _, value = decoded.partition(":")
238
+ headers[name.strip().lower()] = value.strip()
239
+
240
+ # WebSocket upgrade?
241
+ is_upgrade = (
242
+ "upgrade" in headers.get("connection", "").lower()
243
+ and headers.get("upgrade", "").lower() == "websocket"
244
+ )
245
+ if is_upgrade:
246
+ await self._handle_ws_upgrade(reader, writer, path, headers)
247
+ return
248
+
249
+ # Plain HTTP routing
250
+ await self._route_http(reader, writer, method, path, headers)
251
+
252
+ # ------------------------------------------------------------------
253
+ # HTTP routing
254
+ # ------------------------------------------------------------------
255
+
256
+ async def _route_http(
257
+ self,
258
+ reader: asyncio.StreamReader,
259
+ writer: asyncio.StreamWriter,
260
+ method: str,
261
+ path: str,
262
+ headers: dict[str, str],
263
+ ) -> None:
264
+ if method == "GET" and path == "/health":
265
+ await self._send_json(writer, 200, {"status": "ok", "version": "1"})
266
+
267
+ elif method == "GET" and path == "/":
268
+ html = _get_phone_ui_html().encode("utf-8")
269
+ await self._send_response(
270
+ writer,
271
+ 200,
272
+ [
273
+ ("Content-Type", "text/html; charset=utf-8"),
274
+ ("Content-Security-Policy", _CSP_HEADER),
275
+ ],
276
+ html,
277
+ )
278
+
279
+ elif method == "POST" and path == "/pair":
280
+ cl = int(headers.get("content-length", 0))
281
+ body = await reader.read(cl) if cl > 0 else b""
282
+ await self._handle_pair(writer, body)
283
+
284
+ else:
285
+ await self._send_response(writer, 404, [], b"Not Found")
286
+
287
+ async def _handle_pair(
288
+ self, writer: asyncio.StreamWriter, body: bytes
289
+ ) -> None:
290
+ try:
291
+ data = json.loads(body) if body else {}
292
+ token = str(data.get("token", ""))
293
+ except (json.JSONDecodeError, ValueError):
294
+ await self._send_json(writer, 400, {"error": "invalid body"})
295
+ return
296
+
297
+ try:
298
+ session_value = self._token_manager.exchange(token)
299
+ except ValueError as exc:
300
+ await self._send_json(writer, 401, {"error": str(exc)})
301
+ return
302
+
303
+ csrf_token = secrets.token_urlsafe(32)
304
+ self._sessions[session_value] = csrf_token
305
+
306
+ await self._send_response(
307
+ writer,
308
+ 200,
309
+ [
310
+ ("Content-Type", "application/json"),
311
+ (
312
+ "Set-Cookie",
313
+ f"session={session_value}; HttpOnly; SameSite=Strict; Path=/",
314
+ ),
315
+ ("Set-Cookie", f"csrf={csrf_token}; SameSite=Strict; Path=/"),
316
+ ],
317
+ json.dumps({"status": "ok"}).encode(),
318
+ )
319
+
320
+ # ------------------------------------------------------------------
321
+ # HTTP response helpers
322
+ # ------------------------------------------------------------------
323
+
324
+ async def _send_json(
325
+ self, writer: asyncio.StreamWriter, status: int, data: dict
326
+ ) -> None:
327
+ body = json.dumps(data).encode()
328
+ await self._send_response(
329
+ writer, status, [("Content-Type", "application/json")], body
330
+ )
331
+
332
+ async def _send_response(
333
+ self,
334
+ writer: asyncio.StreamWriter,
335
+ status: int,
336
+ headers: list[tuple[str, str]],
337
+ body: bytes,
338
+ ) -> None:
339
+ status_text = _STATUS_TEXT.get(status, "Unknown")
340
+ lines = [
341
+ f"HTTP/1.1 {status} {status_text}",
342
+ f"Content-Length: {len(body)}",
343
+ "Connection: close",
344
+ ]
345
+ for name, value in headers:
346
+ lines.append(f"{name}: {value}")
347
+ response = "\r\n".join(lines) + "\r\n\r\n"
348
+ writer.write(response.encode() + body)
349
+ await writer.drain()
350
+
351
+ # ------------------------------------------------------------------
352
+ # WebSocket upgrade + session
353
+ # ------------------------------------------------------------------
354
+
355
+ async def _handle_ws_upgrade(
356
+ self,
357
+ reader: asyncio.StreamReader,
358
+ writer: asyncio.StreamWriter,
359
+ path: str,
360
+ headers: dict[str, str],
361
+ ) -> None:
362
+ if path != "/ws":
363
+ await self._send_response(writer, 404, [], b"Not Found")
364
+ return
365
+
366
+ # Authenticate via session cookie
367
+ cookies = _parse_cookies(headers.get("cookie", ""))
368
+ session_value = cookies.get("session", "")
369
+ if not session_value or session_value not in self._sessions:
370
+ await self._send_response(writer, 401, [], b"Unauthorized")
371
+ return
372
+
373
+ # CSRF double-submit check
374
+ stored_csrf = self._sessions[session_value]
375
+ csrf_cookie = cookies.get("csrf", "")
376
+ csrf_header = headers.get("x-csrf-token", "")
377
+ if not csrf_header or csrf_header != stored_csrf or csrf_cookie != stored_csrf:
378
+ await self._send_response(writer, 403, [], b"CSRF check failed")
379
+ return
380
+
381
+ # Complete WebSocket handshake
382
+ ws_key = headers.get("sec-websocket-key", "")
383
+ accept = _ws_accept_key(ws_key)
384
+ handshake = (
385
+ "HTTP/1.1 101 Switching Protocols\r\n"
386
+ "Upgrade: websocket\r\n"
387
+ "Connection: Upgrade\r\n"
388
+ f"Sec-WebSocket-Accept: {accept}\r\n"
389
+ "\r\n"
390
+ )
391
+ writer.write(handshake.encode())
392
+ await writer.drain()
393
+
394
+ await self._ws_session(reader, writer)
395
+
396
+ async def _ws_session(
397
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
398
+ ) -> None:
399
+ conn_id = secrets.token_hex(8)
400
+ sub_queue: queue.Queue = self._event_fanout.subscribe(conn_id, maxsize=100)
401
+ send_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=100)
402
+ permission_handler = RemotePermissionPromptHandler(send_queue)
403
+ stop_event = asyncio.Event()
404
+ tasks: set[asyncio.Task] = set()
405
+
406
+ # Send initial session state
407
+ await _put_send_queue(
408
+ send_queue,
409
+ json.dumps({"type": "state", "status": "connected", "turn": 0, "cost_usd": 0.0}),
410
+ )
411
+
412
+ try:
413
+ await asyncio.gather(
414
+ self._ws_sender(writer, send_queue, stop_event),
415
+ self._ws_fanout_relay(sub_queue, send_queue, stop_event, permission_handler, tasks),
416
+ self._ws_receiver(reader, writer, send_queue, stop_event, permission_handler),
417
+ return_exceptions=True,
418
+ )
419
+ finally:
420
+ stop_event.set()
421
+ self._event_fanout.unsubscribe(conn_id)
422
+ permission_handler.cancel_all()
423
+ # Let any in-flight permission tasks resolve
424
+ if tasks:
425
+ await asyncio.gather(*tasks, return_exceptions=True)
426
+
427
+ # ------------------------------------------------------------------
428
+ # WebSocket subtasks
429
+ # ------------------------------------------------------------------
430
+
431
+ async def _ws_sender(
432
+ self,
433
+ writer: asyncio.StreamWriter,
434
+ send_queue: asyncio.Queue,
435
+ stop_event: asyncio.Event,
436
+ ) -> None:
437
+ try:
438
+ while not stop_event.is_set():
439
+ try:
440
+ msg: str = await asyncio.wait_for(send_queue.get(), timeout=0.1)
441
+ except asyncio.TimeoutError:
442
+ continue
443
+ await _ws_write_frame(writer, _OP_TEXT, msg.encode())
444
+ except (ConnectionError, BrokenPipeError, asyncio.IncompleteReadError):
445
+ pass
446
+ except asyncio.CancelledError:
447
+ pass
448
+ finally:
449
+ stop_event.set()
450
+
451
+ async def _ws_fanout_relay(
452
+ self,
453
+ sub_queue: queue.Queue,
454
+ send_queue: asyncio.Queue,
455
+ stop_event: asyncio.Event,
456
+ permission_handler: RemotePermissionPromptHandler,
457
+ tasks: set,
458
+ ) -> None:
459
+ try:
460
+ while not stop_event.is_set():
461
+ event = await _try_queue_get(sub_queue, timeout=0.05)
462
+ if event is None:
463
+ continue
464
+
465
+ if event.type == "permission_request":
466
+ payload = event.payload or {}
467
+ bridge_request_id = payload.get("request_id", "")
468
+ description = payload.get("prompt", "")
469
+ risk_level = payload.get("risk_level", "medium")
470
+ task = asyncio.create_task(
471
+ self._do_permission(
472
+ bridge_request_id, description, risk_level, permission_handler
473
+ )
474
+ )
475
+ tasks.add(task)
476
+ task.add_done_callback(tasks.discard)
477
+ else:
478
+ msg = self._format_event(event)
479
+ if msg:
480
+ await _put_send_queue(send_queue, msg)
481
+ except asyncio.CancelledError:
482
+ pass
483
+ finally:
484
+ stop_event.set()
485
+
486
+ async def _do_permission(
487
+ self,
488
+ bridge_request_id: str,
489
+ description: str,
490
+ risk_level: str,
491
+ permission_handler: RemotePermissionPromptHandler,
492
+ ) -> None:
493
+ approved = await permission_handler(description, risk_level)
494
+ self._permission_bridge.respond(bridge_request_id, approved)
495
+
496
+ def _format_event(self, event) -> str | None:
497
+ """Convert a SessionEvent to a JSON string for the WebSocket client."""
498
+ if event.type == "transcript":
499
+ p = event.payload or {}
500
+ return json.dumps(
501
+ {"type": "transcript", "text": str(p.get("text", "")), "turn": int(p.get("turn", 0))}
502
+ )
503
+ if event.type == "state":
504
+ p = event.payload or {}
505
+ return json.dumps(
506
+ {
507
+ "type": "state",
508
+ "status": str(p.get("status", "unknown")),
509
+ "turn": int(p.get("turn", 0)),
510
+ "cost_usd": float(p.get("cost_usd", 0.0)),
511
+ }
512
+ )
513
+ # Forward other event types as generic messages
514
+ return json.dumps({"type": "event", "event_type": event.type, "payload": event.payload})
515
+
516
+ async def _ws_receiver(
517
+ self,
518
+ reader: asyncio.StreamReader,
519
+ writer: asyncio.StreamWriter,
520
+ send_queue: asyncio.Queue,
521
+ stop_event: asyncio.Event,
522
+ permission_handler: RemotePermissionPromptHandler,
523
+ ) -> None:
524
+ try:
525
+ while not stop_event.is_set():
526
+ try:
527
+ opcode, payload = await asyncio.wait_for(
528
+ _ws_read_frame(reader), timeout=0.5
529
+ )
530
+ except asyncio.TimeoutError:
531
+ continue
532
+ except (asyncio.IncompleteReadError, ConnectionError, EOFError):
533
+ break
534
+
535
+ if opcode == _OP_PING:
536
+ await _ws_write_frame(writer, _OP_PONG, payload)
537
+ elif opcode == _OP_CLOSE:
538
+ close_payload = payload[:2] if len(payload) >= 2 else b""
539
+ try:
540
+ await _ws_write_frame(writer, _OP_CLOSE, close_payload)
541
+ except Exception:
542
+ pass
543
+ break
544
+ elif opcode == _OP_TEXT:
545
+ await self._handle_ws_message(
546
+ payload.decode("utf-8", "replace"),
547
+ send_queue,
548
+ permission_handler,
549
+ )
550
+ except asyncio.CancelledError:
551
+ pass
552
+ finally:
553
+ stop_event.set()
554
+
555
+ async def _handle_ws_message(
556
+ self,
557
+ text: str,
558
+ send_queue: asyncio.Queue,
559
+ permission_handler: RemotePermissionPromptHandler,
560
+ ) -> None:
561
+ try:
562
+ data = json.loads(text)
563
+ except json.JSONDecodeError:
564
+ log.warning("RemoteServer: invalid JSON from client: %r", text)
565
+ return
566
+
567
+ msg_type = data.get("type")
568
+
569
+ if msg_type == "ping":
570
+ await _put_send_queue(send_queue, json.dumps({"type": "pong"}))
571
+
572
+ elif msg_type == "input":
573
+ raw_text = data.get("text", "")
574
+ is_cmd = raw_text.startswith("/")
575
+ input_msg = RemoteInputMessage(text=raw_text, command=is_cmd)
576
+ filtered = self._command_filter.filter(input_msg)
577
+ if filtered is not None:
578
+ self._input_broker.put("remote", filtered.text)
579
+ self._audit_log.record("input_sent", {"text": raw_text[:200]})
580
+
581
+ elif msg_type == "permission_response":
582
+ req_id = data.get("id", "")
583
+ decision = data.get("decision", "deny")
584
+ permission_handler.resolve(req_id, decision)
585
+ action = "permission_approved" if decision == "approve" else "permission_denied"
586
+ self._audit_log.record(action, {"id": req_id})
@@ -0,0 +1,61 @@
1
+ """Pairing token service — single-use, TTL-bounded, cryptographically secure."""
2
+ from __future__ import annotations
3
+ import logging
4
+ import secrets
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass, field
8
+
9
+ __all__ = ["PairingTokenService"]
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ _TTL = 90.0 # seconds
14
+
15
+
16
+ @dataclass
17
+ class _Token:
18
+ value: str
19
+ issued_at: float
20
+ used: bool = False
21
+
22
+
23
+ class PairingTokenService:
24
+ """Issues single-use pairing tokens with a 90-second TTL.
25
+
26
+ Thread-safe. Tokens are never logged.
27
+ """
28
+
29
+ def __init__(self) -> None:
30
+ self._tokens: dict[str, _Token] = {}
31
+ self._lock = threading.Lock()
32
+
33
+ def issue(self) -> str:
34
+ """Return a new cryptographically random token."""
35
+ token = secrets.token_urlsafe(32)
36
+ with self._lock:
37
+ self._tokens[token] = _Token(value=token, issued_at=time.time())
38
+ return token
39
+
40
+ def exchange(self, token: str) -> str:
41
+ """Validate token and return a new session cookie value.
42
+
43
+ Raises ValueError on unknown, expired, or already-used tokens.
44
+ """
45
+ with self._lock:
46
+ t = self._tokens.get(token)
47
+ if t is None:
48
+ raise ValueError("token not found")
49
+ if t.used:
50
+ raise ValueError("token already used")
51
+ if time.time() - t.issued_at > _TTL:
52
+ del self._tokens[token]
53
+ raise ValueError("token expired")
54
+ t.used = True
55
+ session_value = secrets.token_urlsafe(32)
56
+ return session_value
57
+
58
+ def revoke(self, token: str) -> None:
59
+ """Remove a token unconditionally."""
60
+ with self._lock:
61
+ self._tokens.pop(token, None)