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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- 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()
|