drukbox-python-sdk 0.0.7__tar.gz → 0.2.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.
@@ -25,7 +25,7 @@ object is a sandbox host.
25
25
 
26
26
  ```text
27
27
  src/drukbox_sdk/
28
- api.py # SandboxAPI, records, parsers, HTTP request handling
28
+ api.py # SandboxAPI, records (SandboxHost, Secret, Issuer, ...), HTTP request handling
29
29
  exceptions.py # SDK exception hierarchy
30
30
  __init__.py # Public exports
31
31
  tests/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: drukbox-python-sdk
3
- Version: 0.0.7
3
+ Version: 0.2.0
4
4
  Summary: Async Python client for Drukbox.
5
5
  Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
6
6
  Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
@@ -15,10 +15,10 @@ Description-Content-Type: text/markdown
15
15
 
16
16
  Async Python client for the [Drukbox] host API.
17
17
 
18
- The SDK provisions sandbox VMs, reads host state, deletes hosts, and
19
- returns the SSH connection details a caller needs. It speaks HTTP only:
20
- SSH sessions, file transfer, command execution, and retry orchestration
21
- belong in the caller.
18
+ The SDK builds reusable sandbox templates, provisions sandbox VMs, reads host
19
+ state, deletes hosts and templates, and returns the SSH connection details a
20
+ caller needs. It speaks HTTP only: SSH sessions, file transfer, command
21
+ execution, and retry orchestration belong in the caller.
22
22
 
23
23
  ## Install
24
24
 
@@ -70,6 +70,9 @@ Public exports live in `drukbox_sdk`:
70
70
 
71
71
  - `SandboxAPI`
72
72
  - `SandboxHost`
73
+ - `SandboxTemplate`
74
+ - `Secret`
75
+ - `Issuer`
73
76
  - `DoctorReport` and `DoctorCheck`
74
77
  - `HTTPProxy` and `HTTPProxyAttachment`
75
78
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -87,16 +90,54 @@ Supported host operations:
87
90
  - `aclose`
88
91
 
89
92
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
90
- `provider`, `instance_type`, `disk_gb`, and `Idempotency-Key` inputs.
91
- `instance_type` (provider-native size, e.g. `t3.xlarge` / `cx33`) and `disk_gb`
92
- pin the VM shape; omit either for the provider default. `expires_at` mirrors the
93
- wire contract: omit it for the default lease, pass a datetime for an explicit
94
- expiry, or pass `None` for a never-reaped (permanent) host.
93
+ `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
94
+ and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
95
+ `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
96
+ provider default. Omit `expires_at` for the default lease, pass a datetime for
97
+ an explicit expiry, or pass `permanent=True` for a never-reaped host.
98
+ `template` accepts a template ID. An explicit `image` wins over it.
99
+
100
+ `secrets` gives the box credentials it never sees. Each entry is a `Secret`,
101
+ a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
102
+ from. The box gets a placeholder per entry in the service's variable, and the
103
+ Drukbox swaps it on the way out. A custom service names its `host` and
104
+ `auth_variable`:
105
+
106
+ ```python
107
+ from drukbox_sdk import Issuer, Secret
108
+
109
+ host = await sandbox.create_host(
110
+ secrets={
111
+ "anthropic": Issuer(
112
+ "https://mint.example/box-1/anthropic",
113
+ headers={"Authorization": "Bearer ..."},
114
+ refresh="1h",
115
+ ),
116
+ "openai": Secret("sk-..."),
117
+ "acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
118
+ },
119
+ )
120
+ ```
121
+
122
+ The response carries no secret. A service name Drukbox does not know raises
123
+ `SandboxValidationError`. A deployment without the secrets proxy raises
124
+ `SandboxConflictError`.
95
125
 
96
126
  `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
97
127
  `expires_at` to extend by the service's default TTL; renewal never makes a host
98
128
  permanent.
99
129
 
130
+ Supported template operations:
131
+
132
+ - `create_template`
133
+ - `get_template`
134
+ - `list_templates`
135
+ - `delete_template`
136
+
137
+ `create_template` returns immediately with a `building` record. Poll
138
+ `get_template` until the status is `available` or `failed`. A failed record
139
+ carries the reason in `last_error`.
140
+
100
141
  Supported HTTP-proxy operations:
101
142
 
102
143
  - `create_http_proxy`
@@ -132,4 +173,4 @@ uv run pytest
132
173
  Tests use `respx` to fake the Drukbox HTTP API. They do not need a
133
174
  real network, VM provider, or Drukbox service.
134
175
 
135
- [Drukbox]: https://github.com/clawhaven/drukbox
176
+ [Drukbox]: https://github.com/czpython/drukbox
@@ -2,10 +2,10 @@
2
2
 
3
3
  Async Python client for the [Drukbox] host API.
4
4
 
5
- The SDK provisions sandbox VMs, reads host state, deletes hosts, and
6
- returns the SSH connection details a caller needs. It speaks HTTP only:
7
- SSH sessions, file transfer, command execution, and retry orchestration
8
- belong in the caller.
5
+ The SDK builds reusable sandbox templates, provisions sandbox VMs, reads host
6
+ state, deletes hosts and templates, and returns the SSH connection details a
7
+ caller needs. It speaks HTTP only: SSH sessions, file transfer, command
8
+ execution, and retry orchestration belong in the caller.
9
9
 
10
10
  ## Install
11
11
 
@@ -57,6 +57,9 @@ Public exports live in `drukbox_sdk`:
57
57
 
