boxd 0.2.18.dev896__tar.gz → 0.2.19.dev901__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {boxd-0.2.18.dev896/src/boxd.egg-info → boxd-0.2.19.dev901}/PKG-INFO +241 -3
  2. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/README.md +240 -2
  3. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/pyproject.toml +1 -1
  4. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/__init__.py +51 -0
  5. boxd-0.2.19.dev901/src/boxd/_generated/api_pb2.py +626 -0
  6. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_generated/api_pb2_grpc.py +47 -0
  7. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_mappers.py +68 -1
  8. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_requests.py +11 -0
  9. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/models.py +149 -3
  10. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/machines.py +179 -4
  11. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/vars.py +2 -0
  12. boxd-0.2.19.dev901/src/boxd/spec.py +475 -0
  13. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901/src/boxd.egg-info}/PKG-INFO +241 -3
  14. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd.egg-info/SOURCES.txt +2 -0
  15. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_mappers.py +8 -0
  16. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_namespaces.py +25 -0
  17. boxd-0.2.19.dev901/tests/test_spec.py +471 -0
  18. boxd-0.2.18.dev896/src/boxd/_generated/api_pb2.py +0 -600
  19. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/LICENSE +0 -0
  20. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/setup.cfg +0 -0
  21. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_client.py +0 -0
  22. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_credentials.py +0 -0
  23. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_generated/__init__.py +0 -0
  24. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_streaming.py +0 -0
  25. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_transport.py +0 -0
  26. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_urls.py +0 -0
  27. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/_version_check.py +0 -0
  28. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/errors.py +0 -0
  29. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/__init__.py +0 -0
  30. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/account.py +0 -0
  31. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/credentials.py +0 -0
  32. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/disks.py +0 -0
  33. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/domains.py +0 -0
  34. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/orgs.py +0 -0
  35. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/snapshots.py +0 -0
  36. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd/resources/teams.py +0 -0
  37. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd.egg-info/dependency_links.txt +0 -0
  38. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd.egg-info/requires.txt +0 -0
  39. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/src/boxd.egg-info/top_level.txt +0 -0
  40. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_credentials.py +0 -0
  41. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_delete_self.py +0 -0
  42. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_requests.py +0 -0
  43. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_streaming.py +0 -0
  44. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_transport.py +0 -0
  45. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_urls.py +0 -0
  46. {boxd-0.2.18.dev896 → boxd-0.2.19.dev901}/tests/test_version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boxd
3
- Version: 0.2.18.dev896
3
+ Version: 0.2.19.dev901
4
4
  Summary: Python SDK for the boxd cloud VM platform
5
5
  Author: Azin
6
6
  License-Expression: MIT
@@ -250,6 +250,228 @@ private to you unless you pass `shared=True`.
250
250
  usable. Call `wait_until_ready` before doing anything that depends on it
251
251
  running — especially before forking it again.
252
252
 
