cubepod 0.4.0__tar.gz

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,60 @@
1
+ # Binaries and build artifacts
2
+ bin/
3
+ dist/
4
+ target/
5
+ *.exe
6
+ *.exe~
7
+ *.dll
8
+ *.so
9
+ *.dylib
10
+ *.test
11
+ *.out
12
+ coverage.txt
13
+ coverage.xml
14
+
15
+ # Go workspace / vendor
16
+ go.work
17
+ go.work.sum
18
+
19
+ # Python
20
+ __pycache__/
21
+ *.py[cod]
22
+ *$py.class
23
+ .venv/
24
+ venv/
25
+ .pytest_cache/
26
+ .mypy_cache/
27
+ .ruff_cache/
28
+ *.egg-info/
29
+ .eggs/
30
+
31
+ # Node / TypeScript
32
+ node_modules/
33
+ .pnpm-store/
34
+ *.tsbuildinfo
35
+
36
+ # Local development & runtime artifacts
37
+ .env
38
+ .env.*
39
+ !.env.example
40
+ deploy/k8s/env.local
41
+ *.img
42
+ *.qcow2
43
+ *.raw
44
+ tmp/
45
+ logs/
46
+ .cubepod-data/
47
+
48
+ # Kubernetes / Helm secrets
49
+ deploy/helm/cubepod/values.local.yaml
50
+ *ca-secret.yaml
51
+ *.pem
52
+ *.key
53
+ *.crt
54
+
55
+ # IDE / OS
56
+ .idea/
57
+ .vscode/
58
+ *.swp
59
+ *.swo
60
+ .DS_Store
cubepod-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: cubepod
3
+ Version: 0.4.0
4
+ Summary: Async-first Python SDK for CubePod (E2B-compatible persistent MicroVM sandboxes)
5
+ Project-URL: Homepage, https://github.com/cubeplexai/cubepod
6
+ Project-URL: Repository, https://github.com/cubeplexai/cubepod
7
+ Project-URL: Issues, https://github.com/cubeplexai/cubepod/issues
8
+ Author: CubePlex Maintainers
9
+ License-Expression: Apache-2.0
10
+ Keywords: ai-agents,cubepod,e2b,kata-containers,microvm,sandbox
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: httpx>=0.27.0
21
+ Provides-Extra: test
22
+ Requires-Dist: e2b-code-interpreter>=2.0.0; extra == 'test'
23
+ Requires-Dist: e2b>=2.0.0; extra == 'test'
24
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
25
+ Requires-Dist: pytest>=8.0.0; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # CubePod Python SDK
29
+
30
+ Async-first Python client for **CubePod** — a persistent, writable-RootFS MicroVM execution platform for AI agents with an E2B-compatible Control & Data Plane API.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install cubepod
36
+ ```
37
+
38
+ Requires Python 3.11+.
39
+
40
+ ## Quickstart (Async & Sync)
41
+
42
+ ```python
43
+ import asyncio
44
+ from cubepod import AsyncCubePod, CubePod
45
+
46
+ async def main() -> None:
47
+ async with AsyncCubePod(base_url="http://localhost:8080", api_key="...") as client:
48
+ box = await client.create("cubeplex-ubuntu24-desktop")
49
+ res = await client.execute(box.sandbox_id, "python3 --version")
50
+ print(res.output)
51
+ await client.delete(box.sandbox_id)
52
+
53
+ asyncio.run(main())
54
+
55
+ # Synchronous client
56
+ with CubePod(base_url="http://localhost:8080", api_key="...") as client:
57
+ box = client.create("cubeplex-ubuntu24-desktop")
58
+ print(client.execute(box.sandbox_id, "uname -a").output)
59
+ client.delete(box.sandbox_id)
60
+ ```
61
+
62
+ ## Command Execution & Cursor Logs
63
+
64
+ - **Foreground**: `await client.execute(sandbox_id, "echo hello", workdir="/workspace", envs={"FOO": "bar"})`
65
+ - **Background & Incremental Polling**:
66
+ ```python
67
+ cmd = await client.start_command(sandbox_id, "for i in 1 2 3; do echo $i; sleep 1; done")
68
+ logs = await client.poll_command(sandbox_id, cmd.id, cursor=0)
69
+ # Or stream until completion:
70
+ async for chunk in client.stream_command(sandbox_id, cmd.id):
71
+ print(chunk.content, end="")
72
+ ```
73
+
74
+ ## Files & Streaming Archive Sync
75
+
76
+ ```python
77
+ await client.write_file(sandbox_id, "/workspace/hello.txt", "hello cubepod\n")
78
+
79
+ # Stream large files incrementally
80
+ stream = await client.stream_file(sandbox_id, "/workspace/hello.txt")
81
+ async for chunk in stream:
82
+ print(chunk)
83
+
84
+ # Atomic tar.gz directory sync into the sandbox
85
+ await client.archive_sync(
86
+ sandbox_id,
87
+ {"app/main.py": "print('synced')\n"},
88
+ dest="/workspace",
89
+ )
90
+ ```
91
+
92
+ ## Persistent Lifecycle, Reset & 1s CoW Fork
93
+
94
+ CubePod decouples the container RootFS `upperdir` from `/workspace`:
95
+
96
+ - `await client.pause(sandbox_id)` — suspend compute while retaining both RootFS Upper and Workspace block PVCs.
97
+ - `await client.connect(sandbox_id)` — resume a paused sandbox automatically.
98
+ - `await client.reset_rootfs(sandbox_id)` — wipe RootFS `upperdir` changes while keeping `/workspace` intact.
99
+ - `await client.fork(sandbox_id)` — create a Copy-on-Write clone of both RootFS and Workspace volumes.
100
+
101
+ ## Zero-Trust `cpodref_` Egress
102
+
103
+ Never inject raw API credentials into agent sandboxes. Pass `secret_bindings` with `cpodref_` placeholders at `create(...)` time; `cubepod-egress` swaps placeholders for real credentials over per-sandbox mTLS only when outbound requests leave the sandbox toward allowed target hosts.
@@ -0,0 +1,76 @@
1
+ # CubePod Python SDK
2
+
3
+ Async-first Python client for **CubePod** — a persistent, writable-RootFS MicroVM execution platform for AI agents with an E2B-compatible Control & Data Plane API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install cubepod
9
+ ```
10
+
11
+ Requires Python 3.11+.
12
+
13
+ ## Quickstart (Async & Sync)
14
+
15
+ ```python
16
+ import asyncio
17
+ from cubepod import AsyncCubePod, CubePod
18
+
19
+ async def main() -> None:
20
+ async with AsyncCubePod(base_url="http://localhost:8080", api_key="...") as client:
21
+ box = await client.create("cubeplex-ubuntu24-desktop")
22
+ res = await client.execute(box.sandbox_id, "python3 --version")
23
+ print(res.output)
24
+ await client.delete(box.sandbox_id)
25
+
26
+ asyncio.run(main())
27
+
28
+ # Synchronous client
29
+ with CubePod(base_url="http://localhost:8080", api_key="...") as client:
30
+ box = client.create("cubeplex-ubuntu24-desktop")
31
+ print(client.execute(box.sandbox_id, "uname -a").output)
32
+ client.delete(box.sandbox_id)
33
+ ```
34
+
35
+ ## Command Execution & Cursor Logs
36
+
37
+ - **Foreground**: `await client.execute(sandbox_id, "echo hello", workdir="/workspace", envs={"FOO": "bar"})`
38
+ - **Background & Incremental Polling**:
39
+ ```python
40
+ cmd = await client.start_command(sandbox_id, "for i in 1 2 3; do echo $i; sleep 1; done")
41
+ logs = await client.poll_command(sandbox_id, cmd.id, cursor=0)
42
+ # Or stream until completion:
43
+ async for chunk in client.stream_command(sandbox_id, cmd.id):
44
+ print(chunk.content, end="")
45
+ ```
46
+
47
+ ## Files & Streaming Archive Sync
48
+
49
+ ```python
50
+ await client.write_file(sandbox_id, "/workspace/hello.txt", "hello cubepod\n")
51
+
52
+ # Stream large files incrementally
53
+ stream = await client.stream_file(sandbox_id, "/workspace/hello.txt")
54
+ async for chunk in stream:
55
+ print(chunk)
56
+
57
+ # Atomic tar.gz directory sync into the sandbox
58
+ await client.archive_sync(
59
+ sandbox_id,
60
+ {"app/main.py": "print('synced')\n"},
61
+ dest="/workspace",
62
+ )
63
+ ```
64
+
65
+ ## Persistent Lifecycle, Reset & 1s CoW Fork
66
+
67
+ CubePod decouples the container RootFS `upperdir` from `/workspace`:
68
+
69
+ - `await client.pause(sandbox_id)` — suspend compute while retaining both RootFS Upper and Workspace block PVCs.
70
+ - `await client.connect(sandbox_id)` — resume a paused sandbox automatically.
71
+ - `await client.reset_rootfs(sandbox_id)` — wipe RootFS `upperdir` changes while keeping `/workspace` intact.
72
+ - `await client.fork(sandbox_id)` — create a Copy-on-Write clone of both RootFS and Workspace volumes.
73
+
74
+ ## Zero-Trust `cpodref_` Egress
75
+
76
+ Never inject raw API credentials into agent sandboxes. Pass `secret_bindings` with `cpodref_` placeholders at `create(...)` time; `cubepod-egress` swaps placeholders for real credentials over per-sandbox mTLS only when outbound requests leave the sandbox toward allowed target hosts.
@@ -0,0 +1,33 @@
1
+ """CubePod Python SDK — Sync & Async client for persistent MicroVM agent sandboxes."""
2
+
3
+ from .async_client import AsyncCubePod
4
+ from .exceptions import CubePodFileTooLargeError
5
+ from .models import (
6
+ CommandInfo,
7
+ CommandLogs,
8
+ DesktopSession,
9
+ ExecutionResult,
10
+ FileEntry,
11
+ PanelEndpoint,
12
+ SandboxInfo,
13
+ _build_tar_gz_bytes,
14
+ )
15
+ from .sync_client import CubePod, SyncCubePod
16
+
17
+ __version__ = "0.4.0"
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "SandboxInfo",
22
+ "ExecutionResult",
23
+ "CommandInfo",
24
+ "CommandLogs",
25
+ "PanelEndpoint",
26
+ "FileEntry",
27
+ "DesktopSession",
28
+ "CubePodFileTooLargeError",
29
+ "_build_tar_gz_bytes",
30
+ "AsyncCubePod",
31
+ "CubePod",
32
+ "SyncCubePod",
33
+ ]
@@ -0,0 +1,306 @@
1
+ """Shared payload builders and response parsers for sync and async CubePod clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any, cast
7
+
8
+ import httpx
9
+
10
+ from .models import (
11
+ CommandInfo,
12
+ CommandLogs,
13
+ DesktopSession,
14
+ ExecutionResult,
15
+ FileEntry,
16
+ PanelEndpoint,
17
+ SandboxInfo,
18
+ _build_tar_gz_bytes,
19
+ )
20
+
21
+
22
+ def _normalize_base_url(base_url: str) -> str:
23
+ normalized = base_url.strip().rstrip("/")
24
+ if normalized and not normalized.startswith(("http://", "https://")):
25
+ normalized = f"http://{normalized}"
26
+ return normalized
27
+
28
+
29
+ def _json_dict(resp: httpx.Response) -> dict[str, Any]:
30
+ return cast(dict[str, Any], resp.json())
31
+
32
+
33
+ def _json_list(resp: httpx.Response, wrapper_key: str) -> list[dict[str, Any]]:
34
+ data = resp.json()
35
+ if isinstance(data, list):
36
+ return cast(list[dict[str, Any]], data)
37
+ return cast(list[dict[str, Any]], data.get(wrapper_key, []))
38
+
39
+
40
+ def _parse_info(data: dict[str, Any]) -> SandboxInfo:
41
+ raw_metadata = data.get("metadata")
42
+ metadata: dict[str, str] = (
43
+ {str(k): str(v) for k, v in raw_metadata.items()}
44
+ if isinstance(raw_metadata, dict)
45
+ else {}
46
+ )
47
+ envd_port_raw = data.get("envdPort")
48
+ rootfs_gen_raw = data.get("rootfsGeneration")
49
+ return SandboxInfo(
50
+ sandbox_id=str(data["sandboxID"]),
51
+ template_id=str(data["templateID"]),
52
+ state=str(data["state"]),
53
+ envd_port=int(envd_port_raw) if envd_port_raw is not None else 49983,
54
+ rootfs_generation=int(rootfs_gen_raw) if rootfs_gen_raw is not None else 1,
55
+ metadata=metadata,
56
+ raw=dict(data),
57
+ )
58
+
59
+
60
+ def _parse_panel_endpoint(data: dict[str, Any]) -> PanelEndpoint:
61
+ return PanelEndpoint(
62
+ url=str(data["url"]),
63
+ token=str(data["token"]),
64
+ expires_at=str(data["expiresAt"]) if data.get("expiresAt") is not None else None,
65
+ )
66
+
67
+
68
+ def _parse_desktop_session(data: dict[str, Any]) -> DesktopSession:
69
+ cdp_raw = data.get("cdpUrl") if "cdpUrl" in data else data.get("cdp_url")
70
+ expires_raw = data.get("expiresAt") if "expiresAt" in data else data.get("expires_at")
71
+ return DesktopSession(
72
+ status=str(data.get("status", "")),
73
+ desktop_url=str(data.get("desktopUrl") or data.get("desktop_url") or ""),
74
+ terminal_url=str(data.get("terminalUrl") or data.get("terminal_url") or ""),
75
+ cdp_url=str(cdp_raw) if cdp_raw is not None else None,
76
+ expires_at=str(expires_raw) if expires_raw is not None else None,
77
+ )
78
+
79
+
80
+ def _parse_execution_result(data: dict[str, Any]) -> ExecutionResult:
81
+ exit_code = data.get("exit_code") if "exit_code" in data else data.get("exitCode")
82
+ output = data.get("output", "") or data.get("stdout", "")
83
+ cmd_id = data.get("id") or data.get("cmdID", "")
84
+ return ExecutionResult(
85
+ id=str(cmd_id),
86
+ exit_code=int(exit_code) if exit_code is not None else None,
87
+ output=str(output),
88
+ running=bool(data.get("running", False)),
89
+ pid=int(data["pid"]) if data.get("pid") is not None else None,
90
+ cursor=int(data.get("cursor", 0)),
91
+ )
92
+
93
+
94
+ def _parse_command_info(
95
+ data: dict[str, Any],
96
+ *,
97
+ default_id: str = "",
98
+ default_running: bool = False,
99
+ ) -> CommandInfo:
100
+ cmd_id = data.get("id") or data.get("cmdID", default_id)
101
+ exit_code = data.get("exit_code") if "exit_code" in data else data.get("exitCode")
102
+ output = data.get("output", "") or data.get("stdout", "")
103
+ return CommandInfo(
104
+ id=str(cmd_id),
105
+ pid=int(data["pid"]) if data.get("pid") is not None else None,
106
+ running=bool(data.get("running", default_running)),
107
+ exit_code=int(exit_code) if exit_code is not None else None,
108
+ cursor=int(data.get("cursor", 0)),
109
+ output=str(output),
110
+ )
111
+
112
+
113
+ def _parse_command_logs(
114
+ data: dict[str, Any],
115
+ *,
116
+ command_id: str,
117
+ default_cursor: int,
118
+ ) -> CommandLogs:
119
+ cmd_id = data.get("id") or data.get("cmdID", command_id)
120
+ exit_code = data.get("exit_code") if "exit_code" in data else data.get("exitCode")
121
+ return CommandLogs(
122
+ id=str(cmd_id),
123
+ content=str(data.get("content", "")),
124
+ cursor=int(data.get("cursor", default_cursor)),
125
+ running=bool(data.get("running", False)),
126
+ exit_code=int(exit_code) if exit_code is not None else None,
127
+ )
128
+
129
+
130
+ def _parse_file_entries(data: Any) -> list[FileEntry]:
131
+ entries = data if isinstance(data, list) else data.get("entries", [])
132
+ result: list[FileEntry] = []
133
+ for e in entries:
134
+ raw_mod = e.get("modified_at") or e.get("modifiedAt") or e.get("mtime")
135
+ result.append(
136
+ FileEntry(
137
+ name=str(e.get("name", "")),
138
+ path=str(e.get("path", "")),
139
+ is_dir=bool(e.get("is_dir", False)),
140
+ size=int(e.get("size", 0)),
141
+ mode=str(e.get("mode", "0644")),
142
+ modified_at=str(raw_mod) if raw_mod is not None else None,
143
+ )
144
+ )
145
+ return result
146
+
147
+
148
+ def _build_create_payload(
149
+ template_id: str,
150
+ *,
151
+ volume_key: str | None = None,
152
+ metadata: dict[str, str] | None = None,
153
+ network_policy: dict[str, Any] | None = None,
154
+ secret_bindings: list[dict[str, Any]] | None = None,
155
+ timeout_seconds: int | None = None,
156
+ warm_pool_ref: str | None = None,
157
+ resource: dict[str, str] | None = None,
158
+ ) -> dict[str, Any]:
159
+ payload: dict[str, Any] = {"templateID": template_id, "metadata": metadata or {}}
160
+ if timeout_seconds is not None:
161
+ payload["timeoutSeconds"] = timeout_seconds
162
+
163
+ ext: dict[str, Any] = {}
164
+ if volume_key:
165
+ ext["volumeKey"] = volume_key
166
+ if warm_pool_ref:
167
+ ext["warmPoolRef"] = warm_pool_ref
168
+ if resource:
169
+ ext["resource"] = resource
170
+ if ext:
171
+ payload["extension"] = ext
172
+
173
+ if network_policy is not None:
174
+ payload["networkPolicy"] = dict(network_policy)
175
+ if secret_bindings is not None:
176
+ if "networkPolicy" not in payload:
177
+ payload["networkPolicy"] = {}
178
+ payload["networkPolicy"]["secretBindings"] = secret_bindings
179
+ return payload
180
+
181
+
182
+ def _build_fork_payload(
183
+ *,
184
+ volume_key: str | None = None,
185
+ metadata: dict[str, str] | None = None,
186
+ ) -> dict[str, Any] | None:
187
+ payload: dict[str, Any] = {}
188
+ if metadata is not None:
189
+ payload["metadata"] = metadata
190
+ if volume_key:
191
+ payload["extension"] = {"volumeKey": volume_key}
192
+ return payload or None
193
+
194
+
195
+ def _build_command_payload(
196
+ command: str,
197
+ *,
198
+ background: bool,
199
+ workdir: str | None = None,
200
+ envs: dict[str, str] | None = None,
201
+ uid: int | None = 1000,
202
+ gid: int | None = 1000,
203
+ ) -> dict[str, Any]:
204
+ payload: dict[str, Any] = {
205
+ "command": command,
206
+ "background": background,
207
+ }
208
+ if uid is not None:
209
+ payload["uid"] = uid
210
+ if gid is not None:
211
+ payload["gid"] = gid
212
+ if workdir is not None:
213
+ payload["workdir"] = workdir
214
+ if envs is not None:
215
+ payload["envs"] = envs
216
+ return payload
217
+
218
+
219
+ def _build_archive_sync_request(
220
+ files: dict[str, str | bytes] | bytes,
221
+ *,
222
+ dest: str = "/workspace",
223
+ uid: int | None = None,
224
+ gid: int | None = None,
225
+ remove_paths: Sequence[str] | None = None,
226
+ ) -> tuple[bytes, dict[str, str]]:
227
+ if isinstance(files, (bytes, bytearray)):
228
+ tar_bytes = bytes(files)
229
+ else:
230
+ tar_bytes = _build_tar_gz_bytes(
231
+ files,
232
+ uid=uid if uid is not None else 1000,
233
+ gid=gid if gid is not None else 1000,
234
+ )
235
+ params: dict[str, str] = {"dest": dest}
236
+ if uid is not None:
237
+ params["uid"] = str(uid)
238
+ if gid is not None:
239
+ params["gid"] = str(gid)
240
+ if remove_paths:
241
+ joined = ",".join(p for p in remove_paths if p)
242
+ if joined:
243
+ params["remove_paths"] = joined
244
+ return tar_bytes, params
245
+
246
+
247
+ def _build_desktop_start_payload(
248
+ *,
249
+ resolution: str | None = None,
250
+ initial_url: str | None = None,
251
+ expires_in_seconds: int = 3600,
252
+ ) -> dict[str, Any]:
253
+ payload: dict[str, Any] = {"expiresInSeconds": expires_in_seconds}
254
+ if resolution is not None:
255
+ payload["resolution"] = resolution
256
+ if initial_url is not None:
257
+ payload["initialUrl"] = initial_url
258
+ return payload
259
+
260
+
261
+ def _build_template_register_payload(
262
+ *,
263
+ name: str | None,
264
+ alias: str | None,
265
+ tags: list[str] | None,
266
+ cpu_count: int | None,
267
+ memory_mb: int | None,
268
+ min_free_disk_mb: int | None,
269
+ ) -> dict[str, Any]:
270
+ payload: dict[str, Any] = {}
271
+ if name is not None:
272
+ payload["name"] = name
273
+ if alias is not None:
274
+ payload["alias"] = alias
275
+ if tags is not None:
276
+ payload["tags"] = tags
277
+ if cpu_count is not None:
278
+ payload["cpuCount"] = cpu_count
279
+ if memory_mb is not None:
280
+ payload["memoryMB"] = memory_mb
281
+ if min_free_disk_mb is not None:
282
+ payload["minFreeDiskMb"] = min_free_disk_mb
283
+ return payload
284
+
285
+
286
+ def _build_template_result(created: dict[str, Any]) -> dict[str, Any]:
287
+ return {
288
+ "templateID": str(created.get("templateID", "")),
289
+ "buildID": str(created.get("buildID", "")),
290
+ "aliases": created.get("aliases", []),
291
+ "names": created.get("names", []),
292
+ "tags": created.get("tags", []),
293
+ }
294
+
295
+
296
+ def _format_build_error(build_id: str, reason: Any) -> str:
297
+ if isinstance(reason, dict):
298
+ msg = reason.get("message") or str(reason)
299
+ step = reason.get("step")
300
+ if step:
301
+ msg = f"{msg} (step: {step})"
302
+ elif reason:
303
+ msg = str(reason)
304
+ else:
305
+ msg = "unknown build error"
306
+ return f"Template build {build_id} failed: {msg}"