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/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)
|