genesis-sandbox-client-python 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.
@@ -0,0 +1,993 @@
1
+ """Low-level HTTP client for the Genesis Sandbox service (stdlib only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import email.message
6
+ import json
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ import uuid
12
+ from collections.abc import Callable, Iterator
13
+ from typing import Any
14
+
15
+ from .control_probe import RuntimeControlClientMixin
16
+ from .errors import APIError, ProtocolError, TransportError
17
+ from .execution_governance import trusted_governance as validate_trusted_governance
18
+ from .invocation_output import output_request
19
+ from .json_codec import loads as decode_response_json
20
+ from .session_exec_client import SessionExecClientMixin, validate_exec_record
21
+ from .types import SSEEvent
22
+ from .workspace_client import WorkspaceClientMixin
23
+ from .workspace_lifecycle import WorkspaceLifecycleClientMixin
24
+
25
+
26
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
27
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
28
+ # Bearer 身份只发往配置的服务,禁止重定向携带凭据。
29
+ return None
30
+
31
+
32
+ def _urlopen(request, timeout=None):
33
+ return urllib.request.build_opener(_NoRedirect()).open(request, timeout=timeout)
34
+
35
+
36
+ def _header_message(headers: dict[str, str]) -> email.message.Message:
37
+ """dict 头转 email.message.Message(urllib.error.HTTPError 的类型要求)。"""
38
+ message = email.message.Message()
39
+ for key, value in headers.items():
40
+ message[key] = value
41
+ return message
42
+
43
+
44
+ _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
45
+
46
+
47
+ def _validate_base_url(base_url: str) -> str:
48
+ parsed = urllib.parse.urlparse(base_url)
49
+ if (
50
+ parsed.scheme not in {"http", "https"}
51
+ or not parsed.netloc
52
+ or parsed.username
53
+ or parsed.password
54
+ or parsed.query
55
+ or parsed.fragment
56
+ ):
57
+ raise ValueError("base_url must be an absolute http(s) URL")
58
+ # 与 Platform 端点约束一致:拒绝内嵌凭据/查询/片段,也拒绝携带路径前缀。
59
+ if parsed.path not in {"", "/"}:
60
+ raise ValueError("base_url must not contain a path")
61
+ if parsed.scheme != "https" and parsed.hostname not in _LOOPBACK_HOSTS:
62
+ raise ValueError("non-https base_url is only allowed for loopback hosts (localhost/127.0.0.1/::1)")
63
+ return base_url.rstrip("/")
64
+
65
+
66
+ def _validate_token(token: str) -> str:
67
+ """拒绝空白边缘与 CR/LF,避免 Authorization 头注入。"""
68
+ if not isinstance(token, str) or not token or token.strip() != token or any(c in token for c in "\r\n"):
69
+ raise ValueError("token must be a non-empty string without surrounding whitespace or CR/LF")
70
+ return token
71
+
72
+
73
+ def _opaque_cursor_to_int(cursor: int | str | None, name: str = "cursor") -> int:
74
+ """游标对外保持不透明字符串;此处仅内部换算为服务端整数游标。"""
75
+ if cursor is None:
76
+ return 0
77
+ try:
78
+ value = int(str(cursor).strip())
79
+ except ValueError:
80
+ raise ValueError(f"{name} is opaque; pass the next_cursor value returned by read_exec_logs") from None
81
+ if value < 0:
82
+ raise ValueError(f"{name} must not be negative")
83
+ return value
84
+
85
+
86
+ _DEFAULT_TIMEOUT = object()
87
+
88
+
89
+ def _build_environment(
90
+ profile: str | None = None,
91
+ hints: list[str] | None = None,
92
+ strict: bool = False,
93
+ description: str | None = None,
94
+ profile_revision: str | None = None,
95
+ ) -> dict[str, Any] | None:
96
+ """构造精确 profile 或能力 hints;两种选择方式互斥。"""
97
+ if profile and hints:
98
+ raise ValueError("profile and hints are mutually exclusive")
99
+ if profile_revision and not profile:
100
+ raise ValueError("profile_revision requires profile")
101
+ if profile:
102
+ exact_profile: dict[str, Any] = {"name": profile}
103
+ if profile_revision:
104
+ exact_profile["revision"] = profile_revision
105
+ return {"profile": exact_profile}
106
+ if hints:
107
+ h: dict[str, Any] = {"capabilities": hints}
108
+ if strict:
109
+ h["strict"] = True
110
+ if description:
111
+ h["description"] = description
112
+ return {"hints": h}
113
+ return None
114
+
115
+
116
+ def _validate_resolution(environment: dict[str, Any] | None, resolution_id: str | None) -> None:
117
+ if environment and resolution_id:
118
+ raise ValueError("resolution_id and environment selector are mutually exclusive")
119
+
120
+
121
+ class Client(WorkspaceClientMixin, SessionExecClientMixin, WorkspaceLifecycleClientMixin, RuntimeControlClientMixin):
122
+ def __init__(
123
+ self,
124
+ base_url: str,
125
+ token: str | None = None,
126
+ timeout: float = 30,
127
+ *,
128
+ max_attempts: int = 3,
129
+ retry_base_delay: float = 0.2,
130
+ user_agent: str = "genesis-sandbox-client-python/1",
131
+ token_provider: Callable[[], str] | None = None,
132
+ ) -> None:
133
+ """创建绑定服务端点的客户端。
134
+
135
+ ``token_provider``:可选的凭据回调 ``Callable[[], str]``,在每次请求发出前
136
+ 取值(含每次重试),优先于固定 ``token``;返回空值时回退到固定 token。
137
+ 适用于短期凭据轮转。凭据只发往配置的 base_url,不随重定向转发。
138
+ """
139
+ self.base_url = _validate_base_url(base_url)
140
+ if token is not None:
141
+ token = _validate_token(token)
142
+ if token_provider is not None and not callable(token_provider):
143
+ raise ValueError("token_provider must be callable")
144
+ if timeout <= 0:
145
+ raise ValueError("timeout must be positive")
146
+ self.token = token
147
+ self.token_provider = token_provider
148
+ self.timeout = timeout
149
+ self.max_attempts = max(1, int(max_attempts))
150
+ self.retry_base_delay = max(0.0, float(retry_base_delay))
151
+ self.user_agent = user_agent
152
+
153
+ def _auth_token(self) -> str | None:
154
+ """每请求前解析凭据:token_provider 优先于固定 token。"""
155
+ if self.token_provider is not None:
156
+ provided = self.token_provider()
157
+ if provided:
158
+ return _validate_token(provided)
159
+ return self.token
160
+
161
+ def _request(
162
+ self,
163
+ method: str,
164
+ path: str,
165
+ body: dict[str, Any] | None = None,
166
+ timeout: float | None = None,
167
+ max_retries: int | None = None,
168
+ deadline_seconds: float | None = None,
169
+ ) -> Any:
170
+ # 幂等身份用于查询原结果,不证明未知写操作可以自动重放。
171
+ max_attempts = self._attempts_for(method)
172
+ if max_retries is not None and self._is_idempotent(method):
173
+ max_attempts = max(1, int(max_retries))
174
+ delay_seconds = self.retry_base_delay
175
+ last_err: BaseException | None = None
176
+
177
+ for attempt in range(max_attempts):
178
+ url = f"{self.base_url}{path}"
179
+ headers = {
180
+ "Content-Type": "application/json",
181
+ "Accept": "application/json",
182
+ "User-Agent": self.user_agent,
183
+ }
184
+ token = self._auth_token()
185
+ if token:
186
+ headers["Authorization"] = f"Bearer {token}"
187
+
188
+ data = None
189
+ if body is not None:
190
+ data = json.dumps(body).encode("utf-8")
191
+
192
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
193
+
194
+ try:
195
+ if deadline_seconds is None:
196
+ opened = _urlopen(req, timeout=self.timeout if timeout is None else timeout)
197
+ else:
198
+ from .deadline_http import deadline_urlopen
199
+
200
+ opened = deadline_urlopen(req, deadline_seconds)
201
+ with opened as response:
202
+ if response.status == 204:
203
+ return None
204
+ raw = response.read((16 << 20) + 1)
205
+ if len(raw) > 16 << 20:
206
+ raise TransportError("sandbox JSON response exceeds limit")
207
+ res_body = raw.decode("utf-8")
208
+ if not res_body:
209
+ return None
210
+ try:
211
+ return decode_response_json(res_body)
212
+ except ValueError as exc:
213
+ raise ProtocolError(f"sandbox response is not valid JSON: {exc}") from exc
214
+ except urllib.error.HTTPError as exc:
215
+ last_err = exc
216
+ if self._is_transient(exc.code) and attempt < max_attempts - 1:
217
+ time.sleep(self._retry_after(exc, delay_seconds))
218
+ delay_seconds *= 2
219
+ continue
220
+ raise self._api_error(exc) from None
221
+ except ProtocolError:
222
+ raise
223
+ except Exception as exc:
224
+ last_err = exc
225
+ if attempt < max_attempts - 1:
226
+ time.sleep(delay_seconds)
227
+ delay_seconds *= 2
228
+ continue
229
+ raise TransportError(f"sandbox request failed: {exc}") from exc
230
+ raise TransportError(f"sandbox request failed after {max_attempts} attempts: {last_err}")
231
+
232
+ def _raw_request(
233
+ self,
234
+ method: str,
235
+ path: str,
236
+ data: bytes | None = None,
237
+ content_type: str | None = None,
238
+ extra_headers: dict[str, str] | None = None,
239
+ timeout: Any = _DEFAULT_TIMEOUT,
240
+ ):
241
+ max_attempts = self._attempts_for(method) if data is None else 1
242
+ delay_seconds = self.retry_base_delay
243
+ last_err: BaseException | None = None
244
+
245
+ for attempt in range(max_attempts):
246
+ url = f"{self.base_url}{path}"
247
+ headers: dict[str, str] = {}
248
+ if content_type:
249
+ headers["Content-Type"] = content_type
250
+ token = self._auth_token()
251
+ if token:
252
+ headers["Authorization"] = f"Bearer {token}"
253
+ headers["Accept"] = "application/json"
254
+ headers["User-Agent"] = self.user_agent
255
+ if extra_headers:
256
+ headers.update(extra_headers)
257
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
258
+ try:
259
+ request_timeout = self.timeout if timeout is _DEFAULT_TIMEOUT else timeout
260
+ response = _urlopen(req, timeout=request_timeout)
261
+ if response.status == 429 or 500 <= response.status <= 599:
262
+ response.close()
263
+ raise urllib.error.HTTPError(
264
+ url, response.status, "Transient status", _header_message(headers), None
265
+ )
266
+ return response
267
+ except urllib.error.HTTPError as exc:
268
+ last_err = exc
269
+ if self._is_transient(exc.code) and attempt < max_attempts - 1:
270
+ time.sleep(self._retry_after(exc, delay_seconds))
271
+ delay_seconds *= 2
272
+ continue
273
+ raise self._api_error(exc) from None
274
+ except Exception as exc:
275
+ last_err = exc
276
+ if attempt < max_attempts - 1:
277
+ time.sleep(delay_seconds)
278
+ delay_seconds *= 2
279
+ continue
280
+ raise TransportError(f"sandbox raw request failed: {exc}") from exc
281
+ raise TransportError(f"sandbox raw request failed after {max_attempts} attempts: {last_err}")
282
+
283
+ def _attempts_for(self, method: str) -> int:
284
+ return self.max_attempts if self._is_idempotent(method) else 1
285
+
286
+ @staticmethod
287
+ def _is_idempotent(method: str) -> bool:
288
+ return method.upper() in {"GET", "HEAD", "OPTIONS"}
289
+
290
+ @staticmethod
291
+ def _is_transient(status: int) -> bool:
292
+ return status == 429 or status >= 500
293
+
294
+ @staticmethod
295
+ def _retry_after(exc: urllib.error.HTTPError, fallback: float) -> float:
296
+ value = exc.headers.get("Retry-After") if exc.headers else None
297
+ if value is None:
298
+ return fallback
299
+ try:
300
+ return max(0.0, float(value))
301
+ except ValueError:
302
+ return fallback
303
+
304
+ @staticmethod
305
+ def _api_error(exc: urllib.error.HTTPError) -> APIError:
306
+ try:
307
+ raw = exc.read(65536).decode("utf-8", errors="replace")
308
+ payload = decode_response_json(raw)
309
+ except Exception:
310
+ payload = {}
311
+ raw = str(exc.reason)
312
+ details = payload.get("details") if isinstance(payload, dict) else {}
313
+ retry_after = Client._retry_after(exc, 0.0)
314
+ return APIError(
315
+ exc.code,
316
+ payload.get("error_code", "") if isinstance(payload, dict) else "",
317
+ payload.get("message", raw) if isinstance(payload, dict) else raw,
318
+ request_id=payload.get("request_id", "") if isinstance(payload, dict) else "",
319
+ details=details if isinstance(details, dict) else {},
320
+ retry_after=retry_after or None,
321
+ )
322
+
323
+ def lease(
324
+ self,
325
+ workspace_id: str | None = None,
326
+ profile: str | None = None,
327
+ hints: list[str] | None = None,
328
+ resolution_id: str | None = None,
329
+ metadata: dict[str, str] | None = None,
330
+ ) -> dict[str, Any]:
331
+ payload: dict[str, Any] = {}
332
+ if workspace_id:
333
+ payload["workspace_id"] = workspace_id
334
+ env = _build_environment(profile, hints)
335
+ _validate_resolution(env, resolution_id)
336
+ if env:
337
+ payload["environment"] = env
338
+ if resolution_id:
339
+ payload["resolution_id"] = resolution_id
340
+ if metadata:
341
+ payload["metadata"] = metadata
342
+ return self._request("POST", "/v1/sandboxes:lease", payload)
343
+
344
+ def release(self, sandbox_id: str) -> dict[str, Any]:
345
+ return self._request("POST", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}:release")
346
+
347
+ def destroy(self, sandbox_id: str) -> dict[str, Any] | None:
348
+ return self._request("DELETE", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}")
349
+
350
+ def list_sandboxes(self) -> dict[str, Any]:
351
+ return self._request("GET", "/v1/sandboxes")
352
+
353
+ def get_sandbox(self, sandbox_id: str) -> dict[str, Any]:
354
+ return self._request("GET", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}")
355
+
356
+ def renew(self, sandbox_id: str, extend_seconds: int) -> dict[str, Any]:
357
+ return self._request(
358
+ "POST",
359
+ f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}:renew",
360
+ {"extend_seconds": int(extend_seconds)},
361
+ )
362
+
363
+ def submit_job(
364
+ self,
365
+ *,
366
+ code: str | None = None,
367
+ command: list[str] | None = None,
368
+ profile: str | None = None,
369
+ hints: list[str] | None = None,
370
+ strict: bool = False,
371
+ description: str | None = None,
372
+ resolution_id: str | None = None,
373
+ timeout_seconds: int | None = None,
374
+ queue_wait_timeout_seconds: int | None = None,
375
+ workspace_id: str | None = None,
376
+ session_id: str | None = None,
377
+ env: dict[str, str] | None = None,
378
+ metadata: dict[str, str] | None = None,
379
+ idempotency_key: str | None = None,
380
+ callback_url: str | None = None,
381
+ input_artifact_ids: list[str] | None = None,
382
+ ) -> dict[str, Any]:
383
+ if bool(code) == bool(command):
384
+ raise ValueError("exactly one of code or command is required")
385
+ payload: dict[str, Any] = {}
386
+ environment = _build_environment(profile, hints, strict=strict, description=description)
387
+ _validate_resolution(environment, resolution_id)
388
+ if environment:
389
+ payload["environment"] = environment
390
+ if resolution_id:
391
+ payload["resolution_id"] = resolution_id
392
+ if code:
393
+ payload["code"] = code
394
+ if command:
395
+ payload["command"] = command
396
+ if timeout_seconds:
397
+ payload["timeout_seconds"] = int(timeout_seconds)
398
+ if queue_wait_timeout_seconds:
399
+ payload["queue_wait_timeout_seconds"] = int(queue_wait_timeout_seconds)
400
+ if workspace_id:
401
+ payload["workspace_id"] = workspace_id
402
+ if session_id:
403
+ payload["session_id"] = session_id
404
+ if env:
405
+ payload["env"] = env
406
+ if metadata:
407
+ payload["metadata"] = metadata
408
+ if idempotency_key:
409
+ payload["idempotency_key"] = idempotency_key
410
+ if callback_url:
411
+ payload["callback_url"] = callback_url
412
+ if input_artifact_ids:
413
+ payload["input_artifact_ids"] = input_artifact_ids
414
+ return self._request("POST", "/v1/jobs", payload)
415
+
416
+ def wait_job(self, job_id: str, poll_interval: float = 0.2, max_wait: float = 300.0) -> dict[str, Any]:
417
+ if poll_interval <= 0 or max_wait < 0:
418
+ raise ValueError("poll_interval must be positive and max_wait cannot be negative")
419
+ deadline = time.monotonic() + max_wait
420
+ while time.monotonic() < deadline:
421
+ result = self.get_job(job_id)
422
+ if result.get("status") in {"succeeded", "failed", "cancelled", "timed_out", "interrupted"}:
423
+ return result
424
+ time.sleep(poll_interval)
425
+ raise TimeoutError(f"job {job_id} did not complete within {max_wait}s")
426
+
427
+ def list_jobs(
428
+ self, status: str | None = None, limit: int | None = None, offset: int | None = None
429
+ ) -> dict[str, Any]:
430
+ params: dict[str, Any] = {}
431
+ if status:
432
+ params["status"] = status
433
+ if limit:
434
+ params["limit"] = int(limit)
435
+ if offset:
436
+ params["offset"] = int(offset)
437
+ query = urllib.parse.urlencode(params)
438
+ return self._request("GET", "/v1/jobs" + (f"?{query}" if query else ""))
439
+
440
+ def get_job(self, job_id: str) -> dict[str, Any]:
441
+ return self._request("GET", f"/v1/jobs/{urllib.parse.quote(job_id)}")
442
+
443
+ def cancel_job(self, job_id: str) -> dict[str, Any]:
444
+ return self._request("POST", f"/v1/jobs/{urllib.parse.quote(job_id)}:cancel")
445
+
446
+ def upload_job_file(self, job_id: str, name: str, content: bytes | str) -> dict[str, Any]:
447
+ if isinstance(content, str):
448
+ content = content.encode("utf-8")
449
+ path = f"/v1/jobs/{urllib.parse.quote(job_id)}/files?name={urllib.parse.quote(name)}"
450
+ with self._raw_request("POST", path, data=content, content_type="application/octet-stream") as response:
451
+ return decode_response_json(response.read().decode("utf-8"))
452
+
453
+ def download_artifact(self, artifact_id: str) -> bytes:
454
+ path = f"/v1/artifacts/{urllib.parse.quote(artifact_id)}"
455
+ with self._raw_request("GET", path) as response:
456
+ return response.read()
457
+
458
+ def list_job_artifacts(self, job_id: str, offset: int | None = None, limit: int | None = None) -> dict[str, Any]:
459
+ params: dict[str, Any] = {}
460
+ if offset:
461
+ params["offset"] = int(offset)
462
+ if limit:
463
+ params["limit"] = int(limit)
464
+ query = urllib.parse.urlencode(params)
465
+ path = f"/v1/jobs/{urllib.parse.quote(job_id)}/artifacts"
466
+ if query:
467
+ path += f"?{query}"
468
+ return self._request("GET", path)
469
+
470
+ def job_logs(self, job_id: str, cursor: int | str = 0) -> Iterator[SSEEvent]:
471
+ path = f"/v1/jobs/{urllib.parse.quote(job_id)}/logs"
472
+ if cursor:
473
+ path += f"?cursor={int(cursor)}"
474
+ return self._event_stream(path)
475
+
476
+ def exec_session(
477
+ self, sandbox_id: str, command: list[str] | None = None, code: str | None = None, language: str | None = None
478
+ ) -> dict[str, Any]:
479
+ payload: dict[str, Any] = {}
480
+ if command:
481
+ payload["command"] = command
482
+ if code:
483
+ payload["code"] = code
484
+ if language:
485
+ payload["language"] = language
486
+ return self._request("POST", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/sessions", payload)
487
+
488
+ def create_session(
489
+ self,
490
+ workspace_id: str | None = None,
491
+ profile: str | None = None,
492
+ hints: list[str] | None = None,
493
+ strict: bool = False,
494
+ description: str | None = None,
495
+ resolution_id: str | None = None,
496
+ state_policy: str | None = None,
497
+ ttl_seconds: int | None = None,
498
+ metadata: dict[str, str] | None = None,
499
+ workspace_retention: str | None = None,
500
+ workspace_ttl_seconds: int | None = None,
501
+ idempotency_key: str | None = None,
502
+ ) -> dict[str, Any]:
503
+ payload: dict[str, Any] = {}
504
+ if workspace_id:
505
+ payload["workspace_id"] = workspace_id
506
+ environment = _build_environment(profile, hints, strict=strict, description=description)
507
+ _validate_resolution(environment, resolution_id)
508
+ if environment:
509
+ payload["environment"] = environment
510
+ if resolution_id:
511
+ payload["resolution_id"] = resolution_id
512
+ if state_policy:
513
+ payload["state_policy"] = state_policy
514
+ if ttl_seconds:
515
+ payload["ttl_seconds"] = int(ttl_seconds)
516
+ if metadata:
517
+ payload["metadata"] = metadata
518
+ if workspace_retention:
519
+ payload["workspace_retention"] = workspace_retention
520
+ if workspace_ttl_seconds:
521
+ payload["workspace_ttl_seconds"] = int(workspace_ttl_seconds)
522
+ if idempotency_key:
523
+ payload["idempotency_key"] = idempotency_key
524
+ return self._request("POST", "/v1/sessions", payload)
525
+
526
+ def get_session(self, session_id: str) -> dict[str, Any]:
527
+ return self._request("GET", f"/v1/sessions/{urllib.parse.quote(session_id)}")
528
+
529
+ def lookup_session(self, idempotency_key: str) -> dict[str, Any] | None:
530
+ """按幂等键查回 Session(会话创建响应丢失后的恢复路径)。
531
+
532
+ ``GET /v1/sessions:lookup?idempotency_key=...``:命中返回 Session dict;
533
+ 404 返回 None(与 get_exec_by_operation 的 APIError 风格不同——lookup 的
534
+ 404 是“未找到”结果,不是未创建过的证明)。
535
+ 其余错误仍抛 APIError / TransportError。
536
+
537
+ 注意:服务端 404 无法区分“从未创建”与“去重/回执保留期已过”,无法证明
538
+ 未执行过;必须核对原身份及保留窗口,不得据此自动重新提交。
539
+ """
540
+ if (
541
+ not isinstance(idempotency_key, str)
542
+ or not idempotency_key.strip()
543
+ or len(idempotency_key.encode("utf-8")) > 128
544
+ or any(ord(char) < 32 for char in idempotency_key)
545
+ ):
546
+ raise ValueError("idempotency_key is required")
547
+ try:
548
+ result = self._request(
549
+ "GET", f"/v1/sessions:lookup?idempotency_key={urllib.parse.quote(str(idempotency_key))}"
550
+ )
551
+ if (
552
+ not isinstance(result, dict)
553
+ or not result.get("session_id")
554
+ or result.get("idempotency_key") != idempotency_key
555
+ ):
556
+ raise ProtocolError("session lookup original identity mismatch")
557
+ return result
558
+ except APIError as exc:
559
+ if exc.status_code == 404:
560
+ return None
561
+ raise
562
+
563
+ def delete_session(self, session_id: str) -> dict[str, Any] | None:
564
+ return self._request("DELETE", f"/v1/sessions/{urllib.parse.quote(session_id)}")
565
+
566
+ def suspend_session(self, session_id: str) -> dict[str, Any]:
567
+ return self._request("POST", f"/v1/sessions/{urllib.parse.quote(session_id)}:suspend")
568
+
569
+ def resume_session(self, session_id: str) -> dict[str, Any]:
570
+ # 服务端按 Session 锁串行恢复,重复请求复用已激活的 runtime。
571
+ return self._request("POST", f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}:resume")
572
+
573
+ def renew_session(
574
+ self, session_id: str, extend_seconds: int, timeout: float | None = None, max_retries: int = 1
575
+ ) -> dict[str, Any]:
576
+ # renew 是无原生幂等身份的 POST:按写重试纪律单次发送(显式调大 max_retries
577
+ # 也会被钳制);重试节奏由调用方(如会话心跳循环)负责。
578
+ return self._request(
579
+ "POST",
580
+ f"/v1/sessions/{urllib.parse.quote(session_id)}:renew",
581
+ {"extend_seconds": int(extend_seconds)},
582
+ timeout=timeout,
583
+ max_retries=max_retries,
584
+ )
585
+
586
+ def exec_named_session(
587
+ self,
588
+ session_id: str,
589
+ command: list[str] | None = None,
590
+ code: str | None = None,
591
+ language: str | None = None,
592
+ working_dir: str | None = None,
593
+ env: dict[str, str] | None = None,
594
+ timeout_seconds: int | None = None,
595
+ timeout: float | None = None,
596
+ operation_id: str | None = None,
597
+ subprocess_policy: str | None = None,
598
+ output_directory: str | None = None,
599
+ ) -> dict[str, Any]:
600
+ """同步执行(阻塞至回执)。
601
+
602
+ ``operation_id`` 是本次执行的原生幂等身份:传输重试始终复用同一个值。
603
+ 未显式传入时本地生成 uuid4——进程在拿到回执前崩溃则该身份丢失,无法
604
+ lookup 恢复、重复提交也可能绕过去重。**生产必须显式传入并持久保存
605
+ operation_id**,默认生成仅用于交互式/可丢弃场景。
606
+ """
607
+ if bool(code) == bool(command):
608
+ raise ValueError("exactly one of code or command is required")
609
+ payload: dict[str, Any] = {"operation_id": operation_id or uuid.uuid4().hex, **output_request(output_directory)}
610
+ if subprocess_policy:
611
+ if subprocess_policy not in {"allow", "deny"}:
612
+ raise ValueError("invalid subprocess_policy")
613
+ payload["subprocess_policy"] = subprocess_policy
614
+ if command:
615
+ payload["command"] = command
616
+ if code:
617
+ payload["code"] = code
618
+ if language:
619
+ payload["language"] = language
620
+ if working_dir:
621
+ payload["working_dir"] = working_dir
622
+ if env:
623
+ payload["env"] = env
624
+ if timeout_seconds:
625
+ payload["timeout_seconds"] = int(timeout_seconds)
626
+ return self._request(
627
+ "POST",
628
+ f"/v1/sessions/{urllib.parse.quote(session_id)}/exec",
629
+ payload,
630
+ timeout=timeout if timeout is not None else max(self.timeout, (timeout_seconds or 180) + 15),
631
+ )
632
+
633
+ def exec_session_async(
634
+ self,
635
+ session_id: str,
636
+ command: list[str] | None = None,
637
+ code: str | None = None,
638
+ language: str | None = None,
639
+ working_dir: str | None = None,
640
+ env: dict[str, str] | None = None,
641
+ timeout_seconds: int | None = None,
642
+ callback_url: str | None = None,
643
+ operation_id: str | None = None,
644
+ subprocess_policy: str | None = None,
645
+ output_directory: str | None = None,
646
+ trusted_governance: dict[str, Any] | None = None,
647
+ ) -> dict[str, Any]:
648
+ """异步执行提交,返回含 exec_id 的回执。
649
+
650
+ ``operation_id`` 语义同 exec_named_session:重放复用同一身份;未显式传入时
651
+ 本地生成 uuid4,进程崩溃后无法 lookup。**生产必须显式传入并持久保存
652
+ operation_id**,响应丢失时用 get_exec_by_operation 按同一身份查回执。
653
+ """
654
+ if bool(code) == bool(command):
655
+ raise ValueError("exactly one of code or command is required")
656
+ payload: dict[str, Any] = {"operation_id": operation_id or uuid.uuid4().hex, **output_request(output_directory)}
657
+ if subprocess_policy:
658
+ if subprocess_policy not in {"allow", "deny"}:
659
+ raise ValueError("invalid subprocess_policy")
660
+ payload["subprocess_policy"] = subprocess_policy
661
+ if command:
662
+ payload["command"] = command
663
+ if code:
664
+ payload["code"] = code
665
+ if language:
666
+ payload["language"] = language
667
+ if working_dir:
668
+ payload["working_dir"] = working_dir
669
+ if env:
670
+ payload["env"] = env
671
+ if timeout_seconds:
672
+ payload["timeout_seconds"] = int(timeout_seconds)
673
+ if callback_url:
674
+ payload["callback_url"] = callback_url
675
+ if trusted_governance is not None:
676
+ if not operation_id:
677
+ raise ValueError("trusted governance requires a persisted original operation_id")
678
+ payload["trusted_governance"] = validate_trusted_governance(trusted_governance)
679
+ return self._request("POST", f"/v1/sessions/{urllib.parse.quote(session_id)}/exec:async", payload)
680
+
681
+ def get_exec(self, session_id: str, exec_id: str) -> dict[str, Any]:
682
+ result = self._request(
683
+ "GET",
684
+ f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/execs/{urllib.parse.quote(exec_id, safe='')}",
685
+ )
686
+ return validate_exec_record(result, session_id, execution=exec_id)
687
+
688
+ def get_exec_by_operation(self, session_id: str, operation_id: str) -> dict[str, Any]:
689
+ result = self._request(
690
+ "GET",
691
+ f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/execs:lookup"
692
+ f"?operation_id={urllib.parse.quote(operation_id, safe='')}",
693
+ )
694
+ return validate_exec_record(result, session_id, operation=operation_id)
695
+
696
+ def cancel_exec(
697
+ self, session_id: str, exec_id: str, timeout: float | None = None, max_retries: int = 1
698
+ ) -> dict[str, Any]:
699
+ """请求取消执行,返回 ExecRecord 原始回执。
700
+
701
+ 取消语义(接受 vs 停止确认):服务端在确认停止后才返回终态 ExecRecord
702
+ (内联等待至多 5 秒),超时未确认停止以错误表达。因此:
703
+
704
+ - 成功返回终态 record = 停止已确认(或执行先完成、以实际终态为准);
705
+ - 抛错(含 "execution stop was not confirmed"、网络失败、客户端超时)
706
+ = 停止未知,调用方不得当作已停止。
707
+
708
+ 需要稳定 outcome 分类时用 SandboxSession.cancel_exec_receipt /
709
+ CancelReceipt.from_record;停止未知会以 ExecRecoveryError(phase="cancel")
710
+ 抛出。取消不做盲重试(max_retries 默认 1)。
711
+ """
712
+ result = self._request(
713
+ "POST",
714
+ f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/execs/"
715
+ f"{urllib.parse.quote(exec_id, safe='')}:cancel",
716
+ timeout=timeout,
717
+ max_retries=max_retries,
718
+ )
719
+ return validate_exec_record(result, session_id, execution=exec_id)
720
+
721
+ def stream_exec_logs(self, session_id: str, exec_id: str, cursor: int | str = 0) -> Iterator[SSEEvent]:
722
+ """SSE 惰性生成器,逐条产出 SSEEvent。
723
+
724
+ ``cursor`` 对外应视为不透明字符串(服务端为整数游标,业务层不得解析),
725
+ 仅接受 read_exec_logs 返回的 next_cursor 或 0/None 表示从头读取。
726
+ """
727
+ path = f"/v1/sessions/{urllib.parse.quote(session_id)}/execs/{urllib.parse.quote(exec_id)}/logs"
728
+ if cursor:
729
+ path += f"?cursor={int(cursor)}"
730
+ return self._event_stream(path)
731
+
732
+ def read_exec_logs(
733
+ self, session_id: str, exec_id: str, *, cursor: int | str | None = None, byte_budget: int, max_events: int
734
+ ) -> dict[str, Any]:
735
+ """按预算读取一页日志,返回 events、next_cursor、exhausted、gap_detected。
736
+
737
+ events 项包含 cursor、stream、data、bytes;next_cursor 是继续读取时原样回传的不透明游标。
738
+ byte_budget 计 data 的 UTF-8 字节数,max_events 计事件数;任一耗尽即返回 exhausted=False。
739
+ 至少返回一个事件以免单条超预算时空转,其余超预算事件留待下页,next_cursor 不越过它。
740
+ gap_detected 显式标记执行内事件游标不连续,缺失事件不可补齐。
741
+ exhausted 只表示日志流读尽,与执行终态相互独立;执行状态由 get_exec 查询。
742
+ 执行仍在运行且预算未耗尽时会等待新事件;流式消费或自行控制等待请用 stream_exec_logs。
743
+ """
744
+ if not isinstance(byte_budget, int) or isinstance(byte_budget, bool) or byte_budget < 1:
745
+ raise ValueError("byte_budget must be a positive integer")
746
+ if not isinstance(max_events, int) or isinstance(max_events, bool) or max_events < 1:
747
+ raise ValueError("max_events must be a positive integer")
748
+ start = _opaque_cursor_to_int(cursor)
749
+ events: list[dict[str, Any]] = []
750
+ bytes_read = 0
751
+ last_cursor = start
752
+ expected = start + 1
753
+ gap_detected = False
754
+ exhausted = True
755
+ for ev in self.stream_exec_logs(session_id, exec_id, cursor=start):
756
+ try:
757
+ ev_cursor = _opaque_cursor_to_int(ev.id, name="log event cursor")
758
+ except ValueError as exc:
759
+ # 服务端每条日志事件必须携带游标 id;缺失/非法属协议破坏。
760
+ raise ProtocolError(str(exc)) from exc
761
+ size = len(ev.data.encode("utf-8"))
762
+ if events and (bytes_read + size > byte_budget or len(events) >= max_events):
763
+ # 超预算事件不消费:服务端从 next_cursor 之后继续重放,无丢失。
764
+ exhausted = False
765
+ break
766
+ if ev_cursor != expected:
767
+ gap_detected = True
768
+ expected = ev_cursor + 1
769
+ last_cursor = ev_cursor
770
+ bytes_read += size
771
+ events.append({"cursor": str(ev_cursor), "stream": ev.event, "data": ev.data, "bytes": size})
772
+ return {
773
+ "events": events,
774
+ "next_cursor": str(last_cursor),
775
+ "exhausted": exhausted,
776
+ "gap_detected": gap_detected,
777
+ }
778
+
779
+ def _event_stream(self, path: str) -> Iterator[SSEEvent]:
780
+ response = self._raw_request(
781
+ "GET",
782
+ path,
783
+ extra_headers={"Accept": "text/event-stream"},
784
+ timeout=None,
785
+ )
786
+ try:
787
+ event_id = ""
788
+ event_type = "message"
789
+ data_lines: list[str] = []
790
+ for raw_line in response:
791
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
792
+ if line == "":
793
+ if data_lines:
794
+ yield SSEEvent(event_id, event_type, "\n".join(data_lines))
795
+ event_id, event_type, data_lines = "", "message", []
796
+ elif line.startswith("id:"):
797
+ event_id = line[3:].lstrip()
798
+ elif line.startswith("event:"):
799
+ event_type = line[6:].lstrip()
800
+ elif line.startswith("data:"):
801
+ value = line[5:]
802
+ data_lines.append(value[1:] if value.startswith(" ") else value)
803
+ if data_lines:
804
+ yield SSEEvent(event_id, event_type, "\n".join(data_lines))
805
+ finally:
806
+ response.close()
807
+
808
+ def get_session_context(self, session_id: str) -> dict[str, Any]:
809
+ return self._request("GET", f"/v1/sessions/{urllib.parse.quote(session_id)}/context")
810
+
811
+ def patch_session_context(self, session_id: str, cwd: str | None = None) -> dict[str, Any]:
812
+ payload: dict[str, Any] = {}
813
+ if cwd:
814
+ payload["cwd"] = cwd
815
+ return self._request("PATCH", f"/v1/sessions/{urllib.parse.quote(session_id)}/context", payload)
816
+
817
+ def get_catalog(
818
+ self, capability: str | None = None, tag: str | None = None, limit: int | None = None, offset: int | None = None
819
+ ) -> dict[str, Any]:
820
+ params: dict[str, Any] = {}
821
+ if capability:
822
+ params["capability"] = capability
823
+ if tag:
824
+ params["tag"] = tag
825
+ if limit is not None:
826
+ params["limit"] = int(limit)
827
+ if offset is not None:
828
+ params["offset"] = int(offset)
829
+ query = urllib.parse.urlencode(params)
830
+ return self._request("GET", "/v1/environment/catalog" + (f"?{query}" if query else ""))
831
+
832
+ def resolve_environment(
833
+ self,
834
+ profile: str | None = None,
835
+ hints: list[str] | None = None,
836
+ strict: bool = False,
837
+ description: str | None = None,
838
+ ttl_seconds: int | None = None,
839
+ profile_revision: str | None = None,
840
+ include_facts: bool = False,
841
+ ) -> dict[str, Any]:
842
+ environment = _build_environment(
843
+ profile, hints, strict=strict, description=description, profile_revision=profile_revision
844
+ )
845
+ if environment is None:
846
+ environment = {"hints": {}}
847
+ payload: dict[str, Any] = {"environment": environment}
848
+ if include_facts:
849
+ payload["include_facts"] = True
850
+ if ttl_seconds is not None:
851
+ if int(ttl_seconds) < 60:
852
+ raise ValueError("ttl_seconds must be at least 60")
853
+ payload["ttl_seconds"] = int(ttl_seconds)
854
+ return self._request("POST", "/v1/environment:resolve", payload)
855
+
856
+ def upload_session_file(
857
+ self, session_id: str, path: str, content: bytes | str, if_match: str | None = None, create_only: bool = False
858
+ ) -> dict[str, Any]:
859
+ if isinstance(content, str):
860
+ content = content.encode("utf-8")
861
+ target = f"/v1/sessions/{urllib.parse.quote(session_id)}/files?path={urllib.parse.quote(path, safe='')}"
862
+ extra_headers: dict[str, str] = {}
863
+ if if_match:
864
+ extra_headers["If-Match"] = if_match
865
+ if create_only:
866
+ extra_headers["If-None-Match"] = "*"
867
+ with self._raw_request(
868
+ "PUT",
869
+ target,
870
+ data=content,
871
+ content_type="application/octet-stream",
872
+ extra_headers=extra_headers or None,
873
+ ) as response:
874
+ return decode_response_json(response.read().decode("utf-8"))
875
+
876
+ def download_session_file(self, session_id: str, path: str, *, max_bytes: int = 2 * 1024 * 1024) -> bytes:
877
+ """在读取源头限制文件预算;超限拒绝,不返回不完整的成功内容。"""
878
+ if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or not 1 <= max_bytes <= 16 * 1024 * 1024:
879
+ raise ValueError("max_bytes must be between 1 and 16777216")
880
+ target = (
881
+ f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/files?path={urllib.parse.quote(path, safe='')}"
882
+ )
883
+ with self._raw_request("GET", target) as response:
884
+ content = response.read(max_bytes + 1)
885
+ if len(content) > max_bytes:
886
+ raise ProtocolError("session file exceeds the download byte budget")
887
+ return content
888
+
889
+ def list_session_files(
890
+ self, session_id: str, path: str = ".", recursive: bool = False, limit: int | None = None, offset: int = 0
891
+ ) -> dict[str, Any]:
892
+ params: dict[str, Any] = {"path": path, "recursive": str(bool(recursive)).lower(), "offset": int(offset)}
893
+ if limit is not None:
894
+ params["limit"] = int(limit)
895
+ query = urllib.parse.urlencode(params)
896
+ return self._request("GET", f"/v1/sessions/{urllib.parse.quote(session_id)}/files:list?{query}")
897
+
898
+ def remove_session_file(
899
+ self, session_id: str, path: str, recursive: bool = False, if_match: str | None = None
900
+ ) -> dict[str, Any] | None:
901
+ params = urllib.parse.urlencode({"path": path, "recursive": str(bool(recursive)).lower()})
902
+ with self._raw_request(
903
+ "DELETE",
904
+ f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/files?{params}",
905
+ extra_headers={"If-Match": if_match} if if_match else None,
906
+ ) as response:
907
+ data = response.read(16385)
908
+ if len(data) > 16384:
909
+ raise ProtocolError("delete receipt exceeds byte budget")
910
+ return decode_response_json(data) if data else None
911
+
912
+ def build_dependencies(
913
+ self,
914
+ *,
915
+ resolution_id: str | None = None,
916
+ environment: dict[str, Any] | None = None,
917
+ language: str | None = None,
918
+ manifest: str | None = None,
919
+ lockfile: str | None = None,
920
+ packages: list[str] | None = None,
921
+ ) -> dict[str, Any]:
922
+ if (resolution_id is None) == (environment is None):
923
+ raise ValueError("exactly one of resolution_id or environment is required")
924
+ if environment is not None:
925
+ profile = environment.get("profile") if isinstance(environment, dict) else None
926
+ if (
927
+ not isinstance(profile, dict)
928
+ or not profile.get("name")
929
+ or not profile.get("revision")
930
+ or environment.get("hints") is not None
931
+ ):
932
+ raise ValueError("environment must contain an exact profile name and revision")
933
+ payload: dict[str, Any] = {}
934
+ if resolution_id:
935
+ payload["resolution_id"] = resolution_id
936
+ if environment:
937
+ payload["environment"] = environment
938
+ if language:
939
+ payload["language"] = language
940
+ if manifest:
941
+ payload["manifest"] = manifest
942
+ if lockfile:
943
+ payload["lockfile"] = lockfile
944
+ if packages:
945
+ payload["packages"] = packages
946
+ return self._request("POST", "/v1/dependencies:build", payload)
947
+
948
+ def get_dependency_build(self, fingerprint: str) -> dict[str, Any]:
949
+ return self._request("GET", f"/v1/dependencies/{urllib.parse.quote(fingerprint)}")
950
+
951
+ def start_gui(
952
+ self,
953
+ sandbox_id: str,
954
+ kind: str | None = None,
955
+ resolution: str | None = None,
956
+ ttl_seconds: int | None = None,
957
+ metadata: dict[str, str] | None = None,
958
+ ) -> dict[str, Any]:
959
+ payload: dict[str, Any] = {}
960
+ if kind:
961
+ payload["kind"] = kind
962
+ if resolution:
963
+ payload["resolution"] = resolution
964
+ if ttl_seconds:
965
+ payload["ttl_seconds"] = ttl_seconds
966
+ if metadata:
967
+ payload["metadata"] = metadata
968
+ return self._request("POST", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/gui:start", payload)
969
+
970
+ def stop_gui(self, sandbox_id: str) -> dict[str, Any]:
971
+ return self._request("POST", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/gui:stop")
972
+
973
+ def get_viewer(self, sandbox_id: str) -> dict[str, Any]:
974
+ return self._request("GET", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/viewer")
975
+
976
+ def wait_viewer(
977
+ self, sandbox_id: str, kind: str | None = None, timeout: float = 30, poll_interval: float = 0.5
978
+ ) -> dict[str, Any]:
979
+ deadline = time.monotonic() + timeout
980
+ last_error: BaseException | None = None
981
+ while time.monotonic() < deadline:
982
+ try:
983
+ viewer = self.get_viewer(sandbox_id)
984
+ if viewer and viewer.get("ready") and (not kind or viewer.get("kind") == kind):
985
+ return viewer
986
+ except Exception as exc:
987
+ last_error = exc
988
+ time.sleep(poll_interval)
989
+ raise TimeoutError(f"viewer not ready before timeout; last_error={last_error}")
990
+
991
+ def patch_sandbox(self, sandbox_id: str, metadata: dict[str, str], resource_version: str) -> dict[str, Any]:
992
+ payload = {"metadata": metadata, "resource_version": resource_version}
993
+ return self._request("PATCH", f"/v1/sandboxes/{urllib.parse.quote(sandbox_id)}/metadata", payload)