hotcell 0.1.1__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.
- hotcell/__init__.py +796 -0
- hotcell/py.typed +0 -0
- hotcell-0.1.1.dist-info/METADATA +59 -0
- hotcell-0.1.1.dist-info/RECORD +6 -0
- hotcell-0.1.1.dist-info/WHEEL +5 -0
- hotcell-0.1.1.dist-info/top_level.txt +1 -0
hotcell/__init__.py
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
"""hotcell — Python client for the hotcell self-hostable agent sandbox daemon.
|
|
2
|
+
|
|
3
|
+
Mirrors the TypeScript SDK (and, by extension, the Cloudflare Sandbox surface)
|
|
4
|
+
so existing harnesses port with minimal changes, but points at *your* self-hosted
|
|
5
|
+
daemon instead of the edge. Dependency-free: standard library only.
|
|
6
|
+
|
|
7
|
+
from hotcell import HotcellClient
|
|
8
|
+
|
|
9
|
+
client = HotcellClient(endpoint="http://127.0.0.1:4750")
|
|
10
|
+
sandbox = client.get_sandbox()
|
|
11
|
+
result = sandbox.exec("python3 -c 'print(2+2)'")
|
|
12
|
+
print(result.stdout) # "4\n"
|
|
13
|
+
sandbox.destroy()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"HotcellClient",
|
|
28
|
+
"Sandbox",
|
|
29
|
+
"Session",
|
|
30
|
+
"CodeContext",
|
|
31
|
+
"HotcellError",
|
|
32
|
+
"ExecResult",
|
|
33
|
+
"ExecEvent",
|
|
34
|
+
"SandboxInfo",
|
|
35
|
+
"FileInfo",
|
|
36
|
+
"SandboxStats",
|
|
37
|
+
"SandboxUsage",
|
|
38
|
+
"CostBreakdown",
|
|
39
|
+
"SandboxMetrics",
|
|
40
|
+
"ProcessHandle",
|
|
41
|
+
"ExposedPort",
|
|
42
|
+
"SessionInfo",
|
|
43
|
+
"CodeOutput",
|
|
44
|
+
"CodeResult",
|
|
45
|
+
"BackupInfo",
|
|
46
|
+
"FileChangeEvent",
|
|
47
|
+
"get_sandbox",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
DEFAULT_ENDPOINT = "http://127.0.0.1:4750"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HotcellError(RuntimeError):
|
|
54
|
+
"""Raised when the daemon returns a non-2xx response."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, method: str, path: str, status: int, body: str):
|
|
57
|
+
super().__init__(f"hotcell {method} {path} -> {status}: {body}")
|
|
58
|
+
self.status = status
|
|
59
|
+
self.body = body
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- data types ------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ExecResult:
|
|
67
|
+
stdout: str
|
|
68
|
+
stderr: str
|
|
69
|
+
exit_code: int
|
|
70
|
+
success: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ExecEvent:
|
|
75
|
+
type: str # "stdout" | "stderr" | "exit"
|
|
76
|
+
data: Optional[str] = None
|
|
77
|
+
exit_code: Optional[int] = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class SandboxInfo:
|
|
82
|
+
id: str
|
|
83
|
+
image: str
|
|
84
|
+
status: str # "running" | "paused" | "stopped"
|
|
85
|
+
created_at: str
|
|
86
|
+
labels: Dict[str, str]
|
|
87
|
+
persist: bool
|
|
88
|
+
last_activity_at: str
|
|
89
|
+
sleep_after_ms: int
|
|
90
|
+
limits: Dict[str, Any] # {memoryMb?, cpus?, pidsLimit?}; {} = unlimited
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, d: Dict[str, Any]) -> "SandboxInfo":
|
|
94
|
+
return cls(
|
|
95
|
+
id=d["id"],
|
|
96
|
+
image=d["image"],
|
|
97
|
+
status=d["status"],
|
|
98
|
+
created_at=d.get("createdAt", ""),
|
|
99
|
+
labels=d.get("labels", {}),
|
|
100
|
+
persist=d.get("persist", True),
|
|
101
|
+
last_activity_at=d.get("lastActivityAt", ""),
|
|
102
|
+
sleep_after_ms=d.get("sleepAfterMs", 0),
|
|
103
|
+
limits=d.get("limits", {}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class FileInfo:
|
|
109
|
+
path: str
|
|
110
|
+
name: str
|
|
111
|
+
is_directory: bool
|
|
112
|
+
size: int
|
|
113
|
+
modified_at: str
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_dict(cls, d: Dict[str, Any]) -> "FileInfo":
|
|
117
|
+
return cls(
|
|
118
|
+
path=d["path"],
|
|
119
|
+
name=d["name"],
|
|
120
|
+
is_directory=d.get("isDirectory", False),
|
|
121
|
+
size=d.get("size", 0),
|
|
122
|
+
modified_at=d.get("modifiedAt", ""),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class SandboxStats:
|
|
128
|
+
cpu_percent: float
|
|
129
|
+
cpu_total_usage_ns: int
|
|
130
|
+
online_cpus: int
|
|
131
|
+
mem_bytes: int
|
|
132
|
+
mem_limit_bytes: int
|
|
133
|
+
net_rx_bytes: int
|
|
134
|
+
net_tx_bytes: int
|
|
135
|
+
pids: int
|
|
136
|
+
sampled_at: str
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_dict(cls, d: Dict[str, Any]) -> "SandboxStats":
|
|
140
|
+
return cls(
|
|
141
|
+
cpu_percent=d.get("cpuPercent", 0.0),
|
|
142
|
+
cpu_total_usage_ns=d.get("cpuTotalUsageNs", 0),
|
|
143
|
+
online_cpus=d.get("onlineCpus", 1),
|
|
144
|
+
mem_bytes=d.get("memBytes", 0),
|
|
145
|
+
mem_limit_bytes=d.get("memLimitBytes", 0),
|
|
146
|
+
net_rx_bytes=d.get("netRxBytes", 0),
|
|
147
|
+
net_tx_bytes=d.get("netTxBytes", 0),
|
|
148
|
+
pids=d.get("pids", 0),
|
|
149
|
+
sampled_at=d.get("sampledAt", ""),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class SandboxUsage:
|
|
155
|
+
cpu_seconds: float
|
|
156
|
+
mem_byte_seconds: float
|
|
157
|
+
egress_bytes: int
|
|
158
|
+
provider_calls: int = 0
|
|
159
|
+
provider_bytes: int = 0
|
|
160
|
+
provider_tokens_in: int = 0
|
|
161
|
+
provider_tokens_out: int = 0
|
|
162
|
+
provider_cost: float = 0.0 # provider-reported LLM cost in USD (OpenRouter usage.cost)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_dict(cls, d: Dict[str, Any]) -> "SandboxUsage":
|
|
166
|
+
return cls(
|
|
167
|
+
cpu_seconds=d.get("cpuSeconds", 0.0),
|
|
168
|
+
mem_byte_seconds=d.get("memByteSeconds", 0.0),
|
|
169
|
+
egress_bytes=d.get("egressBytes", 0),
|
|
170
|
+
provider_calls=d.get("providerCalls", 0),
|
|
171
|
+
provider_bytes=d.get("providerBytes", 0),
|
|
172
|
+
provider_tokens_in=d.get("providerTokensIn", 0),
|
|
173
|
+
provider_tokens_out=d.get("providerTokensOut", 0),
|
|
174
|
+
provider_cost=d.get("providerCost", 0.0),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class CostBreakdown:
|
|
180
|
+
cpu: float
|
|
181
|
+
mem: float
|
|
182
|
+
egress: float
|
|
183
|
+
provider: float # LLM cost (provider-reported, e.g. OpenRouter usage.cost)
|
|
184
|
+
total: float
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_dict(cls, d: Dict[str, Any]) -> "CostBreakdown":
|
|
188
|
+
return cls(
|
|
189
|
+
cpu=d.get("cpu", 0.0),
|
|
190
|
+
mem=d.get("mem", 0.0),
|
|
191
|
+
egress=d.get("egress", 0.0),
|
|
192
|
+
provider=d.get("provider", 0.0),
|
|
193
|
+
total=d.get("total", 0.0),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class SandboxMetrics:
|
|
199
|
+
status: str
|
|
200
|
+
live: Optional[SandboxStats]
|
|
201
|
+
usage: SandboxUsage
|
|
202
|
+
cost: CostBreakdown
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_dict(cls, d: Dict[str, Any]) -> "SandboxMetrics":
|
|
206
|
+
live = d.get("live")
|
|
207
|
+
return cls(
|
|
208
|
+
status=d.get("status", ""),
|
|
209
|
+
live=SandboxStats.from_dict(live) if live else None,
|
|
210
|
+
usage=SandboxUsage.from_dict(d.get("usage", {})),
|
|
211
|
+
cost=CostBreakdown.from_dict(d.get("cost", {})),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class ProcessHandle:
|
|
217
|
+
proc_id: str
|
|
218
|
+
pid: int
|
|
219
|
+
command: str
|
|
220
|
+
status: str
|
|
221
|
+
exit_code: Optional[int]
|
|
222
|
+
started_at: str
|
|
223
|
+
log_path: str
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def from_dict(cls, d: Dict[str, Any]) -> "ProcessHandle":
|
|
227
|
+
return cls(
|
|
228
|
+
proc_id=d["procId"],
|
|
229
|
+
pid=d.get("pid", 0),
|
|
230
|
+
command=d.get("command", ""),
|
|
231
|
+
status=d.get("status", ""),
|
|
232
|
+
exit_code=d.get("exitCode"),
|
|
233
|
+
started_at=d.get("startedAt", ""),
|
|
234
|
+
log_path=d.get("logPath", ""),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class ExposedPort:
|
|
240
|
+
port: int
|
|
241
|
+
expose_id: str
|
|
242
|
+
token: Optional[str]
|
|
243
|
+
created_at: str
|
|
244
|
+
url: str
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_dict(cls, d: Dict[str, Any]) -> "ExposedPort":
|
|
248
|
+
return cls(
|
|
249
|
+
port=d["port"],
|
|
250
|
+
expose_id=d.get("exposeId", ""),
|
|
251
|
+
token=d.get("token"),
|
|
252
|
+
created_at=d.get("createdAt", ""),
|
|
253
|
+
url=d.get("url", ""),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class SessionInfo:
|
|
259
|
+
session_id: str
|
|
260
|
+
cwd: str
|
|
261
|
+
env: Dict[str, str]
|
|
262
|
+
created_at: str
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def from_dict(cls, d: Dict[str, Any]) -> "SessionInfo":
|
|
266
|
+
return cls(
|
|
267
|
+
session_id=d["sessionId"],
|
|
268
|
+
cwd=d.get("cwd", ""),
|
|
269
|
+
env=d.get("env", {}),
|
|
270
|
+
created_at=d.get("createdAt", ""),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass
|
|
275
|
+
class CodeOutput:
|
|
276
|
+
type: str
|
|
277
|
+
text: str
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass
|
|
281
|
+
class CodeResult:
|
|
282
|
+
stdout: str
|
|
283
|
+
stderr: str
|
|
284
|
+
results: List[CodeOutput]
|
|
285
|
+
error: Optional[str]
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def from_dict(cls, d: Dict[str, Any]) -> "CodeResult":
|
|
289
|
+
return cls(
|
|
290
|
+
stdout=d.get("stdout", ""),
|
|
291
|
+
stderr=d.get("stderr", ""),
|
|
292
|
+
results=[CodeOutput(type=r.get("type", "text"), text=r.get("text", ""))
|
|
293
|
+
for r in d.get("results", [])],
|
|
294
|
+
error=d.get("error"),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class BackupInfo:
|
|
300
|
+
backup_id: str
|
|
301
|
+
sandbox_id: str
|
|
302
|
+
created_at: str
|
|
303
|
+
bytes: int
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def from_dict(cls, d: Dict[str, Any]) -> "BackupInfo":
|
|
307
|
+
return cls(
|
|
308
|
+
backup_id=d["backupId"],
|
|
309
|
+
sandbox_id=d.get("sandboxId", ""),
|
|
310
|
+
created_at=d.get("createdAt", ""),
|
|
311
|
+
bytes=d.get("bytes", 0),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class FileChangeEvent:
|
|
317
|
+
type: str # "created" | "modified" | "deleted"
|
|
318
|
+
path: str
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- client ----------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class HotcellClient:
|
|
325
|
+
"""Entry point. Talks to one hotcell daemon over its REST API."""
|
|
326
|
+
|
|
327
|
+
def __init__(self, endpoint: Optional[str] = None,
|
|
328
|
+
api_key: Optional[str] = None):
|
|
329
|
+
self.endpoint = (endpoint or os.environ.get("HOTCELL_ENDPOINT")
|
|
330
|
+
or DEFAULT_ENDPOINT).rstrip("/")
|
|
331
|
+
# API key sent as `Authorization: Bearer <key>`; required when the daemon
|
|
332
|
+
# runs with HOTCELL_API_KEY. Falls back to the HOTCELL_API_KEY env var.
|
|
333
|
+
self.api_key = api_key if api_key is not None else os.environ.get("HOTCELL_API_KEY", "")
|
|
334
|
+
|
|
335
|
+
def get_sandbox(
|
|
336
|
+
self,
|
|
337
|
+
id: Optional[str] = None,
|
|
338
|
+
*,
|
|
339
|
+
image: Optional[str] = None,
|
|
340
|
+
env: Optional[Dict[str, str]] = None,
|
|
341
|
+
labels: Optional[Dict[str, str]] = None,
|
|
342
|
+
persist: Optional[bool] = None,
|
|
343
|
+
sleep_after: Optional[int] = None,
|
|
344
|
+
egress: Optional[bool] = None,
|
|
345
|
+
setup: Optional[List[str]] = None,
|
|
346
|
+
repo: Optional[str] = None,
|
|
347
|
+
repo_ref: Optional[str] = None,
|
|
348
|
+
memory_mb: Optional[float] = None,
|
|
349
|
+
cpus: Optional[float] = None,
|
|
350
|
+
pids_limit: Optional[int] = None,
|
|
351
|
+
) -> "Sandbox":
|
|
352
|
+
"""Attach to a sandbox by id, or (id omitted) provision a fresh one."""
|
|
353
|
+
if id:
|
|
354
|
+
info = self.request("GET", f"/sandboxes/{id}")
|
|
355
|
+
return Sandbox(self, SandboxInfo.from_dict(info))
|
|
356
|
+
body: Dict[str, Any] = {}
|
|
357
|
+
if image is not None:
|
|
358
|
+
body["image"] = image
|
|
359
|
+
if env is not None:
|
|
360
|
+
body["env"] = env
|
|
361
|
+
if labels is not None:
|
|
362
|
+
body["labels"] = labels
|
|
363
|
+
if persist is not None:
|
|
364
|
+
body["persist"] = persist
|
|
365
|
+
if sleep_after is not None:
|
|
366
|
+
body["sleepAfter"] = sleep_after
|
|
367
|
+
if egress is not None:
|
|
368
|
+
body["egress"] = egress
|
|
369
|
+
if setup is not None:
|
|
370
|
+
body["setup"] = setup
|
|
371
|
+
if repo is not None:
|
|
372
|
+
body["repo"] = repo
|
|
373
|
+
if repo_ref is not None:
|
|
374
|
+
body["repoRef"] = repo_ref
|
|
375
|
+
if memory_mb is not None:
|
|
376
|
+
body["memoryMb"] = memory_mb
|
|
377
|
+
if cpus is not None:
|
|
378
|
+
body["cpus"] = cpus
|
|
379
|
+
if pids_limit is not None:
|
|
380
|
+
body["pidsLimit"] = pids_limit
|
|
381
|
+
info = self.request("POST", "/sandboxes", body)
|
|
382
|
+
return Sandbox(self, SandboxInfo.from_dict(info))
|
|
383
|
+
|
|
384
|
+
def list(self) -> List[SandboxInfo]:
|
|
385
|
+
data = self.request("GET", "/sandboxes")
|
|
386
|
+
return [SandboxInfo.from_dict(s) for s in data.get("sandboxes", [])]
|
|
387
|
+
|
|
388
|
+
def health(self) -> Dict[str, Any]:
|
|
389
|
+
return self.request("GET", "/healthz")
|
|
390
|
+
|
|
391
|
+
def info(self) -> Dict[str, Any]:
|
|
392
|
+
"""Daemon info: driver, default image, proxy port, cost rates."""
|
|
393
|
+
return self.request("GET", "/info")
|
|
394
|
+
|
|
395
|
+
def list_backups(self) -> List[BackupInfo]:
|
|
396
|
+
data = self.request("GET", "/backups")
|
|
397
|
+
return [BackupInfo.from_dict(b) for b in data.get("backups", [])]
|
|
398
|
+
|
|
399
|
+
def delete_backup(self, backup_id: str) -> None:
|
|
400
|
+
self.request("DELETE", f"/backups/{backup_id}")
|
|
401
|
+
|
|
402
|
+
# -- HTTP plumbing ------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def request(self, method: str, path: str, body: Any = None) -> Any:
|
|
405
|
+
"""Issue a JSON request and return the parsed response (or None)."""
|
|
406
|
+
resp = self._open(method, path, body=body)
|
|
407
|
+
with resp:
|
|
408
|
+
raw = resp.read()
|
|
409
|
+
return json.loads(raw) if raw else None
|
|
410
|
+
|
|
411
|
+
def stream(
|
|
412
|
+
self,
|
|
413
|
+
method: str,
|
|
414
|
+
path: str,
|
|
415
|
+
*,
|
|
416
|
+
body: Any = None,
|
|
417
|
+
params: Optional[Dict[str, str]] = None,
|
|
418
|
+
) -> Iterator[Dict[str, Any]]:
|
|
419
|
+
"""Issue a request and yield decoded Server-Sent-Event JSON frames."""
|
|
420
|
+
resp = self._open(method, path, body=body, params=params)
|
|
421
|
+
with resp:
|
|
422
|
+
for raw in resp:
|
|
423
|
+
line = raw.decode("utf-8").rstrip("\r\n")
|
|
424
|
+
if not line.startswith("data:"):
|
|
425
|
+
continue # skip comments (": ...") and blank separators
|
|
426
|
+
payload = line[5:].strip()
|
|
427
|
+
if payload:
|
|
428
|
+
yield json.loads(payload)
|
|
429
|
+
|
|
430
|
+
def _open(
|
|
431
|
+
self,
|
|
432
|
+
method: str,
|
|
433
|
+
path: str,
|
|
434
|
+
*,
|
|
435
|
+
body: Any = None,
|
|
436
|
+
params: Optional[Dict[str, str]] = None,
|
|
437
|
+
):
|
|
438
|
+
url = self.endpoint + path
|
|
439
|
+
if params:
|
|
440
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
441
|
+
data = None
|
|
442
|
+
headers = {}
|
|
443
|
+
if self.api_key:
|
|
444
|
+
headers["authorization"] = "Bearer " + self.api_key
|
|
445
|
+
if body is not None:
|
|
446
|
+
data = json.dumps(body).encode("utf-8")
|
|
447
|
+
headers["content-type"] = "application/json"
|
|
448
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
449
|
+
try:
|
|
450
|
+
return urllib.request.urlopen(req)
|
|
451
|
+
except urllib.error.HTTPError as e:
|
|
452
|
+
detail = e.read().decode("utf-8", "replace")
|
|
453
|
+
raise HotcellError(method, path, e.code, detail) from None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class Sandbox:
|
|
457
|
+
"""A single sandbox. Mirrors the TS `Sandbox` class, snake_cased."""
|
|
458
|
+
|
|
459
|
+
def __init__(self, client: HotcellClient, info: SandboxInfo):
|
|
460
|
+
self._client = client
|
|
461
|
+
self._info = info
|
|
462
|
+
|
|
463
|
+
@property
|
|
464
|
+
def id(self) -> str:
|
|
465
|
+
return self._info.id
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def status(self) -> str:
|
|
469
|
+
return self._info.status
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def info(self) -> SandboxInfo:
|
|
473
|
+
return self._info
|
|
474
|
+
|
|
475
|
+
# -- exec ---------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def exec(
|
|
478
|
+
self,
|
|
479
|
+
command: str,
|
|
480
|
+
*,
|
|
481
|
+
cwd: Optional[str] = None,
|
|
482
|
+
env: Optional[Dict[str, str]] = None,
|
|
483
|
+
session_id: Optional[str] = None,
|
|
484
|
+
) -> ExecResult:
|
|
485
|
+
"""Run a command to completion, returning aggregated output."""
|
|
486
|
+
stdout, stderr, exit_code = [], [], 0
|
|
487
|
+
for event in self.exec_stream(command, cwd=cwd, env=env, session_id=session_id):
|
|
488
|
+
if event.type == "stdout":
|
|
489
|
+
stdout.append(event.data or "")
|
|
490
|
+
elif event.type == "stderr":
|
|
491
|
+
stderr.append(event.data or "")
|
|
492
|
+
elif event.type == "exit":
|
|
493
|
+
exit_code = event.exit_code or 0
|
|
494
|
+
return ExecResult("".join(stdout), "".join(stderr), exit_code, exit_code == 0)
|
|
495
|
+
|
|
496
|
+
def exec_stream(
|
|
497
|
+
self,
|
|
498
|
+
command: str,
|
|
499
|
+
*,
|
|
500
|
+
cwd: Optional[str] = None,
|
|
501
|
+
env: Optional[Dict[str, str]] = None,
|
|
502
|
+
session_id: Optional[str] = None,
|
|
503
|
+
) -> Iterator[ExecEvent]:
|
|
504
|
+
"""Run a command, yielding output events as they stream in."""
|
|
505
|
+
body = {"command": command, "cwd": cwd, "env": env, "sessionId": session_id}
|
|
506
|
+
for frame in self._client.stream(
|
|
507
|
+
"POST", f"/sandboxes/{self.id}/exec", body=body
|
|
508
|
+
):
|
|
509
|
+
yield ExecEvent(
|
|
510
|
+
type=frame["type"],
|
|
511
|
+
data=frame.get("data"),
|
|
512
|
+
exit_code=frame.get("exitCode"),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# -- lifecycle ----------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
def stop(self) -> None:
|
|
518
|
+
self._info = SandboxInfo.from_dict(
|
|
519
|
+
self._client.request("POST", f"/sandboxes/{self.id}/stop")
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def start(self) -> None:
|
|
523
|
+
self._info = SandboxInfo.from_dict(
|
|
524
|
+
self._client.request("POST", f"/sandboxes/{self.id}/start")
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
def destroy(self) -> None:
|
|
528
|
+
self._client.request("DELETE", f"/sandboxes/{self.id}")
|
|
529
|
+
|
|
530
|
+
def metrics(self) -> SandboxMetrics:
|
|
531
|
+
"""Live stats, cumulative usage, and cost. Passive (won't keep alive)."""
|
|
532
|
+
return SandboxMetrics.from_dict(
|
|
533
|
+
self._client.request("GET", f"/sandboxes/{self.id}/metrics")
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def metrics_history(self) -> List[Dict[str, Any]]:
|
|
537
|
+
"""Recent live-metrics samples (oldest->newest) for sparklines/history."""
|
|
538
|
+
data = self._client.request("GET", f"/sandboxes/{self.id}/metrics/history")
|
|
539
|
+
return data.get("samples", [])
|
|
540
|
+
|
|
541
|
+
# -- egress credential proxy (LLM gateway) ------------------------------
|
|
542
|
+
|
|
543
|
+
def create_egress_token(self) -> Dict[str, Any]:
|
|
544
|
+
"""Mint an egress token; returns {token, providers}. Point the sandbox's
|
|
545
|
+
LLM SDK at a provider baseUrl and use the token in place of the real key."""
|
|
546
|
+
return self._client.request("POST", f"/sandboxes/{self.id}/egress-tokens")
|
|
547
|
+
|
|
548
|
+
def list_egress_tokens(self) -> Dict[str, Any]:
|
|
549
|
+
"""List this sandbox's egress tokens and available provider routes."""
|
|
550
|
+
return self._client.request("GET", f"/sandboxes/{self.id}/egress-tokens")
|
|
551
|
+
|
|
552
|
+
def revoke_egress_token(self, token: str) -> None:
|
|
553
|
+
"""Revoke a previously minted egress token."""
|
|
554
|
+
self._client.request("DELETE", f"/sandboxes/{self.id}/egress-tokens/{token}")
|
|
555
|
+
|
|
556
|
+
# -- files --------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
def write_file(self, path: str, content: str, *, mode: Optional[str] = None) -> None:
|
|
559
|
+
self._client.request(
|
|
560
|
+
"POST", f"/sandboxes/{self.id}/files/write",
|
|
561
|
+
{"path": path, "content": content, "mode": mode},
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def read_file(self, path: str) -> str:
|
|
565
|
+
return self._client.request(
|
|
566
|
+
"POST", f"/sandboxes/{self.id}/files/read", {"path": path}
|
|
567
|
+
)["content"]
|
|
568
|
+
|
|
569
|
+
def mkdir(self, path: str, *, parents: bool = False) -> None:
|
|
570
|
+
self._client.request(
|
|
571
|
+
"POST", f"/sandboxes/{self.id}/files/mkdir",
|
|
572
|
+
{"path": path, "parents": parents},
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def list_files(self, path: str) -> List[FileInfo]:
|
|
576
|
+
data = self._client.request(
|
|
577
|
+
"POST", f"/sandboxes/{self.id}/files/list", {"path": path}
|
|
578
|
+
)
|
|
579
|
+
return [FileInfo.from_dict(e) for e in data.get("entries", [])]
|
|
580
|
+
|
|
581
|
+
def watch(
|
|
582
|
+
self, path: str = "/workspace", *, interval_ms: Optional[int] = None
|
|
583
|
+
) -> Iterator[FileChangeEvent]:
|
|
584
|
+
"""Watch a path recursively, yielding change events until closed."""
|
|
585
|
+
params = {"path": path}
|
|
586
|
+
if interval_ms:
|
|
587
|
+
params["interval"] = str(interval_ms)
|
|
588
|
+
for frame in self._client.stream(
|
|
589
|
+
"GET", f"/sandboxes/{self.id}/watch", params=params
|
|
590
|
+
):
|
|
591
|
+
yield FileChangeEvent(type=frame["type"], path=frame["path"])
|
|
592
|
+
|
|
593
|
+
# -- processes ----------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
def start_process(
|
|
596
|
+
self,
|
|
597
|
+
command: str,
|
|
598
|
+
*,
|
|
599
|
+
cwd: Optional[str] = None,
|
|
600
|
+
env: Optional[Dict[str, str]] = None,
|
|
601
|
+
) -> ProcessHandle:
|
|
602
|
+
return ProcessHandle.from_dict(
|
|
603
|
+
self._client.request(
|
|
604
|
+
"POST", f"/sandboxes/{self.id}/processes",
|
|
605
|
+
{"command": command, "cwd": cwd, "env": env},
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
def list_processes(self) -> List[ProcessHandle]:
|
|
610
|
+
data = self._client.request("GET", f"/sandboxes/{self.id}/processes")
|
|
611
|
+
return [ProcessHandle.from_dict(p) for p in data.get("processes", [])]
|
|
612
|
+
|
|
613
|
+
def kill_process(self, proc_id: str, signal: Optional[str] = None) -> None:
|
|
614
|
+
self._client.request(
|
|
615
|
+
"DELETE", f"/sandboxes/{self.id}/processes/{proc_id}",
|
|
616
|
+
{"signal": signal} if signal else None,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
def stream_logs(self, proc_id: str, *, follow: bool = False) -> Iterator[str]:
|
|
620
|
+
params = {"follow": "1" if follow else "0"}
|
|
621
|
+
for frame in self._client.stream(
|
|
622
|
+
"GET", f"/sandboxes/{self.id}/processes/{proc_id}/logs", params=params
|
|
623
|
+
):
|
|
624
|
+
if frame.get("type") == "log":
|
|
625
|
+
yield frame.get("data", "")
|
|
626
|
+
elif frame.get("type") == "end":
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
def wait_for_port(
|
|
630
|
+
self,
|
|
631
|
+
port: int,
|
|
632
|
+
*,
|
|
633
|
+
timeout_ms: Optional[int] = None,
|
|
634
|
+
interval_ms: Optional[int] = None,
|
|
635
|
+
host: Optional[str] = None,
|
|
636
|
+
) -> bool:
|
|
637
|
+
body: Dict[str, Any] = {"port": port}
|
|
638
|
+
if timeout_ms is not None:
|
|
639
|
+
body["timeoutMs"] = timeout_ms
|
|
640
|
+
if interval_ms is not None:
|
|
641
|
+
body["intervalMs"] = interval_ms
|
|
642
|
+
if host is not None:
|
|
643
|
+
body["host"] = host
|
|
644
|
+
return self._client.request(
|
|
645
|
+
"POST", f"/sandboxes/{self.id}/wait-port", body
|
|
646
|
+
)["ready"]
|
|
647
|
+
|
|
648
|
+
# -- ports --------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
def expose_port(self, port: int, *, token: Optional[str] = None) -> ExposedPort:
|
|
651
|
+
return ExposedPort.from_dict(
|
|
652
|
+
self._client.request(
|
|
653
|
+
"POST", f"/sandboxes/{self.id}/expose", {"port": port, "token": token}
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
def unexpose_port(self, port: int) -> None:
|
|
658
|
+
self._client.request("DELETE", f"/sandboxes/{self.id}/expose/{port}")
|
|
659
|
+
|
|
660
|
+
def list_exposed_ports(self) -> List[ExposedPort]:
|
|
661
|
+
data = self._client.request("GET", f"/sandboxes/{self.id}/expose")
|
|
662
|
+
return [ExposedPort.from_dict(p) for p in data.get("exposed", [])]
|
|
663
|
+
|
|
664
|
+
# -- env + sessions -----------------------------------------------------
|
|
665
|
+
|
|
666
|
+
def set_env_vars(self, env: Dict[str, str]) -> Dict[str, str]:
|
|
667
|
+
return self._client.request(
|
|
668
|
+
"POST", f"/sandboxes/{self.id}/env", {"env": env}
|
|
669
|
+
)["env"]
|
|
670
|
+
|
|
671
|
+
def get_env_vars(self) -> Dict[str, str]:
|
|
672
|
+
return self._client.request("GET", f"/sandboxes/{self.id}/env")["env"]
|
|
673
|
+
|
|
674
|
+
def create_session(
|
|
675
|
+
self,
|
|
676
|
+
*,
|
|
677
|
+
id: Optional[str] = None,
|
|
678
|
+
cwd: Optional[str] = None,
|
|
679
|
+
env: Optional[Dict[str, str]] = None,
|
|
680
|
+
) -> "Session":
|
|
681
|
+
info = self._client.request(
|
|
682
|
+
"POST", f"/sandboxes/{self.id}/sessions",
|
|
683
|
+
{"id": id, "cwd": cwd, "env": env},
|
|
684
|
+
)
|
|
685
|
+
return Session(self, SessionInfo.from_dict(info))
|
|
686
|
+
|
|
687
|
+
def list_sessions(self) -> List[SessionInfo]:
|
|
688
|
+
data = self._client.request("GET", f"/sandboxes/{self.id}/sessions")
|
|
689
|
+
return [SessionInfo.from_dict(s) for s in data.get("sessions", [])]
|
|
690
|
+
|
|
691
|
+
# -- backups ------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
def create_backup(self) -> BackupInfo:
|
|
694
|
+
return BackupInfo.from_dict(
|
|
695
|
+
self._client.request("POST", f"/sandboxes/{self.id}/backups")
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
def restore_backup(self, backup_id: str) -> None:
|
|
699
|
+
self._client.request(
|
|
700
|
+
"POST", f"/sandboxes/{self.id}/restore", {"backupId": backup_id}
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
def list_backups(self) -> List[BackupInfo]:
|
|
704
|
+
data = self._client.request("GET", f"/sandboxes/{self.id}/backups")
|
|
705
|
+
return [BackupInfo.from_dict(b) for b in data.get("backups", [])]
|
|
706
|
+
|
|
707
|
+
# -- code interpreter ---------------------------------------------------
|
|
708
|
+
|
|
709
|
+
def create_code_context(self, *, language: str = "python") -> "CodeContext":
|
|
710
|
+
info = self._client.request(
|
|
711
|
+
"POST", f"/sandboxes/{self.id}/code-contexts", {"language": language}
|
|
712
|
+
)
|
|
713
|
+
return CodeContext(self, info["contextId"], info.get("language", language))
|
|
714
|
+
|
|
715
|
+
def list_code_contexts(self) -> List[Dict[str, Any]]:
|
|
716
|
+
return self._client.request(
|
|
717
|
+
"GET", f"/sandboxes/{self.id}/code-contexts"
|
|
718
|
+
).get("contexts", [])
|
|
719
|
+
|
|
720
|
+
def run_code(
|
|
721
|
+
self,
|
|
722
|
+
code: str,
|
|
723
|
+
*,
|
|
724
|
+
context: Optional["CodeContext"] = None,
|
|
725
|
+
language: Optional[str] = None,
|
|
726
|
+
timeout_ms: Optional[int] = None,
|
|
727
|
+
) -> CodeResult:
|
|
728
|
+
return CodeResult.from_dict(
|
|
729
|
+
self._client.request(
|
|
730
|
+
"POST", f"/sandboxes/{self.id}/run-code",
|
|
731
|
+
{
|
|
732
|
+
"code": code,
|
|
733
|
+
"contextId": context.context_id if context else None,
|
|
734
|
+
"language": language,
|
|
735
|
+
"timeoutMs": timeout_ms,
|
|
736
|
+
},
|
|
737
|
+
)
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class Session:
|
|
742
|
+
"""A persistent cwd + env overlay; `cd` persists across commands."""
|
|
743
|
+
|
|
744
|
+
def __init__(self, sandbox: Sandbox, info: SessionInfo):
|
|
745
|
+
self._sandbox = sandbox
|
|
746
|
+
self._info = info
|
|
747
|
+
|
|
748
|
+
@property
|
|
749
|
+
def session_id(self) -> str:
|
|
750
|
+
return self._info.session_id
|
|
751
|
+
|
|
752
|
+
def exec(self, command: str, **kwargs: Any) -> ExecResult:
|
|
753
|
+
return self._sandbox.exec(command, session_id=self.session_id, **kwargs)
|
|
754
|
+
|
|
755
|
+
def exec_stream(self, command: str, **kwargs: Any) -> Iterator[ExecEvent]:
|
|
756
|
+
return self._sandbox.exec_stream(command, session_id=self.session_id, **kwargs)
|
|
757
|
+
|
|
758
|
+
def set_env_vars(self, env: Dict[str, str]) -> SessionInfo:
|
|
759
|
+
self._info = SessionInfo.from_dict(
|
|
760
|
+
self._sandbox._client.request(
|
|
761
|
+
"POST",
|
|
762
|
+
f"/sandboxes/{self._sandbox.id}/sessions/{self.session_id}/env",
|
|
763
|
+
{"env": env},
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
return self._info
|
|
767
|
+
|
|
768
|
+
def destroy(self) -> None:
|
|
769
|
+
self._sandbox._client.request(
|
|
770
|
+
"DELETE", f"/sandboxes/{self._sandbox.id}/sessions/{self.session_id}"
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
class CodeContext:
|
|
775
|
+
"""A long-lived interpreter kernel; variables/imports persist across runs."""
|
|
776
|
+
|
|
777
|
+
def __init__(self, sandbox: Sandbox, context_id: str, language: str):
|
|
778
|
+
self._sandbox = sandbox
|
|
779
|
+
self.context_id = context_id
|
|
780
|
+
self.language = language
|
|
781
|
+
|
|
782
|
+
def run_code(self, code: str, *, timeout_ms: Optional[int] = None) -> CodeResult:
|
|
783
|
+
return self._sandbox.run_code(code, context=self, timeout_ms=timeout_ms)
|
|
784
|
+
|
|
785
|
+
def destroy(self) -> None:
|
|
786
|
+
self._sandbox._client.request(
|
|
787
|
+
"DELETE",
|
|
788
|
+
f"/sandboxes/{self._sandbox.id}/code-contexts/{self.context_id}",
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def get_sandbox(
|
|
793
|
+
client: HotcellClient, id: Optional[str] = None, **options: Any
|
|
794
|
+
) -> Sandbox:
|
|
795
|
+
"""Convenience matching the Cloudflare `getSandbox(binding, id)` shape."""
|
|
796
|
+
return client.get_sandbox(id, **options)
|
hotcell/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotcell
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python client for the hotcell self-hostable agent sandbox daemon
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://hotcell.sh
|
|
7
|
+
Project-URL: Repository, https://github.com/sinameraji/hotcell
|
|
8
|
+
Keywords: sandbox,ai-agents,hotcell
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# hotcell (Python SDK)
|
|
13
|
+
|
|
14
|
+
Dependency-free Python client for the [hotcell](https://github.com/sinameraji/hotcell)
|
|
15
|
+
self-hostable agent sandbox daemon. Mirrors the TypeScript SDK surface (snake_cased)
|
|
16
|
+
so the same workflows work from either language.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from hotcell import HotcellClient
|
|
20
|
+
|
|
21
|
+
client = HotcellClient(endpoint="http://127.0.0.1:4750") # or $HOTCELL_ENDPOINT
|
|
22
|
+
sandbox = client.get_sandbox() # create a fresh sandbox
|
|
23
|
+
|
|
24
|
+
# run a command
|
|
25
|
+
result = sandbox.exec("python3 -c 'print(2 + 2)'")
|
|
26
|
+
print(result.stdout, result.exit_code) # "4\n" 0
|
|
27
|
+
|
|
28
|
+
# files
|
|
29
|
+
sandbox.write_file("/workspace/hi.txt", "hello")
|
|
30
|
+
print(sandbox.read_file("/workspace/hi.txt")) # "hello"
|
|
31
|
+
|
|
32
|
+
# stateful code interpreter
|
|
33
|
+
ctx = sandbox.create_code_context(language="python")
|
|
34
|
+
ctx.run_code("x = 21")
|
|
35
|
+
print(ctx.run_code("x * 2").results[0].text) # "42"
|
|
36
|
+
|
|
37
|
+
# metrics + cost
|
|
38
|
+
m = sandbox.metrics()
|
|
39
|
+
print(m.cost.total, m.usage.cpu_seconds)
|
|
40
|
+
|
|
41
|
+
sandbox.destroy()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Standard library only (`urllib`, `json`) — no third-party dependencies. Requires
|
|
45
|
+
Python ≥ 3.9 and a running hotcell daemon.
|
|
46
|
+
|
|
47
|
+
## Surface
|
|
48
|
+
|
|
49
|
+
`HotcellClient`: `get_sandbox`, `list`, `health`, `info`, `list_backups`, `delete_backup`.
|
|
50
|
+
|
|
51
|
+
`Sandbox`: `exec`, `exec_stream`, `stop`, `start`, `destroy`, `metrics`,
|
|
52
|
+
`write_file`, `read_file`, `mkdir`, `list_files`, `watch`, `start_process`,
|
|
53
|
+
`list_processes`, `kill_process`, `stream_logs`, `wait_for_port`, `expose_port`,
|
|
54
|
+
`unexpose_port`, `list_exposed_ports`, `set_env_vars`, `get_env_vars`,
|
|
55
|
+
`create_session`, `list_sessions`, `create_backup`, `restore_backup`,
|
|
56
|
+
`list_backups`, `create_code_context`, `list_code_contexts`, `run_code`.
|
|
57
|
+
|
|
58
|
+
`Session`: `exec`, `exec_stream`, `set_env_vars`, `destroy`.
|
|
59
|
+
`CodeContext`: `run_code`, `destroy`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
hotcell/__init__.py,sha256=wR3rYGsr20fdkxOjL0shb_s6pkEw3KA1jNxYj9EjoHw,25626
|
|
2
|
+
hotcell/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
hotcell-0.1.1.dist-info/METADATA,sha256=G541yr2YBTpDHJZHxn_AltznUVWcTgrsoOp2eqqPcEw,2111
|
|
4
|
+
hotcell-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
5
|
+
hotcell-0.1.1.dist-info/top_level.txt,sha256=BbRQ6qC_ZsR8YIwv0s2GT6JK-9tiOkmo5qEHPUpKqVc,8
|
|
6
|
+
hotcell-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hotcell
|