253
+ ### Machine specs
254
+
255
+ A machine spec describes a machine in one value: what it boots from, its size,
256
+ labels, networks, isolation, egress, env, the secrets it gets, the
257
+ integrations and agent logins it needs, and the packages it installs on first boot. Build one from reusable pieces and create from it:
258
+
259
+ ```python
260
+ from boxd import Machine, team, snapshot, github, slack, jira, claude, secrets
261
+
262
+ engineering = (
263
+ Machine.spec()
264
+ .from_(team("eng")) # a team's default snapshot; or snapshot("eng-base")
265
+ .network("eng")
266
+ .label("team", "eng")
267
+ .env("NODE_ENV", "development")
268
+ .egress("github.com", "*.atlassian.net", "*.ubuntu.com")
269
+ .packages("apt-get update", "apt-get install -y nginx")
270
+ .with_(github(shared="eng-bot"), claude())
271
+ )
272
+
273
+ mike = engineering.size("2x8").with_(slack(), jira(shared="jira"), secrets("NPM_TOKEN"))
274
+ m = boxd.machines.create("mike-dev", org="acme", spec=mike)
275
+ m.spec # the spec as it stands (update_spec, and label, network, egress and size edits, update it)
276
+ m.created_spec # the spec it was created with, never changed
277
+ mike.resolve() # the same spec as a dict, without creating anything
278
+ ```
279
+
280
+ | Builder | Sets |
281
+ |---|---|
282
+ | `.from_(team(name))` / `.from_(snapshot(name))` | `base`. Omit for the default image. |
283
+ | `.size("1x4" \| "2x8" \| "4x16" \| …)` | `size`, `<vcpu>x<memory GiB>` |
284
+ | `.assign_to(user_id)` | `assignTo`: create the machine for another member of the org (below) |
285
+ | `.label(key, value)` / `.label({...})` | `labels` |
286
+ | `.network(*names)` | `networks` |
287
+ | `.isolated()` | `isolated` |
288
+ | `.egress(*hosts)` | `egress`: the machine's egress allowlist from birth |
289
+ | `.env(key, value)` / `.env({...})` | `env`: wins over org and personal vars of the same name |
290
+ | `.packages(*commands)` | `packages`: shell commands run once after the first boot (below) |
291
+ | `.with_(*pieces)` | merges pieces or other builders |
292
+
293
+ Pieces: `slack()`, `jira()`, `github()`, `asana()` (pass `shared="tag"` to use
294
+ the org connection shared under that tag instead of the owner's own),
295
+ `claude()`, `codex()`, and `secrets(*names)` (below). Integrations and agent logins are recorded on the
296
+ machine; a missing one never blocks the create (see "Integrations the machine
297
+ needs"). A piece is a plain dict (`{"labels": {...}}`, or
298
+ `{"connections": {"notion": "personal"}}` for any toolkit), so you can write your own.
299
+
300
+ Pieces merge in order: maps (`labels`, `env`, `connections`) merge with the
301
+ later piece winning, lists (`networks`, `egress`, `secrets`, `agents`,
302
+ `packages`) combine in order without duplicates, `isolated` only turns on, and `base`, `size` or `assignTo`
303
+ set twice to different values raises `MachineSpecError` (a `ValueError`). So
304
+ does a key a spec doesn't have, and a name in both `env` and `secrets`. The server checks every field and names it in its error
305
+ (`spec.size: ...`).
306
+
307
+ A spec carries its machine's base, size, labels, networks, isolation and env,
308
+ so `create(spec=...)` takes none of `image`, `env`, `labels`, `networks`,
309
+ `isolated`, `vcpu` or `memory` beside it. A spec whose base is a snapshot (or a
310
+ team with a default snapshot) restores it. A snapshot keeps its machine's spec:
311
+ restoring it gives the new machine that spec, and
312
+ `create(from_snapshot=..., spec=...)` merges a spec on top by the same rules;
313
+ its `size`, if set, must be the snapshot's. A fork keeps its source's spec.
314
+
315
+ #### Secrets
316
+
317
+ A spec names the secrets its machine gets; their values stay in
318
+ `boxd.secrets`. Usually they are `unattached` secrets, which reach no machine
319
+ until a spec lists them:
320
+
321
+ ```python
322
+ boxd.secrets.set("NPM_TOKEN", "npm_…", scope="unattached", org="acme")
323
+
324
+ m = boxd.machines.create("mike-dev", org="acme", spec=engineering.with_(secrets("NPM_TOKEN")))
325
+ ```
326
+
327
+ A name resolves, on your own machine, to your own secret first
328
+ (`private-unattached`, then `private`), else the org's (`unattached`, `all`,
329
+ `shared`). On a machine created for someone else, or a shared machine, only
330
+ the org's secrets count. Only org owners and admins may list an org
331
+ `unattached` secret in a spec, and an org `shared` secret only reaches
332
+ shared machines, so a private machine can't list one. A name that resolves to
333
+ nothing, or that the create may not list, fails the create, naming it.
334
+
335
+ On the machine, the spec's `env` and its `secrets` win over your `private`
336
+ vars, which win over the org's `shared` vars (shared machines only), which win
337
+ over the org's `all` vars. A name can't be in both `env` and `secrets`.
338
+
339
+ The machine keeps a reference, not a copy: rotating the secret reaches it, and
340
+ deleting the secret takes it away
341
+ (setting it again with the same name brings it back). Forks, snapshots and
342
+ restores resolve the
343
+ names again, and fail naming any that no longer resolve. A secret's
344
+ `bound_machines` says how many machines list it.
345
+
346
+ #### Creating a machine for someone else
347
+
348
+ An org owner or admin can create a machine for another member of the org with
349
+ `.assign_to(user_id)` (the member's user id, as `orgs.get_members()` lists it):
350
+
351
+ ```python
352
+ m = boxd.machines.create(
353
+ "mike-dev",
354
+ org="acme", # required: the org the machine is for
355
+ spec=engineering.size("2x8").assign_to("usr_e393f70e…"),
356
+ )
357
+ ```
358
+
359
+ The machine is the member's from the start: private to them, billed to the
360
+ org, and set up with their own integrations and agent logins. You can't see or
361
+ reach it afterwards, so `create()` answers with its id and name only, and the
362
+ create can't also set `delete_after`. The member sees `created_by` (your user
363
+ id) and `created_by_name` on it. Refused, naming `spec.assignTo`: without
364
+ `org`, with `shared=True`, for a caller who isn't an owner or admin of the org,
365
+ with an org API key or from inside a machine, for someone who isn't a member,
366
+ and with a snapshot or disk the member couldn't use. Your own id is a plain
367
+ create. `assignTo` is never inherited: forks, snapshots and restores drop it.
368
+
369
+ #### Integrations the machine needs
370
+
371
+ A spec's `connections` and `agents` are the machine's requirements. Reading the
372
+ machine (`get`, `list`, the machine `create` returns) tells you, for each one,
373
+ whether it is connected right now:
374
+
375
+ ```python
376
+ m = boxd.machines.get("mike-dev")
377
+ for r in m.requirements:
378
+ # kind="connection" name="jira" scope="shared" tag="jira" met=False can_fix=True reason=None
379
+ print(r.name, r.scope, r.tag, "connected" if r.met else "connect it" if r.can_fix else r.reason or "ask an org admin")
380
+ ```
381
+
382
+ A `personal` connection is the machine owner's own connection for that toolkit
383
+ in the machine's org (on a machine created with `assign_to`, the member's), a
384
+ `shared` one the org's connection shared under `tag`, and an agent the owner's
385
+ Claude Code or Codex sign-in. `can_fix` says whether you could connect it: the
386
+ owner for personal ones and agents, an org owner or admin for shared ones.
387
+ Connect from the console: the machine's page lists them with a Connect button.
388
+
389
+ An unmet requirement is only unavailable on the machine; nothing blocks
390
+ creating, booting or using it. Some can't be met on the machine as it is, and
391
+ `reason` says why: an isolated machine gets no integrations or agent logins,
392
+ and a shared machine never gets anyone's personal connection or agent login
393
+ (require `shared:<tag>` instead). The create refuses a toolkit this cluster
394
+ can't connect (`spec.connections.<toolkit>: ...`), and the GitHub App
395
+ (`github-app`) only as `"shared:org"`.
396
+
397
+ #### Packages
398
+
399
+ `packages` are shell commands the machine runs once, in order, after its first
400
+ boot: as root, with bash (sh on an image without it),
401
+ `DEBIAN_FRONTEND=noninteractive`, no stdin and a 30-minute timeout each. Their
402
+ output is appended to `/var/log/boxd/packages.log` in the machine. The first
403
+ failure (a non-zero exit, a timeout) stops the rest; the machine stays usable.
404
+ Reading the machine tells you how each one went:
405
+
406
+ ```python
407
+ m = boxd.machines.get("mike-dev")
408
+ for p in m.packages:
409
+ # command="apt-get install -y nginx" state="failed" exit_code=100 output_tail="E: Unable to locate…" reason=None …
410
+ print(p.state, p.exit_code, p.command)
411
+ ```
412
+
413
+ `state` is `"pending"`, `"running"`, `"done"` or `"failed"`; the commands
414
+ after a failure stay `"pending"`. `output_tail` is the last 4 KiB of the
415
+ command's output, and `reason` says why a failed one has no exit code (it
416
+ timed out, or the run was interrupted). Nothing runs again: not on a reboot, a
417
+ resume, a wake or a move. Restoring a snapshot whose spec lists packages marks
418
+ those done and runs only the ones the restore's spec adds; a fork keeps its
419
+ source's states and runs nothing. The machine doesn't auto-suspend or
420
+ hibernate while a command runs. At most 50 commands (a restore counts the
421
+ snapshot's too), each at most 4 KiB; the server names the one it refuses
422
+ (`spec.packages[2]: ...`). Commands that download something need the hosts in
423
+ the spec's `egress`, if it has one.
424
+
425
+ #### Updating a machine's spec
426
+
427
+ `update_spec` changes the spec of a machine created from one and applies what
428
+ changes. Pass a JSON Merge Patch, or a function that adds to the current spec:
429
+
430
+ ```python
431
+ # Patch: a field left out is untouched, a scalar replaces, maps merge key by
432
+ # key (None removes a key), a list replaces the whole list, None removes a field.
433
+ boxd.machines.update_spec("mike-dev", {
434
+ "labels": {"owner": "mike", "team": None},
435
+ "networks": ["eng", "ops"],
436
+ "packages": ["apt-get install -y nginx", "npm i -g pnpm"],
437
+ })
438
+
439
+ # Builder: reads the spec, adds with the composition rules, sends the difference.
440
+ boxd.machines.update_spec("mike-dev", lambda s: s.network("ops").env("LOG_LEVEL", "debug"))
441
+
442
+ # Dry run: the diff, nothing changed.
443
+ diff = boxd.machines.update_spec("mike-dev", {"size": "4x16"}, dry_run=True)
444
+ for c in diff.changes:
445
+ print(c.field, c.apply, c.note, c.warnings)
446
+ if diff.reboot_required:
447
+ boxd.machines.update_spec("mike-dev", {"size": "4x16"}, reboot=True)
448
+ ```
449
+
450
+ Each change in `changes` says how it applies (`apply`):
451
+
452
+ | Field | `apply` |
453
+ |---|---|
454
+ | `labels`, `networks`, `egress` | `"live"` |
455
+ | `connections`, `agents` | `"live"`: what the machine needs (`requirements`) changes |
456
+ | `packages` | `"live"`: added commands run now, after the ones before them. Removing one undoes nothing (a warning says so); a command that already ran isn't run again; commands after a failed one don't run (a warning says so) |
457
+ | `env`, `secrets` | `"reboot_recommended"`: new shells and exec sessions get them now; processes already running keep the old values until they restart (`reboot_recommended`) |
458
+ | `size` | `"reboot"` on a running machine, which reboots to apply it (as `resize`), so it needs `reboot=True`; `"live"` on a stopped one |
459
+ | `base`, `isolated`, `assignTo` | `"refused"`: fixed when the machine is created |
460
+
461
+ A refused change (also: new packages on a fork, a size change in standby or
462
+ hibernated) refuses the whole patch, and so does a reboot without
463
+ `reboot=True`; nothing is applied. Otherwise the spec and the labels,
464
+ networks, egress and secrets it drives change together, then the size, then
465
+ the new packages run. The patched spec is checked like a create's (an
466
+ unknown toolkit, a secret you may not list, more than 50 commands in all are
467
+ refused, naming the field). `machine.created_spec` never changes, so the
468
+ console shows the spec badge as "updated". Allowed to whoever may edit the
469
+ machine's labels: its owner, or an org owner or admin on a shared machine.
470
+ The builder form only adds: a `size` that differs from the spec's raises, so
471
+ use a patch to replace or remove. `spec_patch(from_spec, to_spec)` computes the
472
+ patch the builder form sends. `AsyncBoxd` has the same `await
473
+ boxd.machines.update_spec(...)`.
474
+
253
475
  ### Egress allowlist
