drukbox-python-sdk 0.0.6__tar.gz → 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paulo Alvarado Garcia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,11 +1,24 @@
1
+ Metadata-Version: 2.5
2
+ Name: drukbox-python-sdk
3
+ Version: 0.1.0
4
+ Summary: Async Python client for Drukbox.
5
+ Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
6
+ Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
7
+ Project-URL: Issues, https://github.com/clawhaven/drukbox-python-sdk/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: httpx>=0.28
12
+ Description-Content-Type: text/markdown
13
+
1
14
  # drukbox-python-sdk
2
15
 
3
16
  Async Python client for the [Drukbox] host API.
4
17
 
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.
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.
9
22
 
10
23
  ## Install
11
24
 
@@ -57,7 +70,9 @@ Public exports live in `drukbox_sdk`:
57
70
 
58
71
  - `SandboxAPI`
59
72
  - `SandboxHost`
73
+ - `SandboxTemplate`
60
74
  - `DoctorReport` and `DoctorCheck`
75
+ - `HTTPProxy` and `HTTPProxyAttachment`
61
76
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
62
77
  unavailable, and unclassified response errors
63
78
 
@@ -67,12 +82,46 @@ Supported host operations:
67
82
  - `get_host`
68
83
  - `attach`
69
84
  - `list_hosts`
85
+ - `renew_host`
70
86
  - `delete_host`
71
87
  - `doctor`
72
88
  - `aclose`
73
89
 
74
90
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
75
- `provider`, and `Idempotency-Key` inputs.
91
+ `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and
92
+ `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
93
+ `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
94
+ provider default. Omit `expires_at` for the default lease, pass a datetime for
95
+ an explicit expiry, or pass `permanent=True` for a never-reaped host.
96
+ `template` accepts a template ID. An explicit `image` wins over it.
97
+
98
+ `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
99
+ `expires_at` to extend by the service's default TTL; renewal never makes a host
100
+ permanent.
101
+
102
+ Supported template operations:
103
+
104
+ - `create_template`
105
+ - `get_template`
106
+ - `list_templates`
107
+ - `delete_template`
108
+
109
+ `create_template` returns immediately with a `building` record. Poll
110
+ `get_template` until the status is `available` or `failed`. A failed record
111
+ carries the reason in `last_error`.
112
+
113
+ Supported HTTP-proxy operations:
114
+
115
+ - `create_http_proxy`
116
+ - `delete_http_proxy`
117
+ - `attach_http_proxy`
118
+ - `detach_http_proxy`
119
+
120
+ HTTP proxies are account-bound exe.dev resources, not host state — deleting a
121
+ host does not remove proxies fronting it. `create_http_proxy` takes an
122
+ origin-only `target` (scheme + host, no path/query/fragment/credentials) and at
123
+ least one `headers` entry; `attach_http_proxy` / `detach_http_proxy` point a
124
+ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
76
125
 
