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
+