pulse-coding-agent 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 (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,984 @@
1
+ """Remote Sandbox Server.
2
+
3
+ Exposes the RemoteWorker over an authenticated WebSocket protocol.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import asyncio
10
+ import base64
11
+ import binascii
12
+ import datetime
13
+ import hmac
14
+ import http
15
+ import io
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ import shutil
21
+ import sqlite3
22
+ import tarfile
23
+ from contextlib import suppress
24
+ from pathlib import Path, PurePosixPath
25
+ from typing import Any
26
+
27
+ import websockets
28
+
29
+ from pulse import __version__
30
+ from pulse.production import is_secure_remote_token
31
+ from pulse.sandbox.errors import SandboxSecurityError
32
+ from pulse.sandbox.remote.models import (
33
+ SubmitExecutionRequest,
34
+ SubmitExecutionResponse,
35
+ validate_execution_id,
36
+ )
37
+ from pulse.sandbox.remote.worker import RemoteWorker
38
+ from pulse.storage import migrate_database
39
+
40
+ REMOTE_EXECUTION_SCHEMA_VERSION = 2
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ MAX_ARTIFACT_COMPRESSED_BYTES = 32 * 1024 * 1024
45
+ MAX_ARTIFACT_UNPACKED_BYTES = 512 * 1024 * 1024
46
+ MAX_ARTIFACT_MEMBERS = 10_000
47
+ _SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
48
+
49
+
50
+ def _request_id(value: object) -> str | None:
51
+ return value if isinstance(value, str) and _SAFE_REQUEST_ID.fullmatch(value) else None
52
+
53
+
54
+ def _extract_remote_artifact(data: bytes, workspace: Path) -> None:
55
+ """Extract a bounded regular-file archive into a new workspace."""
56
+ if len(data) > MAX_ARTIFACT_COMPRESSED_BYTES:
57
+ raise SandboxSecurityError("Remote artifact exceeds the compressed size limit")
58
+ if workspace.exists():
59
+ raise SandboxSecurityError("Remote artifact workspace already exists")
60
+ workspace.mkdir(parents=True, exist_ok=False)
61
+ try:
62
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
63
+ members = archive.getmembers()
64
+ if len(members) > MAX_ARTIFACT_MEMBERS:
65
+ raise SandboxSecurityError("Remote artifact contains too many members")
66
+ total_size = 0
67
+ root = workspace.resolve()
68
+ for member in members:
69
+ normalized = PurePosixPath(member.name.replace("\\", "/"))
70
+ if (
71
+ normalized.is_absolute()
72
+ or ".." in normalized.parts
73
+ or member.issym()
74
+ or member.islnk()
75
+ or not (member.isfile() or member.isdir())
76
+ ):
77
+ raise SandboxSecurityError("Unsafe remote artifact member")
78
+ target = (root / Path(*normalized.parts)).resolve()
79
+ try:
80
+ target.relative_to(root)
81
+ except ValueError as error:
82
+ raise SandboxSecurityError("Remote artifact path traversal detected") from error
83
+ if member.isfile():
84
+ total_size += member.size
85
+ if total_size > MAX_ARTIFACT_UNPACKED_BYTES:
86
+ raise SandboxSecurityError("Remote artifact exceeds the unpacked size limit")
87
+ if hasattr(tarfile, "data_filter"):
88
+ archive.extractall(path=root, filter="data")
89
+ else: # pragma: no cover - compatibility for early Python 3.11
90
+ archive.extractall(path=root)
91
+ except Exception:
92
+ shutil.rmtree(workspace, ignore_errors=True)
93
+ raise
94
+
95
+
96
+ def _append_log_entry(path: Path, entry: str) -> None:
97
+ """Append a stream entry off the event loop."""
98
+ with path.open("a", encoding="utf-8") as stream_log:
99
+ stream_log.write(entry)
100
+
101
+
102
+ def _read_log_entries(path: Path) -> list[str]:
103
+ """Read complete historical stream entries off the event loop."""
104
+ with path.open("r", encoding="utf-8") as stream_log:
105
+ return [line.strip() for line in stream_log if line.strip()]
106
+
107
+
108
+ class RemoteExecutionStore:
109
+ def __init__(self, db_path: Path):
110
+ self.db_path = db_path.resolve()
111
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
112
+ self._ensure_schema()
113
+
114
+ def _connect(self) -> sqlite3.Connection:
115
+ conn = sqlite3.connect(self.db_path, timeout=10.0)
116
+ conn.execute("PRAGMA journal_mode=WAL")
117
+ return conn
118
+
119
+ def _ensure_schema(self) -> None:
120
+ def migration(conn: sqlite3.Connection, _current: int) -> None:
121
+ conn.execute("""CREATE TABLE IF NOT EXISTS executions (
122
+ execution_id TEXT PRIMARY KEY,
123
+ tenant_id TEXT NOT NULL,
124
+ status TEXT NOT NULL,
125
+ correlation_id TEXT,
126
+ result_json TEXT,
127
+ created_at TEXT NOT NULL,
128
+ updated_at TEXT NOT NULL
129
+ )""")
130
+ columns = {
131
+ row[1] for row in conn.execute("PRAGMA table_info(executions)").fetchall()
132
+ }
133
+ if "correlation_id" not in columns:
134
+ conn.execute("ALTER TABLE executions ADD COLUMN correlation_id TEXT")
135
+
136
+ migrate_database(self.db_path, REMOTE_EXECUTION_SCHEMA_VERSION, migration)
137
+
138
+ def create(
139
+ self,
140
+ execution_id: str,
141
+ tenant_id: str,
142
+ correlation_id: str | None = None,
143
+ ) -> None:
144
+ now = datetime.datetime.now(datetime.UTC).isoformat()
145
+ with self._connect() as conn:
146
+ conn.execute(
147
+ "INSERT INTO executions (execution_id, tenant_id, status, correlation_id, created_at, updated_at) "
148
+ "VALUES (?, ?, ?, ?, ?, ?)",
149
+ (execution_id, tenant_id, "RUNNING", correlation_id, now, now),
150
+ )
151
+
152
+ def update(
153
+ self, execution_id: str, status: str, result_json: dict[str, Any] | None = None
154
+ ) -> None:
155
+ now = datetime.datetime.now(datetime.UTC).isoformat()
156
+ with self._connect() as conn:
157
+ if result_json:
158
+ conn.execute(
159
+ "UPDATE executions SET status = ?, result_json = ?, updated_at = ? WHERE execution_id = ?",
160
+ (status, json.dumps(result_json), now, execution_id),
161
+ )
162
+ else:
163
+ conn.execute(
164
+ "UPDATE executions SET status = ?, updated_at = ? WHERE execution_id = ?",
165
+ (status, now, execution_id),
166
+ )
167
+
168
+ def get(self, execution_id: str, tenant_id: str) -> dict[str, Any] | None:
169
+ with self._connect() as conn:
170
+ row = conn.execute(
171
+ "SELECT status, result_json, created_at, updated_at, correlation_id FROM executions WHERE execution_id = ? AND tenant_id = ?",
172
+ (execution_id, tenant_id),
173
+ ).fetchone()
174
+ if not row:
175
+ return None
176
+ return {
177
+ "status": row[0],
178
+ "result": json.loads(row[1]) if row[1] else None,
179
+ "created_at": row[2],
180
+ "updated_at": row[3],
181
+ "correlation_id": row[4],
182
+ }
183
+
184
+ def exists(self, execution_id: str) -> bool:
185
+ with self._connect() as conn:
186
+ return conn.execute(
187
+ "SELECT 1 FROM executions WHERE execution_id = ?", (execution_id,)
188
+ ).fetchone() is not None
189
+
190
+ def cleanup_old(self, max_age_hours: int = 1) -> list[tuple[str, str]]:
191
+ threshold = (
192
+ datetime.datetime.now(datetime.UTC)
193
+ - datetime.timedelta(hours=max_age_hours)
194
+ ).isoformat()
195
+ with self._connect() as conn:
196
+ rows = conn.execute(
197
+ "SELECT execution_id, tenant_id FROM executions WHERE status IN ('COMPLETED', 'FAILED') AND updated_at < ?",
198
+ (threshold,),
199
+ ).fetchall()
200
+ for row in rows:
201
+ conn.execute("DELETE FROM executions WHERE execution_id = ?", (row[0],))
202
+ return rows
203
+
204
+ def mark_interrupted_running_unknown(self) -> int:
205
+ """Quarantine executions left RUNNING by a server restart.
206
+
207
+ A fresh server process has no proof that the old container completed
208
+ or that its side effects were not applied. Reporting UNKNOWN makes
209
+ callers reconcile deliberately instead of replaying the command.
210
+ """
211
+ now = datetime.datetime.now(datetime.UTC).isoformat()
212
+ with self._connect() as conn:
213
+ cursor = conn.execute(
214
+ "UPDATE executions SET status = 'UNKNOWN', updated_at = ? WHERE status = 'RUNNING'",
215
+ (now,),
216
+ )
217
+ return cursor.rowcount
218
+
219
+
220
+ class RemoteServer:
221
+ """Provides the network boundary for the remote worker."""
222
+
223
+ def __init__(
224
+ self,
225
+ host: str = "127.0.0.1",
226
+ port: int = 8080,
227
+ auth_token: str | None = None,
228
+ max_concurrent_executions: int = 10,
229
+ workspace_root: Path | None = None,
230
+ database_path: Path | None = None,
231
+ retention_hours: int = 24,
232
+ production_mode: bool = False,
233
+ ) -> None:
234
+ self.host = host
235
+ self.port = port
236
+ # Support multiple tokens via comma-separated string if provided
237
+ if not auth_token:
238
+ raise ValueError(
239
+ "PULSE_REMOTE_TOKEN must be configured; insecure default tokens are disabled."
240
+ )
241
+ self.valid_tokens = {token.strip() for token in auth_token.split(",") if token.strip()}
242
+ if production_mode and any(
243
+ not is_secure_remote_token(token) for token in self.valid_tokens
244
+ ):
245
+ raise ValueError(
246
+ "Every production PULSE_REMOTE_TOKEN must be random, non-placeholder, "
247
+ "and at least 32 characters."
248
+ )
249
+ if not self.valid_tokens:
250
+ raise ValueError("PULSE_REMOTE_TOKEN does not contain a usable token.")
251
+ if not 1 <= max_concurrent_executions <= 128:
252
+ raise ValueError("max_concurrent_executions must be between 1 and 128.")
253
+ if not 1 <= retention_hours <= 8760:
254
+ raise ValueError("retention_hours must be between 1 and 8760.")
255
+ self.max_concurrent_executions = max_concurrent_executions
256
+ self.retention_hours = retention_hours
257
+ self.worker = RemoteWorker(workspace_root)
258
+ self.store = RemoteExecutionStore(
259
+ database_path or Path(".remote_sandbox.db")
260
+ )
261
+ self._ready = False
262
+ self._ready_event = asyncio.Event()
263
+ self._active_executions: dict[str, asyncio.Task[Any]] = {}
264
+ # Track tenant for each execution
265
+ self._execution_tenants: dict[str, str] = {}
266
+ # Track attached clients per execution
267
+ self._attached_websockets: dict[
268
+ str, set[websockets.WebSocketServerProtocol]
269
+ ] = {}
270
+
271
+ async def _ttl_cleanup(self) -> None:
272
+ while True:
273
+ await asyncio.sleep(300)
274
+ try:
275
+ old = self.store.cleanup_old(max_age_hours=self.retention_hours)
276
+ for eid, tid in old:
277
+ self.worker.cleanup_workspace(tid, eid)
278
+ except (OSError, RuntimeError) as e:
279
+ logger.error(f"TTL cleanup failed: {e}")
280
+
281
+ async def _process_request(self, connection, request) -> Any | None:
282
+ from websockets.http11 import Response
283
+
284
+ """Enforce authenticated transport (R3)."""
285
+ if request.path in {"/healthz", "/readyz"}:
286
+ ready = request.path == "/healthz" or self._ready
287
+ status = http.HTTPStatus.OK if ready else http.HTTPStatus.SERVICE_UNAVAILABLE
288
+ body = json.dumps(
289
+ {"status": "ok" if ready else "not_ready"}, separators=(",", ":")
290
+ ).encode("utf-8")
291
+ return Response(status, status.phrase, websockets.Headers(), body)
292
+
293
+ auth_header = request.headers.get("Authorization")
294
+ if not auth_header or not auth_header.startswith("Bearer "):
295
+ return Response(
296
+ http.HTTPStatus.UNAUTHORIZED,
297
+ "Unauthorized",
298
+ websockets.Headers(),
299
+ b"Unauthorized",
300
+ )
301
+
302
+ token = auth_header.split(" ", 1)[1]
303
+ if not any(hmac.compare_digest(token, valid) for valid in self.valid_tokens):
304
+ logger.warning("Unauthorized remote sandbox connection attempt.")
305
+ return Response(
306
+ http.HTTPStatus.UNAUTHORIZED,
307
+ "Unauthorized",
308
+ websockets.Headers(),
309
+ b"Unauthorized",
310
+ )
311
+
312
+ return None
313
+
314
+ async def start(self) -> None:
315
+ """Start the WebSocket server."""
316
+ await self.worker.initialize()
317
+ interrupted = self.store.mark_interrupted_running_unknown()
318
+ if interrupted:
319
+ logger.warning(
320
+ "Marked %s interrupted remote executions as UNKNOWN.", interrupted
321
+ )
322
+
323
+ # Phase 3: TLS Enforcement
324
+ import os
325
+ import ssl
326
+
327
+ ssl_context = None
328
+
329
+ tls_cert = os.environ.get("PULSE_TLS_CERT")
330
+ tls_key = os.environ.get("PULSE_TLS_KEY")
331
+ tls_ca = os.environ.get("PULSE_TLS_CA")
332
+
333
+ if tls_cert and tls_key and tls_ca:
334
+ try:
335
+ ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
336
+ ssl_context.load_cert_chain(certfile=tls_cert, keyfile=tls_key)
337
+ ssl_context.load_verify_locations(cafile=tls_ca)
338
+ ssl_context.verify_mode = ssl.CERT_REQUIRED
339
+ except (ssl.SSLError, OSError) as e:
340
+ raise RuntimeError(f"Failed to load mTLS certificates: {e}")
341
+ elif self.host in ("127.0.0.1", "localhost", "::1"):
342
+ logger.warning("Starting in insecure loopback mode without mTLS.")
343
+ else:
344
+ raise RuntimeError(
345
+ "mTLS certificates (PULSE_TLS_CERT, PULSE_TLS_KEY, PULSE_TLS_CA) "
346
+ "are strictly required for non-loopback connections."
347
+ )
348
+
349
+ cleanup_task = asyncio.create_task(self._ttl_cleanup())
350
+ try:
351
+ async with websockets.serve(
352
+ self._handle_client,
353
+ self.host,
354
+ self.port,
355
+ process_request=self._process_request,
356
+ ping_interval=20,
357
+ ping_timeout=20,
358
+ max_size=1024 * 1024 * 50, # Enforce 50MB max payload size (R7)
359
+ ssl=ssl_context,
360
+ ) as websocket_server:
361
+ sockets = websocket_server.sockets
362
+ if sockets:
363
+ self.port = int(sockets[0].getsockname()[1])
364
+ self._ready = True
365
+ self._ready_event.set()
366
+ scheme = "wss" if ssl_context else "ws"
367
+ logger.info(
368
+ "Remote Sandbox Server listening on %s://%s:%s",
369
+ scheme,
370
+ self.host,
371
+ self.port,
372
+ )
373
+ await asyncio.Future() # run forever
374
+ finally:
375
+ self._ready = False
376
+ self._ready_event.clear()
377
+ cleanup_task.cancel()
378
+ with suppress(asyncio.CancelledError):
379
+ await cleanup_task
380
+
381
+ async def wait_until_ready(self, timeout: float = 5.0) -> None:
382
+ """Wait until the listening socket is bound and accepting connections."""
383
+ await asyncio.wait_for(self._ready_event.wait(), timeout=timeout)
384
+
385
+ async def _handle_client(
386
+ self, websocket: websockets.WebSocketServerProtocol
387
+ ) -> None:
388
+ """Handle incoming client WebSocket connections."""
389
+
390
+ # Derive tenant_id from auth header
391
+ auth_header = websocket.request.headers.get("Authorization", "")
392
+ tenant_id = (
393
+ auth_header.split(" ")[1]
394
+ if auth_header.startswith("Bearer ")
395
+ else "unknown"
396
+ )
397
+ import hashlib
398
+
399
+ tenant_hash = hashlib.sha256(tenant_id.encode()).hexdigest()[:12]
400
+
401
+ async def run_execution(req: SubmitExecutionRequest, tenant_id: str) -> None:
402
+ """Background task for running the execution."""
403
+
404
+ async def scoped_send_output(stream: str, data: bytes) -> None:
405
+ log_entry = (
406
+ json.dumps(
407
+ {
408
+ "type": "stream",
409
+ "payload": {
410
+ "execution_id": req.execution_id,
411
+ "stream": stream,
412
+ "data": data.decode("utf-8", errors="replace"),
413
+ },
414
+ }
415
+ )
416
+ + "\n"
417
+ )
418
+
419
+ log_file = (
420
+ self.worker.workspace_base_path
421
+ / tenant_id
422
+ / req.execution_id
423
+ / ".pulse_stream.log"
424
+ )
425
+ try:
426
+ if log_file.parent.exists():
427
+ await asyncio.to_thread(_append_log_entry, log_file, log_entry)
428
+ except OSError:
429
+ logger.warning(
430
+ "Unable to persist remote stream for %s.", req.execution_id
431
+ )
432
+
433
+ attached = self._attached_websockets.get(req.execution_id, set())
434
+ dead_ws = set()
435
+ for ws in attached:
436
+ try:
437
+ await ws.send(log_entry)
438
+ except websockets.exceptions.ConnectionClosed:
439
+ dead_ws.add(ws)
440
+ for ws in dead_ws:
441
+ attached.discard(ws)
442
+
443
+ try:
444
+ self.store.create(
445
+ req.execution_id, tenant_id, correlation_id=req.correlation_id
446
+ )
447
+ result = await self.worker.execute_request(
448
+ req, tenant_id=tenant_id, output_callback=scoped_send_output
449
+ )
450
+ self.store.update(req.execution_id, "COMPLETED", result.to_dict())
451
+
452
+ res_msg = json.dumps({"type": "result", "payload": result.to_dict()})
453
+ attached = self._attached_websockets.get(req.execution_id, set())
454
+ dead_ws = set()
455
+ for ws in attached:
456
+ try:
457
+ await ws.send(res_msg)
458
+ except websockets.exceptions.ConnectionClosed:
459
+ dead_ws.add(ws)
460
+ for ws in dead_ws:
461
+ attached.discard(ws)
462
+ except (OSError, RuntimeError, sqlite3.Error):
463
+ logger.error("Remote execution failed with an internal error.")
464
+ err_msg = "Remote execution failed with an internal error."
465
+ from pulse.sandbox.process import ProcessResult
466
+
467
+ fallback_res = ProcessResult(
468
+ command=req.command,
469
+ exit_code=-1,
470
+ stdout="",
471
+ stderr=err_msg,
472
+ duration_ms=0,
473
+ timed_out=False,
474
+ truncated=False,
475
+ pid=None,
476
+ overlay_path=None,
477
+ termination_reason="error",
478
+ )
479
+ self.store.update(req.execution_id, "FAILED", fallback_res.to_dict())
480
+
481
+ err_json = json.dumps(
482
+ {"type": "result", "payload": fallback_res.to_dict()}
483
+ )
484
+ attached = self._attached_websockets.get(req.execution_id, set())
485
+ dead_ws = set()
486
+ for ws in attached:
487
+ try:
488
+ await ws.send(err_json)
489
+ except websockets.exceptions.ConnectionClosed:
490
+ dead_ws.add(ws)
491
+ for ws in dead_ws:
492
+ attached.discard(ws)
493
+ finally:
494
+ self._active_executions.pop(req.execution_id, None)
495
+ self._execution_tenants.pop(req.execution_id, None)
496
+ self._attached_websockets.pop(req.execution_id, None)
497
+
498
+ client_executions: set[str] = set()
499
+
500
+ try:
501
+ async for message in websocket:
502
+ request_id: str | None = None
503
+ try:
504
+ data = json.loads(message)
505
+ if not isinstance(data, dict):
506
+ raise TypeError("message must be an object")
507
+ action = data.get("action")
508
+ payload = data.get("payload", {})
509
+ if not isinstance(action, str) or not isinstance(payload, dict):
510
+ raise TypeError("action and payload have invalid types")
511
+ request_id = _request_id(payload.get("request_id"))
512
+
513
+ if action == "submit":
514
+ if (
515
+ len(self._active_executions)
516
+ >= self.max_concurrent_executions
517
+ ):
518
+ logger.warning(
519
+ "Concurrency limit reached, rejecting execution."
520
+ )
521
+ await websocket.send(
522
+ json.dumps(
523
+ {
524
+ "type": "error",
525
+ "payload": {
526
+ "message": "Server is at maximum capacity, please try again later.",
527
+ "request_id": request_id,
528
+ },
529
+ }
530
+ )
531
+ )
532
+ continue
533
+
534
+ req = SubmitExecutionRequest.from_dict(payload)
535
+ if req.execution_id in self._active_executions or self.store.exists(
536
+ req.execution_id
537
+ ):
538
+ raise ValueError("execution_id is already in use")
539
+ # Acknowledge submission
540
+ ack = SubmitExecutionResponse(
541
+ execution_id=req.execution_id, status="STARTING"
542
+ )
543
+ ack_payload = ack.to_dict() | {"request_id": request_id}
544
+ await websocket.send(
545
+ json.dumps({"type": "response", "payload": ack_payload})
546
+ )
547
+
548
+ # Execute in background for streaming (R4)
549
+ task = asyncio.create_task(run_execution(req, tenant_hash))
550
+ self._active_executions[req.execution_id] = task
551
+ self._execution_tenants[req.execution_id] = tenant_hash
552
+ if req.execution_id not in self._attached_websockets:
553
+ self._attached_websockets[req.execution_id] = set()
554
+ self._attached_websockets[req.execution_id].add(websocket)
555
+ client_executions.add(req.execution_id)
556
+
557
+ elif action == "cancel":
558
+ execution_id = validate_execution_id(payload.get("execution_id"))
559
+ if execution_id:
560
+ # Cross-tenant prevention
561
+ if self._execution_tenants.get(execution_id) != tenant_hash:
562
+ await websocket.send(
563
+ json.dumps(
564
+ {"type": "error", "payload": "Unauthorized"}
565
+ )
566
+ )
567
+ continue
568
+
569
+ # Cancel the worker execution
570
+ await self.worker.cancel(execution_id)
571
+ # The task will complete or raise CancelledError
572
+ task = self._active_executions.get(execution_id)
573
+ if task and not task.done():
574
+ task.cancel()
575
+
576
+ # Update store explicitly
577
+ self.store.update(
578
+ execution_id, "FAILED", {"error": "CANCELLED"}
579
+ )
580
+
581
+ await websocket.send(
582
+ json.dumps(
583
+ {
584
+ "type": "response",
585
+ "payload": {
586
+ "status": "CANCELLED",
587
+ "request_id": request_id,
588
+ },
589
+ }
590
+ )
591
+ )
592
+
593
+ elif action == "upload_artifact":
594
+ execution_id = validate_execution_id(payload.get("execution_id"))
595
+ b64_data = payload.get("data")
596
+ if execution_id and isinstance(b64_data, str) and b64_data:
597
+ try:
598
+ data = base64.b64decode(b64_data, validate=True)
599
+ workspace = (
600
+ self.worker.workspace_base_path
601
+ / tenant_hash
602
+ / execution_id
603
+ )
604
+ _extract_remote_artifact(data, workspace)
605
+ await websocket.send(
606
+ json.dumps(
607
+ {
608
+ "type": "response",
609
+ "payload": {
610
+ "status": "UPLOADED",
611
+ "request_id": request_id,
612
+ },
613
+ }
614
+ )
615
+ )
616
+ except (
617
+ OSError,
618
+ ValueError,
619
+ RuntimeError,
620
+ tarfile.TarError,
621
+ binascii.Error,
622
+ ):
623
+ logger.error("Remote artifact upload was rejected.")
624
+ await websocket.send(
625
+ json.dumps(
626
+ {
627
+ "type": "error",
628
+ "payload": {
629
+ "message": "Upload failed.",
630
+ "request_id": request_id,
631
+ },
632
+ }
633
+ )
634
+ )
635
+
636
+ elif action == "download_artifact":
637
+ execution_id = validate_execution_id(payload.get("execution_id"))
638
+ if execution_id:
639
+ if not self.store.get(execution_id, tenant_hash):
640
+ await websocket.send(
641
+ json.dumps(
642
+ {
643
+ "type": "error",
644
+ "payload": {
645
+ "message": "NOT_FOUND",
646
+ "request_id": request_id,
647
+ },
648
+ }
649
+ )
650
+ )
651
+ continue
652
+ overlay_path = self.worker.get_overlay_path(execution_id)
653
+ if not overlay_path or not overlay_path.exists():
654
+ await websocket.send(
655
+ json.dumps(
656
+ {
657
+ "type": "response",
658
+ "payload": {
659
+ "status": "NO_ARTIFACT",
660
+ "request_id": request_id,
661
+ },
662
+ }
663
+ )
664
+ )
665
+ else:
666
+ bio = io.BytesIO()
667
+ with tarfile.open(fileobj=bio, mode="w:gz") as tar:
668
+ for item in overlay_path.iterdir():
669
+ tar.add(item, arcname=item.name)
670
+ b64_out = base64.b64encode(bio.getvalue()).decode(
671
+ "ascii"
672
+ )
673
+ await websocket.send(
674
+ json.dumps(
675
+ {
676
+ "type": "response",
677
+ "payload": {
678
+ "status": "DOWNLOADED",
679
+ "data": b64_out,
680
+ "request_id": request_id,
681
+ },
682
+ }
683
+ )
684
+ )
685
+ # Cleanup overlay after download
686
+ self.worker.cleanup_overlay(execution_id)
687
+
688
+ elif action == "reconcile":
689
+ # Clean up orphaned workspaces not matching active executions for this tenant
690
+ orphans = 0
691
+ tenant_workspace_path = (
692
+ self.worker.workspace_base_path / tenant_hash
693
+ )
694
+ if tenant_workspace_path.exists():
695
+ for ws in tenant_workspace_path.iterdir():
696
+ if (
697
+ ws.is_dir()
698
+ and ws.name not in self._active_executions
699
+ ):
700
+ self.worker.cleanup_workspace(tenant_hash, ws.name)
701
+ orphans += 1
702
+ await websocket.send(
703
+ json.dumps(
704
+ {
705
+ "type": "response",
706
+ "payload": {
707
+ "status": "RECONCILED",
708
+ "orphans_cleaned": orphans,
709
+ "request_id": request_id,
710
+ },
711
+ }
712
+ )
713
+ )
714
+
715
+ elif action == "status":
716
+ execution_id = validate_execution_id(payload.get("execution_id"))
717
+ state = self.store.get(execution_id, tenant_hash)
718
+ if not state:
719
+ await websocket.send(
720
+ json.dumps(
721
+ {
722
+ "type": "response",
723
+ "payload": {
724
+ "status": "NOT_FOUND",
725
+ "request_id": request_id,
726
+ },
727
+ }
728
+ )
729
+ )
730
+ elif (
731
+ state["status"] in {"COMPLETED", "FAILED"}
732
+ and state["result"]
733
+ ):
734
+ await websocket.send(
735
+ json.dumps(
736
+ {
737
+ "type": "response",
738
+ "payload": {
739
+ "status": state["status"],
740
+ "request_id": request_id,
741
+ },
742
+ }
743
+ )
744
+ )
745
+
746
+ elif action == "attach":
747
+ execution_id = validate_execution_id(payload.get("execution_id"))
748
+ state = self.store.get(execution_id, tenant_hash)
749
+ if not state:
750
+ await websocket.send(
751
+ json.dumps(
752
+ {
753
+ "type": "error",
754
+ "payload": {
755
+ "message": "NOT_FOUND",
756
+ "request_id": request_id,
757
+ },
758
+ }
759
+ )
760
+ )
761
+ continue
762
+
763
+ # Dump historical logs
764
+ log_file = (
765
+ self.worker.workspace_base_path
766
+ / tenant_hash
767
+ / execution_id
768
+ / ".pulse_stream.log"
769
+ )
770
+ if log_file.exists():
771
+ try:
772
+ for line in await asyncio.to_thread(
773
+ _read_log_entries, log_file
774
+ ):
775
+ await websocket.send(line)
776
+ except OSError:
777
+ logger.warning(
778
+ "Unable to read remote stream history for %s.",
779
+ execution_id,
780
+ )
781
+
782
+ if state["status"] == "RUNNING":
783
+ if execution_id not in self._attached_websockets:
784
+ self._attached_websockets[execution_id] = set()
785
+ if (
786
+ len(self._attached_websockets[execution_id]) > 0
787
+ and websocket
788
+ not in self._attached_websockets[execution_id]
789
+ ):
790
+ await websocket.send(
791
+ json.dumps(
792
+ {
793
+ "type": "error",
794
+ "payload": {
795
+ "message": "Another client is already attached",
796
+ "request_id": request_id,
797
+ },
798
+ }
799
+ )
800
+ )
801
+ continue
802
+
803
+ self._attached_websockets[execution_id].add(websocket)
804
+ client_executions.add(execution_id)
805
+ await websocket.send(
806
+ json.dumps(
807
+ {
808
+ "type": "response",
809
+ "payload": {
810
+ "status": "ATTACHED",
811
+ "request_id": request_id,
812
+ },
813
+ }
814
+ )
815
+ )
816
+ elif (
817
+ state["status"] in {"COMPLETED", "FAILED"}
818
+ and state["result"]
819
+ ):
820
+ await websocket.send(
821
+ json.dumps(
822
+ {
823
+ "type": "response",
824
+ "payload": {
825
+ "status": state["status"],
826
+ "request_id": request_id,
827
+ },
828
+ }
829
+ )
830
+ )
831
+ await websocket.send(
832
+ json.dumps(
833
+ {"type": "result", "payload": state["result"]}
834
+ )
835
+ )
836
+ else:
837
+ await websocket.send(
838
+ json.dumps(
839
+ {
840
+ "type": "response",
841
+ "payload": {
842
+ "status": state["status"],
843
+ "request_id": request_id,
844
+ },
845
+ }
846
+ )
847
+ )
848
+
849
+ else:
850
+ await websocket.send(
851
+ json.dumps(
852
+ {
853
+ "type": "error",
854
+ "payload": {
855
+ "message": "Unknown action.",
856
+ "request_id": request_id,
857
+ },
858
+ }
859
+ )
860
+ )
861
+
862
+ except (
863
+ KeyError,
864
+ ValueError,
865
+ OSError,
866
+ RuntimeError,
867
+ TypeError,
868
+ RecursionError,
869
+ ):
870
+ logger.error("Rejected an invalid remote protocol message.")
871
+ await websocket.send(
872
+ json.dumps(
873
+ {
874
+ "type": "error",
875
+ "payload": {
876
+ "message": "Invalid remote protocol request.",
877
+ "request_id": request_id,
878
+ },
879
+ }
880
+ )
881
+ )
882
+ except websockets.exceptions.ConnectionClosed:
883
+ logger.info("Client disconnected.")
884
+ finally:
885
+ # R5/GAP-07: Detach client but do NOT cancel execution
886
+ for eid in client_executions:
887
+ if eid in self._attached_websockets:
888
+ self._attached_websockets[eid].discard(websocket)
889
+
890
+
891
+ def main() -> None:
892
+ parser = argparse.ArgumentParser(
893
+ prog="pulse-remote",
894
+ description="Run Pulse's authenticated remote sandbox worker.",
895
+ )
896
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
897
+ parser.add_argument(
898
+ "--host",
899
+ default=os.environ.get("PULSE_REMOTE_HOST", "127.0.0.1"),
900
+ help="Bind host (default: PULSE_REMOTE_HOST or 127.0.0.1).",
901
+ )
902
+ parser.add_argument(
903
+ "--port",
904
+ default=os.environ.get("PULSE_REMOTE_PORT", "8080"),
905
+ type=int,
906
+ help="Bind port (default: PULSE_REMOTE_PORT or 8080).",
907
+ )
908
+ parser.add_argument(
909
+ "--workspace-root",
910
+ type=Path,
911
+ default=Path(
912
+ os.environ.get("PULSE_REMOTE_WORKSPACE_ROOT", ".pulse/remote-workspaces")
913
+ ),
914
+ help="Tenant workspace root (default: PULSE_REMOTE_WORKSPACE_ROOT).",
915
+ )
916
+ parser.add_argument(
917
+ "--database",
918
+ type=Path,
919
+ default=Path(
920
+ os.environ.get("PULSE_REMOTE_DB", ".pulse/remote-executions.sqlite3")
921
+ ),
922
+ help="Durable execution database (default: PULSE_REMOTE_DB).",
923
+ )
924
+ parser.add_argument(
925
+ "--max-concurrency",
926
+ type=int,
927
+ default=os.environ.get("PULSE_REMOTE_MAX_CONCURRENCY", "10"),
928
+ help="Maximum active executions (1-128).",
929
+ )
930
+ parser.add_argument(
931
+ "--retention-hours",
932
+ type=int,
933
+ default=os.environ.get("PULSE_REMOTE_RETENTION_HOURS", "24"),
934
+ help="Retention for terminal execution records (1-8760 hours).",
935
+ )
936
+ parser.add_argument(
937
+ "--development",
938
+ action="store_true",
939
+ help="Allow short development tokens; only appropriate on loopback.",
940
+ )
941
+ args = parser.parse_args()
942
+
943
+ logging.basicConfig(level=logging.INFO)
944
+ token = os.environ.get("PULSE_REMOTE_TOKEN")
945
+ if args.development and args.host not in {"127.0.0.1", "localhost", "::1"}:
946
+ parser.error("--development is restricted to a loopback host")
947
+ if not args.development:
948
+ if not args.workspace_root.is_absolute() or not args.database.is_absolute():
949
+ parser.error(
950
+ "production remote paths must be absolute; set "
951
+ "PULSE_REMOTE_WORKSPACE_ROOT and PULSE_REMOTE_DB"
952
+ )
953
+ if args.host not in {"127.0.0.1", "localhost", "::1"}:
954
+ for variable in ("PULSE_TLS_CERT", "PULSE_TLS_KEY", "PULSE_TLS_CA"):
955
+ value = os.environ.get(variable, "")
956
+ path = Path(value) if value else None
957
+ if not path or not path.is_absolute() or not path.is_file():
958
+ parser.error(
959
+ f"{variable} must reference an existing absolute file "
960
+ "for a non-loopback worker"
961
+ )
962
+ try:
963
+ server = RemoteServer(
964
+ host=args.host,
965
+ port=args.port,
966
+ auth_token=token,
967
+ max_concurrent_executions=args.max_concurrency,
968
+ workspace_root=args.workspace_root,
969
+ database_path=args.database,
970
+ retention_hours=args.retention_hours,
971
+ production_mode=not args.development,
972
+ )
973
+ asyncio.run(server.start())
974
+ except KeyboardInterrupt:
975
+ logger.info("Server shutting down.")
976
+ except (OSError, RuntimeError, ValueError):
977
+ print(
978
+ "pulse-remote failed to start. Check host, port, token, TLS, and storage settings."
979
+ )
980
+ raise SystemExit(1) from None
981
+
982
+
983
+ if __name__ == "__main__":
984
+ main()