254
476
 
255
477
  A machine can be limited to what it may reach on the internet:
@@ -266,7 +488,8 @@ addresses of allowlisted names learned as the machine resolves them. Nothing
266
488
  that is not named is reachable. The hosts of any bound secret the machine
267
489
  holds are always admitted. Takes effect within a second and is not visible or
268
490
  changeable from inside the machine. A fork inherits its source's allowlist; a
269
- machine restored from a snapshot starts unrestricted.
491
+ machine restored from a snapshot starts unrestricted, unless its machine spec
492
+ lists `egress`.
270
493
 
271
494
  ### Labels
272
495
 
@@ -345,6 +568,14 @@ machine.source.id # str | None — provenance; may not resolve
345
568
  machine.hibernated_at # datetime | None — None = not hibernated
346
569
  machine.last_connected_at # datetime | None — None = never connected
347
570
  machine.boot_time_ms # int | None — last boot; None = never booted
571
+ machine.spec # dict | None — the machine spec it runs under
572
+ machine.created_spec # dict | None — the spec it was created with
573
+ machine.created_by # str | None — who created it for you (spec assignTo); None = you did
574
+ machine.created_by_name # str | None — a name to show for created_by
575
+ machine.requirements # list[MachineRequirement] — the spec's connections and agents:
576
+ # kind, name, scope ("personal"/"shared"), tag, met, can_fix, reason
577
+ machine.packages # list[MachinePackage] — the spec's packages and how each went:
578
+ # command, state, exit_code, output_tail, reason, started_at, finished_at
348
579
  ```
349
580
 
350
581
  `None` always means "not set": a port that was never allocated, a boot that
@@ -558,7 +789,7 @@ boxd.env.list(org="acme")
558
789
  boxd.env.delete("MODE", scope="all")
559
790
 
560
791
  boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
561
- boxd.secrets.list() # Secret(name, scope, domains) — no value
792
+ boxd.secrets.list() # Secret(name, scope, domains, bound_machines) — no value
562
793
  boxd.secrets.delete("API_TOKEN", scope="shared")
563
794
  ```
