opencomputer-sdk 0.4.6__tar.gz → 0.4.8__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.
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/PKG-INFO +1 -1
- opencomputer_sdk-0.4.8/examples/test_declarative_images.py +337 -0
- opencomputer_sdk-0.4.8/examples/test_secretstore.py +241 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/opencomputer/__init__.py +7 -1
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/opencomputer/agent.py +6 -0
- opencomputer_sdk-0.4.8/opencomputer/image.py +168 -0
- opencomputer_sdk-0.4.8/opencomputer/project.py +182 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/opencomputer/pty.py +4 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/opencomputer/sandbox.py +56 -9
- opencomputer_sdk-0.4.8/opencomputer/snapshot.py +105 -0
- opencomputer_sdk-0.4.8/opencomputer/sse.py +73 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/pyproject.toml +1 -1
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/.gitignore +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/README.md +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/run_all_tests.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_commands.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_concurrent.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_default_template.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_domain_tls.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_environment.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_exec.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_file_ops.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_multi_template.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_python_sdk.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_reconnect.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/examples/test_timeout.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/opencomputer/commands.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/opencomputer/exec.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/opencomputer/filesystem.py +0 -0
- {opencomputer_sdk-0.4.6 → opencomputer_sdk-0.4.8}/opencomputer/template.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencomputer-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.8
|
|
4
4
|
Summary: Python SDK for OpenComputer - cloud sandbox platform
|
|
5
5
|
Project-URL: Homepage, https://github.com/diggerhq/opensandbox
|
|
6
6
|
Project-URL: Repository, https://github.com/diggerhq/opensandbox
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Declarative Image Builder & Snapshots Test (Python SDK)
|
|
4
|
+
|
|
5
|
+
Tests both patterns for defining sandbox environments:
|
|
6
|
+
|
|
7
|
+
Pattern 1 — On-demand images (cached by content hash):
|
|
8
|
+
image = Image.base().apt_install(["curl"])
|
|
9
|
+
sandbox = await Sandbox.create(image=image)
|
|
10
|
+
|
|
11
|
+
Pattern 2 — Pre-built named snapshots:
|
|
12
|
+
await snapshots.create(name="my-env", image=image)
|
|
13
|
+
sandbox = await Sandbox.create(snapshot="my-env")
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python examples/test_declarative_images.py
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from opencomputer import Sandbox, Image, Snapshots
|
|
24
|
+
|
|
25
|
+
passed = 0
|
|
26
|
+
failed = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def green(msg: str) -> None:
|
|
30
|
+
print(f"\033[32m\u2713 {msg}\033[0m")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def red(msg: str) -> None:
|
|
34
|
+
print(f"\033[31m\u2717 {msg}\033[0m")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bold(msg: str) -> None:
|
|
38
|
+
print(f"\033[1m{msg}\033[0m")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def dim(msg: str) -> None:
|
|
42
|
+
print(f"\033[2m {msg}\033[0m")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check(desc: str, condition: bool, detail: str = "") -> None:
|
|
46
|
+
global passed, failed
|
|
47
|
+
if condition:
|
|
48
|
+
green(desc)
|
|
49
|
+
passed += 1
|
|
50
|
+
else:
|
|
51
|
+
red(f"{desc} ({detail})" if detail else desc)
|
|
52
|
+
failed += 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def main() -> None:
|
|
56
|
+
global passed, failed
|
|
57
|
+
|
|
58
|
+
bold("\n╔══════════════════════════════════════════════════════╗")
|
|
59
|
+
bold("║ Declarative Image Builder & Snapshots Test (Python) ║")
|
|
60
|
+
bold("╚══════════════════════════════════════════════════════════╝\n")
|
|
61
|
+
|
|
62
|
+
sandboxes: list[Sandbox] = []
|
|
63
|
+
snapshots = Snapshots()
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
# ── Test: Image builder basics ──────────────────────────────
|
|
67
|
+
bold("━━━ Test: Image builder basics ━━━\n")
|
|
68
|
+
|
|
69
|
+
base = Image.base()
|
|
70
|
+
with_curl = base.apt_install(["curl"])
|
|
71
|
+
with_python = with_curl.pip_install(["requests"])
|
|
72
|
+
|
|
73
|
+
check("Image.base() uses default base", base.to_dict()["base"] == "base")
|
|
74
|
+
check("Image is immutable — apt_install returns new instance", len(base.to_dict()["steps"]) == 0)
|
|
75
|
+
check("Chained image has 1 step", len(with_curl.to_dict()["steps"]) == 1)
|
|
76
|
+
check("Further chained image has 2 steps", len(with_python.to_dict()["steps"]) == 2)
|
|
77
|
+
|
|
78
|
+
check("Different images have different cache keys", base.cache_key() != with_curl.cache_key())
|
|
79
|
+
check("Same image produces same cache key", base.cache_key() == Image.base().cache_key())
|
|
80
|
+
|
|
81
|
+
# File operations
|
|
82
|
+
with_file = base.add_file("/workspace/config.json", '{"key": "value"}')
|
|
83
|
+
check("add_file creates a step", len(with_file.to_dict()["steps"]) == 1)
|
|
84
|
+
check("add_file step type is correct", with_file.to_dict()["steps"][0]["type"] == "add_file")
|
|
85
|
+
print()
|
|
86
|
+
|
|
87
|
+
# ── Pattern 1: On-demand image creation ────────────────────
|
|
88
|
+
bold("━━━ Pattern 1: On-demand image (first build — cold) ━━━\n")
|
|
89
|
+
|
|
90
|
+
image = (
|
|
91
|
+
Image.base()
|
|
92
|
+
.apt_install(["curl", "jq"])
|
|
93
|
+
.run_commands("mkdir -p /workspace/project")
|
|
94
|
+
.env({"MY_VAR": "hello-from-image", "PROJECT_ROOT": "/workspace/project"})
|
|
95
|
+
.workdir("/workspace/project")
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
dim(f"Cache key: {image.cache_key()}")
|
|
99
|
+
dim("Creating sandbox from image (this will build on first run)...")
|
|
100
|
+
|
|
101
|
+
t1_start = time.time()
|
|
102
|
+
sandbox1 = await Sandbox.create(image=image, timeout=300)
|
|
103
|
+
t1_elapsed = int((time.time() - t1_start) * 1000)
|
|
104
|
+
sandboxes.append(sandbox1)
|
|
105
|
+
|
|
106
|
+
green(f"Sandbox created: {sandbox1.sandbox_id} ({t1_elapsed}ms)")
|
|
107
|
+
check("Sandbox created successfully", sandbox1.status in ("running", "creating"))
|
|
108
|
+
print()
|
|
109
|
+
|
|
110
|
+
# ── Verify installed packages ──────────────────────────────
|
|
111
|
+
bold("━━━ Verify: Packages installed ━━━\n")
|
|
112
|
+
|
|
113
|
+
curl_check = await sandbox1.commands.run("which curl")
|
|
114
|
+
check("curl is installed", curl_check.exit_code == 0, f"exit={curl_check.exit_code}")
|
|
115
|
+
|
|
116
|
+
jq_check = await sandbox1.commands.run("which jq")
|
|
117
|
+
check("jq is installed", jq_check.exit_code == 0, f"exit={jq_check.exit_code}")
|
|
118
|
+
print()
|
|
119
|
+
|
|
120
|
+
# ── Verify env vars ────────────────────────────────────────
|
|
121
|
+
bold("━━━ Verify: Environment variables ━━━\n")
|
|
122
|
+
|
|
123
|
+
env_check = await sandbox1.commands.run("bash -lc 'echo $MY_VAR'")
|
|
124
|
+
check("MY_VAR is set", env_check.stdout.strip() == "hello-from-image", f'got: "{env_check.stdout.strip()}"')
|
|
125
|
+
|
|
126
|
+
root_check = await sandbox1.commands.run("bash -lc 'echo $PROJECT_ROOT'")
|
|
127
|
+
check("PROJECT_ROOT is set", root_check.stdout.strip() == "/workspace/project", f'got: "{root_check.stdout.strip()}"')
|
|
128
|
+
print()
|
|
129
|
+
|
|
130
|
+
# ── Cache hit: second sandbox from same image ──────────────
|
|
131
|
+
bold("━━━ Pattern 1: Cache hit (second sandbox, same image) ━━━\n")
|
|
132
|
+
|
|
133
|
+
dim("Creating second sandbox from same image (should be cached)...")
|
|
134
|
+
t2_start = time.time()
|
|
135
|
+
sandbox2 = await Sandbox.create(image=image, timeout=300)
|
|
136
|
+
t2_elapsed = int((time.time() - t2_start) * 1000)
|
|
137
|
+
sandboxes.append(sandbox2)
|
|
138
|
+
|
|
139
|
+
green(f"Second sandbox created: {sandbox2.sandbox_id} ({t2_elapsed}ms)")
|
|
140
|
+
|
|
141
|
+
if t1_elapsed > 5000:
|
|
142
|
+
check(
|
|
143
|
+
f"Cache hit is faster ({t2_elapsed}ms vs {t1_elapsed}ms cold build)",
|
|
144
|
+
t2_elapsed < t1_elapsed,
|
|
145
|
+
f"cold={t1_elapsed}ms, cached={t2_elapsed}ms",
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
dim(f"Cold build was fast ({t1_elapsed}ms) — skipping speedup check")
|
|
149
|
+
|
|
150
|
+
curl_check2 = await sandbox2.commands.run("which curl")
|
|
151
|
+
check("Cached sandbox also has curl", curl_check2.exit_code == 0)
|
|
152
|
+
print()
|
|
153
|
+
|
|
154
|
+
# Kill sandboxes before creating more
|
|
155
|
+
for sb in sandboxes:
|
|
156
|
+
try:
|
|
157
|
+
await sb.kill()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
sandboxes.clear()
|
|
161
|
+
|
|
162
|
+
# ── Pattern 2: Pre-built named snapshot ────────────────────
|
|
163
|
+
bold("━━━ Pattern 2: Create named snapshot ━━━\n")
|
|
164
|
+
|
|
165
|
+
snapshot_image = (
|
|
166
|
+
Image.base()
|
|
167
|
+
.run_commands(
|
|
168
|
+
"echo 'snapshot-marker' > /workspace/snapshot-test.txt",
|
|
169
|
+
"mkdir -p /workspace/data",
|
|
170
|
+
)
|
|
171
|
+
.env({"SNAPSHOT_ENV": "from-snapshot"})
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
dim("Creating snapshot 'test-env-py'...")
|
|
175
|
+
snapshot_info = await snapshots.create(name="test-env-py", image=snapshot_image)
|
|
176
|
+
green(f"Snapshot created: {snapshot_info['name']} (status={snapshot_info['status']})")
|
|
177
|
+
check("Snapshot status is ready", snapshot_info["status"] == "ready")
|
|
178
|
+
print()
|
|
179
|
+
|
|
180
|
+
# ── List snapshots ─────────────────────────────────────────
|
|
181
|
+
bold("━━━ Verify: List snapshots ━━━\n")
|
|
182
|
+
|
|
183
|
+
snapshot_list = await snapshots.list()
|
|
184
|
+
found = next((s for s in snapshot_list if s["name"] == "test-env-py"), None)
|
|
185
|
+
check("Snapshot appears in list", found is not None)
|
|
186
|
+
print()
|
|
187
|
+
|
|
188
|
+
# ── Get snapshot by name ───────────────────────────────────
|
|
189
|
+
bold("━━━ Verify: Get snapshot by name ━━━\n")
|
|
190
|
+
|
|
191
|
+
fetched = await snapshots.get("test-env-py")
|
|
192
|
+
check("Fetched snapshot matches", fetched["name"] == "test-env-py")
|
|
193
|
+
check("Fetched snapshot is ready", fetched["status"] == "ready")
|
|
194
|
+
print()
|
|
195
|
+
|
|
196
|
+
# ── Create sandbox from named snapshot ─────────────────────
|
|
197
|
+
bold("━━━ Pattern 2: Create sandbox from named snapshot ━━━\n")
|
|
198
|
+
|
|
199
|
+
dim("Creating sandbox from snapshot 'test-env-py'...")
|
|
200
|
+
sandbox3 = await Sandbox.create(snapshot="test-env-py", timeout=300)
|
|
201
|
+
sandboxes.append(sandbox3)
|
|
202
|
+
|
|
203
|
+
check("Sandbox created successfully", sandbox3.status in ("running", "creating"))
|
|
204
|
+
|
|
205
|
+
marker_check = await sandbox3.commands.run("cat /workspace/snapshot-test.txt")
|
|
206
|
+
check("Snapshot marker file exists", marker_check.stdout.strip() == "snapshot-marker", f'got: "{marker_check.stdout.strip()}"')
|
|
207
|
+
|
|
208
|
+
snapshot_env_check = await sandbox3.commands.run("bash -lc 'echo $SNAPSHOT_ENV'")
|
|
209
|
+
check("Snapshot env var is set", snapshot_env_check.stdout.strip() == "from-snapshot", f'got: "{snapshot_env_check.stdout.strip()}"')
|
|
210
|
+
print()
|
|
211
|
+
|
|
212
|
+
# ── Delete snapshot ────────────────────────────────────────
|
|
213
|
+
bold("━━━ Cleanup: Delete snapshot ━━━\n")
|
|
214
|
+
|
|
215
|
+
await snapshots.delete("test-env-py")
|
|
216
|
+
green("Snapshot 'test-env-py' deleted")
|
|
217
|
+
|
|
218
|
+
list_after = await snapshots.list()
|
|
219
|
+
deleted_found = next((s for s in list_after if s["name"] == "test-env-py"), None)
|
|
220
|
+
check("Snapshot no longer in list", deleted_found is None)
|
|
221
|
+
print()
|
|
222
|
+
|
|
223
|
+
# Kill sandbox before next test
|
|
224
|
+
for sb in sandboxes:
|
|
225
|
+
try:
|
|
226
|
+
await sb.kill()
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
sandboxes.clear()
|
|
230
|
+
|
|
231
|
+
# ── Test: addFile step ─────────────────────────────────────
|
|
232
|
+
bold("━━━ Test: add_file — bake files into the image ━━━\n")
|
|
233
|
+
|
|
234
|
+
file_image = (
|
|
235
|
+
Image.base()
|
|
236
|
+
.add_file("/workspace/config.json", '{"env": "production", "debug": false}')
|
|
237
|
+
.add_file("/workspace/setup.sh", "#!/bin/bash\necho 'Hello from setup!'")
|
|
238
|
+
.run_commands("chmod +x /workspace/setup.sh")
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
dim("Creating sandbox with embedded files...")
|
|
242
|
+
sandbox4 = await Sandbox.create(image=file_image, timeout=300)
|
|
243
|
+
sandboxes.append(sandbox4)
|
|
244
|
+
|
|
245
|
+
config_check = await sandbox4.commands.run("cat /workspace/config.json")
|
|
246
|
+
check("add_file wrote config.json", '"env": "production"' in config_check.stdout, f'got: "{config_check.stdout.strip()}"')
|
|
247
|
+
|
|
248
|
+
setup_check = await sandbox4.commands.run("/workspace/setup.sh")
|
|
249
|
+
check("add_file wrote executable script", setup_check.stdout.strip() == "Hello from setup!", f'got: "{setup_check.stdout.strip()}"')
|
|
250
|
+
print()
|
|
251
|
+
|
|
252
|
+
# Kill sandbox before next test
|
|
253
|
+
for sb in sandboxes:
|
|
254
|
+
try:
|
|
255
|
+
await sb.kill()
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
sandboxes.clear()
|
|
259
|
+
|
|
260
|
+
# ── Test: Build log streaming ──────────────────────────────
|
|
261
|
+
bold("━━━ Test: Build log streaming (on_build_log callback) ━━━\n")
|
|
262
|
+
|
|
263
|
+
log_image = Image.base().run_commands("echo 'build step 1'", "echo 'build step 2'")
|
|
264
|
+
|
|
265
|
+
build_logs: list[str] = []
|
|
266
|
+
dim("Creating sandbox with build log streaming...")
|
|
267
|
+
sandbox5 = await Sandbox.create(
|
|
268
|
+
image=log_image,
|
|
269
|
+
timeout=300,
|
|
270
|
+
on_build_log=lambda log: (build_logs.append(log), dim(f" build: {log}")),
|
|
271
|
+
)
|
|
272
|
+
sandboxes.append(sandbox5)
|
|
273
|
+
|
|
274
|
+
check("Build log callback was called", len(build_logs) > 0, f"got {len(build_logs)} logs")
|
|
275
|
+
check("Sandbox created via SSE stream", sandbox5.status in ("running", "creating"))
|
|
276
|
+
print()
|
|
277
|
+
|
|
278
|
+
# Kill sandbox before next test
|
|
279
|
+
for sb in sandboxes:
|
|
280
|
+
try:
|
|
281
|
+
await sb.kill()
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
sandboxes.clear()
|
|
285
|
+
|
|
286
|
+
# ── Test: Snapshot build log streaming ──────────────────────
|
|
287
|
+
bold("━━━ Test: Snapshot build log streaming (on_build_logs) ━━━\n")
|
|
288
|
+
|
|
289
|
+
snapshot_log_image = Image.base().run_commands("echo 'snapshot build'")
|
|
290
|
+
|
|
291
|
+
snapshot_logs: list[str] = []
|
|
292
|
+
dim("Creating snapshot with build log streaming...")
|
|
293
|
+
streamed_snapshot = await snapshots.create(
|
|
294
|
+
name="test-streamed-py",
|
|
295
|
+
image=snapshot_log_image,
|
|
296
|
+
on_build_logs=lambda log: (snapshot_logs.append(log), dim(f" snapshot build: {log}")),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
check("Snapshot build log callback was called", len(snapshot_logs) > 0, f"got {len(snapshot_logs)} logs")
|
|
300
|
+
check("Streamed snapshot has name", streamed_snapshot["name"] == "test-streamed-py")
|
|
301
|
+
check("Streamed snapshot is ready", streamed_snapshot["status"] == "ready")
|
|
302
|
+
|
|
303
|
+
await snapshots.delete("test-streamed-py")
|
|
304
|
+
green("Cleaned up test-streamed-py snapshot")
|
|
305
|
+
print()
|
|
306
|
+
|
|
307
|
+
except Exception as err:
|
|
308
|
+
red(f"Fatal error: {err}")
|
|
309
|
+
import traceback
|
|
310
|
+
traceback.print_exc()
|
|
311
|
+
failed += 1
|
|
312
|
+
finally:
|
|
313
|
+
bold("━━━ Cleanup ━━━\n")
|
|
314
|
+
for sb in sandboxes:
|
|
315
|
+
try:
|
|
316
|
+
await sb.kill()
|
|
317
|
+
dim(f"Killed {sb.sandbox_id}")
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
try:
|
|
321
|
+
await snapshots.delete("test-env-py")
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
try:
|
|
325
|
+
await snapshots.delete("test-streamed-py")
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
bold("========================================")
|
|
330
|
+
bold(f" Results: {passed} passed, {failed} failed")
|
|
331
|
+
bold("========================================\n")
|
|
332
|
+
if failed > 0:
|
|
333
|
+
sys.exit(1)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Secret Stores & Secrets Test
|
|
4
|
+
|
|
5
|
+
Tests:
|
|
6
|
+
1. Create a secret store
|
|
7
|
+
2. List secret stores
|
|
8
|
+
3. Get secret store by ID
|
|
9
|
+
4. Update secret store
|
|
10
|
+
5. Set secrets on a store
|
|
11
|
+
6. List secrets (names only, values never returned)
|
|
12
|
+
7. Create sandbox with secret store (inherits secrets)
|
|
13
|
+
8. Verify secrets are injected as sealed env vars
|
|
14
|
+
9. Delete secret
|
|
15
|
+
10. Delete secret store
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python examples/test_projects.py
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from opencomputer import Sandbox, SecretStore
|
|
26
|
+
|
|
27
|
+
GREEN = "\033[32m"
|
|
28
|
+
RED = "\033[31m"
|
|
29
|
+
BOLD = "\033[1m"
|
|
30
|
+
DIM = "\033[2m"
|
|
31
|
+
RESET = "\033[0m"
|
|
32
|
+
|
|
33
|
+
passed = 0
|
|
34
|
+
failed = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def green(msg: str) -> None:
|
|
38
|
+
print(f"{GREEN}\u2713 {msg}{RESET}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def red(msg: str) -> None:
|
|
42
|
+
print(f"{RED}\u2717 {msg}{RESET}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def bold(msg: str) -> None:
|
|
46
|
+
print(f"{BOLD}{msg}{RESET}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dim(msg: str) -> None:
|
|
50
|
+
print(f"{DIM} {msg}{RESET}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check(desc: str, condition: bool, detail: str = "") -> None:
|
|
54
|
+
global passed, failed
|
|
55
|
+
if condition:
|
|
56
|
+
green(desc)
|
|
57
|
+
passed += 1
|
|
58
|
+
else:
|
|
59
|
+
red(f"{desc} ({detail})" if detail else desc)
|
|
60
|
+
failed += 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def main() -> None:
|
|
64
|
+
global passed, failed
|
|
65
|
+
|
|
66
|
+
bold("\n\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557")
|
|
67
|
+
bold("\u2551 Secret Stores & Secrets Test \u2551")
|
|
68
|
+
bold("\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\n")
|
|
69
|
+
|
|
70
|
+
store_id = None
|
|
71
|
+
sandbox = None
|
|
72
|
+
store_name = f"test-store-{int(time.time())}"
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
# -- Test 1: Create secret store --
|
|
76
|
+
bold("--- Test 1: Create secret store ---\n")
|
|
77
|
+
|
|
78
|
+
store = await SecretStore.create(
|
|
79
|
+
name=store_name,
|
|
80
|
+
egress_allowlist=["api.anthropic.com"],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
store_id = store["id"]
|
|
84
|
+
check("Store created", bool(store["id"]))
|
|
85
|
+
check("Name matches", store["name"] == store_name)
|
|
86
|
+
check("Egress allowlist set", len(store.get("egressAllowlist", [])) == 1)
|
|
87
|
+
dim(f"Store ID: {store_id}")
|
|
88
|
+
print()
|
|
89
|
+
|
|
90
|
+
# -- Test 2: List secret stores --
|
|
91
|
+
bold("--- Test 2: List secret stores ---\n")
|
|
92
|
+
|
|
93
|
+
stores = await SecretStore.list()
|
|
94
|
+
check("List returns list", isinstance(stores, list))
|
|
95
|
+
found = any(s["id"] == store_id for s in stores)
|
|
96
|
+
check("Created store in list", found)
|
|
97
|
+
dim(f"Total stores: {len(stores)}")
|
|
98
|
+
print()
|
|
99
|
+
|
|
100
|
+
# -- Test 3: Get secret store --
|
|
101
|
+
bold("--- Test 3: Get secret store by ID ---\n")
|
|
102
|
+
|
|
103
|
+
fetched = await SecretStore.get(store_id)
|
|
104
|
+
check("Get returns correct store", fetched["id"] == store_id)
|
|
105
|
+
check("Get has correct name", fetched["name"] == store_name)
|
|
106
|
+
print()
|
|
107
|
+
|
|
108
|
+
# -- Test 4: Update secret store --
|
|
109
|
+
bold("--- Test 4: Update secret store ---\n")
|
|
110
|
+
|
|
111
|
+
updated_name = f"{store_name}-updated"
|
|
112
|
+
updated = await SecretStore.update(
|
|
113
|
+
store_id,
|
|
114
|
+
name=updated_name,
|
|
115
|
+
egress_allowlist=["api.anthropic.com", "*.openai.com"],
|
|
116
|
+
)
|
|
117
|
+
check("Name updated", updated["name"] == updated_name)
|
|
118
|
+
check("Egress allowlist updated", len(updated.get("egressAllowlist", [])) == 2)
|
|
119
|
+
print()
|
|
120
|
+
|
|
121
|
+
# -- Test 5: Set secrets --
|
|
122
|
+
bold("--- Test 5: Set secrets ---\n")
|
|
123
|
+
|
|
124
|
+
await SecretStore.set_secret(store_id, "TEST_API_KEY", "sk-test-12345")
|
|
125
|
+
green("Set TEST_API_KEY")
|
|
126
|
+
|
|
127
|
+
await SecretStore.set_secret(store_id, "DATABASE_URL", "postgres://localhost/test")
|
|
128
|
+
green("Set DATABASE_URL")
|
|
129
|
+
|
|
130
|
+
await SecretStore.set_secret(store_id, "TEMP_SECRET", "will-be-deleted")
|
|
131
|
+
green("Set TEMP_SECRET")
|
|
132
|
+
print()
|
|
133
|
+
|
|
134
|
+
# -- Test 6: List secrets --
|
|
135
|
+
bold("--- Test 6: List secret entries ---\n")
|
|
136
|
+
|
|
137
|
+
entries = await SecretStore.list_secrets(store_id)
|
|
138
|
+
check("Returns list", isinstance(entries, list))
|
|
139
|
+
names = [e["name"] for e in entries]
|
|
140
|
+
check("Has TEST_API_KEY", "TEST_API_KEY" in names)
|
|
141
|
+
check("Has DATABASE_URL", "DATABASE_URL" in names)
|
|
142
|
+
check("Has TEMP_SECRET", "TEMP_SECRET" in names)
|
|
143
|
+
check("3 secrets total", len(entries) == 3, f"got {len(entries)}")
|
|
144
|
+
dim(f"Secret names: {', '.join(names)}")
|
|
145
|
+
print()
|
|
146
|
+
|
|
147
|
+
# -- Test 7: Create sandbox with secret store --
|
|
148
|
+
bold("--- Test 7: Create sandbox with secret store ---\n")
|
|
149
|
+
|
|
150
|
+
sandbox = await Sandbox.create(
|
|
151
|
+
secret_store=updated_name,
|
|
152
|
+
timeout=120,
|
|
153
|
+
)
|
|
154
|
+
check("Sandbox created", bool(sandbox.sandbox_id))
|
|
155
|
+
dim(f"Sandbox ID: {sandbox.sandbox_id}")
|
|
156
|
+
print()
|
|
157
|
+
|
|
158
|
+
# -- Test 8: Verify secrets are sealed in sandbox --
|
|
159
|
+
bold("--- Test 8: Verify secrets sealed in sandbox ---\n")
|
|
160
|
+
|
|
161
|
+
# Secrets should be sealed tokens (osb_sealed_*) inside the VM.
|
|
162
|
+
# The MITM proxy replaces sealed tokens with real values on outbound HTTPS requests,
|
|
163
|
+
# so the real secret never exists in VM memory.
|
|
164
|
+
api_key_result = await sandbox.commands.run("echo $TEST_API_KEY")
|
|
165
|
+
api_key_val = api_key_result.stdout.strip()
|
|
166
|
+
check("TEST_API_KEY is sealed", api_key_val.startswith("osb_sealed_"),
|
|
167
|
+
f'got "{api_key_val}"')
|
|
168
|
+
|
|
169
|
+
db_url_result = await sandbox.commands.run("echo $DATABASE_URL")
|
|
170
|
+
db_url_val = db_url_result.stdout.strip()
|
|
171
|
+
check("DATABASE_URL is sealed", db_url_val.startswith("osb_sealed_"),
|
|
172
|
+
f'got "{db_url_val}"')
|
|
173
|
+
|
|
174
|
+
temp_result = await sandbox.commands.run("echo $TEMP_SECRET")
|
|
175
|
+
temp_val = temp_result.stdout.strip()
|
|
176
|
+
check("TEMP_SECRET is sealed", temp_val.startswith("osb_sealed_"),
|
|
177
|
+
f'got "{temp_val}"')
|
|
178
|
+
print()
|
|
179
|
+
|
|
180
|
+
# -- Test 9: Delete secret --
|
|
181
|
+
bold("--- Test 9: Delete secret ---\n")
|
|
182
|
+
|
|
183
|
+
await SecretStore.delete_secret(store_id, "TEMP_SECRET")
|
|
184
|
+
green("Deleted TEMP_SECRET")
|
|
185
|
+
|
|
186
|
+
after_delete = await SecretStore.list_secrets(store_id)
|
|
187
|
+
after_names = [e["name"] for e in after_delete]
|
|
188
|
+
check("TEMP_SECRET removed", "TEMP_SECRET" not in after_names)
|
|
189
|
+
check("2 secrets remaining", len(after_delete) == 2, f"got {len(after_delete)}")
|
|
190
|
+
print()
|
|
191
|
+
|
|
192
|
+
# -- Test 10: Delete secret store --
|
|
193
|
+
bold("--- Test 10: Delete secret store ---\n")
|
|
194
|
+
|
|
195
|
+
# Kill sandbox first
|
|
196
|
+
await sandbox.kill()
|
|
197
|
+
green("Sandbox killed")
|
|
198
|
+
sandbox = None
|
|
199
|
+
|
|
200
|
+
await SecretStore.delete(store_id)
|
|
201
|
+
green("Secret store deleted")
|
|
202
|
+
|
|
203
|
+
# Verify it's gone
|
|
204
|
+
try:
|
|
205
|
+
await SecretStore.get(store_id)
|
|
206
|
+
red("Store should not exist after delete")
|
|
207
|
+
failed += 1
|
|
208
|
+
except Exception:
|
|
209
|
+
green("Store not found after delete (expected)")
|
|
210
|
+
passed += 1
|
|
211
|
+
store_id = None
|
|
212
|
+
print()
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
red(f"Fatal error: {e}")
|
|
216
|
+
import traceback
|
|
217
|
+
traceback.print_exc()
|
|
218
|
+
failed += 1
|
|
219
|
+
finally:
|
|
220
|
+
# Cleanup
|
|
221
|
+
if sandbox:
|
|
222
|
+
try:
|
|
223
|
+
await sandbox.kill()
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
if store_id:
|
|
227
|
+
try:
|
|
228
|
+
await SecretStore.delete(store_id)
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
# --- Summary ---
|
|
233
|
+
bold("========================================")
|
|
234
|
+
bold(f" Results: {passed} passed, {failed} failed")
|
|
235
|
+
bold("========================================\n")
|
|
236
|
+
if failed > 0:
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
asyncio.run(main())
|
|
@@ -4,8 +4,11 @@ from opencomputer.sandbox import Sandbox
|
|
|
4
4
|
from opencomputer.agent import Agent, AgentEvent, AgentSession, AgentSessionInfo
|
|
5
5
|
from opencomputer.filesystem import Filesystem
|
|
6
6
|
from opencomputer.exec import Exec, ProcessResult, ExecSessionInfo
|
|
7
|
+
from opencomputer.image import Image
|
|
7
8
|
from opencomputer.pty import Pty, PtySession
|
|
8
9
|
from opencomputer.template import Template
|
|
10
|
+
from opencomputer.project import SecretStore
|
|
11
|
+
from opencomputer.snapshot import Snapshots
|
|
9
12
|
|
|
10
13
|
__all__ = [
|
|
11
14
|
"Sandbox",
|
|
@@ -17,9 +20,12 @@ __all__ = [
|
|
|
17
20
|
"Exec",
|
|
18
21
|
"ProcessResult",
|
|
19
22
|
"ExecSessionInfo",
|
|
23
|
+
"Image",
|
|
20
24
|
"Pty",
|
|
21
25
|
"PtySession",
|
|
22
26
|
"Template",
|
|
27
|
+
"SecretStore",
|
|
28
|
+
"Snapshots",
|
|
23
29
|
]
|
|
24
30
|
|
|
25
|
-
__version__ = "0.5.
|
|
31
|
+
__version__ = "0.5.1"
|
|
@@ -48,11 +48,13 @@ class Agent:
|
|
|
48
48
|
sandbox_id: str,
|
|
49
49
|
connect_url: str,
|
|
50
50
|
token: str,
|
|
51
|
+
api_key: str = "",
|
|
51
52
|
):
|
|
52
53
|
self._client = client
|
|
53
54
|
self._sandbox_id = sandbox_id
|
|
54
55
|
self._connect_url = connect_url
|
|
55
56
|
self._token = token
|
|
57
|
+
self._api_key = api_key
|
|
56
58
|
|
|
57
59
|
async def start(
|
|
58
60
|
self,
|
|
@@ -138,8 +140,12 @@ class Agent:
|
|
|
138
140
|
"https://", "wss://"
|
|
139
141
|
)
|
|
140
142
|
ws_endpoint = f"{ws_url}/sandboxes/{self._sandbox_id}/exec/{session_id}"
|
|
143
|
+
# WebSocket API cannot set custom headers, so pass credentials as query params.
|
|
144
|
+
# Prefer JWT token (direct worker access); fall back to API key (control plane).
|
|
141
145
|
if self._token:
|
|
142
146
|
ws_endpoint += f"?token={self._token}"
|
|
147
|
+
elif self._api_key:
|
|
148
|
+
ws_endpoint += f"?api_key={self._api_key}"
|
|
143
149
|
|
|
144
150
|
ws = await websockets.connect(ws_endpoint)
|
|
145
151
|
|