77
126
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
78
127
  runs one cheap, non-mutating probe per dependency (database, active VM
@@ -96,4 +145,4 @@ uv run pytest
96
145
  Tests use `respx` to fake the Drukbox HTTP API. They do not need a
97
146
  real network, VM provider, or Drukbox service.
98
147
 
99
- [Drukbox]: https://github.com/clawhaven/drukbox
148
+ [Drukbox]: https://github.com/czpython/drukbox
@@ -1,19 +1,11 @@
1
- Metadata-Version: 2.4
2
- Name: drukbox-python-sdk
3
- Version: 0.0.6
4
- Summary: Async Python client for Drukbox.
5
- Requires-Python: >=3.11
6
- Requires-Dist: httpx>=0.28
7
- Description-Content-Type: text/markdown
8
-
9
1
  # drukbox-python-sdk
10
2
 
11
3
  Async Python client for the [Drukbox] host API.
12
4
 
13
- The SDK provisions sandbox VMs, reads host state, deletes hosts, and
14
- returns the SSH connection details a caller needs. It speaks HTTP only:
15
- SSH sessions, file transfer, command execution, and retry orchestration
16
- 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.
17
9
 
18
10
  ## Install
19
11
 
@@ -65,7 +57,9 @@ Public exports live in `drukbox_sdk`:
65
57
 
66
58
  - `SandboxAPI`
67
59
  - `SandboxHost`
60
+ - `SandboxTemplate`
68
61
  - `DoctorReport` and `DoctorCheck`
62
+ - `HTTPProxy` and `HTTPProxyAttachment`
69
63
  - `SandboxAPIError` and typed subclasses for auth, not found, conflict,
70
64
  unavailable, and unclassified response errors
71
65
 
@@ -75,12 +69,46 @@ Supported host operations:
75
69
  - `get_host`
76
70
  - `attach`
77
71
  - `list_hosts`
72
+ - `renew_host`
78
73
  - `delete_host`
79
74
  - `doctor`
80
75
  - `aclose`
81
76
 
82
77
  `create_host` supports the service's optional `image`, `env`, `expires_at`,
83
- `provider`, and `Idempotency-Key` inputs.
78
+ `permanent`, `provider`, `instance_type`, `disk_gb`, `template`, and
79
+ `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
80
+ `t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
81
+ provider default. Omit `expires_at` for the default lease, pass a datetime for
82
+ an explicit expiry, or pass `permanent=True` for a never-reaped host.
83
+ `template` accepts a template ID. An explicit `image` wins over it.
84
+
85
+ `renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
86
+ `expires_at` to extend by the service's default TTL; renewal never makes a host
87
+ permanent.
88
+
89
+ Supported template operations:
90
+
91
+ - `create_template`
92
+ - `get_template`
93
+ - `list_templates`
94
+ - `delete_template`
95
+
96
+ `create_template` returns immediately with a `building` record. Poll
97
+ `get_template` until the status is `available` or `failed`. A failed record
98
+ carries the reason in `last_error`.
99
+
100
+ Supported HTTP-proxy operations:
101
+
102
+ - `create_http_proxy`
103
+ - `delete_http_proxy`
104
+ - `attach_http_proxy`
105
+ - `detach_http_proxy`
106
+
107
+ HTTP proxies are account-bound exe.dev resources, not host state — deleting a
108
+ host does not remove proxies fronting it. `create_http_proxy` takes an
109
+ origin-only `target` (scheme + host, no path/query/fragment/credentials) and at
110
+ least one `headers` entry; `attach_http_proxy` / `detach_http_proxy` point a
111
+ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
84
112
 
85
113
  `doctor` fetches `GET /doctor` — read-only dependency health. The service
86
114
  runs one cheap, non-mutating probe per dependency (database, active VM
@@ -104,4 +132,4 @@ uv run pytest
104
132
  Tests use `respx` to fake the Drukbox HTTP API. They do not need a
105
133
  real network, VM provider, or Drukbox service.
106
134
 
107
- [Drukbox]: https://github.com/clawhaven/drukbox
135
+ [Drukbox]: https://github.com/czpython/drukbox
@@ -4,9 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "drukbox-python-sdk"
7
- version = "0.0.6"
7
+ version = "0.1.0"
8
8
  description = "Async Python client for Drukbox."
9
9
  readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
10
12
  # Tracks the lowest Python any current consumer supports. Bump only
11
13
  # when a real consumer needs a 3.12+ feature.
12
14
  requires-python = ">=3.11"
@@ -14,6 +16,11 @@ dependencies = [
14
16
  "httpx>=0.28",
15
17
  ]
16
18
 
19
+ [project.urls]
20
+ Homepage = "https://github.com/clawhaven/drukbox-python-sdk"
21
+ Repository = "https://github.com/clawhaven/drukbox-python-sdk"
22
+ Issues = "https://github.com/clawhaven/drukbox-python-sdk/issues"
23
+
17
24
  [tool.hatch.build.targets.wheel]
18
25
  packages = ["src/drukbox_sdk"]
19
26
 
@@ -7,8 +7,11 @@ detail and may change without notice.
7
7
  from .api import (
8
8
  DoctorCheck,
9
9
  DoctorReport,
10
+ HTTPProxy,
11
+ HTTPProxyAttachment,
10
12
  SandboxAPI,
11
13
  SandboxHost,
14
+ SandboxTemplate,
12
15
  )
13
16
  from .exceptions import (
14
17
  SandboxAPIError,
@@ -24,6 +27,8 @@ from .exceptions import (
24
27
  __all__ = [
25
28
  "DoctorCheck",
26
29
  "DoctorReport",
30
+ "HTTPProxy",
31
+ "HTTPProxyAttachment",
27
32
  "SandboxAPI",
28
33
  "SandboxAPIError",
29
34
  "SandboxAuthError",
@@ -32,6 +37,7 @@ __all__ = [
32
37
  "SandboxNotFoundError",
33
38
  "SandboxProvisioningError",
34
39
  "SandboxResponseError",
40
+ "SandboxTemplate",
35
41
  "SandboxUnavailableError",
36
42
  "SandboxValidationError",
37
43
  ]
@@ -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, fields
42
+ from dataclasses import dataclass
36
43
  from datetime import datetime
37
44
  from typing import Any, Self
38
45
 
@@ -85,6 +92,8 @@ class SandboxHost:
85
92
  status: str
86
93
  provider: str
87
94
  image: str
95
+ instance_type: str | None
96
+ disk_gb: int | None
88
97
  external_ssh_host: str
89
98
  external_ssh_port: int
90
99
  ssh_username: str
@@ -99,18 +108,27 @@ class SandboxHost:
99
108
  expires_at: str | None
100
109
 
101
110
 
102
- _SANDBOX_HOST_FIELDS = {field.name for field in fields(SandboxHost)}
103
-
104
-
105
- def _parse_sandbox_host(data: dict[str, Any]) -> SandboxHost:
106
- """Build a :class:`SandboxHost` picking only known fields.
111
+ @dataclass(frozen=True)
112
+ class SandboxTemplate:
113
+ """Snapshot of a reusable sandbox template as returned by the service.
107
114
 
108
- Defensive against the service adding new fields — those flow
109
- through harmlessly without breaking the SDK on the unsuspecting
110
- caller's side.
115
+ ``status`` moves from ``"building"`` to ``"available"`` or
116
+ ``"failed"``. While the build runs, ``image`` is empty. A failed
117
+ build carries its reason in ``last_error``. The record has no
118
+ ``setup_script`` because the service never echoes it back.
111
119
  """
112
120
 
113
- return SandboxHost(**{key: data[key] for key in _SANDBOX_HOST_FIELDS})
121
+ id: str
122
+ provider: str
123
+ base_image: str
124
+ setup_script_hash: str
125
+ label: str
126
+ image: str
127
+ status: str
128
+ last_error: str
129
+ created_at: str
130
+ updated_at: str
131
+ last_used_at: str | None
114
132
 
115
133
 
116
134
  @dataclass(frozen=True)
@@ -134,23 +152,30 @@ class DoctorReport:
134
152
  checks: list[DoctorCheck]
135
153
 
136
154
 
137
- def _parse_doctor_check(data: dict[str, Any]) -> DoctorCheck:
138
- return DoctorCheck(
139
- name=data["name"],
140
- status=data["status"],
141
- detail=data["detail"],
142
- latency_ms=data["latency_ms"],
143
- hint=data["hint"],
144
- )
155
+ @dataclass(frozen=True)
156
+ class HTTPProxy:
157
+ """An account-bound HTTP proxy as returned by the service.
145
158
 
159
+ ``status`` echoes the lifecycle step the call reached
160
+ (``"created"``). Proxies are account resources, not host state:
161
+ deleting a host they front does not remove them.
162
+ """
163
+
164
+ name: str
165
+ status: str
146
166
 
147
- def _parse_doctor_report(data: dict[str, Any]) -> DoctorReport:
148
- return DoctorReport(
149
- ok=data["ok"],
150
- active_provider=data["active_provider"],
151
- tailscale_enabled=data["tailscale_enabled"],
152
- checks=[_parse_doctor_check(check) for check in data["checks"]],
153
- )
167
+
168
+ @dataclass(frozen=True)
169
+ class HTTPProxyAttachment:
170
+ """The result of pointing an HTTP proxy at a host's backing VM.
171
+
172
+ ``host_id`` is the string UUID of the attached host; ``status``
173
+ echoes the step reached (``"attached"``).
174
+ """
175
+
176
+ name: str
177
+ host_id: str
178
+ status: str
154
179
 
155
180
 
156
181
  class SandboxAPI:
@@ -204,18 +229,18 @@ class SandboxAPI:
204
229
  timeout = float(os.environ.get(f"{prefix}SERVICE_TIMEOUT", "300"))
205
230
  return cls(base_url=url, token=token, timeout=timeout)
206
231
 
207
- # ------------------------------------------------------------------
208
- # Host lifecycle
209
- # ------------------------------------------------------------------
210
-
211
232
  async def create_host(
212
233
  self,
213
234
  *,
235
+ disk_gb: int | None = None,
214
236
  env: dict[str, str] | None = None,
215
237
  expires_at: datetime | None = None,
216
238
  idempotency_key: str | None = None,
217
239
  image: str | None = None,
240
+ instance_type: str | None = None,
241
+ permanent: bool = False,
218
242
  provider: str | None = None,
243
+ template: uuid.UUID | str | None = None,
219
244
  ) -> SandboxHost:
220
245
  """Provision a new host.
221
246
 
@@ -234,30 +259,55 @@ class SandboxAPI:
234
259
  ``provider`` pins which VM provider serves the request; omit it to
235
260
  use the service default. An unknown provider raises
236
261
  :class:`SandboxResponseError` (the service rejects it with 400).
262
+
263
+ ``template`` names a template by its ID, from
264
+ :meth:`create_template` or :meth:`get_template`. An explicit
265
+ ``image`` wins when you pass both. A template that is not
266
+ ``available`` raises :class:`SandboxConflictError`. An unknown
267
+ ID raises :class:`SandboxResponseError`.
268
+
269
+ ``instance_type`` (provider-native size, e.g. ``t3.xlarge`` /
270
+ ``cx33``) and ``disk_gb`` (root disk size) pin the VM shape; omit
271
+ either to use the provider's configured default. A size the active
272
+ provider can't serve raises :class:`SandboxResponseError` (400).
273
+
274
+ Lease: omit ``expires_at`` for the service's default lease, pass a
275
+ ``datetime`` for an explicit expiry, or pass ``permanent=True`` for
276
+ a host the janitor never reaps.
237
277
  """
238
278
 
279
+ if expires_at and permanent:
280
+ raise ValueError("a permanent host cannot carry an expires_at")
281
+
239
282
  payload: dict[str, Any] = {}
283
+ if disk_gb is not None:
284
+ payload["disk_gb"] = disk_gb
240
285
  if env is not None:
241
286
  payload["env"] = env
242
- if expires_at is not None:
287
+ if expires_at:
243
288
  payload["expires_at"] = expires_at.isoformat()
289
+ if permanent:
290
+ # An explicit null on the wire is the opt-out from expiry.
291
+ payload["expires_at"] = None
244
292
  if image is not None:
245
293
  payload["image"] = image
294
+ if instance_type is not None:
295
+ payload["instance_type"] = instance_type
246
296
  if provider is not None:
247
297
  payload["provider"] = provider
298
+ if template is not None:
299
+ payload["template"] = str(template)
248
300
 
249
301
  headers: dict[str, str] = {}
250
302
  if idempotency_key is not None:
251
303
  headers["Idempotency-Key"] = idempotency_key
252
304
 
253
305
  data = await self._request("POST", "/hosts", json=payload, headers=headers)
254
- assert isinstance(data, dict)
255
- return _parse_sandbox_host(data)
306
+ return SandboxHost(**data)
256
307
 
257
308
  async def get_host(self, host_id: uuid.UUID | str) -> SandboxHost:
258
309
  data = await self._request("GET", f"/hosts/{host_id}")
259
- assert isinstance(data, dict)
260
- return _parse_sandbox_host(data)
310
+ return SandboxHost(**data)
261
311
 
262
312
  async def attach(self, host_id: uuid.UUID | str) -> SandboxHost:
263
313
  """Alias for :meth:`get_host` that reads better at call sites
@@ -273,12 +323,84 @@ class SandboxAPI:
273
323
 
274
324
  async def list_hosts(self) -> list[SandboxHost]:
275
325
  data = await self._request("GET", "/hosts")
276
- assert isinstance(data, list)
277
- return [_parse_sandbox_host(item) for item in data]
326
+ return [SandboxHost(**item) for item in data]
327
+
328
+ async def renew_host(
329
+ self,
330
+ host_id: uuid.UUID | str,
331
+ *,
332
+ expires_at: datetime | None = None,
333
+ ) -> SandboxHost:
334
+ """Extend a host's lease and return the updated record.
335
+
336
+ Omit ``expires_at`` to extend by the service's default TTL from
337
+ now; pass one to set an explicit future expiry. Renewal never
338
+ makes a host permanent — that is a create-time choice.
339
+
340
+ Raises :class:`SandboxNotFoundError` for an unknown host and
341
+ :class:`SandboxConflictError` when the host is in a non-renewable
342
+ state (still provisioning, errored, or an unclaimed pool host).
343
+ """
344
+
345
+ payload: dict[str, Any] = {}
346
+ if expires_at is not None:
347
+ payload["expires_at"] = expires_at.isoformat()
348
+ data = await self._request("POST", f"/hosts/{host_id}/renew", json=payload)
349
+ return SandboxHost(**data)
278
350
 
279
351
  async def delete_host(self, host_id: uuid.UUID | str) -> None:
280
352
  await self._request("DELETE", f"/hosts/{host_id}")
281
353
 
354
+ async def create_template(
355
+ self,
356
+ *,
357
+ setup_script: str,
358
+ provider: str | None = None,
359
+ base_image: str | None = None,
360
+ label: str | None = None,
361
+ ) -> SandboxTemplate:
362
+ """Start a template build and return immediately.
363
+
364
+ The service responds 202 with ``status="building"``. Poll
365
+ :meth:`get_template` until the status becomes ``"available"`` or
366
+ ``"failed"``. A failed record carries the reason in
367
+ ``last_error``. A repeated post with the same provider, base
368
+ image, and setup script returns the existing record without a
369
+ rebuild.
370
+ """
371
+
372
+ payload: dict[str, Any] = {"setup_script": setup_script}
373
+ if provider is not None:
374
+ payload["provider"] = provider
375
+ if base_image is not None:
376
+ payload["base_image"] = base_image
377
+ if label is not None:
378
+ payload["label"] = label
379
+
380
+ data = await self._request("POST", "/templates", json=payload)
381
+ return SandboxTemplate(**data)
382
+
383
+ async def get_template(self, template_id: uuid.UUID | str) -> SandboxTemplate:
384
+ """Fetch one template. Callers poll this after :meth:`create_template`."""
385
+
386
+ data = await self._request("GET", f"/templates/{template_id}")
387
+ return SandboxTemplate(**data)
388
+
389
+ async def list_templates(self) -> list[SandboxTemplate]:
390
+ """List templates newest first."""
391
+
392
+ data = await self._request("GET", "/templates")
393
+ return [SandboxTemplate(**item) for item in data]
394
+
395
+ async def delete_template(self, template_id: uuid.UUID | str) -> None:
396
+ """Delete a template.
397
+
398
+ A template whose status is ``building`` raises
399
+ :class:`SandboxConflictError`.
400
+ """
401
+
402
+ await self._request("DELETE", f"/templates/{template_id}")
403
+
282
404
  async def doctor(self) -> DoctorReport:
283
405
  """Fetch ``GET /doctor`` — read-only dependency health.
284
406
 
@@ -287,19 +409,65 @@ class SandboxAPI:
287
409
  """
288
410
 
289
411
  data = await self._request("GET", "/doctor")
290
- assert isinstance(data, dict)
291
- return _parse_doctor_report(data)
412
+ return DoctorReport(
413
+ ok=data["ok"],
414
+ active_provider=data["active_provider"],
415
+ tailscale_enabled=data["tailscale_enabled"],
416
+ checks=[DoctorCheck(**check) for check in data["checks"]],
417
+ )
292
418
 
293
- async def aclose(self) -> None:
294
- if self._client is None:
295
- return
296
- await self._client.aclose()
297
- self._client = None
298
- self._client_loop = None
419
+ async def create_http_proxy(
420
+ self,
421
+ *,
422
+ name: str,
423
+ target: str,
424
+ headers: dict[str, str],
425
+ ) -> HTTPProxy:
426
+ """Create an account-bound HTTP proxy in front of ``target``.
427
+
428
+ ``target`` must be an origin-only URL (scheme + host, no path,
429
+ query, fragment, or credentials); ``headers`` must carry at least
430
+ one entry. The service rejects a malformed target or empty headers
431
+ with :class:`SandboxValidationError`; a name that already exists
432
+ raises :class:`SandboxConflictError`.
433
+ """
434
+
435
+ payload: dict[str, Any] = {"name": name, "target": target, "headers": headers}
436
+ data = await self._request("POST", "/http-proxies", json=payload)
437
+ return HTTPProxy(**data)
299
438
 
300
- # ------------------------------------------------------------------
301
- # Internals
302
- # ------------------------------------------------------------------
439
+ async def delete_http_proxy(self, name: str) -> None:
440
+ """Delete an HTTP proxy by name. Unknown name raises
441
+ :class:`SandboxNotFoundError`."""
442
+
443
+ await self._request("DELETE", f"/http-proxies/{name}")
444
+
445
+ async def attach_http_proxy(
446
+ self,
447
+ name: str,
448
+ host_id: uuid.UUID | str,
449
+ ) -> HTTPProxyAttachment:
450
+ """Point proxy ``name`` at ``host_id``'s backing VM.
451
+
452
+ The host must have a backing VM (``bootstrapping`` or ``active``);
453
+ otherwise the service raises :class:`SandboxConflictError`. An
454
+ unknown host or proxy raises :class:`SandboxNotFoundError`.
455
+ """
456
+
457
+ data = await self._request("POST", f"/http-proxies/{name}/hosts/{host_id}")
458
+ return HTTPProxyAttachment(**data)
459
+
460
+ async def detach_http_proxy(self, name: str, host_id: uuid.UUID | str) -> None:
461
+ """Detach proxy ``name`` from ``host_id``. Unknown host or proxy
462
+ raises :class:`SandboxNotFoundError`."""
463
+
464
+ await self._request("DELETE", f"/http-proxies/{name}/hosts/{host_id}")
465
+
466
+ async def aclose(self) -> None:
467
+ if self._client:
468
+ await self._client.aclose()
469
+ self._client = None
470
+ self._client_loop = None
303
471
 
304
472
  async def _request(
305
473
  self,
@@ -308,13 +476,13 @@ class SandboxAPI:
308
476
  *,
309
477
  headers: dict[str, str] | None = None,
310
478
  json: dict[str, Any] | None = None,
311
- ) -> dict[str, Any] | list[Any]:
479
+ ) -> Any:
312
480
  client = await self._get_client()
313
481
  request_headers = {
314
482
  "Authorization": f"Bearer {self.token}",
315
483
  "Accept": "application/json",
316
484
  }
317
- if headers is not None:
485
+ if headers:
318
486
  request_headers.update(headers)
319
487
 
320
488
  try:
@@ -354,9 +522,8 @@ class SandboxAPI:
354
522
  if response.status_code == 422:
355
523
  # FastAPI's 422 detail is a list of error dicts; join the messages
356
524
  # so it reads as a line, not a raw list repr.
357
- detail = json_response.get("detail", "validation error")
358
- if isinstance(detail, list):
359
- detail = "; ".join(item.get("msg", "") for item in detail) or "validation error"
525
+ errors = json_response.get("detail") or []
526
+ detail = "; ".join(error.get("msg", "") for error in errors) or "validation error"
360
527
  raise SandboxValidationError(detail)
361
528
 
362
529
  if response.status_code >= 400:
@@ -365,20 +532,16 @@ class SandboxAPI:
365
532
 
366
533
  async def _get_client(self) -> httpx.AsyncClient:
367
534
  running_loop = asyncio.get_running_loop()
368
- # Fast-path: client exists and is bound to the current loop.
369
- if self._client is not None and self._client_loop is running_loop:
535
+ if self._client and self._client_loop is running_loop:
370
536
  return self._client
371
- # Slow-path: either no client yet, or the client is bound to a
372
- # stale loop (e.g. fixture teardown). Serialize through the
373
- # lock.
374
- if self._client_lock is None:
537
+ if not self._client_lock:
375
538
  self._client_lock = asyncio.Lock()
376
539
  async with self._client_lock:
377
540
  # Re-check under the lock; another coroutine may have raced
378
541
  # ahead.
379
- if self._client is not None and self._client_loop is running_loop:
542
+ if self._client and self._client_loop is running_loop:
380
543
  return self._client
381
- if self._client is not None:
544
+ if self._client:
382
545
  # Stale-loop client: closing on its own loop is unsafe,
383
546
  # so drop the reference and rely on GC. httpx will emit
384
547
  # an "unclosed client" warning, which is the correct
@@ -22,11 +22,11 @@ import pytest
22
22
  import respx
23
23
 
24
24
  from drukbox_sdk import (
25
- DoctorReport,
25
+ HTTPProxy,
26
+ HTTPProxyAttachment,
26
27
  SandboxAPI,
27
28
  SandboxAuthError,
28
29
  SandboxConflictError,
29
- SandboxHost,
30
30
  SandboxNotFoundError,
31
31
  SandboxProvisioningError,
32
32
  SandboxResponseError,
@@ -81,6 +81,8 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
81
81
  "status": "provisioning",
82
82
  "provider": "exe",
83
83
  "image": "ghcr.io/drukbox/sandbox:test",
84
+ "instance_type": None,
85
+ "disk_gb": None,
84
86
  "external_ssh_host": "203.0.113.42",
85
87
  "external_ssh_port": 22,
86
88
  "ssh_username": "exedev",
@@ -98,6 +100,26 @@ def _host_payload(**overrides: Any) -> dict[str, Any]:
98
100
  return payload
99
101
 
100
102
 
103
+ def _template_payload(**overrides: Any) -> dict[str, Any]:
104
+ """One canonical template payload used across tests. Each case overrides fields."""
105
+
106
+ payload: dict[str, Any] = {
107
+ "id": str(uuid4()),
108
+ "provider": "exe",
109
+ "base_image": "ubuntu-24.04",
110
+ "setup_script_hash": "ab12" * 16,
111
+ "label": "Python tools",
112
+ "image": "",
113
+ "status": "building",
114
+ "last_error": "",
115
+ "created_at": "2026-08-24T12:00:00+00:00",
116
+ "updated_at": "2026-08-24T12:00:00+00:00",
117
+ "last_used_at": None,
118
+ }
119
+ payload.update(overrides)
120
+ return payload
121
+
122
+
101
123
  @pytest.fixture
102
124
  async def api() -> AsyncGenerator[SandboxAPI, None]:
103
125
  """Fresh SandboxAPI per test. Closed via finalizer."""
@@ -107,11 +129,6 @@ async def api() -> AsyncGenerator[SandboxAPI, None]:
107
129
  await client.aclose()
108
130
 
109
131
 
110
- # ---------------------------------------------------------------------------
111
- # Happy paths
112
- # ---------------------------------------------------------------------------
113
-
114
-
115
132
  @respx.mock
116
133
  async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI):
117
134
  expires_at = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
@@ -137,7 +154,6 @@ async def test_create_host_posts_payload_and_returns_parsed_host(api: SandboxAPI
137
154
  assert '"env":{"FOO":"bar"}' in body
138
155
  assert f'"expires_at":"{expires_at.isoformat()}"' in body
139
156
 
140
- assert isinstance(host, SandboxHost)
141
157
  assert host.id == payload["id"]
142
158
  assert host.external_ssh_host == payload["external_ssh_host"]
143
159
  assert host.external_ssh_port == payload["external_ssh_port"]
@@ -166,9 +182,52 @@ async def test_create_host_omits_image_and_env_when_unset(api: SandboxAPI):
166
182
  assert "env" not in body
167
183
  assert "expires_at" not in body
168
184
  assert "provider" not in body
185
+ assert "instance_type" not in body
186
+ assert "disk_gb" not in body
187
+ assert "template" not in body
169
188
  assert "Idempotency-Key" not in route.calls.last.request.headers
170
189
 
171
190
 
191
+ @respx.mock
192
+ async def test_create_host_permanent_sends_explicit_null(api: SandboxAPI):
193
+ """``permanent=True`` opts out of expiry as the wire's explicit
194
+ ``"expires_at":null`` — distinct from omitting it (default lease),
195
+ covered by test_create_host_omits_image_and_env_when_unset."""
196
+
197
+ payload = _host_payload(expires_at=None)
198
+ route = respx.post(f"{BASE_URL}/hosts").mock(
199
+ return_value=httpx.Response(201, json=payload),
200
+ )
201
+
202
+ await api.create_host(permanent=True)
203
+
204
+ assert '"expires_at":null' in route.calls.last.request.content.decode()
205
+
206
+
207
+ async def test_create_host_rejects_permanent_with_expires_at(api: SandboxAPI):
208
+ with pytest.raises(ValueError, match="permanent"):
209
+ await api.create_host(
210
+ permanent=True,
211
+ expires_at=datetime(2026, 9, 1, tzinfo=UTC),
212
+ )
213
+
214
+
215
+ @respx.mock
216
+ async def test_create_host_sends_sizing_when_set(api: SandboxAPI):
217
+ payload = _host_payload(instance_type="t3.xlarge", disk_gb=100)
218
+ route = respx.post(f"{BASE_URL}/hosts").mock(
219
+ return_value=httpx.Response(201, json=payload),
220
+ )
221
+
222
+ host = await api.create_host(instance_type="t3.xlarge", disk_gb=100)
223
+
224
+ body = route.calls.last.request.content.decode()
225
+ assert '"instance_type":"t3.xlarge"' in body
226
+ assert '"disk_gb":100' in body
227
+ assert host.instance_type == "t3.xlarge"
228
+ assert host.disk_gb == 100
229
+
230
+
172
231
  @respx.mock
173
232
  async def test_create_host_sends_provider_when_set(api: SandboxAPI):
174
233
  payload = _host_payload(provider="hetzner")
@@ -182,6 +241,33 @@ async def test_create_host_sends_provider_when_set(api: SandboxAPI):
182
241
  assert host.provider == "hetzner"
183
242
 
184
243
 
244
+ @respx.mock
245
+ async def test_create_host_sends_template_when_set(api: SandboxAPI):
246
+ payload = _host_payload()
247
+ route = respx.post(f"{BASE_URL}/hosts").mock(
248
+ return_value=httpx.Response(201, json=payload),
249
+ )
250
+
251
+ template_id = uuid4()
252
+ await api.create_host(template=template_id)
253
+
254
+ assert f'"template":"{template_id}"' in route.calls.last.request.content.decode()
255
+
256
+
257
+ @respx.mock
258
+ async def test_create_host_409_when_template_is_building(api: SandboxAPI):
259
+ template_id = uuid4()
260
+ respx.post(f"{BASE_URL}/hosts").mock(
261
+ return_value=httpx.Response(
262
+ 409,
263
+ json={"detail": f"template {template_id} is building"},
264
+ ),
265
+ )
266
+
267
+ with pytest.raises(SandboxConflictError, match="is building"):
268
+ await api.create_host(template=template_id)
269
+
270
+
185
271
  @respx.mock
186
272
  async def test_get_host_returns_parsed_host(api: SandboxAPI):
187
273
  payload = _host_payload(status="active")
@@ -232,25 +318,55 @@ async def test_delete_host_swallows_204(api: SandboxAPI):
232
318
  await api.delete_host(host_id)
233
319
 
234
320
 
235
- # ---------------------------------------------------------------------------
236
- # Forwards compatibility — service adds fields
237
- # ---------------------------------------------------------------------------
321
+ @respx.mock
322
+ async def test_renew_host_posts_expires_at_and_returns_host(api: SandboxAPI):
323
+ expires_at = datetime(2026, 7, 1, 12, 0, tzinfo=UTC)
324
+ payload = _host_payload(status="active", expires_at=expires_at.isoformat())
325
+ route = respx.post(f"{BASE_URL}/hosts/{payload['id']}/renew").mock(
326
+ return_value=httpx.Response(200, json=payload),
327
+ )
328
+
329
+ host = await api.renew_host(payload["id"], expires_at=expires_at)
330
+
331
+ assert f'"expires_at":"{expires_at.isoformat()}"' in route.calls.last.request.content.decode()
332
+ assert host.expires_at == payload["expires_at"]
238
333
 
239
334
 
240
335
  @respx.mock
241
- async def test_unknown_fields_in_host_payload_are_ignored(api: SandboxAPI):
242
- """If the service ships a new field tomorrow, today's SDK must not
243
- break. The host parser picks known fields only."""
336
+ async def test_renew_host_omits_expires_at_when_unset(api: SandboxAPI):
337
+ """Omitted expires_at means "extend by the default TTL"; the SDK must
338
+ send an empty body, not an explicit null."""
244
339
 
245
- payload = _host_payload(future_field="surprise", another_one=42)
246
- respx.get(f"{BASE_URL}/hosts/{payload['id']}").mock(
340
+ payload = _host_payload(status="active")
341
+ route = respx.post(f"{BASE_URL}/hosts/{payload['id']}/renew").mock(
247
342
  return_value=httpx.Response(200, json=payload),
248
343
  )
249
344
 
250
- host = await api.get_host(payload["id"])
345
+ await api.renew_host(payload["id"])
346
+
347
+ assert "expires_at" not in route.calls.last.request.content.decode()
348
+
349
+
350
+ @respx.mock
351
+ async def test_renew_host_404_raises_not_found(api: SandboxAPI):
352
+ host_id = uuid4()
353
+ respx.post(f"{BASE_URL}/hosts/{host_id}/renew").mock(
354
+ return_value=httpx.Response(404, json={"detail": "host not found"}),
355
+ )
356
+
357
+ with pytest.raises(SandboxNotFoundError, match="host not found"):
358
+ await api.renew_host(host_id)
359
+
360
+
361
+ @respx.mock
362
+ async def test_renew_host_409_when_not_renewable(api: SandboxAPI):
363
+ host_id = uuid4()
364
+ respx.post(f"{BASE_URL}/hosts/{host_id}/renew").mock(
365
+ return_value=httpx.Response(409, json={"detail": "cannot renew a host in status error"}),
366
+ )
251
367
 
252
- assert host.id == payload["id"] # Old fields still parsed.
253
- assert not hasattr(host, "future_field") # New field silently dropped.
368
+ with pytest.raises(SandboxConflictError, match="cannot renew"):
369
+ await api.renew_host(host_id)
254
370
 
255
371
 
256
372
  @respx.mock
@@ -284,9 +400,89 @@ async def test_get_host_returns_none_private_key_after_create(api: SandboxAPI):
284
400
  assert host.private_key is None
285
401
 
286
402
 
287
- # ---------------------------------------------------------------------------
288
- # Error classification
289
- # ---------------------------------------------------------------------------
403
+ @respx.mock
404
+ async def test_create_template_posts_required_payload_and_returns_building_record(
405
+ api: SandboxAPI,
406
+ ):
407
+ payload = _template_payload()
408
+ route = respx.post(f"{BASE_URL}/templates").mock(
409
+ return_value=httpx.Response(202, json=payload),
410
+ )
411
+
412
+ template = await api.create_template(setup_script="apt-get install -y ripgrep")
413
+
414
+ sent = route.calls.last.request
415
+ assert sent.headers["Authorization"] == "Bearer t-test"
416
+ assert sent.headers["Accept"] == "application/json"
417
+ body = sent.content.decode()
418
+ assert '"setup_script":"apt-get install -y ripgrep"' in body
419
+ assert "provider" not in body
420
+ assert "base_image" not in body
421
+ assert "label" not in body
422
+ assert template.id == payload["id"]
423
+ assert template.status == "building"
424
+ assert template.image == ""
425
+ assert template.last_used_at is None
426
+
427
+
428
+ @respx.mock
429
+ async def test_get_template_returns_parsed_template(api: SandboxAPI):
430
+ payload = _template_payload(
431
+ status="available",
432
+ image="derived:image",
433
+ last_used_at="2026-08-24T12:30:00+00:00",
434
+ )
435
+ respx.get(f"{BASE_URL}/templates/{payload['id']}").mock(
436
+ return_value=httpx.Response(200, json=payload),
437
+ )
438
+
439
+ template = await api.get_template(payload["id"])
440
+
441
+ assert template.status == "available"
442
+ assert template.image == "derived:image"
443
+ assert template.last_used_at == payload["last_used_at"]
444
+
445
+
446
+ @respx.mock
447
+ async def test_list_templates_parses_records_in_service_order(api: SandboxAPI):
448
+ payloads = [
449
+ _template_payload(label="newest"),
450
+ _template_payload(label="older"),
451
+ ]
452
+ respx.get(f"{BASE_URL}/templates").mock(
453
+ return_value=httpx.Response(200, json=payloads),
454
+ )
455
+
456
+ templates = await api.list_templates()
457
+
458
+ assert [template.id for template in templates] == [payload["id"] for payload in payloads]
459
+ assert [template.label for template in templates] == ["newest", "older"]
460
+
461
+
462
+ @respx.mock
463
+ async def test_delete_template_swallows_204(api: SandboxAPI):
464
+ template_id = uuid4()
465
+ route = respx.delete(f"{BASE_URL}/templates/{template_id}").mock(
466
+ return_value=httpx.Response(204),
467
+ )
468
+
469
+ await api.delete_template(template_id)
470
+
471
+ assert route.called
472
+
473
+
474
+ @respx.mock
475
+ async def test_delete_template_409_while_building(api: SandboxAPI):
476
+ template_id = uuid4()
477
+ respx.delete(f"{BASE_URL}/templates/{template_id}").mock(
478
+ return_value=httpx.Response(
479
+ 409,
480
+ json={"detail": "TEMPLATE_STATE: template is still building"},
481
+ ),
482
+ )
483
+
484
+ with pytest.raises(SandboxConflictError, match="TEMPLATE_STATE"):
485
+ await api.delete_template(template_id)
290
486
 
291
487
 
292
488
  @respx.mock
@@ -433,11 +629,6 @@ async def test_error_detail_absent_uses_fallback_message(api: SandboxAPI):
433
629
  await api.list_hosts()
434
630
 
435
631
 
436
- # ---------------------------------------------------------------------------
437
- # Lifecycle + loop affinity
438
- # ---------------------------------------------------------------------------
439
-
440
-
441
632
  @respx.mock
442
633
  async def test_aclose_drops_client_and_is_idempotent(api: SandboxAPI):
443
634
  respx.get(f"{BASE_URL}/hosts").mock(return_value=httpx.Response(200, json=[]))
@@ -484,11 +675,6 @@ def test_cross_loop_reuse_rebinds_client_without_crashing():
484
675
  assert bound_loops[0] is not bound_loops[1]
485
676
 
486
677
 
487
- # ---------------------------------------------------------------------------
488
- # from_env
489
- # ---------------------------------------------------------------------------
490
-
491
-
492
678
  def test_from_env_reads_prefixed_vars(monkeypatch: pytest.MonkeyPatch):
493
679
  monkeypatch.setenv("SANDBOX_SERVICE_URL", "https://from-env.test")
494
680
  monkeypatch.setenv("SANDBOX_SERVICE_TOKEN", "env-token")
@@ -536,7 +722,6 @@ async def test_doctor_parses_report_and_checks(api: SandboxAPI):
536
722
 
537
723
  assert route.called
538
724
  assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
539
- assert isinstance(report, DoctorReport)
540
725
  assert report.ok is True
541
726
  assert report.active_provider == "aws"
542
727
  assert report.tailscale_enabled is True
@@ -583,36 +768,116 @@ async def test_doctor_surfaces_failure_with_hint(api: SandboxAPI):
583
768
 
584
769
 
585
770
  @respx.mock
586
- async def test_doctor_ignores_unknown_check_fields(api: SandboxAPI):
587
- """A future service adding a field to a check must not break today's SDK."""
771
+ async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
772
+ """Doctor is service-token only; a bad token raises like every other call."""
588
773
 
589
- payload = _doctor_payload(
590
- checks=[
591
- {
592
- "name": "db",
593
- "status": "ok",
594
- "detail": "select 1 -> 1",
595
- "latency_ms": 2,
596
- "hint": None,
597
- "future_field": "surprise",
598
- },
599
- ],
774
+ respx.get(f"{BASE_URL}/doctor").mock(
775
+ return_value=httpx.Response(401, json={"detail": "service token required"}),
600
776
  )
601
- respx.get(f"{BASE_URL}/doctor").mock(return_value=httpx.Response(200, json=payload))
602
777
 
603
- report = await api.doctor()
778
+ with pytest.raises(SandboxAuthError, match="service token required"):
779
+ await api.doctor()
604
780
 
605
- assert report.checks[0].name == "db"
606
- assert not hasattr(report.checks[0], "future_field")
781
+
782
+ @respx.mock
783
+ async def test_create_http_proxy_posts_payload_and_returns_record(api: SandboxAPI):
784
+ route = respx.post(f"{BASE_URL}/http-proxies").mock(
785
+ return_value=httpx.Response(201, json={"name": "gh", "status": "created"}),
786
+ )
787
+
788
+ proxy = await api.create_http_proxy(
789
+ name="gh",
790
+ target="https://api.github.com",
791
+ headers={"Authorization": "Bearer x"},
792
+ )
793
+
794
+ body = route.calls.last.request.content.decode()
795
+ assert route.calls.last.request.headers["Authorization"] == "Bearer t-test"
796
+ assert '"name":"gh"' in body
797
+ assert '"target":"https://api.github.com"' in body
798
+ assert '"headers":{"Authorization":"Bearer x"}' in body
799
+ assert proxy == HTTPProxy(name="gh", status="created")
607
800
 
608
801
 
609
802
  @respx.mock
610
- async def test_doctor_401_raises_sandbox_auth_error(api: SandboxAPI):
611
- """Doctor is service-token only; a bad token raises like every other call."""
803
+ async def test_create_http_proxy_409_raises_conflict(api: SandboxAPI):
804
+ respx.post(f"{BASE_URL}/http-proxies").mock(
805
+ return_value=httpx.Response(409, json={"detail": "http proxy already exists"}),
806
+ )
612
807
 
613
- respx.get(f"{BASE_URL}/doctor").mock(
614
- return_value=httpx.Response(401, json={"detail": "service token required"}),
808
+ with pytest.raises(SandboxConflictError, match="already exists"):
809
+ await api.create_http_proxy(name="gh", target="https://api.github.com", headers={"X": "y"})
810
+
811
+
812
+ @respx.mock
813
+ async def test_create_http_proxy_422_raises_validation(api: SandboxAPI):
814
+ """A target carrying a path is rejected server-side; the SDK surfaces
815
+ it as the don't-retry validation error."""
816
+
817
+ respx.post(f"{BASE_URL}/http-proxies").mock(
818
+ return_value=httpx.Response(
819
+ 422,
820
+ json={"detail": [{"msg": "target URL must not contain a path"}]},
821
+ ),
615
822
  )
616
823
 
617
- with pytest.raises(SandboxAuthError, match="service token required"):
618
- await api.doctor()
824
+ with pytest.raises(SandboxValidationError, match="must not contain a path"):
825
+ await api.create_http_proxy(
826
+ name="gh", target="https://api.github.com/v3", headers={"X": "y"}
827
+ )
828
+
829
+
830
+ @respx.mock
831
+ async def test_delete_http_proxy_swallows_204(api: SandboxAPI):
832
+ respx.delete(f"{BASE_URL}/http-proxies/gh").mock(return_value=httpx.Response(204))
833
+
834
+ await api.delete_http_proxy("gh")
835
+
836
+
837
+ @respx.mock
838
+ async def test_delete_http_proxy_404_raises_not_found(api: SandboxAPI):
839
+ respx.delete(f"{BASE_URL}/http-proxies/gh").mock(
840
+ return_value=httpx.Response(404, json={"detail": "http proxy not found"}),
841
+ )
842
+
843
+ with pytest.raises(SandboxNotFoundError, match="not found"):
844
+ await api.delete_http_proxy("gh")
845
+
846
+
847
+ @respx.mock
848
+ async def test_attach_http_proxy_returns_attachment(api: SandboxAPI):
849
+ host_id = uuid4()
850
+ route = respx.post(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
851
+ return_value=httpx.Response(
852
+ 200, json={"name": "gh", "host_id": str(host_id), "status": "attached"}
853
+ ),
854
+ )
855
+
856
+ attachment = await api.attach_http_proxy("gh", host_id)
857
+
858
+ assert route.called
859
+ assert attachment == HTTPProxyAttachment(name="gh", host_id=str(host_id), status="attached")
860
+
861
+
862
+ @respx.mock
863
+ async def test_attach_http_proxy_409_when_host_has_no_vm(api: SandboxAPI):
864
+ """Attaching to a host that isn't bootstrapping/active is a state
865
+ conflict, not a validation error."""
866
+
867
+ host_id = uuid4()
868
+ respx.post(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
869
+ return_value=httpx.Response(409, json={"detail": "host does not have a backing VM"}),
870
+ )
871
+
872
+ with pytest.raises(SandboxConflictError, match="backing VM"):
873
+ await api.attach_http_proxy("gh", host_id)
874
+
875
+
876
+ @respx.mock
877
+ async def test_detach_http_proxy_swallows_204(api: SandboxAPI):
878
+ host_id = uuid4()
879
+ respx.delete(f"{BASE_URL}/http-proxies/gh/hosts/{host_id}").mock(
880
+ return_value=httpx.Response(204),
881
+ )
882
+
883
+ await api.detach_http_proxy("gh", host_id)