mv37-workdir 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# ---------------------------------------------------------------------------
|
|
2
|
+
# Rust / Cargo
|
|
3
|
+
# ---------------------------------------------------------------------------
|
|
4
|
+
/target/
|
|
5
|
+
**/target/
|
|
6
|
+
**/*.rs.bk
|
|
7
|
+
*.pdb
|
|
8
|
+
# Cargo.lock IS committed (this is an application, not a library) — do not ignore it.
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# sandboxd runtime state & SECRETS — never commit these
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# SQLite database + WAL/SHM sidecars
|
|
14
|
+
*.db
|
|
15
|
+
*.db-*
|
|
16
|
+
*.sqlite
|
|
17
|
+
*.sqlite-*
|
|
18
|
+
workdir.db*
|
|
19
|
+
|
|
20
|
+
# AES master key for secret encryption (CRITICAL — never commit)
|
|
21
|
+
secret.key
|
|
22
|
+
**/secret.key
|
|
23
|
+
|
|
24
|
+
# Generated admin/API keys (e.g. from examples/serve_lan.sh) and any *.key
|
|
25
|
+
admin-key.txt
|
|
26
|
+
**/admin-key.txt
|
|
27
|
+
*.key
|
|
28
|
+
|
|
29
|
+
# Local runtime data dirs (when a relative data_dir is used)
|
|
30
|
+
/data/
|
|
31
|
+
/var/
|
|
32
|
+
workspaces/
|
|
33
|
+
jail/
|
|
34
|
+
images/*.ext4
|
|
35
|
+
kernel/
|
|
36
|
+
|
|
37
|
+
# Local config that may hold bootstrap_admin_key / SANDBOXD_SECRET_KEY
|
|
38
|
+
config.toml
|
|
39
|
+
config.local.toml
|
|
40
|
+
config.*.local.toml
|
|
41
|
+
|
|
42
|
+
# Environment files
|
|
43
|
+
.env
|
|
44
|
+
.env.*
|
|
45
|
+
!.env.example
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Logs / temp
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
*.log
|
|
51
|
+
logs/
|
|
52
|
+
tmp/
|
|
53
|
+
*.tmp
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# SDK build/cache artifacts
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Python
|
|
59
|
+
__pycache__/
|
|
60
|
+
*.py[cod]
|
|
61
|
+
*.egg-info/
|
|
62
|
+
sdk/python/dist/
|
|
63
|
+
.venv/
|
|
64
|
+
venv/
|
|
65
|
+
.pytest_cache/
|
|
66
|
+
# TypeScript / Node
|
|
67
|
+
node_modules/
|
|
68
|
+
sdk/typescript/dist/
|
|
69
|
+
sdk/typescript/*.js
|
|
70
|
+
sdk/typescript/*.js.map
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Editors / OS
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
.DS_Store
|
|
76
|
+
.idea/
|
|
77
|
+
.vscode/
|
|
78
|
+
*.swp
|
|
79
|
+
*.swo
|
|
80
|
+
*~
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mv37-workdir
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for workdir
|
|
5
|
+
Project-URL: Homepage, https://workdir.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/mv37-org/workdir
|
|
7
|
+
Project-URL: Issues, https://github.com/mv37-org/workdir/issues
|
|
8
|
+
Author: mv37
|
|
9
|
+
License-Expression: AGPL-3.0-only
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# mv37-workdir
|
|
20
|
+
|
|
21
|
+
Python SDK for [workdir](https://workdir.dev).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install mv37-workdir
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from workdir import Client
|
|
29
|
+
|
|
30
|
+
workdir = Client("https://api.workdir.dev", api_key="...")
|
|
31
|
+
|
|
32
|
+
box = workdir.sandboxes.create()
|
|
33
|
+
print(box.exec("echo hello").stdout)
|
|
34
|
+
box.delete()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The SDK uses only the Python standard library.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# mv37-workdir
|
|
2
|
+
|
|
3
|
+
Python SDK for [workdir](https://workdir.dev).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install mv37-workdir
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from workdir import Client
|
|
11
|
+
|
|
12
|
+
workdir = Client("https://api.workdir.dev", api_key="...")
|
|
13
|
+
|
|
14
|
+
box = workdir.sandboxes.create()
|
|
15
|
+
print(box.exec("echo hello").stdout)
|
|
16
|
+
box.delete()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The SDK uses only the Python standard library.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mv37-workdir"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for workdir"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "AGPL-3.0-only"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "mv37" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: GNU Affero General Public License v3",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Typing :: Typed"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://workdir.dev"
|
|
26
|
+
Repository = "https://github.com/mv37-org/workdir"
|
|
27
|
+
Issues = "https://github.com/mv37-org/workdir/issues"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/workdir"]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Minimal Python SDK for workdir.
|
|
2
|
+
|
|
3
|
+
The default path is one call::
|
|
4
|
+
|
|
5
|
+
from workdir import Client
|
|
6
|
+
client = Client("https://api.sandboxes.example.com", api_key="sk_live_...")
|
|
7
|
+
sandbox = client.sandboxes.create() # cheap default path
|
|
8
|
+
print(sandbox.exec("echo ok").stdout)
|
|
9
|
+
sandbox.delete()
|
|
10
|
+
|
|
11
|
+
Heavier sandboxes require explicit options (spec §3.4)::
|
|
12
|
+
|
|
13
|
+
sandbox = client.sandboxes.create(
|
|
14
|
+
image="browser",
|
|
15
|
+
resources={"cpu": 2, "memory_mb": 4096, "disk_gb": 16},
|
|
16
|
+
browser={"enabled": True, "vnc": True, "cdp": True},
|
|
17
|
+
startup={
|
|
18
|
+
"git": {"url": "https://github.com/acme/app.git", "ref": "main", "depth": 1},
|
|
19
|
+
"commands": [{"name": "install", "run": "pnpm install --frozen-lockfile"}],
|
|
20
|
+
"ports": [3000, 6080],
|
|
21
|
+
"ready": {"http": "http://127.0.0.1:3000", "timeout_seconds": 30},
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
print(sandbox.urls["vnc"])
|
|
25
|
+
|
|
26
|
+
Opt in to an in-sandbox coding agent (opencode), installed on demand::
|
|
27
|
+
|
|
28
|
+
sandbox = client.sandboxes.create(
|
|
29
|
+
coding_agent={"enabled": True},
|
|
30
|
+
startup={"secrets": ["ANTHROPIC_API_KEY"]},
|
|
31
|
+
)
|
|
32
|
+
sandbox.exec("opencode run 'add a test for utils.py'")
|
|
33
|
+
|
|
34
|
+
Uses only the standard library (urllib), so it has zero dependencies.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import urllib.error
|
|
41
|
+
import urllib.parse
|
|
42
|
+
import urllib.request
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from typing import Any, Optional
|
|
45
|
+
|
|
46
|
+
__all__ = ["Client", "ExecResult", "Sandbox", "SandboxError"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SandboxError(Exception):
|
|
50
|
+
def __init__(self, status: int, code: str, message: str):
|
|
51
|
+
super().__init__(f"[{status} {code}] {message}")
|
|
52
|
+
self.status = status
|
|
53
|
+
self.code = code
|
|
54
|
+
self.message = message
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ExecResult:
|
|
59
|
+
exit_code: int
|
|
60
|
+
stdout: str
|
|
61
|
+
stderr: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Http:
|
|
65
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 60.0):
|
|
66
|
+
self.base = base_url.rstrip("/")
|
|
67
|
+
self.key = api_key
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
|
|
70
|
+
def request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
|
71
|
+
url = f"{self.base}{path}"
|
|
72
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
73
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
74
|
+
req.add_header("Authorization", f"Bearer {self.key}")
|
|
75
|
+
req.add_header("Content-Type", "application/json")
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
78
|
+
raw = resp.read()
|
|
79
|
+
return json.loads(raw) if raw else {}
|
|
80
|
+
except urllib.error.HTTPError as e:
|
|
81
|
+
raw = e.read()
|
|
82
|
+
try:
|
|
83
|
+
err = json.loads(raw)["error"]
|
|
84
|
+
raise SandboxError(e.code, err.get("code", "error"), err.get("message", "")) from None
|
|
85
|
+
except (ValueError, KeyError):
|
|
86
|
+
raise SandboxError(e.code, "error", raw.decode(errors="replace")) from None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Sandbox:
|
|
90
|
+
def __init__(self, http: _Http, data: dict):
|
|
91
|
+
self._http = http
|
|
92
|
+
self._data = data
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def id(self) -> str:
|
|
96
|
+
return self._data["id"]
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def state(self) -> str:
|
|
100
|
+
return self._data["state"]
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def boot_path(self) -> str:
|
|
104
|
+
return self._data["boot_path"]
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def timings(self) -> dict:
|
|
108
|
+
return self._data.get("timings", {})
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def urls(self) -> dict:
|
|
112
|
+
return self._data.get("urls", {})
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def price(self) -> dict:
|
|
116
|
+
return self._data.get("price", {})
|
|
117
|
+
|
|
118
|
+
def refresh(self) -> "Sandbox":
|
|
119
|
+
self._data = self._http.request("GET", f"/v1/sandboxes/{self.id}")
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def exec(self, cmd: str, cwd: Optional[str] = None, env: Optional[dict] = None,
|
|
123
|
+
background: bool = False) -> ExecResult:
|
|
124
|
+
body = {"cmd": cmd, "background": background}
|
|
125
|
+
if cwd:
|
|
126
|
+
body["cwd"] = cwd
|
|
127
|
+
if env:
|
|
128
|
+
body["env"] = env
|
|
129
|
+
r = self._http.request("POST", f"/v1/sandboxes/{self.id}/exec", body)
|
|
130
|
+
return ExecResult(r["exit_code"], r["stdout"], r["stderr"])
|
|
131
|
+
|
|
132
|
+
def write_file(self, path: str, content: str) -> None:
|
|
133
|
+
self._http.request("PUT", f"/v1/sandboxes/{self.id}/files",
|
|
134
|
+
{"path": path, "content": content})
|
|
135
|
+
|
|
136
|
+
def read_file(self, path: str) -> str:
|
|
137
|
+
q = urllib.parse.urlencode({"path": path})
|
|
138
|
+
r = self._http.request("GET", f"/v1/sandboxes/{self.id}/files?{q}")
|
|
139
|
+
return r["content"]
|
|
140
|
+
|
|
141
|
+
def expose_port(self, port: int) -> str:
|
|
142
|
+
r = self._http.request("POST", f"/v1/sandboxes/{self.id}/ports/{port}/expose")
|
|
143
|
+
return r["url"]
|
|
144
|
+
|
|
145
|
+
def browser(self) -> dict:
|
|
146
|
+
return self._http.request("GET", f"/v1/sandboxes/{self.id}/browser")
|
|
147
|
+
|
|
148
|
+
def snapshot(self) -> dict:
|
|
149
|
+
return self._http.request("POST", f"/v1/sandboxes/{self.id}/snapshot")
|
|
150
|
+
|
|
151
|
+
def pause(self) -> "Sandbox":
|
|
152
|
+
self._data = self._http.request("POST", f"/v1/sandboxes/{self.id}/pause")
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def resume(self) -> "Sandbox":
|
|
156
|
+
self._data = self._http.request("POST", f"/v1/sandboxes/{self.id}/resume")
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def delete(self) -> None:
|
|
160
|
+
self._http.request("DELETE", f"/v1/sandboxes/{self.id}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _Sandboxes:
|
|
164
|
+
def __init__(self, http: _Http):
|
|
165
|
+
self._http = http
|
|
166
|
+
|
|
167
|
+
def create(self, **options) -> Sandbox:
|
|
168
|
+
# `create()` with no args yields the cheapest, fastest default path.
|
|
169
|
+
body = {k: v for k, v in options.items() if v is not None}
|
|
170
|
+
data = self._http.request("POST", "/v1/sandboxes", body)
|
|
171
|
+
return Sandbox(self._http, data)
|
|
172
|
+
|
|
173
|
+
def get(self, sandbox_id: str) -> Sandbox:
|
|
174
|
+
return Sandbox(self._http, self._http.request("GET", f"/v1/sandboxes/{sandbox_id}"))
|
|
175
|
+
|
|
176
|
+
def list(self) -> list[Sandbox]:
|
|
177
|
+
data = self._http.request("GET", "/v1/sandboxes")
|
|
178
|
+
return [Sandbox(self._http, s) for s in data.get("sandboxes", [])]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _Images:
|
|
182
|
+
def __init__(self, http: _Http):
|
|
183
|
+
self._http = http
|
|
184
|
+
|
|
185
|
+
def create(self, name: str, source: dict, resources_hint: Optional[dict] = None) -> dict:
|
|
186
|
+
body = {"name": name, "source": source}
|
|
187
|
+
if resources_hint:
|
|
188
|
+
body["resources_hint"] = resources_hint
|
|
189
|
+
return self._http.request("POST", "/v1/images", body)
|
|
190
|
+
|
|
191
|
+
def get(self, image_id: str) -> dict:
|
|
192
|
+
return self._http.request("GET", f"/v1/images/{image_id}")
|
|
193
|
+
|
|
194
|
+
def list(self) -> dict:
|
|
195
|
+
return self._http.request("GET", "/v1/images")
|
|
196
|
+
|
|
197
|
+
def delete(self, image_id: str) -> dict:
|
|
198
|
+
return self._http.request("DELETE", f"/v1/images/{image_id}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _Nodes:
|
|
202
|
+
def __init__(self, http: _Http):
|
|
203
|
+
self._http = http
|
|
204
|
+
|
|
205
|
+
def list(self) -> dict:
|
|
206
|
+
return self._http.request("GET", "/v1/nodes")
|
|
207
|
+
|
|
208
|
+
def join_token(self) -> dict:
|
|
209
|
+
return self._http.request("POST", "/v1/nodes/join-token")
|
|
210
|
+
|
|
211
|
+
def drain(self, node_id: str) -> dict:
|
|
212
|
+
return self._http.request("POST", f"/v1/nodes/{node_id}/drain")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _Secrets:
|
|
216
|
+
"""Org-scoped secrets. Values are encrypted at rest and never returned."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, http: _Http):
|
|
219
|
+
self._http = http
|
|
220
|
+
|
|
221
|
+
def set(self, name: str, value: str) -> dict:
|
|
222
|
+
return self._http.request("PUT", f"/v1/secrets/{name}", {"value": value})
|
|
223
|
+
|
|
224
|
+
def list(self) -> list[dict]:
|
|
225
|
+
return self._http.request("GET", "/v1/secrets").get("secrets", [])
|
|
226
|
+
|
|
227
|
+
def delete(self, name: str) -> dict:
|
|
228
|
+
return self._http.request("DELETE", f"/v1/secrets/{name}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Client:
|
|
232
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 60.0):
|
|
233
|
+
self._http = _Http(base_url, api_key, timeout)
|
|
234
|
+
self.sandboxes = _Sandboxes(self._http)
|
|
235
|
+
self.images = _Images(self._http)
|
|
236
|
+
self.nodes = _Nodes(self._http)
|
|
237
|
+
self.secrets = _Secrets(self._http)
|
|
238
|
+
|
|
239
|
+
def usage(self) -> dict:
|
|
240
|
+
return self._http.request("GET", "/v1/usage")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
import os
|
|
245
|
+
client = Client(os.environ.get("WORKDIR_URL", "http://127.0.0.1:8080"),
|
|
246
|
+
os.environ["WORKDIR_API_KEY"])
|
|
247
|
+
sb = client.sandboxes.create()
|
|
248
|
+
print("created", sb.id, "boot_path", sb.boot_path, "boot_ms", sb.timings.get("boot_ms"))
|
|
249
|
+
print("echo:", sb.exec("echo ok").stdout.strip())
|
|
250
|
+
sb.delete()
|
|
251
|
+
print("deleted")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|