564
795
 
@@ -595,6 +826,13 @@ Scope decides which machines a name applies to:
595
826
  | `private` | only your machines in that organization |
596
827
  | `shared` | the organization's shared machines |
597
828
  | `all` | every machine in the organization |
829
+ | `unattached` | secrets only: just the machines whose spec lists it ([Secrets](#secrets)); the organization's with `org`, else your own |
830
+ | `private-unattached` | secrets only: your own unattached secret in the organization named by `org` |
831
+
832
+ `list` reports the organization's unattached secrets as `unattached` and your
833
+ own as `private-unattached`, each with `bound_machines`, the number of machines
834
+ whose spec lists it (0 for every other scope). `unattached` is for secrets
835
+ only: a plain value a machine needs belongs in its spec's `env`.
598
836
 
599
837
  Scope is part of a name's identity — the same name can exist in several scopes
600
838
  at once — so changing it is a `move` between two addresses, and both ends are
@@ -216,6 +216,228 @@ private to you unless you pass `shared=True`.
216
216
  usable. Call `wait_until_ready` before doing anything that depends on it
217
217
  running — especially before forking it again.
218
218
 
219
+ ### Machine specs
220
+
221
+ A machine spec describes a machine in one value: what it boots from, its size,
222
+ labels, networks, isolation, egress, env, the secrets it gets, the
223
+ integrations and agent logins it needs, and the packages it installs on first boot. Build one from reusable pieces and create from it:
224
+
225
+ ```python
226
+ from boxd import Machine, team, snapshot, github, slack, jira, claude, secrets
227
+
228
+ engineering = (
229
+ Machine.spec()
230
+ .from_(team("eng")) # a team's default snapshot; or snapshot("eng-base")
231
+ .network("eng")
232
+ .label("team", "eng")
233
+ .env("NODE_ENV", "development")
234
+ .egress("github.com", "*.atlassian.net", "*.ubuntu.com")
235
+ .packages("apt-get update", "apt-get install -y nginx")
236
+ .with_(github(shared="eng-bot"), claude())
237
+ )
238
+
239
+ mike = engineering.size("2x8").with_(slack(), jira(shared="jira"), secrets("NPM_TOKEN"))
240
+ m = boxd.machines.create("mike-dev", org="acme", spec=mike)
241
+ m.spec # the spec as it stands (update_spec, and label, network, egress and size edits, update it)
242
+ m.created_spec # the spec it was created with, never changed
243
+ mike.resolve() # the same spec as a dict, without creating anything
244
+ ```
245
+
246
+ | Builder | Sets |
247
+ |---|---|
248
+ | `.from_(team(name))` / `.from_(snapshot(name))` | `base`. Omit for the default image. |
249
+ | `.size("1x4" \| "2x8" \| "4x16" \| …)` | `size`, `<vcpu>x<memory GiB>` |
250
+ | `.assign_to(user_id)` | `assignTo`: create the machine for another member of the org (below) |
251
+ | `.label(key, value)` / `.label({...})` | `labels` |
252
+ | `.network(*names)` | `networks` |
253
+ | `.isolated()` | `isolated` |
254
+ | `.egress(*hosts)` | `egress`: the machine's egress allowlist from birth |
255
+ | `.env(key, value)` / `.env({...})` | `env`: wins over org and personal vars of the same name |
256
+ | `.packages(*commands)` | `packages`: shell commands run once after the first boot (below) |
257
+ | `.with_(*pieces)` | merges pieces or other builders |
258
+
259
+ Pieces: `slack()`, `jira()`, `github()`, `asana()` (pass `shared="tag"` to use
260
+ the org connection shared under that tag instead of the owner's own),
261
+ `claude()`, `codex()`, and `secrets(*names)` (below). Integrations and agent logins are recorded on the
262
+ machine; a missing one never blocks the create (see "Integrations the machine
263
+ needs"). A piece is a plain dict (`{"labels": {...}}`, or
264
+ `{"connections": {"notion": "personal"}}` for any toolkit), so you can write your own.
265
+
266
+ Pieces merge in order: maps (`labels`, `env`, `connections`) merge with the
267
+ later piece winning, lists (`networks`, `egress`, `secrets`, `agents`,
268
+ `packages`) combine in order without duplicates, `isolated` only turns on, and `base`, `size` or `assignTo`
269
+ set twice to different values raises `MachineSpecError` (a `ValueError`). So
270
+ does a key a spec doesn't have, and a name in both `env` and `secrets`. The server checks every field and names it in its error
271
+ (`spec.size: ...`).
272
+
273
+ A spec carries its machine's base, size, labels, networks, isolation and env,
274
+ so `create(spec=...)` takes none of `image`, `env`, `labels`, `networks`,
275
+ `isolated`, `vcpu` or `memory` beside it. A spec whose base is a snapshot (or a
276
+ team with a default snapshot) restores it. A snapshot keeps its machine's spec:
277
+ restoring it gives the new machine that spec, and
278
+ `create(from_snapshot=..., spec=...)` merges a spec on top by the same rules;
279
+ its `size`, if set, must be the snapshot's. A fork keeps its source's spec.
280
+
281
+ #### Secrets
282
+
283
+ A spec names the secrets its machine gets; their values stay in
284
+ `boxd.secrets`. Usually they are `unattached` secrets, which reach no machine
285
+ until a spec lists them:
286
+
287
+ ```python
288
+ boxd.secrets.set("NPM_TOKEN", "npm_…", scope="unattached", org="acme")
289
+
290
+ m = boxd.machines.create("mike-dev", org="acme", spec=engineering.with_(secrets("NPM_TOKEN")))
291
+ ```
292
+
293
+ A name resolves, on your own machine, to your own secret first
294
+ (`private-unattached`, then `private`), else the org's (`unattached`, `all`,
295
+ `shared`). On a machine created for someone else, or a shared machine, only
296
+ the org's secrets count. Only org owners and admins may list an org
297
+ `unattached` secret in a spec, and an org `shared` secret only reaches
298
+ shared machines, so a private machine can't list one. A name that resolves to
299
+ nothing, or that the create may not list, fails the create, naming it.
300
+
301
+ On the machine, the spec's `env` and its `secrets` win over your `private`
302
+ vars, which win over the org's `shared` vars (shared machines only), which win
303
+ over the org's `all` vars. A name can't be in both `env` and `secrets`.
304
+
305
+ The machine keeps a reference, not a copy: rotating the secret reaches it, and
306
+ deleting the secret takes it away
307
+ (setting it again with the same name brings it back). Forks, snapshots and
308
+ restores resolve the
309
+ names again, and fail naming any that no longer resolve. A secret's
310
+ `bound_machines` says how many machines list it.
311
+
312
+ #### Creating a machine for someone else
313
+
314
+ An org owner or admin can create a machine for another member of the org with
315
+ `.assign_to(user_id)` (the member's user id, as `orgs.get_members()` lists it):
316
+
317
+ ```python
318
+ m = boxd.machines.create(
319
+ "mike-dev",
320
+ org="acme", # required: the org the machine is for
321
+ spec=engineering.size("2x8").assign_to("usr_e393f70e…"),
322
+ )
323
+ ```
324
+
325
+ The machine is the member's from the start: private to them, billed to the
326
+ org, and set up with their own integrations and agent logins. You can't see or
327
+ reach it afterwards, so `create()` answers with its id and name only, and the
328
+ create can't also set `delete_after`. The member sees `created_by` (your user
329
+ id) and `created_by_name` on it. Refused, naming `spec.assignTo`: without
330
+ `org`, with `shared=True`, for a caller who isn't an owner or admin of the org,
331
+ with an org API key or from inside a machine, for someone who isn't a member,
332
+ and with a snapshot or disk the member couldn't use. Your own id is a plain
333
+ create. `assignTo` is never inherited: forks, snapshots and restores drop it.
334
+
335
+ #### Integrations the machine needs
336
+
337
+ A spec's `connections` and `agents` are the machine's requirements. Reading the
338
+ machine (`get`, `list`, the machine `create` returns) tells you, for each one,
339
+ whether it is connected right now:
340
+
341
+ ```python
342
+ m = boxd.machines.get("mike-dev")
343
+ for r in m.requirements:
344
+ # kind="connection" name="jira" scope="shared" tag="jira" met=False can_fix=True reason=None
345
+ print(r.name, r.scope, r.tag, "connected" if r.met else "connect it" if r.can_fix else r.reason or "ask an org admin")
346
+ ```
347
+
348
+ A `personal` connection is the machine owner's own connection for that toolkit
349
+ in the machine's org (on a machine created with `assign_to`, the member's), a
350
+ `shared` one the org's connection shared under `tag`, and an agent the owner's
351
+ Claude Code or Codex sign-in. `can_fix` says whether you could connect it: the
352
+ owner for personal ones and agents, an org owner or admin for shared ones.
353
+ Connect from the console: the machine's page lists them with a Connect button.
354
+
355
+ An unmet requirement is only unavailable on the machine; nothing blocks
356
+ creating, booting or using it. Some can't be met on the machine as it is, and
357
+ `reason` says why: an isolated machine gets no integrations or agent logins,
358
+ and a shared machine never gets anyone's personal connection or agent login
359
+ (require `shared:<tag>` instead). The create refuses a toolkit this cluster
360
+ can't connect (`spec.connections.<toolkit>: ...`), and the GitHub App
361
+ (`github-app`) only as `"shared:org"`.
362
+
363
+ #### Packages
364
+
365
+ `packages` are shell commands the machine runs once, in order, after its first
366
+ boot: as root, with bash (sh on an image without it),
367
+ `DEBIAN_FRONTEND=noninteractive`, no stdin and a 30-minute timeout each. Their
368
+ output is appended to `/var/log/boxd/packages.log` in the machine. The first
369
+ failure (a non-zero exit, a timeout) stops the rest; the machine stays usable.
370
+ Reading the machine tells you how each one went:
371
+
372
+ ```python
373
+ m = boxd.machines.get("mike-dev")
374
+ for p in m.packages:
375
+ # command="apt-get install -y nginx" state="failed" exit_code=100 output_tail="E: Unable to locate…" reason=None …
376
+ print(p.state, p.exit_code, p.command)
377
+ ```
378
+
379
+ `state` is `"pending"`, `"running"`, `"done"` or `"failed"`; the commands
380
+ after a failure stay `"pending"`. `output_tail` is the last 4 KiB of the
381
+ command's output, and `reason` says why a failed one has no exit code (it
382
+ timed out, or the run was interrupted). Nothing runs again: not on a reboot, a
383
+ resume, a wake or a move. Restoring a snapshot whose spec lists packages marks
384
+ those done and runs only the ones the restore's spec adds; a fork keeps its
385
+ source's states and runs nothing. The machine doesn't auto-suspend or
386
+ hibernate while a command runs. At most 50 commands (a restore counts the
387
+ snapshot's too), each at most 4 KiB; the server names the one it refuses
388
+ (`spec.packages[2]: ...`). Commands that download something need the hosts in
389
+ the spec's `egress`, if it has one.
390
+
391
+ #### Updating a machine's spec
392
+
393
+ `update_spec` changes the spec of a machine created from one and applies what
394
+ changes. Pass a JSON Merge Patch, or a function that adds to the current spec:
395
+
396
+ ```python
397
+ # Patch: a field left out is untouched, a scalar replaces, maps merge key by
398
+ # key (None removes a key), a list replaces the whole list, None removes a field.
399
+ boxd.machines.update_spec("mike-dev", {
400
+ "labels": {"owner": "mike", "team": None},
401
+ "networks": ["eng", "ops"],
402
+ "packages": ["apt-get install -y nginx", "npm i -g pnpm"],
403
+ })
404
+
405
+ # Builder: reads the spec, adds with the composition rules, sends the difference.
406
+ boxd.machines.update_spec("mike-dev", lambda s: s.network("ops").env("LOG_LEVEL", "debug"))
407
+
408
+ # Dry run: the diff, nothing changed.
409
+ diff = boxd.machines.update_spec("mike-dev", {"size": "4x16"}, dry_run=True)
410
+ for c in diff.changes:
411
+ print(c.field, c.apply, c.note, c.warnings)
412
+ if diff.reboot_required:
413
+ boxd.machines.update_spec("mike-dev", {"size": "4x16"}, reboot=True)
414
+ ```
415
+
416
+ Each change in `changes` says how it applies (`apply`):
417
+
418
+ | Field | `apply` |
419
+ |---|---|
420
+ | `labels`, `networks`, `egress` | `"live"` |
421
+ | `connections`, `agents` | `"live"`: what the machine needs (`requirements`) changes |
422
+ | `packages` | `"live"`: added commands run now, after the ones before them. Removing one undoes nothing (a warning says so); a command that already ran isn't run again; commands after a failed one don't run (a warning says so) |
423
+ | `env`, `secrets` | `"reboot_recommended"`: new shells and exec sessions get them now; processes already running keep the old values until they restart (`reboot_recommended`) |
424
+ | `size` | `"reboot"` on a running machine, which reboots to apply it (as `resize`), so it needs `reboot=True`; `"live"` on a stopped one |
425
+ | `base`, `isolated`, `assignTo` | `"refused"`: fixed when the machine is created |
426
+
427
+ A refused change (also: new packages on a fork, a size change in standby or
428
+ hibernated) refuses the whole patch, and so does a reboot without
429
+ `reboot=True`; nothing is applied. Otherwise the spec and the labels,
430
+ networks, egress and secrets it drives change together, then the size, then
431
+ the new packages run. The patched spec is checked like a create's (an
432
+ unknown toolkit, a secret you may not list, more than 50 commands in all are
433
+ refused, naming the field). `machine.created_spec` never changes, so the
434
+ console shows the spec badge as "updated". Allowed to whoever may edit the
435
+ machine's labels: its owner, or an org owner or admin on a shared machine.
436
+ The builder form only adds: a `size` that differs from the spec's raises, so
437
+ use a patch to replace or remove. `spec_patch(from_spec, to_spec)` computes the
438
+ patch the builder form sends. `AsyncBoxd` has the same `await
439
+ boxd.machines.update_spec(...)`.
440
+
219
441
  ### Egress allowlist