58
58
  - `SandboxAPI`
59
59
  - `SandboxHost`
60
+ - `SandboxTemplate`
61
+ - `Secret`
62
+ - `Issuer`
60
63
  - `DoctorReport` and `DoctorCheck`
61
64
  - `HTTPProxy` and `HTTPProxyAttachment`
62
65
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
@@ -74,16 +77,54 @@ Supported host operations:
74
77
  - `aclose`
75
78
 
76
79
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
77
- `provider`, `instance_type`, `disk_gb`, and `Idempotency-Key` inputs.
78
- `instance_type` (provider-native size, e.g. `t3.xlarge` / `cx33`) and `disk_gb`
79
- pin the VM shape; omit either for the provider default. `expires_at` mirrors the
80
- wire contract: omit it for the default lease, pass a datetime for an explicit
81
- expiry, or pass `None` for a never-reaped (permanent) host.
80
+ `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
81
+ and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
82
+ `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
83
+ provider default. Omit `expires_at` for the default lease, pass a datetime for
84
+ an explicit expiry, or pass `permanent=True` for a never-reaped host.
85
+ `template` accepts a template ID. An explicit `image` wins over it.
86
+
87
+ `secrets` gives the box credentials it never sees. Each entry is a `Secret`,
88
+ a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
89
+ from. The box gets a placeholder per entry in the service's variable, and the
90
+ Drukbox swaps it on the way out. A custom service names its `host` and
91
+ `auth_variable`:
92
+
93
+ ```python
94
+ from drukbox_sdk import Issuer, Secret
95
+
96
+ host = await sandbox.create_host(
97
+ secrets={
98
+ "anthropic": Issuer(
99
+ "https://mint.example/box-1/anthropic",
100
+ headers={"Authorization": "Bearer ..."},
101
+ refresh="1h",
102
+ ),
103
+ "openai": Secret("sk-..."),
104
+ "acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
105
+ },
106
+ )
107
+ ```
108
+
109
+ The response carries no secret. A service name Drukbox does not know raises
110
+ `SandboxValidationError`. A deployment without the secrets proxy raises
111
+ `SandboxConflictError`.
82
112
 
