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.
Files changed (32) hide show
  1. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/.gitignore +5 -0
  2. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/PKG-INFO +2 -2
  3. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/README.md +1 -1
  4. opencomputer_sdk-0.5.2/examples/test_large_files.py +219 -0
  5. opencomputer_sdk-0.5.2/examples/test_secret_store_fork.py +333 -0
  6. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/__init__.py +1 -1
  7. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/filesystem.py +7 -3
  8. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/sandbox.py +50 -5
  9. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/snapshot.py +13 -15
  10. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/pyproject.toml +1 -1
  11. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/run_all_tests.py +0 -0
  12. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_commands.py +0 -0
  13. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_concurrent.py +0 -0
  14. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_declarative_images.py +0 -0
  15. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_default_template.py +0 -0
  16. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_domain_tls.py +0 -0
  17. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_environment.py +0 -0
  18. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_exec.py +0 -0
  19. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_file_ops.py +0 -0
  20. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_multi_template.py +0 -0
  21. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_python_sdk.py +0 -0
  22. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_reconnect.py +0 -0
  23. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_secretstore.py +0 -0
  24. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/examples/test_timeout.py +0 -0
  25. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/agent.py +0 -0
  26. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/commands.py +0 -0
  27. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/exec.py +0 -0
  28. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/image.py +0 -0
  29. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/project.py +0 -0
  30. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/pty.py +0 -0
  31. {opencomputer_sdk-0.4.8 → opencomputer_sdk-0.5.2}/opencomputer/sse.py +0 -0
  32. {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.4.8
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
@@ -5,7 +5,7 @@ Python SDK for [OpenComputer](https://github.com/diggerhq/opensandbox) — cloud
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- pip install opencomputer
8
+ pip install opencomputer-sdk
9
9
  ```
10
10
 
11
11
  ## 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())
@@ -28,4 +28,4 @@ __all__ = [
28
28
  "Snapshots",
29
29
  ]
30
30
 
31
- __version__ = "0.5.1"
31
+ __version__ = "0.5.2"
@@ -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
- data = content if isinstance(content, bytes) else content.encode()
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
- use_sse = on_build_log is not None and (image is not None or snapshot is not None)
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 (direct worker if available, else CP)."""
189
- if self._data_client is not None:
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={"timeout": timeout},
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
- if on_build_logs is not None:
70
- headers = {**self._headers, "Accept": "text/event-stream"}
71
- client = httpx.AsyncClient(
72
- base_url=self._api_base, headers=headers, timeout=300.0,
73
- )
74
- try:
75
- async with client.stream("POST", "/snapshots", json=body) as resp:
76
- resp.raise_for_status()
77
- return await parse_sse_stream(resp, on_build_logs)
78
- finally:
79
- await client.aclose()
80
-
81
- resp = await self._client.post("/snapshots", json=body)
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."""
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opencomputer-sdk"
7
- version = "0.4.8"
7
+ version = "0.5.2"
8
8
  description = "Python SDK for OpenComputer - cloud sandbox platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"