opencomputer-sdk 0.4.8__tar.gz → 0.5.2__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.8 → opencomputer_sdk-0.5.2}/.gitignore +5 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/PKG-INFO +2 -2
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/README.md +1 -1
- opencomputer_sdk-0.5.2/examples/test_large_files.py +219 -0
- opencomputer_sdk-0.5.2/examples/test_secret_store_fork.py +333 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/__init__.py +1 -1
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/filesystem.py +7 -3
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/sandbox.py +50 -5
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/snapshot.py +13 -15
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/pyproject.toml +1 -1
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/run_all_tests.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_commands.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_concurrent.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_declarative_images.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_default_template.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_domain_tls.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_environment.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_exec.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_file_ops.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_multi_template.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_python_sdk.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_reconnect.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_secretstore.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_timeout.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/agent.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/commands.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/exec.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/image.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/project.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/pty.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/sse.py +0 -0
- {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/template.py +0 -0
|
@@ -31,6 +31,7 @@ dist/
|
|
|
31
31
|
# Compiled binaries
|
|
32
32
|
bin/
|
|
33
33
|
opensandbox-worker
|
|
34
|
+
/worker
|
|
34
35
|
|
|
35
36
|
# Worker env (secrets)
|
|
36
37
|
worker.env
|
|
@@ -57,3 +58,7 @@ deploy/ec2/*.pem
|
|
|
57
58
|
*.tmp
|
|
58
59
|
*.log
|
|
59
60
|
delme
|
|
61
|
+
deploy/azure/.dev-env-state-*
|
|
62
|
+
deploy/azure/.dev-env-secrets*
|
|
63
|
+
!deploy/azure/.dev-env-secrets.example
|
|
64
|
+
/server
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencomputer-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.2
|
|
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
|
|
@@ -33,7 +33,7 @@ Python SDK for [OpenComputer](https://github.com/diggerhq/opensandbox) — cloud
|
|
|
33
33
|
## Install
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
pip install opencomputer
|
|
36
|
+
pip install opencomputer-sdk
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
## Quick Start
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Large File Streaming Test
|
|
4
|
+
|
|
5
|
+
Tests:
|
|
6
|
+
1. Write/read 100MB file via SDK
|
|
7
|
+
2. Download 100MB file via signed URL
|
|
8
|
+
3. Upload 100MB file via signed URL
|
|
9
|
+
4. read_stream / write_stream 50MB
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python examples/test_large_files.py
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import hashlib
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from opencomputer import Sandbox
|
|
23
|
+
|
|
24
|
+
GREEN = "\033[32m"
|
|
25
|
+
RED = "\033[31m"
|
|
26
|
+
BOLD = "\033[1m"
|
|
27
|
+
DIM = "\033[2m"
|
|
28
|
+
RESET = "\033[0m"
|
|
29
|
+
|
|
30
|
+
passed = 0
|
|
31
|
+
failed = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def green(msg: str) -> None:
|
|
35
|
+
print(f"{GREEN}✓ {msg}{RESET}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def red(msg: str) -> None:
|
|
39
|
+
print(f"{RED}✗ {msg}{RESET}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bold(msg: str) -> None:
|
|
43
|
+
print(f"{BOLD}{msg}{RESET}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dim(msg: str) -> None:
|
|
47
|
+
print(f"{DIM} {msg}{RESET}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check(desc: str, condition: bool, detail: str = "") -> None:
|
|
51
|
+
global passed, failed
|
|
52
|
+
if condition:
|
|
53
|
+
green(desc)
|
|
54
|
+
passed += 1
|
|
55
|
+
else:
|
|
56
|
+
red(f"{desc} ({detail})" if detail else desc)
|
|
57
|
+
failed += 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate_data(size_mb: int) -> bytes:
|
|
61
|
+
"""Generate deterministic data: repeating 256-byte pattern."""
|
|
62
|
+
pattern = bytes(range(256))
|
|
63
|
+
repeats = (size_mb * 1024 * 1024) // 256
|
|
64
|
+
return pattern * repeats
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sha256(data: bytes) -> str:
|
|
68
|
+
return hashlib.sha256(data).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def collect_stream(stream) -> bytes:
|
|
72
|
+
"""Collect an async byte iterator into a single bytes object."""
|
|
73
|
+
chunks = []
|
|
74
|
+
async for chunk in stream:
|
|
75
|
+
chunks.append(chunk)
|
|
76
|
+
return b"".join(chunks)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def byte_chunks(data: bytes, chunk_size: int = 256 * 1024):
|
|
80
|
+
"""Yield data in chunks as an async iterator."""
|
|
81
|
+
for i in range(0, len(data), chunk_size):
|
|
82
|
+
yield data[i : i + chunk_size]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def main() -> None:
|
|
86
|
+
global passed, failed
|
|
87
|
+
|
|
88
|
+
bold("\n╔══════════════════════════════════════════════════╗")
|
|
89
|
+
bold("║ Large File Streaming Test (Python) ║")
|
|
90
|
+
bold("╚══════════════════════════════════════════════════╝\n")
|
|
91
|
+
|
|
92
|
+
sandbox = None
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
sandbox = await Sandbox.create(template="base", timeout=300)
|
|
96
|
+
green(f"Created sandbox: {sandbox.sandbox_id}")
|
|
97
|
+
print()
|
|
98
|
+
|
|
99
|
+
# ── Test 1: Write/read 100MB ─────────────────────────────
|
|
100
|
+
bold("━━━ Test 1: Write/read 100MB file via SDK ━━━\n")
|
|
101
|
+
|
|
102
|
+
data100 = generate_data(100)
|
|
103
|
+
hash100 = sha256(data100)
|
|
104
|
+
dim(f"Generated 100MB data, SHA-256: {hash100[:16]}...")
|
|
105
|
+
|
|
106
|
+
t0 = time.time()
|
|
107
|
+
await sandbox.files.write("/root/large100.bin", data100)
|
|
108
|
+
write_s = time.time() - t0
|
|
109
|
+
dim(f"Write took {write_s:.1f}s ({100 / write_s:.1f} MB/s)")
|
|
110
|
+
green("100MB write completed")
|
|
111
|
+
|
|
112
|
+
t1 = time.time()
|
|
113
|
+
read_back = await sandbox.files.read_bytes("/root/large100.bin")
|
|
114
|
+
read_s = time.time() - t1
|
|
115
|
+
dim(f"Read took {read_s:.1f}s ({100 / read_s:.1f} MB/s)")
|
|
116
|
+
|
|
117
|
+
check(
|
|
118
|
+
"Read returns correct size",
|
|
119
|
+
len(read_back) == len(data100),
|
|
120
|
+
f"got {len(read_back)}, expected {len(data100)}",
|
|
121
|
+
)
|
|
122
|
+
read_hash = sha256(read_back)
|
|
123
|
+
check("SHA-256 matches after read", read_hash == hash100)
|
|
124
|
+
print()
|
|
125
|
+
|
|
126
|
+
# ── Test 2: Download 100MB via signed URL ────────────────
|
|
127
|
+
bold("━━━ Test 2: Download 100MB via signed URL ━━━\n")
|
|
128
|
+
|
|
129
|
+
dl_url = await sandbox.download_url("/root/large100.bin", expires_in=300)
|
|
130
|
+
dim("Download URL generated")
|
|
131
|
+
|
|
132
|
+
t2 = time.time()
|
|
133
|
+
async with httpx.AsyncClient(timeout=120.0) as http:
|
|
134
|
+
dl_resp = await http.get(dl_url)
|
|
135
|
+
check("Signed URL returns 200", dl_resp.status_code == 200)
|
|
136
|
+
|
|
137
|
+
content_length = dl_resp.headers.get("content-length")
|
|
138
|
+
dim(f"Content-Length: {content_length}")
|
|
139
|
+
check("Content-Length is 100MB", content_length == str(len(data100)))
|
|
140
|
+
|
|
141
|
+
dl_data = dl_resp.content
|
|
142
|
+
dl_s = time.time() - t2
|
|
143
|
+
dim(f"Download took {dl_s:.1f}s ({100 / dl_s:.1f} MB/s)")
|
|
144
|
+
|
|
145
|
+
check("Downloaded size correct", len(dl_data) == len(data100))
|
|
146
|
+
dl_hash = sha256(dl_data)
|
|
147
|
+
check("SHA-256 matches via signed URL download", dl_hash == hash100)
|
|
148
|
+
print()
|
|
149
|
+
|
|
150
|
+
# ── Test 3: Upload 100MB via signed URL ──────────────────
|
|
151
|
+
bold("━━━ Test 3: Upload 100MB via signed URL ━━━\n")
|
|
152
|
+
|
|
153
|
+
up_url = await sandbox.upload_url("/root/uploaded100.bin")
|
|
154
|
+
dim("Upload URL generated")
|
|
155
|
+
|
|
156
|
+
t3 = time.time()
|
|
157
|
+
async with httpx.AsyncClient(timeout=120.0) as http:
|
|
158
|
+
up_resp = await http.put(up_url, content=data100)
|
|
159
|
+
up_s = time.time() - t3
|
|
160
|
+
dim(f"Upload took {up_s:.1f}s ({100 / up_s:.1f} MB/s)")
|
|
161
|
+
check("Upload returns 204", up_resp.status_code == 204)
|
|
162
|
+
|
|
163
|
+
up_read_back = await sandbox.files.read_bytes("/root/uploaded100.bin")
|
|
164
|
+
check("Uploaded file size correct", len(up_read_back) == len(data100))
|
|
165
|
+
up_hash = sha256(up_read_back)
|
|
166
|
+
check("SHA-256 matches after signed URL upload", up_hash == hash100)
|
|
167
|
+
print()
|
|
168
|
+
|
|
169
|
+
# ── Test 4: read_stream / write_stream (50MB) ────────────
|
|
170
|
+
bold("━━━ Test 4: read_stream / write_stream 50MB ━━━\n")
|
|
171
|
+
|
|
172
|
+
data50 = generate_data(50)
|
|
173
|
+
hash50 = sha256(data50)
|
|
174
|
+
dim(f"Generated 50MB data, SHA-256: {hash50[:16]}...")
|
|
175
|
+
|
|
176
|
+
# write_stream
|
|
177
|
+
t4 = time.time()
|
|
178
|
+
await sandbox.files.write_stream("/root/stream50.bin", byte_chunks(data50))
|
|
179
|
+
ws_s = time.time() - t4
|
|
180
|
+
dim(f"write_stream took {ws_s:.1f}s")
|
|
181
|
+
green("50MB write_stream completed")
|
|
182
|
+
|
|
183
|
+
# read_stream
|
|
184
|
+
t5 = time.time()
|
|
185
|
+
stream = await sandbox.files.read_stream("/root/stream50.bin")
|
|
186
|
+
stream_data = await collect_stream(stream)
|
|
187
|
+
rs_s = time.time() - t5
|
|
188
|
+
dim(f"read_stream took {rs_s:.1f}s")
|
|
189
|
+
|
|
190
|
+
check(
|
|
191
|
+
"read_stream returns correct size",
|
|
192
|
+
len(stream_data) == len(data50),
|
|
193
|
+
f"got {len(stream_data)}, expected {len(data50)}",
|
|
194
|
+
)
|
|
195
|
+
stream_hash = sha256(stream_data)
|
|
196
|
+
check("SHA-256 matches via read_stream", stream_hash == hash50)
|
|
197
|
+
print()
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
red(f"Fatal error: {e}")
|
|
201
|
+
import traceback
|
|
202
|
+
|
|
203
|
+
traceback.print_exc()
|
|
204
|
+
failed += 1
|
|
205
|
+
finally:
|
|
206
|
+
if sandbox:
|
|
207
|
+
await sandbox.kill()
|
|
208
|
+
green("Sandbox killed")
|
|
209
|
+
|
|
210
|
+
# --- Summary ---
|
|
211
|
+
bold("========================================")
|
|
212
|
+
bold(f" Results: {passed} passed, {failed} failed")
|
|
213
|
+
bold("========================================\n")
|
|
214
|
+
if failed > 0:
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Test secret store inheritance on fork / create_from_checkpoint.
|
|
4
|
+
|
|
5
|
+
Creates two secret stores, a sandbox with store A, checkpoints it,
|
|
6
|
+
then forks with store B attached. Also tests snapshot + secretStore path.
|
|
7
|
+
Verifies:
|
|
8
|
+
1. Secret env vars from both stores are present (layered merge)
|
|
9
|
+
2. Secrets are sealed in-guest (osb_sealed_* tokens, not plaintext)
|
|
10
|
+
3. Actual secret VALUES resolve correctly through the proxy (httpbin echo)
|
|
11
|
+
4. Store B's value wins on collision (SHARED_KEY override)
|
|
12
|
+
5. Snapshot + secretStore path works end-to-end
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
OPENCOMPUTER_API_URL=... OPENCOMPUTER_API_KEY=... python examples/test_secret_store_fork.py
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from opencomputer import Sandbox, SecretStore, Snapshots, Image
|
|
24
|
+
|
|
25
|
+
GREEN = "\033[32m"
|
|
26
|
+
RED = "\033[31m"
|
|
27
|
+
BOLD = "\033[1m"
|
|
28
|
+
DIM = "\033[2m"
|
|
29
|
+
RESET = "\033[0m"
|
|
30
|
+
|
|
31
|
+
passed = 0
|
|
32
|
+
failed = 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def green(msg: str) -> None:
|
|
36
|
+
print(f"{GREEN}\u2713 {msg}{RESET}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def red(msg: str, detail: str = "") -> None:
|
|
40
|
+
suffix = f" \u2014 {detail}" if detail else ""
|
|
41
|
+
print(f"{RED}\u2717 {msg}{suffix}{RESET}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def bold(msg: str) -> None:
|
|
45
|
+
print(f"{BOLD}{msg}{RESET}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dim(msg: str) -> None:
|
|
49
|
+
print(f"{DIM} {msg}{RESET}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check(desc: str, condition: bool, detail: str = "") -> None:
|
|
53
|
+
global passed, failed
|
|
54
|
+
if condition:
|
|
55
|
+
green(desc)
|
|
56
|
+
passed += 1
|
|
57
|
+
else:
|
|
58
|
+
red(desc, detail)
|
|
59
|
+
failed += 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def main() -> None:
|
|
63
|
+
global passed, failed
|
|
64
|
+
|
|
65
|
+
suffix = int(time.time() * 1000)
|
|
66
|
+
store_a_name = f"fork-test-a-{suffix}"
|
|
67
|
+
store_b_name = f"fork-test-b-{suffix}"
|
|
68
|
+
|
|
69
|
+
# Random-ish values for httpbin verification
|
|
70
|
+
git_token_val = f"git_{os.urandom(12).hex()}"
|
|
71
|
+
shared_key_a_val = f"shared_a_{os.urandom(12).hex()}"
|
|
72
|
+
shared_key_b_val = f"shared_b_{os.urandom(12).hex()}"
|
|
73
|
+
api_key_val = f"api_{os.urandom(12).hex()}"
|
|
74
|
+
|
|
75
|
+
store_a_id: str | None = None
|
|
76
|
+
store_b_id: str | None = None
|
|
77
|
+
base_sandbox: Sandbox | None = None
|
|
78
|
+
forked_sandbox: Sandbox | None = None
|
|
79
|
+
snapshot_sandbox: Sandbox | None = None
|
|
80
|
+
snapshot_name: str | None = None
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# -- Setup: two secret stores -----------------------------------------
|
|
84
|
+
bold("\n=== 1. Setup: create two secret stores ===\n")
|
|
85
|
+
|
|
86
|
+
store_a = await SecretStore.create(
|
|
87
|
+
name=store_a_name,
|
|
88
|
+
egress_allowlist=["github.com", "httpbin.org"],
|
|
89
|
+
)
|
|
90
|
+
store_a_id = store_a["id"]
|
|
91
|
+
print(f" Store A: {store_a_id} ({store_a_name})")
|
|
92
|
+
|
|
93
|
+
await SecretStore.set_secret(store_a_id, "GIT_TOKEN", git_token_val,
|
|
94
|
+
allowed_hosts=["github.com", "httpbin.org"])
|
|
95
|
+
await SecretStore.set_secret(store_a_id, "SHARED_KEY", shared_key_a_val,
|
|
96
|
+
allowed_hosts=["httpbin.org"])
|
|
97
|
+
dim(f"GIT_TOKEN = {git_token_val}")
|
|
98
|
+
dim(f"SHARED_KEY(A) = {shared_key_a_val}")
|
|
99
|
+
green("Store A created: GIT_TOKEN + SHARED_KEY, egress=[github.com, httpbin.org]")
|
|
100
|
+
|
|
101
|
+
store_b = await SecretStore.create(
|
|
102
|
+
name=store_b_name,
|
|
103
|
+
egress_allowlist=["api.anthropic.com", "httpbin.org"],
|
|
104
|
+
)
|
|
105
|
+
store_b_id = store_b["id"]
|
|
106
|
+
print(f" Store B: {store_b_id} ({store_b_name})")
|
|
107
|
+
|
|
108
|
+
await SecretStore.set_secret(store_b_id, "API_KEY", api_key_val,
|
|
109
|
+
allowed_hosts=["api.anthropic.com", "httpbin.org"])
|
|
110
|
+
await SecretStore.set_secret(store_b_id, "SHARED_KEY", shared_key_b_val,
|
|
111
|
+
allowed_hosts=["httpbin.org"])
|
|
112
|
+
dim(f"API_KEY = {api_key_val}")
|
|
113
|
+
dim(f"SHARED_KEY(B) = {shared_key_b_val} (should override A)")
|
|
114
|
+
green("Store B created: API_KEY + SHARED_KEY(override), egress=[api.anthropic.com, httpbin.org]")
|
|
115
|
+
|
|
116
|
+
# -- Layer 1: sandbox with store A -------------------------------------
|
|
117
|
+
bold("\n=== 2. Create base sandbox with store A ===\n")
|
|
118
|
+
|
|
119
|
+
base_sandbox = await Sandbox.create(secret_store=store_a_name, timeout=120)
|
|
120
|
+
print(f" Base sandbox: {base_sandbox.sandbox_id}")
|
|
121
|
+
await asyncio.sleep(5)
|
|
122
|
+
|
|
123
|
+
env_base = (await base_sandbox.exec.run("env")).stdout
|
|
124
|
+
check("Base has GIT_TOKEN", "GIT_TOKEN" in env_base)
|
|
125
|
+
check("Base has SHARED_KEY", "SHARED_KEY" in env_base)
|
|
126
|
+
check("Base does NOT have API_KEY", "API_KEY" not in env_base)
|
|
127
|
+
|
|
128
|
+
# Verify sealed
|
|
129
|
+
base_git_echo = (await base_sandbox.exec.run('printf %s "$GIT_TOKEN"')).stdout.strip()
|
|
130
|
+
check(
|
|
131
|
+
"Base: GIT_TOKEN is sealed (not plaintext)",
|
|
132
|
+
base_git_echo.startswith("osb_sealed_") and git_token_val not in base_git_echo,
|
|
133
|
+
f'got "{base_git_echo[:40]}..."',
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Verify values via httpbin
|
|
137
|
+
base_httpbin = (await base_sandbox.exec.run(
|
|
138
|
+
'curl -sS -m 10 https://httpbin.org/headers '
|
|
139
|
+
'-H "X-Git: $GIT_TOKEN" -H "X-Shared: $SHARED_KEY"'
|
|
140
|
+
)).stdout
|
|
141
|
+
check("Base: GIT_TOKEN value resolves via httpbin",
|
|
142
|
+
git_token_val in base_httpbin,
|
|
143
|
+
f"httpbin did not echo back {git_token_val}")
|
|
144
|
+
check("Base: SHARED_KEY(A) value resolves via httpbin",
|
|
145
|
+
shared_key_a_val in base_httpbin,
|
|
146
|
+
f"httpbin did not echo back {shared_key_a_val}")
|
|
147
|
+
|
|
148
|
+
# -- Checkpoint --------------------------------------------------------
|
|
149
|
+
bold("\n=== 3. Create checkpoint from base ===\n")
|
|
150
|
+
|
|
151
|
+
cp = await base_sandbox.create_checkpoint("fork-test-cp")
|
|
152
|
+
print(f" Checkpoint: {cp['id']}")
|
|
153
|
+
|
|
154
|
+
for _ in range(30):
|
|
155
|
+
cps = await base_sandbox.list_checkpoints()
|
|
156
|
+
found = next((c for c in cps if c["id"] == cp["id"]), None)
|
|
157
|
+
if found and found.get("status") == "ready":
|
|
158
|
+
break
|
|
159
|
+
await asyncio.sleep(2)
|
|
160
|
+
green("Checkpoint ready")
|
|
161
|
+
|
|
162
|
+
# -- Layer 2: fork with store B ----------------------------------------
|
|
163
|
+
bold("\n=== 4. Fork from checkpoint with store B (secretStore inheritance) ===\n")
|
|
164
|
+
|
|
165
|
+
forked_sandbox = await Sandbox.create_from_checkpoint(
|
|
166
|
+
cp["id"],
|
|
167
|
+
secret_store=store_b_name,
|
|
168
|
+
timeout=120,
|
|
169
|
+
)
|
|
170
|
+
print(f" Forked sandbox: {forked_sandbox.sandbox_id}")
|
|
171
|
+
await asyncio.sleep(5)
|
|
172
|
+
|
|
173
|
+
# -- 4a. Check env vars exist ------------------------------------------
|
|
174
|
+
bold("\n=== 4a. Verify secret env vars present ===\n")
|
|
175
|
+
|
|
176
|
+
env_fork = (await forked_sandbox.exec.run("env")).stdout
|
|
177
|
+
check("Fork has GIT_TOKEN (inherited from store A)", "GIT_TOKEN" in env_fork)
|
|
178
|
+
check("Fork has API_KEY (from store B)", "API_KEY" in env_fork)
|
|
179
|
+
check("Fork has SHARED_KEY (merged)", "SHARED_KEY" in env_fork)
|
|
180
|
+
check("Fork has HTTP_PROXY (secrets proxy active)", "HTTP_PROXY" in env_fork)
|
|
181
|
+
|
|
182
|
+
sealed_count = env_fork.count("osb_sealed_")
|
|
183
|
+
check(
|
|
184
|
+
f"Fork has 3 sealed secrets (got {sealed_count})",
|
|
185
|
+
sealed_count == 3,
|
|
186
|
+
"expected GIT_TOKEN(A) + SHARED_KEY(B override) + API_KEY(B)",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# -- 4b. Verify secrets are sealed -------------------------------------
|
|
190
|
+
bold("\n=== 4b. Verify secrets are sealed in-guest ===\n")
|
|
191
|
+
|
|
192
|
+
fork_git_echo = (await forked_sandbox.exec.run('printf %s "$GIT_TOKEN"')).stdout.strip()
|
|
193
|
+
check("Fork: GIT_TOKEN is sealed",
|
|
194
|
+
fork_git_echo.startswith("osb_sealed_") and git_token_val not in fork_git_echo,
|
|
195
|
+
f'got "{fork_git_echo[:40]}..."')
|
|
196
|
+
|
|
197
|
+
fork_api_echo = (await forked_sandbox.exec.run('printf %s "$API_KEY"')).stdout.strip()
|
|
198
|
+
check("Fork: API_KEY is sealed",
|
|
199
|
+
fork_api_echo.startswith("osb_sealed_") and api_key_val not in fork_api_echo,
|
|
200
|
+
f'got "{fork_api_echo[:40]}..."')
|
|
201
|
+
|
|
202
|
+
fork_shared_echo = (await forked_sandbox.exec.run('printf %s "$SHARED_KEY"')).stdout.strip()
|
|
203
|
+
check("Fork: SHARED_KEY is sealed",
|
|
204
|
+
fork_shared_echo.startswith("osb_sealed_") and shared_key_b_val not in fork_shared_echo,
|
|
205
|
+
f'got "{fork_shared_echo[:40]}..."')
|
|
206
|
+
|
|
207
|
+
# -- 4c. Verify actual VALUES via httpbin ------------------------------
|
|
208
|
+
bold("\n=== 4c. Verify actual secret values via httpbin (proxy substitution) ===\n")
|
|
209
|
+
|
|
210
|
+
fork_httpbin = (await forked_sandbox.exec.run(
|
|
211
|
+
'curl -sS -m 10 https://httpbin.org/headers '
|
|
212
|
+
'-H "X-Git: $GIT_TOKEN" '
|
|
213
|
+
'-H "X-Api: $API_KEY" '
|
|
214
|
+
'-H "X-Shared: $SHARED_KEY"'
|
|
215
|
+
)).stdout
|
|
216
|
+
dim(f"httpbin response (first 500 chars):")
|
|
217
|
+
dim(fork_httpbin.replace("\n", " ")[:500])
|
|
218
|
+
|
|
219
|
+
check("Fork: GIT_TOKEN value correct (inherited from A)",
|
|
220
|
+
git_token_val in fork_httpbin,
|
|
221
|
+
f"httpbin did not echo back {git_token_val}")
|
|
222
|
+
check("Fork: API_KEY value correct (from B)",
|
|
223
|
+
api_key_val in fork_httpbin,
|
|
224
|
+
f"httpbin did not echo back {api_key_val}")
|
|
225
|
+
check("Fork: SHARED_KEY has B's value (B override wins on collision)",
|
|
226
|
+
shared_key_b_val in fork_httpbin,
|
|
227
|
+
f"httpbin did not echo back B's value {shared_key_b_val}")
|
|
228
|
+
check("Fork: SHARED_KEY does NOT have A's value (overridden by B)",
|
|
229
|
+
shared_key_a_val not in fork_httpbin,
|
|
230
|
+
f"httpbin echoed A's value {shared_key_a_val} -- override did not work")
|
|
231
|
+
|
|
232
|
+
# -- 5. Snapshot/template path -----------------------------------------
|
|
233
|
+
bold("\n=== 5. Create snapshot template, fork with secretStore ===\n")
|
|
234
|
+
|
|
235
|
+
snapshots = Snapshots()
|
|
236
|
+
snapshot_name = f"fork-secret-test-{suffix}"
|
|
237
|
+
dim(f'Creating snapshot "{snapshot_name}" from base image...')
|
|
238
|
+
await snapshots.create(
|
|
239
|
+
name=snapshot_name,
|
|
240
|
+
image=Image.base().run_commands("echo 'template-ready' > /home/sandbox/marker.txt"),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
for _ in range(30):
|
|
244
|
+
info = await snapshots.get(snapshot_name)
|
|
245
|
+
if info.get("status") == "ready":
|
|
246
|
+
break
|
|
247
|
+
await asyncio.sleep(2)
|
|
248
|
+
green(f'Snapshot "{snapshot_name}" ready')
|
|
249
|
+
|
|
250
|
+
snapshot_sandbox = await Sandbox.create(
|
|
251
|
+
snapshot=snapshot_name,
|
|
252
|
+
secret_store=store_b_name,
|
|
253
|
+
timeout=120,
|
|
254
|
+
)
|
|
255
|
+
print(f" Snapshot-forked sandbox: {snapshot_sandbox.sandbox_id}")
|
|
256
|
+
await asyncio.sleep(5)
|
|
257
|
+
|
|
258
|
+
# Verify template content
|
|
259
|
+
marker = (await snapshot_sandbox.exec.run("cat /home/sandbox/marker.txt")).stdout.strip()
|
|
260
|
+
check("Snapshot fork: template content present (/home/sandbox/marker.txt)",
|
|
261
|
+
marker == "template-ready",
|
|
262
|
+
f'got "{marker}"')
|
|
263
|
+
|
|
264
|
+
# Verify secrets
|
|
265
|
+
env_snap = (await snapshot_sandbox.exec.run("env")).stdout
|
|
266
|
+
check("Snapshot fork: has API_KEY (from store B)", "API_KEY" in env_snap)
|
|
267
|
+
check("Snapshot fork: has SHARED_KEY (from store B)", "SHARED_KEY" in env_snap)
|
|
268
|
+
check("Snapshot fork: has HTTP_PROXY (secrets proxy active)", "HTTP_PROXY" in env_snap)
|
|
269
|
+
|
|
270
|
+
snap_api_echo = (await snapshot_sandbox.exec.run('printf %s "$API_KEY"')).stdout.strip()
|
|
271
|
+
check("Snapshot fork: API_KEY is sealed",
|
|
272
|
+
snap_api_echo.startswith("osb_sealed_") and api_key_val not in snap_api_echo,
|
|
273
|
+
f'got "{snap_api_echo[:40]}..."')
|
|
274
|
+
|
|
275
|
+
# Verify values via httpbin
|
|
276
|
+
snap_httpbin = (await snapshot_sandbox.exec.run(
|
|
277
|
+
'curl -sS -m 10 https://httpbin.org/headers '
|
|
278
|
+
'-H "X-Api: $API_KEY" '
|
|
279
|
+
'-H "X-Shared: $SHARED_KEY"'
|
|
280
|
+
)).stdout
|
|
281
|
+
dim(f"httpbin response (first 500 chars):")
|
|
282
|
+
dim(snap_httpbin.replace("\n", " ")[:500])
|
|
283
|
+
|
|
284
|
+
check("Snapshot fork: API_KEY value correct via httpbin",
|
|
285
|
+
api_key_val in snap_httpbin,
|
|
286
|
+
f"httpbin did not echo back {api_key_val}")
|
|
287
|
+
check("Snapshot fork: SHARED_KEY has B's value via httpbin",
|
|
288
|
+
shared_key_b_val in snap_httpbin,
|
|
289
|
+
f"httpbin did not echo back {shared_key_b_val}")
|
|
290
|
+
|
|
291
|
+
# -- Summary -----------------------------------------------------------
|
|
292
|
+
bold(f"\n=== Results: {passed} passed, {failed} failed ===\n")
|
|
293
|
+
|
|
294
|
+
finally:
|
|
295
|
+
bold("=== Cleanup ===")
|
|
296
|
+
if snapshot_sandbox:
|
|
297
|
+
try:
|
|
298
|
+
await snapshot_sandbox.kill()
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
if forked_sandbox:
|
|
302
|
+
try:
|
|
303
|
+
await forked_sandbox.kill()
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
if base_sandbox:
|
|
307
|
+
try:
|
|
308
|
+
await base_sandbox.kill()
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
if snapshot_name:
|
|
312
|
+
try:
|
|
313
|
+
s = Snapshots()
|
|
314
|
+
await s.delete(snapshot_name)
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
if store_b_id:
|
|
318
|
+
try:
|
|
319
|
+
await SecretStore.delete(store_b_id)
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
if store_a_id:
|
|
323
|
+
try:
|
|
324
|
+
await SecretStore.delete(store_a_id)
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
green("Cleanup done")
|
|
328
|
+
|
|
329
|
+
sys.exit(1 if failed > 0 else 0)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
asyncio.run(main())
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
+
from typing import AsyncIterator
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
|
|
@@ -42,9 +43,12 @@ class Filesystem:
|
|
|
42
43
|
resp.raise_for_status()
|
|
43
44
|
return resp.content
|
|
44
45
|
|
|
45
|
-
async def write(self, path: str, content: str | bytes) -> None:
|
|
46
|
-
"""Write content to a file."""
|
|
47
|
-
|
|
46
|
+
async def write(self, path: str, content: str | bytes | AsyncIterator[bytes]) -> None:
|
|
47
|
+
"""Write content to a file. Accepts str, bytes, or an async iterator of bytes for streaming."""
|
|
48
|
+
if isinstance(content, (str, bytes)):
|
|
49
|
+
data: str | bytes | AsyncIterator[bytes] = content if isinstance(content, bytes) else content.encode()
|
|
50
|
+
else:
|
|
51
|
+
data = content
|
|
48
52
|
resp = await self._client.put(
|
|
49
53
|
f"/sandboxes/{self._sandbox_id}/files",
|
|
50
54
|
params={"path": path},
|
|
@@ -70,7 +70,9 @@ class Sandbox:
|
|
|
70
70
|
if key:
|
|
71
71
|
headers["X-API-Key"] = key
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
# Always use SSE for image/snapshot creation to keep the connection alive
|
|
74
|
+
# through proxies (Cloudflare has a 100s idle timeout).
|
|
75
|
+
use_sse = image is not None or snapshot is not None
|
|
74
76
|
if use_sse:
|
|
75
77
|
headers["Accept"] = "text/event-stream"
|
|
76
78
|
|
|
@@ -185,9 +187,8 @@ class Sandbox:
|
|
|
185
187
|
|
|
186
188
|
@property
|
|
187
189
|
def _ops_client(self) -> httpx.AsyncClient:
|
|
188
|
-
"""Return the client for data operations
|
|
189
|
-
|
|
190
|
-
return self._data_client
|
|
190
|
+
"""Return the client for data operations. Always goes through the CP,
|
|
191
|
+
which handles readiness waiting and proxies to workers."""
|
|
191
192
|
return self._client
|
|
192
193
|
|
|
193
194
|
async def kill(self) -> None:
|
|
@@ -217,6 +218,38 @@ class Sandbox:
|
|
|
217
218
|
)
|
|
218
219
|
resp.raise_for_status()
|
|
219
220
|
|
|
221
|
+
async def download_url(self, path: str, *, expires_in: int = 3600) -> str:
|
|
222
|
+
"""Generate a signed download URL for a sandbox file.
|
|
223
|
+
|
|
224
|
+
The URL can be used by anyone (e.g. in a browser) without an API key.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
path: Absolute path inside the sandbox.
|
|
228
|
+
expires_in: URL validity in seconds (default: 3600, max: 86400).
|
|
229
|
+
"""
|
|
230
|
+
resp = await self._client.post(
|
|
231
|
+
f"/sandboxes/{self.sandbox_id}/files/download-url",
|
|
232
|
+
json={"path": path, "expiresIn": expires_in},
|
|
233
|
+
)
|
|
234
|
+
resp.raise_for_status()
|
|
235
|
+
return resp.json()["url"]
|
|
236
|
+
|
|
237
|
+
async def upload_url(self, path: str, *, expires_in: int = 3600) -> str:
|
|
238
|
+
"""Generate a signed upload URL for a sandbox file.
|
|
239
|
+
|
|
240
|
+
The URL can be used by anyone to PUT file content without an API key.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
path: Absolute path inside the sandbox.
|
|
244
|
+
expires_in: URL validity in seconds (default: 3600, max: 86400).
|
|
245
|
+
"""
|
|
246
|
+
resp = await self._client.post(
|
|
247
|
+
f"/sandboxes/{self.sandbox_id}/files/upload-url",
|
|
248
|
+
json={"path": path, "expiresIn": expires_in},
|
|
249
|
+
)
|
|
250
|
+
resp.raise_for_status()
|
|
251
|
+
return resp.json()["url"]
|
|
252
|
+
|
|
220
253
|
@property
|
|
221
254
|
def agent(self) -> Agent:
|
|
222
255
|
"""Access Claude Agent SDK sessions."""
|
|
@@ -302,6 +335,8 @@ class Sandbox:
|
|
|
302
335
|
timeout: int = 300,
|
|
303
336
|
api_key: str | None = None,
|
|
304
337
|
api_url: str | None = None,
|
|
338
|
+
envs: dict[str, str] | None = None,
|
|
339
|
+
secret_store: str | None = None,
|
|
305
340
|
) -> Sandbox:
|
|
306
341
|
"""Create a new sandbox from an existing checkpoint (fork).
|
|
307
342
|
|
|
@@ -310,6 +345,10 @@ class Sandbox:
|
|
|
310
345
|
timeout: Sandbox timeout in seconds (default 300).
|
|
311
346
|
api_key: API key (or OPENCOMPUTER_API_KEY env var).
|
|
312
347
|
api_url: API URL (or OPENCOMPUTER_API_URL env var).
|
|
348
|
+
envs: Environment variables to override on the fork.
|
|
349
|
+
secret_store: Secret store name to attach. If the checkpoint
|
|
350
|
+
already has a store, secrets are merged (new store wins
|
|
351
|
+
on collision, egress allowlists aggregate).
|
|
313
352
|
"""
|
|
314
353
|
url = api_url or os.environ.get("OPENCOMPUTER_API_URL", "https://app.opencomputer.dev")
|
|
315
354
|
url = url.rstrip("/")
|
|
@@ -323,9 +362,15 @@ class Sandbox:
|
|
|
323
362
|
|
|
324
363
|
client = httpx.AsyncClient(base_url=api_base, headers=headers, timeout=120.0)
|
|
325
364
|
|
|
365
|
+
body: dict[str, Any] = {"timeout": timeout}
|
|
366
|
+
if envs:
|
|
367
|
+
body["envs"] = envs
|
|
368
|
+
if secret_store:
|
|
369
|
+
body["secretStore"] = secret_store
|
|
370
|
+
|
|
326
371
|
resp = await client.post(
|
|
327
372
|
f"/sandboxes/from-checkpoint/{checkpoint_id}",
|
|
328
|
-
json=
|
|
373
|
+
json=body,
|
|
329
374
|
)
|
|
330
375
|
resp.raise_for_status()
|
|
331
376
|
data = resp.json()
|
|
@@ -66,21 +66,19 @@ class Snapshots:
|
|
|
66
66
|
"""
|
|
67
67
|
body = {"name": name, "image": image.to_dict()}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
await
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
resp.raise_for_status()
|
|
83
|
-
return resp.json()
|
|
69
|
+
# Always use SSE streaming — builds can take minutes and
|
|
70
|
+
# non-streaming requests will hit Cloudflare 524 timeouts.
|
|
71
|
+
headers = {**self._headers, "Accept": "text/event-stream"}
|
|
72
|
+
log_fn = on_build_logs if on_build_logs is not None else lambda _: None
|
|
73
|
+
client = httpx.AsyncClient(
|
|
74
|
+
base_url=self._api_base, headers=headers, timeout=600.0,
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
async with client.stream("POST", "/snapshots", json=body) as resp:
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
return await parse_sse_stream(resp, log_fn)
|
|
80
|
+
finally:
|
|
81
|
+
await client.aclose()
|
|
84
82
|
|
|
85
83
|
async def list(self) -> list[dict[str, Any]]:
|
|
86
84
|
"""List all named snapshots for the current org."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|