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,389 @@
|
|
|
1
|
+
"""Remote Sandbox Client.
|
|
2
|
+
|
|
3
|
+
Implements the client side of the authenticated WebSocket protocol.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import AsyncGenerator
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import websockets
|
|
17
|
+
from websockets.protocol import State
|
|
18
|
+
|
|
19
|
+
from pulse.sandbox.remote.models import (
|
|
20
|
+
ExecutionResultModel,
|
|
21
|
+
SubmitExecutionRequest,
|
|
22
|
+
SubmitExecutionResponse,
|
|
23
|
+
)
|
|
24
|
+
from pulse.sandbox.remote.protocol import RemoteSandboxClient
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RemoteClient(RemoteSandboxClient):
|
|
30
|
+
"""Client for the Remote Sandbox Server."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, endpoint_url: str, auth_token: str) -> None:
|
|
33
|
+
self.endpoint_url = endpoint_url
|
|
34
|
+
self.auth_token = auth_token
|
|
35
|
+
self._ws: websockets.WebSocketClientProtocol | None = None
|
|
36
|
+
self._results: dict[str, asyncio.Future[ExecutionResultModel]] = {}
|
|
37
|
+
self._responses: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
|
38
|
+
self._queues: dict[str, asyncio.Queue[tuple[str, str] | None]] = {}
|
|
39
|
+
self._listener_task: asyncio.Task[None] | None = None
|
|
40
|
+
|
|
41
|
+
async def connect(self) -> None:
|
|
42
|
+
"""Establish the WebSocket connection."""
|
|
43
|
+
if self._ws and getattr(self._ws, "state", None) is not State.CLOSED:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
headers = {"Authorization": f"Bearer {self.auth_token}"}
|
|
47
|
+
|
|
48
|
+
import os
|
|
49
|
+
import ssl
|
|
50
|
+
|
|
51
|
+
ssl_context = None
|
|
52
|
+
|
|
53
|
+
tls_cert = os.environ.get("PULSE_TLS_CERT")
|
|
54
|
+
tls_key = os.environ.get("PULSE_TLS_KEY")
|
|
55
|
+
tls_ca = os.environ.get("PULSE_TLS_CA")
|
|
56
|
+
|
|
57
|
+
if self.endpoint_url.startswith("wss://"):
|
|
58
|
+
if not (tls_cert and tls_key and tls_ca):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"mTLS certificates (PULSE_TLS_CERT, PULSE_TLS_KEY, PULSE_TLS_CA) are required for wss://"
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
ssl_context = ssl.create_default_context(
|
|
64
|
+
ssl.Purpose.SERVER_AUTH, cafile=tls_ca
|
|
65
|
+
)
|
|
66
|
+
ssl_context.load_cert_chain(certfile=tls_cert, keyfile=tls_key)
|
|
67
|
+
except (ssl.SSLError, OSError) as e:
|
|
68
|
+
raise RuntimeError(f"Failed to load mTLS certificates: {e}")
|
|
69
|
+
elif not (
|
|
70
|
+
"127.0.0.1" in self.endpoint_url
|
|
71
|
+
or "localhost" in self.endpoint_url
|
|
72
|
+
or "[::1]" in self.endpoint_url
|
|
73
|
+
):
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"Insecure ws:// connections are only allowed for loopback (127.0.0.1)."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self._ws = await websockets.connect(
|
|
79
|
+
self.endpoint_url,
|
|
80
|
+
additional_headers=headers,
|
|
81
|
+
ping_interval=20,
|
|
82
|
+
ping_timeout=20,
|
|
83
|
+
open_timeout=5,
|
|
84
|
+
close_timeout=5,
|
|
85
|
+
ssl=ssl_context,
|
|
86
|
+
)
|
|
87
|
+
self._listener_task = asyncio.create_task(self._listen())
|
|
88
|
+
|
|
89
|
+
async def disconnect(self) -> None:
|
|
90
|
+
"""Close the WebSocket connection and reap its listener task."""
|
|
91
|
+
if self._ws:
|
|
92
|
+
await self._ws.close()
|
|
93
|
+
self._ws = None
|
|
94
|
+
if self._listener_task:
|
|
95
|
+
listener = self._listener_task
|
|
96
|
+
self._listener_task = None
|
|
97
|
+
if not listener.done():
|
|
98
|
+
listener.cancel()
|
|
99
|
+
with suppress(asyncio.CancelledError):
|
|
100
|
+
await listener
|
|
101
|
+
|
|
102
|
+
async def _listen(self) -> None:
|
|
103
|
+
"""Background task to listen for messages from the server."""
|
|
104
|
+
if not self._ws:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
async for message in self._ws:
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(message)
|
|
111
|
+
msg_type = data.get("type")
|
|
112
|
+
payload = data.get("payload", {})
|
|
113
|
+
|
|
114
|
+
if msg_type == "result":
|
|
115
|
+
execution_id = payload.get("execution_id")
|
|
116
|
+
fut = self._results.get(execution_id) if execution_id else None
|
|
117
|
+
if fut and not fut.done():
|
|
118
|
+
fut.set_result(ExecutionResultModel.from_dict(payload))
|
|
119
|
+
elif execution_id:
|
|
120
|
+
logger.warning(
|
|
121
|
+
"Received result for unknown execution %s.",
|
|
122
|
+
execution_id,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
queue = self._queues.get(execution_id or "")
|
|
126
|
+
if queue:
|
|
127
|
+
await queue.put(None)
|
|
128
|
+
|
|
129
|
+
elif msg_type == "response":
|
|
130
|
+
request_id = payload.get("request_id")
|
|
131
|
+
if request_id and request_id in self._responses:
|
|
132
|
+
fut = self._responses[request_id]
|
|
133
|
+
if not fut.done():
|
|
134
|
+
fut.set_result(payload)
|
|
135
|
+
continue
|
|
136
|
+
eid = payload.get("execution_id")
|
|
137
|
+
if eid and eid in self._responses:
|
|
138
|
+
fut = self._responses[eid]
|
|
139
|
+
if not fut.done():
|
|
140
|
+
fut.set_result(payload)
|
|
141
|
+
else:
|
|
142
|
+
logger.warning(
|
|
143
|
+
"Received response without a known request ID: %s",
|
|
144
|
+
payload,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
elif msg_type == "stream":
|
|
148
|
+
stream_data = payload.get("data", "")
|
|
149
|
+
stream_type = payload.get("stream", "stdout")
|
|
150
|
+
execution_id = payload.get("execution_id")
|
|
151
|
+
queue = self._queues.get(execution_id or "")
|
|
152
|
+
if queue:
|
|
153
|
+
await queue.put((stream_type, stream_data))
|
|
154
|
+
|
|
155
|
+
elif msg_type == "error":
|
|
156
|
+
request_id = (
|
|
157
|
+
payload.get("request_id")
|
|
158
|
+
if isinstance(payload, dict)
|
|
159
|
+
else None
|
|
160
|
+
)
|
|
161
|
+
error = (
|
|
162
|
+
payload.get("message", "Remote server error")
|
|
163
|
+
if isinstance(payload, dict)
|
|
164
|
+
else str(payload)
|
|
165
|
+
)
|
|
166
|
+
fut = self._responses.get(request_id) if request_id else None
|
|
167
|
+
if fut and not fut.done():
|
|
168
|
+
fut.set_exception(RuntimeError(error))
|
|
169
|
+
else:
|
|
170
|
+
logger.error("Remote server error: %s", error)
|
|
171
|
+
|
|
172
|
+
except (KeyError, ValueError, RuntimeError) as e:
|
|
173
|
+
# check if the message is a direct string payload from attach history
|
|
174
|
+
if (
|
|
175
|
+
isinstance(message, str)
|
|
176
|
+
and message.startswith("{")
|
|
177
|
+
and '"stream"' in message
|
|
178
|
+
):
|
|
179
|
+
try:
|
|
180
|
+
line_data = json.loads(message)
|
|
181
|
+
if line_data.get("type") == "stream":
|
|
182
|
+
stream_payload = line_data.get("payload", {})
|
|
183
|
+
stream_data = stream_payload.get("data", "")
|
|
184
|
+
stream_type = stream_payload.get("stream", "stdout")
|
|
185
|
+
for queue in self._queues.values():
|
|
186
|
+
await queue.put((stream_type, stream_data))
|
|
187
|
+
except (TypeError, ValueError):
|
|
188
|
+
logger.error(f"Error processing server message: {e}")
|
|
189
|
+
else:
|
|
190
|
+
logger.error(f"Error processing server message: {e}")
|
|
191
|
+
except websockets.exceptions.ConnectionClosed:
|
|
192
|
+
logger.info("Connection to remote server closed.")
|
|
193
|
+
for fut in self._results.values():
|
|
194
|
+
if not fut.done():
|
|
195
|
+
fut.set_exception(RuntimeError("Connection closed"))
|
|
196
|
+
for fut in self._responses.values():
|
|
197
|
+
if not fut.done():
|
|
198
|
+
fut.set_exception(RuntimeError("Connection closed"))
|
|
199
|
+
for queue in self._queues.values():
|
|
200
|
+
await queue.put(None)
|
|
201
|
+
|
|
202
|
+
async def submit(self, request: SubmitExecutionRequest) -> SubmitExecutionResponse:
|
|
203
|
+
"""Submit an execution request to the remote worker."""
|
|
204
|
+
await self.connect()
|
|
205
|
+
|
|
206
|
+
self._results[request.execution_id] = asyncio.get_running_loop().create_future()
|
|
207
|
+
self._responses[request.execution_id] = (
|
|
208
|
+
asyncio.get_running_loop().create_future()
|
|
209
|
+
)
|
|
210
|
+
self._queues[request.execution_id] = asyncio.Queue()
|
|
211
|
+
|
|
212
|
+
payload = request.to_dict()
|
|
213
|
+
payload["request_id"] = request.execution_id
|
|
214
|
+
await self._ws.send(
|
|
215
|
+
json.dumps(
|
|
216
|
+
{
|
|
217
|
+
"action": "submit",
|
|
218
|
+
"payload": payload,
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
resp = await self._responses[request.execution_id]
|
|
224
|
+
return SubmitExecutionResponse(
|
|
225
|
+
execution_id=request.execution_id, status=resp.get("status", "STARTING")
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async def cancel(self, execution_id: str) -> None:
|
|
229
|
+
"""Cancel an ongoing execution on the remote worker."""
|
|
230
|
+
if not self._ws or getattr(self._ws, "state", None) is State.CLOSED:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
await self._ws.send(
|
|
234
|
+
json.dumps({"action": "cancel", "payload": {"execution_id": execution_id}})
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
async def get_result(self, execution_id: str) -> ExecutionResultModel:
|
|
238
|
+
"""Wait for and retrieve the final execution result."""
|
|
239
|
+
fut = self._results.get(execution_id)
|
|
240
|
+
if not fut:
|
|
241
|
+
raise ValueError(f"No active execution found for {execution_id}")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
return await fut
|
|
245
|
+
finally:
|
|
246
|
+
self._results.pop(execution_id, None)
|
|
247
|
+
|
|
248
|
+
async def stream_output(
|
|
249
|
+
self, execution_id: str
|
|
250
|
+
) -> AsyncGenerator[tuple[str, str], None]:
|
|
251
|
+
"""Stream stdout and stderr from the remote execution."""
|
|
252
|
+
queue = self._queues.get(execution_id)
|
|
253
|
+
if not queue:
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
while True:
|
|
258
|
+
item = await queue.get()
|
|
259
|
+
if item is None:
|
|
260
|
+
break
|
|
261
|
+
yield item
|
|
262
|
+
finally:
|
|
263
|
+
self._queues.pop(execution_id, None)
|
|
264
|
+
|
|
265
|
+
async def status(self, execution_id: str) -> str:
|
|
266
|
+
"""Query authoritative remote state."""
|
|
267
|
+
await self.connect()
|
|
268
|
+
fut = asyncio.get_running_loop().create_future()
|
|
269
|
+
temp_key = f"status-{uuid.uuid4().hex}"
|
|
270
|
+
self._responses[temp_key] = fut
|
|
271
|
+
try:
|
|
272
|
+
await self._ws.send(
|
|
273
|
+
json.dumps(
|
|
274
|
+
{
|
|
275
|
+
"action": "status",
|
|
276
|
+
"payload": {
|
|
277
|
+
"execution_id": execution_id,
|
|
278
|
+
"request_id": temp_key,
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
resp = await fut
|
|
284
|
+
return resp.get("status", "NOT_FOUND")
|
|
285
|
+
finally:
|
|
286
|
+
self._responses.pop(temp_key, None)
|
|
287
|
+
|
|
288
|
+
async def attach(self, execution_id: str) -> ExecutionResultModel:
|
|
289
|
+
"""Attach to a live execution and stream results, returning the final result."""
|
|
290
|
+
await self.connect()
|
|
291
|
+
|
|
292
|
+
# Setup tracking
|
|
293
|
+
self._results[execution_id] = asyncio.get_running_loop().create_future()
|
|
294
|
+
request_id = f"attach-{uuid.uuid4().hex}"
|
|
295
|
+
self._responses[request_id] = asyncio.get_running_loop().create_future()
|
|
296
|
+
self._queues[execution_id] = asyncio.Queue()
|
|
297
|
+
|
|
298
|
+
await self._ws.send(
|
|
299
|
+
json.dumps(
|
|
300
|
+
{
|
|
301
|
+
"action": "attach",
|
|
302
|
+
"payload": {"execution_id": execution_id, "request_id": request_id},
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
fut = self._results.get(execution_id)
|
|
308
|
+
if not fut:
|
|
309
|
+
raise ValueError(f"No active execution found for {execution_id}")
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
response = await self._responses[request_id]
|
|
313
|
+
if response.get("status") not in {"ATTACHED", "COMPLETED", "FAILED"}:
|
|
314
|
+
raise RuntimeError(
|
|
315
|
+
f"Remote execution {execution_id} cannot be attached: "
|
|
316
|
+
f"{response.get('status', 'UNKNOWN')}"
|
|
317
|
+
)
|
|
318
|
+
return await fut
|
|
319
|
+
finally:
|
|
320
|
+
self._results.pop(execution_id, None)
|
|
321
|
+
self._responses.pop(request_id, None)
|
|
322
|
+
self._queues.pop(execution_id, None)
|
|
323
|
+
|
|
324
|
+
async def reconcile(self) -> None:
|
|
325
|
+
"""Reconcile orphaned or stale executions with the remote worker."""
|
|
326
|
+
await self.connect()
|
|
327
|
+
fut = asyncio.get_running_loop().create_future()
|
|
328
|
+
temp_key = f"reconcile-{uuid.uuid4().hex}"
|
|
329
|
+
self._responses[temp_key] = fut
|
|
330
|
+
try:
|
|
331
|
+
await self._ws.send(
|
|
332
|
+
json.dumps({"action": "reconcile", "payload": {"request_id": temp_key}})
|
|
333
|
+
)
|
|
334
|
+
await fut
|
|
335
|
+
finally:
|
|
336
|
+
self._responses.pop(temp_key, None)
|
|
337
|
+
|
|
338
|
+
async def upload_artifact(self, execution_id: str, archive_data: bytes) -> None:
|
|
339
|
+
"""Upload a workspace snapshot to the remote worker before execution."""
|
|
340
|
+
import base64
|
|
341
|
+
|
|
342
|
+
await self.connect()
|
|
343
|
+
fut = asyncio.get_running_loop().create_future()
|
|
344
|
+
# Use a temporary key for the response
|
|
345
|
+
temp_key = f"upload_{execution_id}"
|
|
346
|
+
self._responses[temp_key] = fut
|
|
347
|
+
try:
|
|
348
|
+
await self._ws.send(
|
|
349
|
+
json.dumps(
|
|
350
|
+
{
|
|
351
|
+
"action": "upload_artifact",
|
|
352
|
+
"payload": {
|
|
353
|
+
"execution_id": execution_id,
|
|
354
|
+
"request_id": temp_key,
|
|
355
|
+
"data": base64.b64encode(archive_data).decode("ascii"),
|
|
356
|
+
},
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
await fut
|
|
361
|
+
finally:
|
|
362
|
+
self._responses.pop(temp_key, None)
|
|
363
|
+
|
|
364
|
+
async def download_artifact(self, execution_id: str) -> bytes:
|
|
365
|
+
"""Download the modified workspace overlay from the remote worker after execution."""
|
|
366
|
+
import base64
|
|
367
|
+
|
|
368
|
+
await self.connect()
|
|
369
|
+
fut = asyncio.get_running_loop().create_future()
|
|
370
|
+
temp_key = f"download_{execution_id}"
|
|
371
|
+
self._responses[temp_key] = fut
|
|
372
|
+
try:
|
|
373
|
+
await self._ws.send(
|
|
374
|
+
json.dumps(
|
|
375
|
+
{
|
|
376
|
+
"action": "download_artifact",
|
|
377
|
+
"payload": {
|
|
378
|
+
"execution_id": execution_id,
|
|
379
|
+
"request_id": temp_key,
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
resp = await fut
|
|
385
|
+
if resp.get("status") == "DOWNLOADED" and "data" in resp:
|
|
386
|
+
return base64.b64decode(resp["data"])
|
|
387
|
+
return b""
|
|
388
|
+
finally:
|
|
389
|
+
self._responses.pop(temp_key, None)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Data models for Remote Sandbox Protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
REMOTE_PROTOCOL_VERSION = "1.0"
|
|
11
|
+
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
12
|
+
_SAFE_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_execution_id(value: object) -> str:
|
|
16
|
+
if not isinstance(value, str) or not _SAFE_IDENTIFIER.fullmatch(value):
|
|
17
|
+
raise ValueError("execution_id must be a safe 1-128 character identifier")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SubmitExecutionRequest:
|
|
23
|
+
protocol_version: str
|
|
24
|
+
execution_id: str
|
|
25
|
+
idempotency_key: str
|
|
26
|
+
command: str | list[str]
|
|
27
|
+
correlation_id: str | None = None
|
|
28
|
+
working_directory: str | None = None
|
|
29
|
+
env: dict[str, str] | None = None
|
|
30
|
+
resource_policy_dict: dict[str, Any] | None = None
|
|
31
|
+
network_policy_dict: dict[str, Any] | None = None
|
|
32
|
+
secret_policy_dict: dict[str, Any] | None = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if self.protocol_version != REMOTE_PROTOCOL_VERSION:
|
|
36
|
+
raise ValueError("unsupported remote protocol version")
|
|
37
|
+
validate_execution_id(self.execution_id)
|
|
38
|
+
if not _SAFE_IDENTIFIER.fullmatch(self.idempotency_key):
|
|
39
|
+
raise ValueError("idempotency_key must be a safe 1-128 character identifier")
|
|
40
|
+
if isinstance(self.command, str):
|
|
41
|
+
if not self.command or len(self.command) > 65_536 or "\x00" in self.command:
|
|
42
|
+
raise ValueError("command must be 1-65536 characters without NUL bytes")
|
|
43
|
+
elif isinstance(self.command, list):
|
|
44
|
+
if not self.command or len(self.command) > 256 or any(
|
|
45
|
+
not isinstance(item, str)
|
|
46
|
+
or not item
|
|
47
|
+
or len(item) > 65_536
|
|
48
|
+
or "\x00" in item
|
|
49
|
+
for item in self.command
|
|
50
|
+
):
|
|
51
|
+
raise ValueError("command arguments are invalid or exceed protocol limits")
|
|
52
|
+
else:
|
|
53
|
+
raise TypeError("command must be a string or an array of strings")
|
|
54
|
+
if self.working_directory:
|
|
55
|
+
path = PurePosixPath(self.working_directory.replace("\\", "/"))
|
|
56
|
+
if path.is_absolute() or ".." in path.parts:
|
|
57
|
+
raise ValueError("working_directory must remain inside the execution workspace")
|
|
58
|
+
if self.env is not None:
|
|
59
|
+
if not isinstance(self.env, dict) or len(self.env) > 128:
|
|
60
|
+
raise ValueError("env must be an object with at most 128 entries")
|
|
61
|
+
if any(
|
|
62
|
+
not isinstance(key, str)
|
|
63
|
+
or not _SAFE_ENV_NAME.fullmatch(key)
|
|
64
|
+
or not isinstance(value, str)
|
|
65
|
+
or len(value) > 65_536
|
|
66
|
+
or "\x00" in value
|
|
67
|
+
for key, value in self.env.items()
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("env contains an invalid name or value")
|
|
70
|
+
for policy in (
|
|
71
|
+
self.resource_policy_dict,
|
|
72
|
+
self.network_policy_dict,
|
|
73
|
+
self.secret_policy_dict,
|
|
74
|
+
):
|
|
75
|
+
if policy is not None and not isinstance(policy, dict):
|
|
76
|
+
raise ValueError("execution policies must be objects")
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"protocol_version": self.protocol_version,
|
|
81
|
+
"execution_id": self.execution_id,
|
|
82
|
+
"idempotency_key": self.idempotency_key,
|
|
83
|
+
"command": self.command,
|
|
84
|
+
"correlation_id": self.correlation_id,
|
|
85
|
+
"working_directory": self.working_directory,
|
|
86
|
+
"env": self.env,
|
|
87
|
+
"resource_policy_dict": self.resource_policy_dict,
|
|
88
|
+
"network_policy_dict": self.network_policy_dict,
|
|
89
|
+
"secret_policy_dict": self.secret_policy_dict,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, data: dict[str, Any]) -> SubmitExecutionRequest:
|
|
94
|
+
return cls(
|
|
95
|
+
protocol_version=data["protocol_version"],
|
|
96
|
+
execution_id=data["execution_id"],
|
|
97
|
+
idempotency_key=data["idempotency_key"],
|
|
98
|
+
command=data["command"],
|
|
99
|
+
correlation_id=data.get("correlation_id"),
|
|
100
|
+
working_directory=data.get("working_directory"),
|
|
101
|
+
env=data.get("env"),
|
|
102
|
+
resource_policy_dict=data.get("resource_policy_dict"),
|
|
103
|
+
network_policy_dict=data.get("network_policy_dict"),
|
|
104
|
+
secret_policy_dict=data.get("secret_policy_dict"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class SubmitExecutionResponse:
|
|
110
|
+
execution_id: str
|
|
111
|
+
status: str
|
|
112
|
+
error: str | None = None
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict[str, Any]:
|
|
115
|
+
return {
|
|
116
|
+
"execution_id": self.execution_id,
|
|
117
|
+
"status": self.status,
|
|
118
|
+
"error": self.error,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_dict(cls, data: dict[str, Any]) -> SubmitExecutionResponse:
|
|
123
|
+
return cls(
|
|
124
|
+
execution_id=data["execution_id"],
|
|
125
|
+
status=data["status"],
|
|
126
|
+
error=data.get("error"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ExecutionResultModel:
|
|
132
|
+
execution_id: str
|
|
133
|
+
command: str
|
|
134
|
+
exit_code: int
|
|
135
|
+
stdout: str
|
|
136
|
+
stderr: str
|
|
137
|
+
duration_ms: float
|
|
138
|
+
timed_out: bool = False
|
|
139
|
+
truncated: bool = False
|
|
140
|
+
termination_reason: str | None = None
|
|
141
|
+
|
|
142
|
+
def to_dict(self) -> dict[str, Any]:
|
|
143
|
+
return {
|
|
144
|
+
"execution_id": self.execution_id,
|
|
145
|
+
"command": self.command,
|
|
146
|
+
"exit_code": self.exit_code,
|
|
147
|
+
"stdout": self.stdout,
|
|
148
|
+
"stderr": self.stderr,
|
|
149
|
+
"duration_ms": self.duration_ms,
|
|
150
|
+
"timed_out": self.timed_out,
|
|
151
|
+
"truncated": self.truncated,
|
|
152
|
+
"termination_reason": self.termination_reason,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_dict(cls, data: dict[str, Any]) -> ExecutionResultModel:
|
|
157
|
+
return cls(
|
|
158
|
+
execution_id=data["execution_id"],
|
|
159
|
+
command=data["command"],
|
|
160
|
+
exit_code=data["exit_code"],
|
|
161
|
+
stdout=data["stdout"],
|
|
162
|
+
stderr=data["stderr"],
|
|
163
|
+
duration_ms=data["duration_ms"],
|
|
164
|
+
timed_out=data.get("timed_out", False),
|
|
165
|
+
truncated=data.get("truncated", False),
|
|
166
|
+
termination_reason=data.get("termination_reason"),
|
|
167
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Remote Sandbox Protocol definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from pulse.sandbox.remote.models import (
|
|
9
|
+
ExecutionResultModel,
|
|
10
|
+
SubmitExecutionRequest,
|
|
11
|
+
SubmitExecutionResponse,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RemoteSandboxClient(Protocol):
|
|
16
|
+
"""Protocol for communicating with a Remote Sandbox Worker.
|
|
17
|
+
|
|
18
|
+
The implementation must support:
|
|
19
|
+
- Authenticated transport
|
|
20
|
+
- Artifact snapshot staging and retrieval
|
|
21
|
+
- Execution submission
|
|
22
|
+
- Execution cancellation
|
|
23
|
+
- Execution reconciliation
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def submit(self, request: SubmitExecutionRequest) -> SubmitExecutionResponse:
|
|
27
|
+
"""Submit an execution request to the remote worker."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
async def cancel(self, execution_id: str) -> None:
|
|
31
|
+
"""Cancel an ongoing execution on the remote worker."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
async def get_result(self, execution_id: str) -> ExecutionResultModel:
|
|
35
|
+
"""Wait for and retrieve the final execution result."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def stream_output(
|
|
39
|
+
self, execution_id: str
|
|
40
|
+
) -> AsyncGenerator[tuple[str, str], None]:
|
|
41
|
+
"""Stream stdout and stderr from the remote execution.
|
|
42
|
+
|
|
43
|
+
Yields tuples of (stdout_chunk, stderr_chunk).
|
|
44
|
+
"""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
async def status(self, execution_id: str) -> str:
|
|
48
|
+
"""Query authoritative execution state (RUNNING, COMPLETED, FAILED, NOT_FOUND)."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
async def attach(self, execution_id: str) -> ExecutionResultModel:
|
|
52
|
+
"""Attach to a live execution and stream results, returning the final result."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
async def reconcile(self) -> None:
|
|
56
|
+
"""Reconcile orphaned or stale executions with the remote worker."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
async def upload_artifact(self, execution_id: str, archive_data: bytes) -> None:
|
|
60
|
+
"""Upload a workspace snapshot to the remote worker before execution."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
async def download_artifact(self, execution_id: str) -> bytes:
|
|
64
|
+
"""Download the modified workspace overlay from the remote worker after execution."""
|
|
65
|
+
...
|