220
442
 
221
443
  A machine can be limited to what it may reach on the internet:
@@ -232,7 +454,8 @@ addresses of allowlisted names learned as the machine resolves them. Nothing
232
454
  that is not named is reachable. The hosts of any bound secret the machine
233
455
  holds are always admitted. Takes effect within a second and is not visible or
234
456
  changeable from inside the machine. A fork inherits its source's allowlist; a
235
- machine restored from a snapshot starts unrestricted.
457
+ machine restored from a snapshot starts unrestricted, unless its machine spec
458
+ lists `egress`.
236
459
 
237
460
  ### Labels
238
461
 
@@ -311,6 +534,14 @@ machine.source.id # str | None — provenance; may not resolve
311
534
  machine.hibernated_at # datetime | None — None = not hibernated
312
535
  machine.last_connected_at # datetime | None — None = never connected
313
536
  machine.boot_time_ms # int | None — last boot; None = never booted
537
+ machine.spec # dict | None — the machine spec it runs under
538
+ machine.created_spec # dict | None — the spec it was created with
539
+ machine.created_by # str | None — who created it for you (spec assignTo); None = you did
540
+ machine.created_by_name # str | None — a name to show for created_by
541
+ machine.requirements # list[MachineRequirement] — the spec's connections and agents:
542
+ # kind, name, scope ("personal"/"shared"), tag, met, can_fix, reason
543
+ machine.packages # list[MachinePackage] — the spec's packages and how each went:
544
+ # command, state, exit_code, output_tail, reason, started_at, finished_at
314
545
  ```
315
546
 
316
547
  `None` always means "not set": a port that was never allocated, a boot that
@@ -524,7 +755,7 @@ boxd.env.list(org="acme")
524
755
  boxd.env.delete("MODE", scope="all")
525
756
 
526
757
  boxd.secrets.set("API_TOKEN", "s3cr3t", scope="shared")
527
- boxd.secrets.list() # Secret(name, scope, domains) — no value
758
+ boxd.secrets.list() # Secret(name, scope, domains, bound_machines) — no value
528
759
  boxd.secrets.delete("API_TOKEN", scope="shared")
529
760
  ```
