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 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
@@ -0,0 +1,2 @@
1
+ # Copyright 2024 SandrPod
2
+ # CLI Package
@@ -0,0 +1,7 @@
1
+ # Copyright 2024 SandrPod
2
+ # CLI Main Entry Point
3
+
4
+ from .main import cli
5
+
6
+ if __name__ == "__main__":
7
+ cli(obj={})
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()