83
113
  `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
84
114
  `expires_at` to extend by the service's default TTL; renewal never makes a host
85
115
  permanent.
86
116
 
117
+ Supported template operations:
118
+
119
+ - `create_template`
120
+ - `get_template`
121
+ - `list_templates`
122
+ - `delete_template`
123
+
124
+ `create_template` returns immediately with a `building` record. Poll
125
+ `get_template` until the status is `available` or `failed`. A failed record
126
+ carries the reason in `last_error`.
127
+
87
128
  Supported HTTP-proxy operations:
88
129
 
89
130
  - `create_http_proxy`
@@ -119,4 +160,4 @@ uv run pytest
119
160
  Tests use `respx` to fake the Drukbox HTTP API. They do not need a
120
161
  real network, VM provider, or Drukbox service.
121
162
 
122
- [Drukbox]: https://github.com/clawhaven/drukbox
163
+ [Drukbox]: https://github.com/czpython/drukbox
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.0.7"
7
+ version = "0.2.0"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,8 +9,11 @@ from .api import (
9
9
  DoctorReport,
10
10
  HTTPProxy,
11
11
  HTTPProxyAttachment,
12
+ Issuer,
12
13
  SandboxAPI,
13
14
  SandboxHost,
15
+ SandboxTemplate,
16
+ Secret,
14
17
  )
15
18
  from .exceptions import (
16
19
  SandboxAPIError,
@@ -28,6 +31,7 @@ __all__ = [
28
31
  "DoctorReport",
29
32
  "HTTPProxy",
30
33
  "HTTPProxyAttachment",
34
+ "Issuer",
31
35
  "SandboxAPI",
32
36
  "SandboxAPIError",
33
37
  "SandboxAuthError",
@@ -36,6 +40,8 @@ __all__ = [
36
40
  "SandboxNotFoundError",
37
41
  "SandboxProvisioningError",
38
42
  "SandboxResponseError",
43
+ "SandboxTemplate",
39
44
  "SandboxUnavailableError",
40
45
  "SandboxValidationError",
46
+ "Secret",
41
47
  ]
@@ -26,13 +26,20 @@ Usage::
26
26
  await sandbox.delete_host(host.id)
27
27
  await sandbox.aclose()
28
28
 
29
+ Templates bake a setup script into a reusable image; hosts fork from it::
30
+
31
+ template = await sandbox.create_template(setup_script=script)
32
+ while template.status == "building":
33
+ template = await sandbox.get_template(template.id)
34
+ host = await sandbox.create_host(template=template.id)
35
+
29
36
  For env-backed config use :meth:`SandboxAPI.from_env`.
30
37
  """
31
38
 
32
39
  import asyncio
33
40
  import os
34
41
  import uuid
35
- from dataclasses import dataclass
42
+ from dataclasses import dataclass, field
36
43
  from datetime import datetime
37
44
  from typing import Any, Self
38
45
 
@@ -58,22 +65,6 @@ _SANDBOX_HTTP_LIMITS = httpx.Limits(
58
65
  )
59
66
 
60
67
 
61
- class _Unset:
62
- """Sentinel distinguishing an omitted argument from an explicit ``None``.
63
-
64
- ``create_host`` mirrors the service's tri-state ``expires_at``: an
65
- omitted field means "default lease", an explicit ``null`` means
66
- "permanent". A plain ``datetime | None`` default can't tell those two
67
- apart, so the omitted case uses this sentinel.
68
- """
69
-
70
- def __repr__(self) -> str:
71
- return "UNSET"
72
-
73
-
74
- _UNSET = _Unset()
75
-
76
-
77
68
  @dataclass(frozen=True)
78
69
  class SandboxHost:
79
70
  """Snapshot of a provisioned host as returned by the service.
@@ -117,6 +108,73 @@ class SandboxHost:
117
108
  expires_at: str | None
118
109
 
119
110
 
111
+ @dataclass(frozen=True, kw_only=True)
112
+ class _Service:
113
+ """The custom service fields of a secret. None is unset. An empty
114
+ ``auth_prefix`` is set, and Drukbox reads it as no prefix."""
115
+
116
+ host: str | None = None
117
+ auth_variable: str | None = None
118
+ auth_header: str | None = None
119
+ auth_prefix: str | None = None
120
+
121
+ def get_service(self) -> dict[str, Any]:
122
+ fields = {
123
+ "host": self.host,
124
+ "auth_variable": self.auth_variable,
125
+ "auth_header": self.auth_header,
126
+ "auth_prefix": self.auth_prefix,
127
+ }
128
+ return {name: value for name, value in fields.items() if value is not None}
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class Secret(_Service):
133
+ """A credential the caller holds."""
134
+
135
+ value: str = field(repr=False)
136
+
137
+ def entry(self) -> dict[str, Any]:
138
+ return {**self.get_service(), "value": self.value}
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Issuer(_Service):
143
+ """A credential Drukbox fetches from ``url`` with ``headers``, and again
144
+ every ``refresh`` when the answer carries no ``expires_at``."""
145
+
146
+ url: str
147
+ headers: dict[str, str] = field(repr=False)
148
+ refresh: str
149
+
150
+ def entry(self) -> dict[str, Any]:
151
+ issuer = {"url": self.url, "headers": dict(self.headers), "refresh": self.refresh}
152
+ return {**self.get_service(), "issuer": issuer}
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class SandboxTemplate:
157
+ """Snapshot of a reusable sandbox template as returned by the service.
158
+
159
+ ``status`` moves from ``"building"`` to ``"available"`` or
160
+ ``"failed"``. While the build runs, ``image`` is empty. A failed
161
+ build carries its reason in ``last_error``. The record has no
162
+ ``setup_script`` because the service never echoes it back.
163
+ """
164
+
165
+ id: str
166
+ provider: str
167
+ base_image: str
168
+ setup_script_hash: str
169
+ label: str
170
+ image: str
171
+ status: str
172
+ last_error: str
173
+ created_at: str
174
+ updated_at: str
175
+ last_used_at: str | None
176
+
177
+
120
178
  @dataclass(frozen=True)
121
179
  class DoctorCheck:
122
180
  """One dependency probe. ``hint`` is a remediation slug, set only on failures."""
@@ -215,20 +273,19 @@ class SandboxAPI:
215
273
  timeout = float(os.environ.get(f"{prefix}SERVICE_TIMEOUT", "300"))
216
274
  return cls(base_url=url, token=token, timeout=timeout)
217
275
 
218
- # ------------------------------------------------------------------
219
- # Host lifecycle
220
- # ------------------------------------------------------------------
221
-
222
276
  async def create_host(
223
277
  self,
224
278
  *,
225
279
  disk_gb: int | None = None,
226
280
  env: dict[str, str] | None = None,
227
- expires_at: datetime | None | _Unset = _UNSET,
281
+ expires_at: datetime | None = None,
228
282
  idempotency_key: str | None = None,
229
283
  image: str | None = None,
230
284
  instance_type: str | None = None,
285
+ permanent: bool = False,
231
286
  provider: str | None = None,
287
+ secrets: dict[str, Secret | Issuer] | None = None,
288
+ template: uuid.UUID | str | None = None,
232
289
  ) -> SandboxHost:
233
290
  """Provision a new host.
234
291
 
@@ -248,45 +305,58 @@ class SandboxAPI:
248
305
  use the service default. An unknown provider raises
249
306
  :class:`SandboxResponseError` (the service rejects it with 400).
250
307
 
308
+ ``template`` names a template by its ID, from
309
+ :meth:`create_template` or :meth:`get_template`. An explicit
310
+ ``image`` wins when you pass both. A template that is not
311
+ ``available`` raises :class:`SandboxConflictError`. An unknown
312
+ ID raises :class:`SandboxResponseError`.
313
+
251
314
  ``instance_type`` (provider-native size, e.g. ``t3.xlarge`` /
252
315
  ``cx33``) and ``disk_gb`` (root disk size) pin the VM shape; omit
253
316
  either to use the provider's configured default. A size the active
254
317
  provider can't serve raises :class:`SandboxResponseError` (400).
255
318
 
256
- Lease (mirrors the wire contract's tri-state ``expires_at``): omit
257
- it for the service's default lease, pass a ``datetime`` for an
258
- explicit expiry, or pass ``None`` for a never-reaped (permanent)
259
- host — exactly as an omitted field vs. an explicit ``null`` behave
260
- on the API.
319
+ Lease: omit ``expires_at`` for the service's default lease, pass a
320
+ ``datetime`` for an explicit expiry, or pass ``permanent=True`` for
321
+ a host the janitor never reaps.
322
+
323
+ ``secrets`` maps a service name to a :class:`Secret` or an
324
+ :class:`Issuer`. The response carries no secret.
261
325
  """
262
326
 
327
+ if expires_at and permanent:
328
+ raise ValueError("a permanent host cannot carry an expires_at")
329
+
263
330
  payload: dict[str, Any] = {}
264
331
  if disk_gb is not None:
265
332
  payload["disk_gb"] = disk_gb
266
333
  if env is not None:
267
334
  payload["env"] = env
268
- # Sentinel default lets us tell "caller omitted expires_at" (default
269
- # lease) from "caller passed None" (explicit null → permanent host).
270
- if not isinstance(expires_at, _Unset):
271
- payload["expires_at"] = None if expires_at is None else expires_at.isoformat()
335
+ if expires_at:
336
+ payload["expires_at"] = expires_at.isoformat()
337
+ if permanent:
338
+ # An explicit null on the wire is the opt-out from expiry.
339
+ payload["expires_at"] = None
272
340
  if image is not None:
273
341
  payload["image"] = image
274
342
  if instance_type is not None:
275
343
  payload["instance_type"] = instance_type
276
344
  if provider is not None:
277
345
  payload["provider"] = provider
346
+ if secrets:
347
+ payload["secrets"] = {name: entry.entry() for name, entry in secrets.items()}
348
+ if template is not None:
349
+ payload["template"] = str(template)
278
350
 
279
351
  headers: dict[str, str] = {}
280
352
  if idempotency_key is not None:
281
353
  headers["Idempotency-Key"] = idempotency_key
282
354
 
283
355
  data = await self._request("POST", "/hosts", json=payload, headers=headers)
284
- assert isinstance(data, dict)
285
356
  return SandboxHost(**data)
286
357
 
287
358
  async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
288
359
  data = await self._request("GET", f"/hosts/{host_id}")
289
- assert isinstance(data, dict)
290
360
  return SandboxHost(**data)
291
361
 
292
362
  async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
@@ -303,7 +373,6 @@ class SandboxAPI:
303
373
 
304
374
  async def list_hosts(self) -> list[SandboxHost]:
305
375
  data = await self._request("GET", "/hosts")
306
- assert isinstance(data, list)
307
376
  return [SandboxHost(**item) for item in data]
308
377
 
309
378
  async def renew_host(
@@ -327,12 +396,61 @@ class SandboxAPI:
327
396
  if expires_at is not None:
328
397
  payload["expires_at"] = expires_at.isoformat()
329
398
  data = await self._request("POST", f"/hosts/{host_id}/renew", json=payload)
330
- assert isinstance(data, dict)
331
399
  return SandboxHost(**data)
332
400
 
333
401
  async def delete_host(self, host_id: uuid.UUID | str) -> None:
334
402
  await self._request("DELETE", f"/hosts/{host_id}")
335
403
 
404
+ async def create_template(
405
+ self,
406
+ *,
407
+ setup_script: str,
408
+ provider: str | None = None,
409
+ base_image: str | None = None,
410
+ label: str | None = None,
411
+ ) -> SandboxTemplate:
412
+ """Start a template build and return immediately.
413
+
414
+ The service responds 202 with ``status="building"``. Poll
415
+ :meth:`get_template` until the status becomes ``"available"`` or
416
+ ``"failed"``. A failed record carries the reason in
417
+ ``last_error``. A repeated post with the same provider, base
418
+ image, and setup script returns the existing record without a
419
+ rebuild.
420
+ """
421
+
422
+ payload: dict[str, Any] = {"setup_script": setup_script}
423
+ if provider is not None:
424
+ payload["provider"] = provider
425
+ if base_image is not None:
426
+ payload["base_image"] = base_image
427
+ if label is not None:
428
+ payload["label"] = label
429
+
430
+ data = await self._request("POST", "/templates", json=payload)
431
+ return SandboxTemplate(**data)
432
+
433
+ async def get_template(self, template_id: uuid.UUID | str) -> SandboxTemplate:
434
+ """Fetch one template. Callers poll this after :meth:`create_template`."""
435
+
436
+ data = await self._request("GET", f"/templates/{template_id}")
437
+ return SandboxTemplate(**data)
438
+
439
+ async def list_templates(self) -> list[SandboxTemplate]:
440
+ """List templates newest first."""
441
+
442
+ data = await self._request("GET", "/templates")
443
+ return [SandboxTemplate(**item) for item in data]
444
+
445
+ async def delete_template(self, template_id: uuid.UUID | str) -> None:
446
+ """Delete a template.
447
+
448
+ A template whose status is ``building`` raises
449
+ :class:`SandboxConflictError`.
450
+ """
451
+
452
+ await self._request("DELETE", f"/templates/{template_id}")
453
+
336
454
  async def doctor(self) -> DoctorReport:
337
455
  """Fetch ``GET /doctor`` — read-only dependency health.
338
456
 
@@ -341,7 +459,6 @@ class SandboxAPI:
341
459
  """
342
460
 
343
461
  data = await self._request("GET", "/doctor")
344
- assert isinstance(data, dict)
345
462
  return DoctorReport(
346
463
  ok=data["ok"],
347
464
  active_provider=data["active_provider"],
@@ -349,10 +466,6 @@ class SandboxAPI:
349
466
  checks=[DoctorCheck(**check) for check in data["checks"]],
350
467
  )
351
468
 
352
- # ------------------------------------------------------------------
353
- # HTTP proxies
354
- # ------------------------------------------------------------------
355
-
356
469
  async def create_http_proxy(
357
470
  self,
358
471
  *,
@@ -371,7 +484,6 @@ class SandboxAPI:
371
484
 
372
485
  payload: dict[str, Any] = {"name": name, "target": target, "headers": headers}
373
486
  data = await self._request("POST", "/http-proxies", json=payload)
374
- assert isinstance(data, dict)
375
487
  return HTTPProxy(**data)
376
488
 
377
489
  async def delete_http_proxy(self, name: str) -> None:
@@ -393,7 +505,6 @@ class SandboxAPI:
393
505
  """
394
506
 
395
507
  data = await self._request("POST", f"/http-proxies/{name}/hosts/{host_id}")
396
- assert isinstance(data, dict)
397
508
  return HTTPProxyAttachment(**data)
398
509
 
399
510
  async def detach_http_proxy(self, name: str, host_id: uuid.UUID | str) -> None:
@@ -403,15 +514,10 @@ class SandboxAPI:
403
514
  await self._request("DELETE", f"/http-proxies/{name}/hosts/{host_id}")
404
515
 
405
516
  async def aclose(self) -> None:
406
- if self._client is None:
407
- return
408
- await self._client.aclose()
409
- self._client = None
410
- self._client_loop = None
411
-
412
- # ------------------------------------------------------------------
413
- # Internals
414
- # ------------------------------------------------------------------
517
+ if self._client:
518
+ await self._client.aclose()
519
+ self._client = None
520
+ self._client_loop = None
415
521
 
416
522
  async def _request(
417
523
  self,
@@ -420,13 +526,13 @@ class SandboxAPI:
420
526
  *,
421
527
  headers: dict[str, str] | None = None,
422
528
  json: dict[str, Any] | None = None,
423
- ) -> dict[str, Any] | list[Any]:
529
+ ) -> Any:
424
530
  client = await self._get_client()
425
531
  request_headers = {
426
532
  "Authorization": f"Bearer {self.token}",
427
533
  "Accept": "application/json",
428
534
  }
429
- if headers is not None:
535
+ if headers:
430
536
  request_headers.update(headers)
431
537
 
432
538
  try:
@@ -466,9 +572,8 @@ class SandboxAPI:
466
572
  if response.status_code == 422:
467
573
  # FastAPI's 422 detail is a list of error dicts; join the messages
468
574
  # so it reads as a line, not a raw list repr.
469
- detail = json_response.get("detail", "validation error")
470
- if isinstance(detail, list):
471
- detail = "; ".join(item.get("msg", "") for item in detail) or "validation error"
575
+ errors = json_response.get("detail") or []
576
+ detail = "; ".join(error.get("msg", "") for error in errors) or "validation error"
472
577
  raise SandboxValidationError(detail)
473
578
 
474
579
  if response.status_code >= 400:
@@ -477,20 +582,16 @@ class SandboxAPI:
477
582
 
478
583
  async def _get_client(self) -> httpx.AsyncClient:
479
584
  running_loop = asyncio.get_running_loop()
480
- # Fast-path: client exists and is bound to the current loop.
481
- if self._client is not None and self._client_loop is running_loop:
585
+ if self._client and self._client_loop is running_loop:
482
586
  return self._client
483
- # Slow-path: either no client yet, or the client is bound to a
484
- # stale loop (e.g. fixture teardown). Serialize through the
485
- # lock.
486
- if self._client_lock is None:
587
+ if not self._client_lock:
487
588
  self._client_lock = asyncio.Lock()
488
589
  async with self._client_lock:
489
590
  # Re-check under the lock; another coroutine may have raced
490
591
  # ahead.
491
- if self._client is not None and self._client_loop is running_loop:
592
+ if self._client and self._client_loop is running_loop:
492
593
  return self._client
493
- if self._client is not None:
594
+ if self._client:
494
595
  # Stale-loop client: closing on its own loop is unsafe,
495
596
  # so drop the reference and rely on GC. httpx will emit
496
597
  # an "unclosed client" warning, which is the correct
@@ -37,8 +37,8 @@ class SandboxNotFoundError(SandboxAPIError):
37
37
  class SandboxConflictError(SandboxAPIError):
38
38
  """409 — the operation conflicts with the current state.
39
39
 
40
- e.g. provisioning a Linux user on a host that already has one with
41
- the same name.
40
+ e.g. a template that is still building, or secrets on a deployment
41
+ without the secrets proxy configured.
42
42
  """
43
43
 
44
44
 
@@ -53,9 +53,10 @@ class SandboxProvisioningError(SandboxAPIError):
53
53
  class SandboxValidationError(SandboxAPIError):
54
54
  """422 — the service rejected the request payload as invalid.
55
55
 
56
- e.g. an env key that isn't a valid environment-variable name, or a
57
- proxy target URL carrying a path or credentials. The request is
58
- malformed, so retrying it unchanged won't help — fix the input.
56
+ e.g. an env key that isn't a valid environment-variable name, a
57
+ proxy target URL carrying a path or credentials, or a secret for a
58
+ service name Drukbox does not know. The request is malformed, so
59
+ retrying it unchanged won't help — fix the input.
59
60
  """
60
61
 
61
62
 
@@ -12,6 +12,7 @@ gives us a controllable transport layer so every test stays in-process.
12
12
  # pyright: reportPrivateUsage=false
13
13
 
14
14
  import asyncio
15
+ import json
15
16
  from collections.abc import AsyncGenerator
16
17
  from datetime import UTC, datetime
17
18
  from typing import Any
@@ -22,18 +23,18 @@ import pytest
22
23
  import respx
23
24
 
24
25
  from drukbox_sdk import (
25
- DoctorReport,
26
26
  HTTPProxy,
27
27
  HTTPProxyAttachment,
28
+ Issuer,
28
29
  SandboxAPI,
29
30
  SandboxAuthError,
30
31
  SandboxConflictError,
31
- SandboxHost,
32
32
  SandboxNotFoundError,
33
33
  SandboxProvisioningError,
34
34
  SandboxResponseError,
35
35
  SandboxUnavailableError,
36
36
  SandboxValidationError,
37
+ Secret,
37
38
  )
38
39
 
39
40
  BASE_URL = "https://sandbox.test"
@@ -102,6 +103,26 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
102
103
  return payload
103
104
 
104
105
 
106
+ def _template_payload(**overrides: Any) -> dict[str, Any]:
107
+ """One canonical template payload used across tests. Each case overrides fields."""
108
+
109
+ payload: dict[str, Any] = {
110
+ "id": str(uuid4()),
111
+ "provider": "exe",
112
+ "base_image": "ubuntu-24.04",
113
+ "setup_script_hash": "ab12" * 16,
114
+ "label": "Python tools",
115
+ "image": "",
116
+ "status": "building",
117
+ "last_error": "",
118
+ "created_at": "2026-08-24T12:00:00+00:00",
119
+ "updated_at": "2026-08-24T12:00:00+00:00",
120
+ "last_used_at": None,
121
+ }
122
+ payload.update(overrides)
123
+ return payload
124
+
125
+
105
126
  @pytest.fixture
106
127
  async def api() -> AsyncGenerator[SandboxAPI, None]:
107
128
  """Fresh SandboxAPI per test. Closed via finalizer."""
@@ -111,11 +132,6 @@ async def api() -> AsyncGenerator[SandboxAPI, None]:
111
132
  await client.aclose()
112
133
 
113
134
 
114
- # ---------------------------------------------------------------------------
115
- # Happy paths
116
- # ---------------------------------------------------------------------------
117
-
118
-
119
135
  @respx.mock
120
136
  async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI):
121
137
  expires_at = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
@@ -141,7 +157,6 @@ async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI
141
157
  assert '"env":{"FOO":"bar"}' in body
142
158
  assert f'"expires_at":"{expires_at.isoformat()}"' in body
143
159
 
144
- assert isinstance(host, SandboxHost)
145
160
  assert host.id == payload["id"]
146
161
  assert host.external_ssh_host == payload["external_ssh_host"]
147
162
  assert host.external_ssh_port == payload["external_ssh_port"]
@@ -172,13 +187,13 @@ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
172
187
  assert "provider" not in body
173
188
  assert "instance_type" not in body
174
189
  assert "disk_gb" not in body
190
+ assert "template" not in body
175
191
  assert "Idempotency-Key" not in route.calls.last.request.headers
176
192
 
177
193
 
178
194
  @respx.mock
179
- async def test_create_host_none_expires_at_sends_explicit_null(api: SandboxAPI):
180
- """Mirroring the wire contract: an explicit ``expires_at=None`` asks for
181
- a permanent (never-reaped) host and must serialize as
195
+ async def test_create_host_permanent_sends_explicit_null(api: SandboxAPI):
196
+ """``permanent=True`` opts out of expiry as the wire's explicit
182
197
  ``"expires_at":null`` — distinct from omitting it (default lease),
183
198
  covered by test_create_host_omits_image_and_env_when_unset."""
184
199
 
@@ -187,11 +202,19 @@ async def test_create_host_none_expires_at_sends_explicit_null(api: SandboxAPI):
187
202
  return_value=httpx.Response(201, json=payload),
188
203
  )
189
204
 
190
- await api.create_host(expires_at=None)
205
+ await api.create_host(permanent=True)
191
206
 
192
207
  assert '"expires_at":null' in route.calls.last.request.content.decode()
193
208
 
194
209
 
210
+ async def test_create_host_rejects_permanent_with_expires_at(api: SandboxAPI):
211
+ with pytest.raises(ValueError, match="permanent"):
212
+ await api.create_host(
213
+ permanent=True,
214
+ expires_at=datetime(2026, 9, 1, tzinfo=UTC),
215
+ )
216
+
217
+
195
218
  @respx.mock
196
219
  async def test_create_host_sends_sizing_when_set(api: SandboxAPI):
197
220
  payload = _host_payload(instance_type="t3.xlarge", disk_gb=100)
@@ -221,6 +244,142 @@ async def test_create_host_sends_provider_when_set(api: SandboxAPI):
221
244
  assert host.provider == "hetzner"
222
245
 
223
246
 
247
+ @respx.mock
248
+ async def test_create_host_sends_template_when_set(api: SandboxAPI):
249
+ payload = _host_payload()
250
+ route = respx.post(f"{BASE_URL}/hosts").mock(
251
+ return_value=httpx.Response(201, json=payload),
252
+ )
253
+
254
+ template_id = uuid4()
255
+ await api.create_host(template=template_id)
256
+
257
+ assert f'"template":"{template_id}"' in route.calls.last.request.content.decode()
258
+
259
+
260
+ @respx.mock
261
+ async def test_create_host_409_when_template_is_building(api: SandboxAPI):
262
+ template_id = uuid4()
263
+ respx.post(f"{BASE_URL}/hosts").mock(
264
+ return_value=httpx.Response(
265
+ 409,
266
+ json={"detail": f"template {template_id} is building"},
267
+ ),
268
+ )
269
+
270
+ with pytest.raises(SandboxConflictError, match="is building"):
271
+ await api.create_host(template=template_id)
272
+
273
+
274
+ @respx.mock
275
+ async def test_create_host_sends_secrets_with_only_the_fields_set(api: SandboxAPI):
276
+ route = respx.post(f"{BASE_URL}/hosts").mock(
277
+ return_value=httpx.Response(201, json=_host_payload()),
278
+ )
279
+
280
+ await api.create_host(
281
+ secrets={
282
+ "anthropic": Issuer(
283
+ "https://mint.test/box-1/anthropic",
284
+ headers={"Authorization": "Bearer d2d"},
285
+ refresh="1h",
286
+ ),
287
+ "openai": Secret("sk-real"),
288
+ "acme": Secret("ak_real", host="api.acme.test", auth_variable="ACME_TOKEN"),
289
+ },
290
+ )
291
+
292
+ body = json.loads(route.calls.last.request.content)
293
+ assert body["secrets"] == {
294
+ "anthropic": {
295
+ "issuer": {
296
+ "url": "https://mint.test/box-1/anthropic",
297
+ "headers": {"Authorization": "Bearer d2d"},
298
+ "refresh": "1h",
299
+ }
300
+ },
301
+ "openai": {"value": "sk-real"},
302
+ "acme": {"value": "ak_real", "host": "api.acme.test", "auth_variable": "ACME_TOKEN"},
303
+ }
304
+
305
+
306
+ @respx.mock
307
+ async def test_create_host_omits_secrets_when_unset(api: SandboxAPI):
308
+ route = respx.post(f"{BASE_URL}/hosts").mock(
309
+ return_value=httpx.Response(201, json=_host_payload()),
310
+ )
311
+
312
+ await api.create_host()
313
+
314
+ assert "secrets" not in json.loads(route.calls.last.request.content)
315
+
316
+
317
+ @respx.mock
318
+ async def test_create_host_keeps_an_empty_auth_prefix(api: SandboxAPI):
319
+ route = respx.post(f"{BASE_URL}/hosts").mock(
320
+ return_value=httpx.Response(201, json=_host_payload()),
321
+ )
322
+ custom = {"host": "api.acme.test", "auth_variable": "ACME_TOKEN", "auth_prefix": ""}
323
+
324
+ await api.create_host(
325
+ secrets={
326
+ "acme": Secret("ak_real", **custom),
327
+ "acme-minted": Issuer("https://mint.test/acme", {"X-Key": "k"}, "1h", **custom),
328
+ },
329
+ )
330
+
331
+ body = json.loads(route.calls.last.request.content)
332
+ assert body["secrets"]["acme"]["auth_prefix"] == ""
333
+ assert body["secrets"]["acme-minted"]["auth_prefix"] == ""
334
+
335
+
336
+ def test_a_record_never_shows_its_credential():
337
+ secret = Secret("VALUE_MARKER", host="api.acme.test", auth_variable="ACME_TOKEN")
338
+ issuer = Issuer("https://mint.test/acme", {"Authorization": "ISSUER_MARKER"}, "1h")
339
+
340
+ shown = repr({"acme": secret, "minted": issuer})
341
+
342
+ assert "VALUE_MARKER" not in shown
343
+ assert "ISSUER_MARKER" not in shown
344
+ assert "api.acme.test" in shown and "https://mint.test/acme" in shown
345
+
346
+
347
+ def test_the_custom_service_fields_are_keywords():
348
+ with pytest.raises(TypeError):
349
+ Secret("ak_real", "api.acme.test") # type: ignore[misc]
350
+ with pytest.raises(TypeError):
351
+ Issuer("https://mint.test", {"X-Key": "k"}, "1h", "api.acme.test") # type: ignore[misc]
352
+
353
+
354
+ @respx.mock
355
+ async def test_create_host_409_without_the_secrets_proxy_raises_conflict(api: SandboxAPI):
356
+ respx.post(f"{BASE_URL}/hosts").mock(
357
+ return_value=httpx.Response(
358
+ 409,
359
+ json={
360
+ "detail": "secrets need the secrets proxy, which this deployment lacks",
361
+ "error_code": "SECRETS_PROXY_NOT_CONFIGURED",
362
+ },
363
+ ),
364
+ )
365
+
366
+ with pytest.raises(SandboxConflictError, match="secrets proxy"):
367
+ await api.create_host(secrets={"openai": Secret("sk-real")})
368
+
369
+
370
+ @respx.mock
371
+ async def test_create_host_422_for_an_unknown_secret_service_raises_validation(api: SandboxAPI):
372
+ respx.post(f"{BASE_URL}/hosts").mock(
373
+ return_value=httpx.Response(
374
+ 422,
375
+ json={"detail": [{"loc": ["body", "secrets"], "msg": "unknown secret service 'acme'"}]},
376
+ ),
377
+ )
378
+
379
+ with pytest.raises(SandboxValidationError, match="unknown secret service"):
380
+ await api.create_host(secrets={"acme": Secret("ak_real")})
381
+
382
+
224
383
  @respx.mock
225
384
  async def test_get_host_returns_parsed_host(api: SandboxAPI):
226
385
  payload = _host_payload(status="active")
@@ -353,9 +512,89 @@ async def test_get_host_returns_none_private_key_after_create(api: SandboxAPI):
353
512
  assert host.private_key is None
354
513
 
355
514
 
356
- # ---------------------------------------------------------------------------
357
- # Error classification
358
- # ---------------------------------------------------------------------------
515
+ @respx.mock
516
+ async def test_create_template_posts_required_payload_and_returns_building_record(
517
+ api: SandboxAPI,
518
+ ):
519
+ payload = _template_payload()
520
+ route = respx.post(f"{BASE_URL}/templates").mock(
521
+ return_value=httpx.Response(202, json=payload),
522
+ )
523
+
524
+ template = await api.create_template(setup_script="apt-get install -y ripgrep")
525
+
526
+ sent = route.calls.last.request
527
+ assert sent.headers["Authorization"] == "Bearer t-test"
528
+ assert sent.headers["Accept"] == "application/json"
529
+ body = sent.content.decode()
530
+ assert '"setup_script":"apt-get install -y ripgrep"' in body
531
+ assert "provider" not in body
532
+ assert "base_image" not in body
533
+ assert "label" not in body
534
+ assert template.id == payload["id"]
535
+ assert template.status == "building"
536
+ assert template.image == ""
537
+ assert template.last_used_at is None
538
+
539
+
540
+ @respx.mock
541
+ async def test_get_template_returns_parsed_template(api: SandboxAPI):
542
+ payload = _template_payload(
543
+ status="available",
544
+ image="derived:image",
545
+ last_used_at="2026-08-24T12:30:00+00:00",
546
+ )
547
+ respx.get(f"{BASE_URL}/templates/{payload['id']}").mock(
548
+ return_value=httpx.Response(200, json=payload),
549
+ )
550
+
551
+ template = await api.get_template(payload["id"])
552
+
553
+ assert template.status == "available"
554
+ assert template.image == "derived:image"
555
+ assert template.last_used_at == payload["last_used_at"]
556
+
557
+
558
+ @respx.mock
559
+ async def test_list_templates_parses_records_in_service_order(api: SandboxAPI):
560
+ payloads = [
561
+ _template_payload(label="newest"),
562
+ _template_payload(label="older"),
563
+ ]
564
+ respx.get(f"{BASE_URL}/templates").mock(
565
+ return_value=httpx.Response(200, json=payloads),
566
+ )
567
+
568
+ templates = await api.list_templates()
569
+
570
+ assert [template.id for template in templates] == [payload["id"] for payload in payloads]
571
+ assert [template.label for template in templates] == ["newest", "older"]
572
+
573
+
574
+ @respx.mock
575
+ async def test_delete_template_swallows_204(api: SandboxAPI):
576
+ template_id = uuid4()
577
+ route = respx.delete(f"{BASE_URL}/templates/{template_id}").mock(
578
+ return_value=httpx.Response(204),
579
+ )
580
+
581
+ await api.delete_template(template_id)
582
+
583
+ assert route.called
584
+
585
+
586
+ @respx.mock
587
+ async def test_delete_template_409_while_building(api: SandboxAPI):
588
+ template_id = uuid4()
589
+ respx.delete(f"{BASE_URL}/templates/{template_id}").mock(
590
+ return_value=httpx.Response(
591
+ 409,
592
+ json={"detail": "TEMPLATE_STATE: template is still building"},
593
+ ),
594
+ )
595
+
596
+ with pytest.raises(SandboxConflictError, match="TEMPLATE_STATE"):
597
+ await api.delete_template(template_id)
359
598
 
360
599
 
361
600
  @respx.mock
@@ -502,11 +741,6 @@ async def test_error_detail_absent_uses_fallback_message(api: SandboxAPI):
502
741
  await api.list_hosts()
503
742
 
504
743
 
505
- # ---------------------------------------------------------------------------
506
- # Lifecycle + loop affinity
507
- # ---------------------------------------------------------------------------
508
-
509
-
510
744
  @respx.mock
511
745
  async def test_aclose_drops_client_and_is_idempotent(api: SandboxAPI):
512
746
  respx.get(f"{BASE_URL}/hosts").mock(return_value=httpx.Response(200, json=[]))
@@ -553,11 +787,6 @@ def test_cross_loop_reuse_rebinds_client_without_crashing():
553
787
  assert bound_loops[0] is not bound_loops[1]
554
788
 
555
789
 
556
- # ---------------------------------------------------------------------------
557
- # from_env
558
- # ---------------------------------------------------------------------------
559
-
560
-
561
790
  def test_from_env_reads_prefixed_vars(monkeypatch: pytest.MonkeyPatch):
562
791
  monkeypatch.setenv("SANDBOX_SERVICE_URL", "https://from-env.test")
563
792
  monkeypatch.setenv("SANDBOX_SERVICE_TOKEN", "env-token")
@@ -605,7 +834,6 @@ async def test_doctor_parses_report_and_checks(api: SandboxAPI):
605
834
 
606
835
  assert route.called
607
836
  assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
608
- assert isinstance(report, DoctorReport)
609
837
  assert report.ok is True
610
838
  assert report.active_provider == "aws"
611
839
  assert report.tailscale_enabled is True
@@ -663,11 +891,6 @@ async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
663
891
  await api.doctor()
664
892
 
665
893
 
666
- # ---------------------------------------------------------------------------
667
- # HTTP proxies
668
- # ---------------------------------------------------------------------------
669
-
670
-
671
894
  @respx.mock
672
895
  async def test_create_http_proxy_posts_payload_and_returns_record(api: SandboxAPI):
673
896
  route = respx.post(f"{BASE_URL}/http-proxies").mock(