530
761
 
@@ -561,6 +792,13 @@ Scope decides which machines a name applies to:
561
792
  | `private` | only your machines in that organization |
562
793
  | `shared` | the organization's shared machines |
563
794
  | `all` | every machine in the organization |
795
+ | `unattached` | secrets only: just the machines whose spec lists it ([Secrets](#secrets)); the organization's with `org`, else your own |
796
+ | `private-unattached` | secrets only: your own unattached secret in the organization named by `org` |
797
+
798
+ `list` reports the organization's unattached secrets as `unattached` and your
799
+ own as `private-unattached`, each with `bound_machines`, the number of machines
800
+ whose spec lists it (0 for every other scope). `unattached` is for secrets
801
+ only: a plain value a machine needs belongs in its spec's `env`.
564
802
 
565
803
  Scope is part of a name's identity — the same name can exist in several scopes
566
804
  at once — so changing it is a `move` between two addresses, and both ends are
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boxd"
3
- version = "0.2.18.dev896"
3
+ version = "0.2.19.dev901"
4
4
  description = "Python SDK for the boxd cloud VM platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -61,6 +61,11 @@ from .models import (
61
61
  MachineAccess,
62
62
  MachineIdle,
63
63
  MachineNetworking,
64
+ MachinePackage,
65
+ MachineSpecApply,
66
+ MachineSpecChange,
67
+ MachineSpecUpdate,
68
+ MachineRequirement,
64
69
  MachineResources,
65
70
  MachineSource,
66
71
  MachineSourceKind,
@@ -96,6 +101,27 @@ from .models import (
96
101
  VarScope,
97
102
  VolumeMount,
98
103
  )
104
+ from .spec import (
105
+ MACHINE_SPEC_VERSION,
106
+ MachineSize,
107
+ MachineSpec,
108
+ MachineSpecBase,
109
+ MachineSpecBuilder,
110
+ MachineSpecError,
111
+ MachineSpecPatch,
112
+ MachineSpecPiece,
113
+ asana,
114
+ claude,
115
+ codex,
116
+ github,
117
+ jira,
118
+ merge_specs,
119
+ secrets,
120
+ slack,
121
+ snapshot,
122
+ spec_patch,
123
+ team,
124
+ )
99
125
 
100
126
  try:
101
127
  from importlib.metadata import version as _pkg_version
@@ -143,6 +169,11 @@ __all__ = [
143
169
  "MachineAccess",
144
170
  "MachineIdle",
145
171
  "MachineNetworking",
172
+ "MachinePackage",
173
+ "MachineSpecApply",
174
+ "MachineSpecChange",
175
+ "MachineSpecUpdate",
176
+ "MachineRequirement",
146
177
  "MachineResources",
147
178
  "MachineSource",
148
179
  "MachineSourceKind",
@@ -178,6 +209,26 @@ __all__ = [
178
209
  "UsageRates",
179
210
  "VarScope",
180
211
  "VolumeMount",
212
+ # Machine specs (`Machine.spec()` starts one)
213
+ "MACHINE_SPEC_VERSION",
214
+ "MachineSize",
215
+ "MachineSpec",
216
+ "MachineSpecBase",
217
+ "MachineSpecBuilder",
218
+ "MachineSpecError",
219
+ "MachineSpecPatch",
220
+ "MachineSpecPiece",
221
+ "asana",
222
+ "claude",
223
+ "codex",
224
+ "github",
225
+ "jira",
226
+ "merge_specs",
227
+ "spec_patch",
228
+ "secrets",
229
+ "slack",
230
+ "snapshot",
231
+ "team",
181
232
  # Errors
182
233
  "APIConnectionError",
183
234
  "APIStatusError",