sandrpod-cli 0.2.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.
- sandrpod_cli/README.md +41 -0
- sandrpod_cli/__init__.py +2 -0
- sandrpod_cli/__main__.py +7 -0
- sandrpod_cli/client.py +572 -0
- sandrpod_cli/main.py +1238 -0
- sandrpod_cli/py.typed +0 -0
- sandrpod_cli-0.2.1.dist-info/METADATA +69 -0
- sandrpod_cli-0.2.1.dist-info/RECORD +11 -0
- sandrpod_cli-0.2.1.dist-info/WHEEL +5 -0
- sandrpod_cli-0.2.1.dist-info/entry_points.txt +2 -0
- sandrpod_cli-0.2.1.dist-info/top_level.txt +1 -0
sandrpod_cli/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# sandrpod-cli
|
|
2
|
+
|
|
3
|
+
Command-line client for [SandrPod](https://github.com/sandrpod/sandrpod) — open-source, **self-hosted execution infrastructure (sandboxes) for AI agents**. Run sandboxes on your own substrate (8 clouds incl. Aliyun/Tencent, plain Docker, or a bare machine), drive them over the native REST API, and stay E2B-compatible.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install sandrpod-cli
|
|
9
|
+
pip install "sandrpod-cli[shell]" # + interactive PTY shell (websocket-client)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Configure
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
export SANDRPOD_API_URL=http://your-server:8080
|
|
16
|
+
export SANDRPOD_API_TOKEN=your-token # only if the server runs with auth
|
|
17
|
+
# or pass per-command: sandrpod-cli --api-url http://your-server:8080 <cmd>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quickstart
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
sandrpod-cli create mybox --provider local # create a sandbox
|
|
24
|
+
sandrpod-cli execute mybox "echo hi; python3 -V" # one-shot exec
|
|
25
|
+
sandrpod-cli run mybox "import numpy; print(1)" # stateful code interpreter (Jupyter-style)
|
|
26
|
+
sandrpod-cli fs write mybox /workspace/a.txt hello # filesystem ops
|
|
27
|
+
sandrpod-cli fs ls mybox
|
|
28
|
+
sandrpod-cli stream mybox "for i in 1 2 3; do echo \$i; sleep 1; done" # real-time output
|
|
29
|
+
sandrpod-cli shell mybox # interactive PTY (needs the [shell] extra)
|
|
30
|
+
sandrpod-cli preview mybox 8000 # proxy a web port inside the sandbox
|
|
31
|
+
sandrpod-cli stats mybox # CPU / memory / disk
|
|
32
|
+
sandrpod-cli delete mybox
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Charts**: `sandrpod-cli run` captures matplotlib figures, saving the rendered PNG plus structured chart data (E2B-style).
|
|
36
|
+
|
|
37
|
+
Full command list (cloud providers, snapshots, tokens, contexts, directory watch, …) is in the [SandrPod repository](https://github.com/sandrpod/sandrpod).
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
sandrpod_cli/__init__.py
ADDED
sandrpod_cli/__main__.py
ADDED
sandrpod_cli/client.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# Copyright 2024 SandrPod
|
|
2
|
+
# CLI Client
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import requests
|
|
7
|
+
from typing import Optional, Dict, Any, List, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CLIClient:
|
|
11
|
+
"""SandrPod CLI 客户端"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
api_url: str = "http://localhost:8080",
|
|
16
|
+
timeout: int = 30,
|
|
17
|
+
token: Optional[str] = None,
|
|
18
|
+
):
|
|
19
|
+
self.api_url = api_url.rstrip("/")
|
|
20
|
+
self.timeout = timeout
|
|
21
|
+
self.session = requests.Session()
|
|
22
|
+
# Only set Authorization in session headers; Content-Type is set per-request
|
|
23
|
+
# so that multipart/form-data uploads can override it automatically.
|
|
24
|
+
if token:
|
|
25
|
+
self.session.headers["Authorization"] = f"Bearer {token}"
|
|
26
|
+
|
|
27
|
+
def _request(self, method: str, path: str, timeout: int = None, **kwargs) -> requests.Response:
|
|
28
|
+
"""发送 HTTP 请求"""
|
|
29
|
+
url = f"{self.api_url}{path}"
|
|
30
|
+
if timeout is None:
|
|
31
|
+
timeout = self.timeout
|
|
32
|
+
# Set Content-Type: application/json for non-file requests
|
|
33
|
+
if "files" not in kwargs and "data" not in kwargs:
|
|
34
|
+
kwargs.setdefault("headers", {})["Content-Type"] = "application/json"
|
|
35
|
+
resp = self.session.request(method, url, timeout=timeout, **kwargs)
|
|
36
|
+
if resp.status_code >= 400:
|
|
37
|
+
try:
|
|
38
|
+
error_body = resp.json().get("message", resp.text)
|
|
39
|
+
except:
|
|
40
|
+
error_body = resp.text
|
|
41
|
+
status_names = {
|
|
42
|
+
400: "Bad Request",
|
|
43
|
+
401: "Unauthorized",
|
|
44
|
+
403: "Forbidden",
|
|
45
|
+
404: "Not Found",
|
|
46
|
+
}
|
|
47
|
+
status_name = status_names.get(resp.status_code, "Error")
|
|
48
|
+
raise requests.HTTPError(
|
|
49
|
+
f"{status_name} (HTTP {resp.status_code}): {error_body}",
|
|
50
|
+
response=resp
|
|
51
|
+
)
|
|
52
|
+
return resp
|
|
53
|
+
|
|
54
|
+
def _handle_error(self, resp: requests.Response) -> None:
|
|
55
|
+
"""处理 HTTP 错误响应"""
|
|
56
|
+
if resp.status_code >= 400:
|
|
57
|
+
try:
|
|
58
|
+
error_body = resp.json().get("message", resp.text)
|
|
59
|
+
except:
|
|
60
|
+
error_body = resp.text
|
|
61
|
+
status_names = {
|
|
62
|
+
400: "Bad Request",
|
|
63
|
+
401: "Unauthorized",
|
|
64
|
+
403: "Forbidden",
|
|
65
|
+
404: "Not Found",
|
|
66
|
+
}
|
|
67
|
+
status_name = status_names.get(resp.status_code, "Error")
|
|
68
|
+
raise requests.HTTPError(
|
|
69
|
+
f"{status_name} (HTTP {resp.status_code}): {error_body}",
|
|
70
|
+
response=resp
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def health(self) -> Dict[str, Any]:
|
|
74
|
+
"""检查服务健康状态"""
|
|
75
|
+
resp = self._request("GET", "/health")
|
|
76
|
+
return resp.json()
|
|
77
|
+
|
|
78
|
+
# ========== Sandbox 操作 ==========
|
|
79
|
+
|
|
80
|
+
def list_sandboxes(self) -> List[Dict[str, Any]]:
|
|
81
|
+
"""列出所有 Sandbox"""
|
|
82
|
+
resp = self._request("GET", "/api/v1/sandboxes")
|
|
83
|
+
return resp.json().get("sandboxes", [])
|
|
84
|
+
|
|
85
|
+
def get_sandbox(self, name: str) -> Dict[str, Any]:
|
|
86
|
+
"""获取 Sandbox 信息"""
|
|
87
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}")
|
|
88
|
+
return resp.json()
|
|
89
|
+
|
|
90
|
+
def create_sandbox(
|
|
91
|
+
self,
|
|
92
|
+
name: str,
|
|
93
|
+
region: str = "local",
|
|
94
|
+
provider_type: str = "local",
|
|
95
|
+
instance_type: str = "",
|
|
96
|
+
image: str = "",
|
|
97
|
+
async_: bool = False,
|
|
98
|
+
ttl_seconds: int = 0,
|
|
99
|
+
cpu_cores: float = 0,
|
|
100
|
+
memory_mb: int = 0,
|
|
101
|
+
) -> Dict[str, Any]:
|
|
102
|
+
"""
|
|
103
|
+
创建 Sandbox
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
name: 名称
|
|
107
|
+
region: 区域
|
|
108
|
+
provider_type: Provider 类型 (local, aws, aliyun, azure, gcp)
|
|
109
|
+
instance_type: 实例类型 (可选)
|
|
110
|
+
image: 容器镜像 ID (可选,空则使用 Poder 默认镜像)
|
|
111
|
+
async_: True 时 server 立即返回 job,后台完成开通
|
|
112
|
+
(轮询 get_job/get_sandbox 跟进;旧版 server 会忽略该字段并同步阻塞)
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
{job_id, status, sandbox} 字典
|
|
116
|
+
"""
|
|
117
|
+
data: Dict[str, Any] = {
|
|
118
|
+
"name": name,
|
|
119
|
+
"region": region,
|
|
120
|
+
"provider_type": provider_type,
|
|
121
|
+
}
|
|
122
|
+
if instance_type:
|
|
123
|
+
data["instance_type"] = instance_type
|
|
124
|
+
if image:
|
|
125
|
+
data["image_id"] = image
|
|
126
|
+
if async_:
|
|
127
|
+
data["async"] = True
|
|
128
|
+
if ttl_seconds:
|
|
129
|
+
data["ttl_seconds"] = int(ttl_seconds)
|
|
130
|
+
if cpu_cores:
|
|
131
|
+
data["cpu_cores"] = float(cpu_cores)
|
|
132
|
+
if memory_mb:
|
|
133
|
+
data["memory_mb"] = int(memory_mb)
|
|
134
|
+
resp = self._request("POST", "/api/v1/sandboxes", json=data)
|
|
135
|
+
return resp.json()
|
|
136
|
+
|
|
137
|
+
def preview(self, name: str, port: int, path: str = "/") -> bytes:
|
|
138
|
+
"""访问沙箱内 localhost:{port} 上的 web 服务(经隧道代理)"""
|
|
139
|
+
if not path.startswith("/"):
|
|
140
|
+
path = "/" + path
|
|
141
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/proxy/{port}{path}")
|
|
142
|
+
return resp.content
|
|
143
|
+
|
|
144
|
+
def snapshot(self, name: str, image: str = "") -> Dict[str, Any]:
|
|
145
|
+
"""把沙箱当前状态提交为镜像(docker commit),返回 {image, sandbox}"""
|
|
146
|
+
path = f"/api/v1/sandboxes/{name}/snapshot"
|
|
147
|
+
if image:
|
|
148
|
+
path += "?image=" + urllib.parse.quote(image, safe="")
|
|
149
|
+
resp = self._request("POST", path)
|
|
150
|
+
return resp.json()
|
|
151
|
+
|
|
152
|
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
153
|
+
"""查询 Job 状态(async create 的进度/错误)"""
|
|
154
|
+
resp = self._request("GET", f"/api/v1/jobs/{job_id}")
|
|
155
|
+
return resp.json()
|
|
156
|
+
|
|
157
|
+
def metrics(self) -> str:
|
|
158
|
+
"""获取 Prometheus 文本格式的 /metrics(需要 admin token)"""
|
|
159
|
+
resp = self._request("GET", "/metrics")
|
|
160
|
+
return resp.text
|
|
161
|
+
|
|
162
|
+
def pty_url(self, name: str) -> str:
|
|
163
|
+
"""交互式 PTY 的 WebSocket URL(http→ws, https→wss)"""
|
|
164
|
+
base = self.api_url
|
|
165
|
+
if base.startswith("https://"):
|
|
166
|
+
base = "wss://" + base[len("https://"):]
|
|
167
|
+
elif base.startswith("http://"):
|
|
168
|
+
base = "ws://" + base[len("http://"):]
|
|
169
|
+
return f"{base}/api/v1/sandboxes/{name}/pty"
|
|
170
|
+
|
|
171
|
+
def auth_header(self) -> Optional[str]:
|
|
172
|
+
"""当前会话的 Authorization 头值(供 WebSocket 复用)"""
|
|
173
|
+
return self.session.headers.get("Authorization")
|
|
174
|
+
|
|
175
|
+
def delete_sandbox(self, name: str) -> None:
|
|
176
|
+
"""删除 Sandbox(同时清理容器,tunnel 不可用时也会删除记录)"""
|
|
177
|
+
self._request("DELETE", f"/api/v1/sandboxes/{name}")
|
|
178
|
+
|
|
179
|
+
def start_sandbox(self, name: str) -> Dict[str, Any]:
|
|
180
|
+
"""启动 Sandbox"""
|
|
181
|
+
resp = self._request("POST", f"/api/v1/sandboxes/{name}/start")
|
|
182
|
+
return resp.json()
|
|
183
|
+
|
|
184
|
+
def stop_sandbox(self, name: str) -> Dict[str, Any]:
|
|
185
|
+
"""停止 Sandbox"""
|
|
186
|
+
resp = self._request("POST", f"/api/v1/sandboxes/{name}/stop")
|
|
187
|
+
return resp.json()
|
|
188
|
+
|
|
189
|
+
def get_sandbox_logs(self, name: str, tail: str = "100") -> str:
|
|
190
|
+
"""获取 Sandbox 日志"""
|
|
191
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/logs?tail={tail}")
|
|
192
|
+
return resp.text
|
|
193
|
+
|
|
194
|
+
# ========== 代码执行 ==========
|
|
195
|
+
|
|
196
|
+
def execute(
|
|
197
|
+
self,
|
|
198
|
+
name: str,
|
|
199
|
+
command: str,
|
|
200
|
+
timeout: int = 30
|
|
201
|
+
) -> Dict[str, Any]:
|
|
202
|
+
"""
|
|
203
|
+
在指定 Sandbox 中执行 shell 命令
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
name: Sandbox 名称
|
|
207
|
+
command: shell 命令
|
|
208
|
+
timeout: 超时时间(秒)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
ExecuteResponse (output, exit_code, truncated)
|
|
212
|
+
"""
|
|
213
|
+
return self.execute_code(name, "bash", command, timeout)
|
|
214
|
+
|
|
215
|
+
def execute_code(
|
|
216
|
+
self,
|
|
217
|
+
name: str,
|
|
218
|
+
language: str,
|
|
219
|
+
code: str,
|
|
220
|
+
timeout: int = 30
|
|
221
|
+
) -> Dict[str, Any]:
|
|
222
|
+
"""
|
|
223
|
+
在指定 Sandbox 中执行代码
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
name: Sandbox 名称
|
|
227
|
+
language: 语言 (python, node, bash)
|
|
228
|
+
code: 代码
|
|
229
|
+
timeout: 超时时间(秒)
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
执行结果
|
|
233
|
+
"""
|
|
234
|
+
data = {
|
|
235
|
+
"language": language,
|
|
236
|
+
"code": code,
|
|
237
|
+
"timeout": timeout,
|
|
238
|
+
}
|
|
239
|
+
resp = self._request("POST", f"/api/v1/sandboxes/execute?sandbox={name}", json=data, timeout=timeout)
|
|
240
|
+
return resp.json()
|
|
241
|
+
|
|
242
|
+
def stream_execute(
|
|
243
|
+
self,
|
|
244
|
+
name: str,
|
|
245
|
+
language: str,
|
|
246
|
+
code: str,
|
|
247
|
+
timeout: int = 30,
|
|
248
|
+
):
|
|
249
|
+
"""
|
|
250
|
+
流式执行代码,逐个 yield 解析后的 SSE 事件。
|
|
251
|
+
|
|
252
|
+
server 的 /api/v1/sandboxes/stream 通过 query 参数接收 language/code/timeout,
|
|
253
|
+
并以 SSE 回传(`event: <type>\\ndata: <data>\\n\\n`,type ∈ stdout/stderr/
|
|
254
|
+
error/exit)。这里按行解析,屏蔽 SSE 协议噪声。
|
|
255
|
+
|
|
256
|
+
Yields:
|
|
257
|
+
dict: {"event": stdout|stderr|error|exit, "data": str}
|
|
258
|
+
"""
|
|
259
|
+
params = {
|
|
260
|
+
"sandbox": name,
|
|
261
|
+
"language": language,
|
|
262
|
+
"code": code,
|
|
263
|
+
"timeout": str(timeout),
|
|
264
|
+
}
|
|
265
|
+
url = f"{self.api_url}/api/v1/sandboxes/stream"
|
|
266
|
+
with self.session.get(url, params=params, stream=True, timeout=timeout) as resp:
|
|
267
|
+
self._handle_error(resp)
|
|
268
|
+
yield from self._iter_sse_events(resp.iter_lines(decode_unicode=True))
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _iter_sse_events(lines):
|
|
272
|
+
"""
|
|
273
|
+
把 SSE 文本行流解析成 {"event", "data"} 事件。
|
|
274
|
+
|
|
275
|
+
Toolbox 的格式为 `event: <type>\\ndata: <data>\\n\\n`;多行输出块的
|
|
276
|
+
续行没有 `data:` 前缀,按当前 event 的续行处理。
|
|
277
|
+
"""
|
|
278
|
+
event = None
|
|
279
|
+
for line in lines:
|
|
280
|
+
if line is None or line == "":
|
|
281
|
+
event = None # blank line ends the current SSE event
|
|
282
|
+
continue
|
|
283
|
+
if line.startswith("event:"):
|
|
284
|
+
event = line[len("event:"):].strip()
|
|
285
|
+
elif line.startswith("data:"):
|
|
286
|
+
data = line[len("data:"):]
|
|
287
|
+
if data.startswith(" "):
|
|
288
|
+
data = data[1:]
|
|
289
|
+
yield {"event": event or "stdout", "data": data}
|
|
290
|
+
else:
|
|
291
|
+
# Continuation line of a multi-line data block.
|
|
292
|
+
yield {"event": event or "stdout", "data": line}
|
|
293
|
+
|
|
294
|
+
# ========== 文件操作 ==========
|
|
295
|
+
|
|
296
|
+
def list_files(self, name: str, path: str = "") -> Dict[str, Any]:
|
|
297
|
+
"""列出目录文件"""
|
|
298
|
+
# path 为空时不传参,让 server 使用项目目录
|
|
299
|
+
params = None if not path else {"path": path}
|
|
300
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/files", params=params)
|
|
301
|
+
return resp.json()
|
|
302
|
+
|
|
303
|
+
def read_file(self, name: str, path: str) -> bytes:
|
|
304
|
+
"""读取文件内容"""
|
|
305
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/files/download", params={"path": path})
|
|
306
|
+
return resp.content
|
|
307
|
+
|
|
308
|
+
def write_file(self, name: str, path: str, content: str) -> Dict[str, Any]:
|
|
309
|
+
"""写入文件 (通过 upload)"""
|
|
310
|
+
files = {"file": (path, content.encode(), "application/octet-stream")}
|
|
311
|
+
encoded_path = urllib.parse.quote(path, safe="")
|
|
312
|
+
resp = self.session.post(
|
|
313
|
+
f"{self.api_url}/api/v1/sandboxes/{name}/toolbox/files/upload?path={encoded_path}",
|
|
314
|
+
files=files,
|
|
315
|
+
timeout=self.timeout
|
|
316
|
+
)
|
|
317
|
+
self._handle_error(resp)
|
|
318
|
+
return resp.json()
|
|
319
|
+
|
|
320
|
+
def upload_files(self, name: str, files: List[Tuple[str, bytes]], path: str = "/") -> Dict[str, Any]:
|
|
321
|
+
"""上传文件列表到指定目录"""
|
|
322
|
+
file_dict = {}
|
|
323
|
+
for fname, fcontent in files:
|
|
324
|
+
file_dict[fname] = (fname, io.BytesIO(fcontent), "application/octet-stream")
|
|
325
|
+
encoded_path = urllib.parse.quote(path, safe="")
|
|
326
|
+
resp = self.session.post(
|
|
327
|
+
f"{self.api_url}/api/v1/sandboxes/{name}/toolbox/files/bulk-upload?path={encoded_path}",
|
|
328
|
+
files=file_dict,
|
|
329
|
+
timeout=self.timeout
|
|
330
|
+
)
|
|
331
|
+
self._handle_error(resp)
|
|
332
|
+
return resp.json()
|
|
333
|
+
|
|
334
|
+
def download_files(self, name: str, paths: List[str]) -> List[bytes]:
|
|
335
|
+
"""下载文件列表"""
|
|
336
|
+
results = []
|
|
337
|
+
for path in paths:
|
|
338
|
+
results.append(self.read_file(name, path))
|
|
339
|
+
return results
|
|
340
|
+
|
|
341
|
+
def create_folder(self, name: str, path: str) -> Dict[str, Any]:
|
|
342
|
+
"""创建目录"""
|
|
343
|
+
resp = self._request("POST", f"/api/v1/sandboxes/{name}/toolbox/files/folder", params={"path": path})
|
|
344
|
+
return resp.json()
|
|
345
|
+
|
|
346
|
+
def move_file(self, name: str, source: str, destination: str) -> Dict[str, Any]:
|
|
347
|
+
"""移动/重命名文件"""
|
|
348
|
+
resp = self._request("POST", f"/api/v1/sandboxes/{name}/toolbox/files/move", params={"source": source, "destination": destination})
|
|
349
|
+
return resp.json()
|
|
350
|
+
|
|
351
|
+
def search_files(self, name: str, path: str = "", pattern: str = "*") -> List[str]:
|
|
352
|
+
"""搜索文件 (glob)"""
|
|
353
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/files/search", params={"path": path, "pattern": pattern})
|
|
354
|
+
return resp.json()
|
|
355
|
+
|
|
356
|
+
def find_in_files(self, name: str, path: str = "", pattern: str = "") -> List[Dict[str, Any]]:
|
|
357
|
+
"""在文件中搜索内容"""
|
|
358
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/files/find", params={"path": path, "pattern": pattern})
|
|
359
|
+
return resp.json()
|
|
360
|
+
|
|
361
|
+
def replace_in_files(self, name: str, files: List[str], pattern: str, new_value: str) -> Dict[str, Any]:
|
|
362
|
+
"""替换文件中的文本"""
|
|
363
|
+
payload = {"files": files, "pattern": pattern, "newValue": new_value}
|
|
364
|
+
resp = self._request("POST", f"/api/v1/sandboxes/{name}/toolbox/files/replace", json=payload)
|
|
365
|
+
return resp.json()
|
|
366
|
+
|
|
367
|
+
def get_file_info(self, name: str, path: str) -> Dict[str, Any]:
|
|
368
|
+
"""获取文件信息"""
|
|
369
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/files/info", params={"path": path})
|
|
370
|
+
return resp.json()
|
|
371
|
+
|
|
372
|
+
# ---- per-sandbox resource stats (toolbox /metrics) ----
|
|
373
|
+
def get_sandbox_stats(self, name: str) -> Dict[str, Any]:
|
|
374
|
+
"""单个沙箱的 CPU/内存/磁盘用量(区别于服务端 Prometheus metrics)。"""
|
|
375
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/metrics")
|
|
376
|
+
return resp.json()
|
|
377
|
+
|
|
378
|
+
# ---- stateful code interpreter (toolbox /code-interpreter/*) ----
|
|
379
|
+
def run_code(self, name: str, code: str, context_id: str = "") -> Dict[str, Any]:
|
|
380
|
+
"""在有状态内核里执行代码;同一 context 内变量跨调用保留。返回 {stdout,stderr,text,error}。"""
|
|
381
|
+
body: Dict[str, Any] = {"code": code}
|
|
382
|
+
if context_id:
|
|
383
|
+
body["context_id"] = context_id
|
|
384
|
+
resp = self._request(
|
|
385
|
+
"POST", f"/api/v1/sandboxes/{name}/toolbox/code-interpreter/execute", json=body)
|
|
386
|
+
return resp.json()
|
|
387
|
+
|
|
388
|
+
def create_code_context(self, name: str, language: str = "python", cwd: str = "") -> Dict[str, Any]:
|
|
389
|
+
"""创建一个新的有状态执行上下文(独立命名空间)。返回 {id,language,cwd}。"""
|
|
390
|
+
resp = self._request(
|
|
391
|
+
"POST", f"/api/v1/sandboxes/{name}/toolbox/code-interpreter/contexts",
|
|
392
|
+
json={"language": language, "cwd": cwd})
|
|
393
|
+
return resp.json()
|
|
394
|
+
|
|
395
|
+
def list_code_contexts(self, name: str) -> list:
|
|
396
|
+
"""列出所有有状态执行上下文。"""
|
|
397
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/code-interpreter/contexts")
|
|
398
|
+
return resp.json() or []
|
|
399
|
+
|
|
400
|
+
def restart_code_context(self, name: str, context_id: str) -> None:
|
|
401
|
+
"""重启上下文的内核(清空其命名空间,保留 id)。"""
|
|
402
|
+
self._request(
|
|
403
|
+
"POST", f"/api/v1/sandboxes/{name}/toolbox/code-interpreter/contexts/{context_id}/restart")
|
|
404
|
+
|
|
405
|
+
def remove_code_context(self, name: str, context_id: str) -> None:
|
|
406
|
+
"""销毁一个上下文及其内核。"""
|
|
407
|
+
self._request(
|
|
408
|
+
"DELETE", f"/api/v1/sandboxes/{name}/toolbox/code-interpreter/contexts/{context_id}")
|
|
409
|
+
|
|
410
|
+
# ---- directory watch (toolbox /watch/*) ----
|
|
411
|
+
def watch_create(self, name: str, path: str, recursive: bool = False) -> str:
|
|
412
|
+
"""开始监视目录,返回 watcher_id。"""
|
|
413
|
+
resp = self._request(
|
|
414
|
+
"POST", f"/api/v1/sandboxes/{name}/toolbox/watch/create",
|
|
415
|
+
json={"path": path, "recursive": recursive})
|
|
416
|
+
return resp.json().get("watcher_id", "")
|
|
417
|
+
|
|
418
|
+
def watch_events(self, name: str, watcher_id: str) -> list:
|
|
419
|
+
"""拉取自上次调用以来累积的文件系统事件 [{name,type}]。"""
|
|
420
|
+
resp = self._request(
|
|
421
|
+
"GET", f"/api/v1/sandboxes/{name}/toolbox/watch/events", params={"id": watcher_id})
|
|
422
|
+
return resp.json().get("events", []) or []
|
|
423
|
+
|
|
424
|
+
def watch_remove(self, name: str, watcher_id: str) -> None:
|
|
425
|
+
"""停止一个 watcher。"""
|
|
426
|
+
self._request(
|
|
427
|
+
"POST", f"/api/v1/sandboxes/{name}/toolbox/watch/remove", json={"watcher_id": watcher_id})
|
|
428
|
+
|
|
429
|
+
def delete_file(self, name: str, path: str) -> Dict[str, Any]:
|
|
430
|
+
"""删除文件/目录"""
|
|
431
|
+
resp = self._request("DELETE", f"/api/v1/sandboxes/{name}/toolbox/files/delete", params={"path": path})
|
|
432
|
+
return resp.json()
|
|
433
|
+
|
|
434
|
+
# ========== Poder 操作 ==========
|
|
435
|
+
|
|
436
|
+
def get_sandbox_env(self, name: str) -> Dict[str, Any]:
|
|
437
|
+
"""
|
|
438
|
+
获取 Sandbox 容器运行环境信息(供 AI 生成脚本时参考)
|
|
439
|
+
|
|
440
|
+
从容器内 Toolbox 直接读取,比沙箱元数据更精确,
|
|
441
|
+
包含 arch、os、os_version、kernel_version、shell、work_dir。
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
name: Sandbox 名称
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
EnvironmentInfo dict
|
|
448
|
+
"""
|
|
449
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/toolbox/info")
|
|
450
|
+
return resp.json()
|
|
451
|
+
|
|
452
|
+
def list_poders(self) -> List[Dict[str, Any]]:
|
|
453
|
+
"""列出所有 Poder"""
|
|
454
|
+
resp = self._request("GET", "/api/v1/poders")
|
|
455
|
+
return resp.json().get("poders", [])
|
|
456
|
+
|
|
457
|
+
def get_poder(self, poder_id: str) -> Dict[str, Any]:
|
|
458
|
+
"""获取单个 Poder 信息"""
|
|
459
|
+
resp = self._request("GET", f"/api/v1/poders/{poder_id}")
|
|
460
|
+
return resp.json()
|
|
461
|
+
|
|
462
|
+
def delete_poder(self, poder_id: str, keep_vm: bool = False) -> None:
|
|
463
|
+
"""
|
|
464
|
+
删除 Poder 记录(若在线则同时断开 tunnel)。
|
|
465
|
+
|
|
466
|
+
对云 provider(aws/aliyun/azure/gcp),默认同时终止其底层 VM;
|
|
467
|
+
keep_vm=True 时只删记录、保留 VM。
|
|
468
|
+
"""
|
|
469
|
+
path = f"/api/v1/poders/{poder_id}"
|
|
470
|
+
if keep_vm:
|
|
471
|
+
path += "?keep_vm=true"
|
|
472
|
+
self._request("DELETE", path)
|
|
473
|
+
|
|
474
|
+
# ---- API tokens (admin) ----
|
|
475
|
+
|
|
476
|
+
def create_token(self, name: str, role: str = "user") -> Dict[str, Any]:
|
|
477
|
+
"""
|
|
478
|
+
签发一个 API token(需 admin)。返回体含裸 key(仅此一次),
|
|
479
|
+
key 为 e2b_<hex> 格式,可直接作 E2B_API_KEY。服务端只存其 hash。
|
|
480
|
+
"""
|
|
481
|
+
resp = self._request("POST", "/api/v1/tokens", json={"name": name, "role": role})
|
|
482
|
+
return resp.json()
|
|
483
|
+
|
|
484
|
+
def list_tokens(self) -> List[Dict[str, Any]]:
|
|
485
|
+
"""列出已签发的 token(不含裸 key,只有 name/prefix/role/created_at)。"""
|
|
486
|
+
resp = self._request("GET", "/api/v1/tokens")
|
|
487
|
+
return resp.json().get("tokens", [])
|
|
488
|
+
|
|
489
|
+
def delete_token(self, prefix: str) -> None:
|
|
490
|
+
"""按显示前缀吊销一个 token(立即生效)。"""
|
|
491
|
+
self._request("DELETE", f"/api/v1/tokens/{prefix}")
|
|
492
|
+
|
|
493
|
+
def create_sandbox_on_poder(
|
|
494
|
+
self,
|
|
495
|
+
poder_id: str,
|
|
496
|
+
name: str,
|
|
497
|
+
region: str = "local",
|
|
498
|
+
provider_type: str = "local",
|
|
499
|
+
instance_type: str = "",
|
|
500
|
+
image: str = "",
|
|
501
|
+
) -> Dict[str, Any]:
|
|
502
|
+
"""
|
|
503
|
+
在指定 Poder 上直接创建 Sandbox(跳过调度器),返回 sandbox 记录。
|
|
504
|
+
"""
|
|
505
|
+
data: Dict[str, Any] = {
|
|
506
|
+
"name": name,
|
|
507
|
+
"region": region,
|
|
508
|
+
"provider_type": provider_type,
|
|
509
|
+
}
|
|
510
|
+
if instance_type:
|
|
511
|
+
data["instance_type"] = instance_type
|
|
512
|
+
if image:
|
|
513
|
+
data["image_id"] = image
|
|
514
|
+
resp = self._request("POST", f"/api/v1/poders/{poder_id}/sandboxes", json=data)
|
|
515
|
+
return resp.json()
|
|
516
|
+
|
|
517
|
+
# ========== Session 操作 ==========
|
|
518
|
+
|
|
519
|
+
def create_session(self, name: str, session_id: str = None) -> Dict[str, Any]:
|
|
520
|
+
"""
|
|
521
|
+
创建 Session
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
name: Sandbox 名称
|
|
525
|
+
session_id: Session ID (可选,自动生成)
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Session 信息
|
|
529
|
+
"""
|
|
530
|
+
data = {}
|
|
531
|
+
if session_id:
|
|
532
|
+
data["session_id"] = session_id
|
|
533
|
+
resp = self._request("POST", f"/api/v1/sandboxes/{name}/session", json=data)
|
|
534
|
+
return resp.json()
|
|
535
|
+
|
|
536
|
+
def list_sessions(self, name: str) -> List[Dict[str, Any]]:
|
|
537
|
+
"""列出所有 Session"""
|
|
538
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/session")
|
|
539
|
+
data = resp.json()
|
|
540
|
+
# Toolbox 返回数组,或 {sessions: [...]} 格式
|
|
541
|
+
if isinstance(data, list):
|
|
542
|
+
return data
|
|
543
|
+
return data.get("sessions", [])
|
|
544
|
+
|
|
545
|
+
def get_session(self, name: str, session_id: str) -> Dict[str, Any]:
|
|
546
|
+
"""获取 Session 信息"""
|
|
547
|
+
resp = self._request("GET", f"/api/v1/sandboxes/{name}/session/{session_id}")
|
|
548
|
+
return resp.json()
|
|
549
|
+
|
|
550
|
+
def delete_session(self, name: str, session_id: str) -> None:
|
|
551
|
+
"""删除 Session"""
|
|
552
|
+
self._request("DELETE", f"/api/v1/sandboxes/{name}/session/{session_id}")
|
|
553
|
+
|
|
554
|
+
def execute_in_session(self, name: str, session_id: str, command: str) -> Dict[str, Any]:
|
|
555
|
+
"""
|
|
556
|
+
在 Session 中执行命令 (保持状态)
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
name: Sandbox 名称
|
|
560
|
+
session_id: Session ID
|
|
561
|
+
command: shell 命令
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
ExecuteResponse (cmd_id, output, exit_code)
|
|
565
|
+
"""
|
|
566
|
+
data = {"command": command}
|
|
567
|
+
resp = self._request("POST", f"/api/v1/sandboxes/{name}/session/{session_id}/exec", json=data)
|
|
568
|
+
return resp.json()
|
|
569
|
+
|
|
570
|
+
def close(self):
|
|
571
|
+
"""关闭客户端"""
|
|
572
|
+
self.session.close()
|