Compare commits

...

10 commits

Author SHA1 Message Date
fb195b8978 Add Forgejo CI smoke workflow (enablement template)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
2026-07-08 12:37:53 +02:00
2d0669c2f8 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-24:
  - update .custodian-brief.md for sand-boxer
2026-06-24 12:57:32 +02:00
774bc5ae0a feat: Packer build orchestration (SAND-WP-0012)
Add vm-packer build mode, profile.vm-packer-build, State Hub progress
notes during long provision, docs/runbook, and build mode tests.
2026-06-24 12:56:32 +02:00
92eaf8bae5 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-24:
  - update .custodian-brief.md for sand-boxer
2026-06-24 12:54:51 +02:00
1f87be4c6b feat: reachability and consumer profiles (SAND-WP-0011)
Add reachability enrichment (tunnel metadata, ops-bridge pointer),
secret_refs boundary resolution, profile.agent-dev and profile.build,
CLI reachability show, API endpoint, consumer smoke scripts, and tests.
2026-06-24 12:54:27 +02:00
7cabf77fb6 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-24:
  - update .custodian-brief.md for sand-boxer
2026-06-24 12:50:47 +02:00
15f031fd65 feat: cloud adapters E2B/Modal and billing export (SAND-WP-0010)
Add credentialed E2B and Modal extensions, burst routing fallback,
fin-hub meter export hook, BYOK docs, and 77 tests.
2026-06-24 12:50:19 +02:00
6d0a1a8b1e chore: sync SAND-WP-0010–0012 State Hub IDs 2026-06-24 12:48:07 +02:00
878aa6a451 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-24:
  - update .custodian-brief.md for sand-boxer
2026-06-24 12:47:55 +02:00
5466330cf4 docs: add workplans SAND-WP-0010–0012 for remaining gaps
Cloud adapters (E2B/Modal/BYOK/fin-hub), reachability and consumer
profiles, Packer orchestration; update gap analysis and SCOPE.
2026-06-24 12:47:08 +02:00
56 changed files with 2150 additions and 73 deletions

View file

@ -2,7 +2,7 @@
# Custodian Brief — sand-boxer # Custodian Brief — sand-boxer
**Domain:** infotech **Domain:** infotech
**Last synced:** 2026-06-24 10:44 UTC **Last synced:** 2026-06-24 10:57 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams ## Active Workstreams

View file

@ -0,0 +1,29 @@
# Canonical CI smoke template (tier 1 routing drill).
# Copy to: .forgejo/workflows/ci-smoke.yaml in consumer repos.
name: CI Smoke
on:
push:
branches:
- main
workflow_dispatch:
jobs:
host-smoke:
runs-on: self-hosted
steps:
- name: Routing probe (host runner)
run: |
set -eu
echo "repository=${GITHUB_REPOSITORY:-unknown}"
echo "sha=${GITHUB_SHA:-unknown}"
echo "runner=${RUNNER_NAME:-unknown}"
uname -a
container-smoke:
runs-on: ubuntu-latest
steps:
- name: Routing probe (container label)
run: |
set -eu
echo "container-smoke ok for ${GITHUB_REPOSITORY:-unknown}"

View file

@ -116,24 +116,24 @@ own tunnels or CAs.
- **Status:** v0 operational — self-hosted compose path proven on CoulombCore; - **Status:** v0 operational — self-hosted compose path proven on CoulombCore;
routing, payments stub, and snapshots shipped routing, payments stub, and snapshots shipped
- **Workplans finished:** SAND-WP-0001–0008 (all workplans in `workplans/`; - **Workplans finished:** SAND-WP-0001–0012 (0003/0004 in sibling repos)
0003/0004 delivered in sibling repos wise-validator / the-custodian) - **Workplans ready:** none (reuse-surface publish / sandboxer01 operator track)
- **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments, - **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments,
snapshots, telemetry, HTTP API snapshots, telemetry, HTTP API
- **Profiles:** `profile.compose-e2e`, `profile.compose-checkpoint`, - **Profiles:** compose e2e/checkpoint, canary, vm-haskell-build, saas-stub,
`profile.sandbox-canary`, `profile.vm-haskell-build`, `profile.saas-stub`, burst-sandbox, e2b-burst, modal-gpu, agent-dev, build, vm-packer-build
`profile.burst-sandbox` - **Extensions:** `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub`,
- **Extensions:** `ext.compose-ssh` (compose + tar snapshots), `ext.e2b`, `ext.modal`
`ext.vm-packer` (attach), `ext.saas-stub` (metered stub + metadata snapshots)
- **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`, - **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`,
`payments`, `snapshots`, `migration-gaps`, `migration-build-machines` `payments`, `snapshots`, `migration-gaps`, `migration-build-machines`
- **Registry:** `capability.execution.sandbox-provision` indexed (draft) - **Registry:** `capability.execution.sandbox-provision` indexed (draft)
- **Tests:** 69 pytest cases; `make check` green - **Tests:** 90 pytest cases; `make check` green
- **Siblings:** wise-validator `validate run` (SAND-WP-0003); the-custodian - **Siblings:** wise-validator `validate run` (SAND-WP-0003); the-custodian
`make e2e REPO=` shim (SAND-WP-0004) `make e2e REPO=` shim (SAND-WP-0004)
Latest gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md` Latest gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
Latest workplan: **SAND-WP-0009** (TTL enforcement — finished). Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
**Ready workplans:** none — gap analysis items complete; operator tracks remain.
--- ---
@ -154,6 +154,10 @@ sandboxer expire [--apply]
sandboxer create --ttl 2h ... sandboxer create --ttl 2h ...
sandboxer credits show / credits add <amount> sandboxer credits show / credits add <amount>
sandboxer inspect host / inspect stale / reap-stale [--apply] sandboxer inspect host / inspect stale / reap-stale [--apply]
sandboxer reachability show <id>
sandboxer create --profile profile.agent-dev --input repo=/path --actor agt --project glas-harness
sandboxer create --profile profile.build --input vm=haskell-build --actor agt --project snuggle-inventor
sandboxer create --profile profile.vm-packer-build --input packer_template=... --input vm_name=haskell-build
make smoke-remote # CoulombCore compose smoke (SANDBOXER_HOST) make smoke-remote # CoulombCore compose smoke (SANDBOXER_HOST)
# Full e2e validation (wise-validator, separate install): # Full e2e validation (wise-validator, separate install):
@ -172,14 +176,15 @@ cd ~/the-custodian && make e2e REPO=activity-core
## What Is Not Possible Yet ## What Is Not Possible Yet
- ~~TTL auto-expiry / `extend_ttl` enforcement~~ — done (SAND-WP-0009) - ~~TTL auto-expiry / `extend_ttl` enforcement~~ — done (SAND-WP-0009)
- Packer build orchestration from `create` (attach-only today) - ~~Packer build orchestration from `create`~~ — done (SAND-WP-0012)
- Real E2B / Modal / Daytona adapters (in-repo stub only) - ~~Real E2B / Modal adapters~~ — done (SAND-WP-0010)
- ~~Consumer profiles (agent-dev, build)~~ — done (SAND-WP-0011)
- Cross-host snapshot transfer - Cross-host snapshot transfer
- Formal ops-bridge tunnel attachment in reachability descriptor - ~~Formal ops-bridge tunnel attachment~~ — done (SAND-WP-0011; descriptor only)
- Dedicated sandboxer01 host (CoulombCore interim only today) - Dedicated sandboxer01 host (CoulombCore interim only today)
- `reuse-surface validate` / federation publish workflow - `reuse-surface validate` / federation publish workflow
- ~~`.repo-classification.yaml`~~ — done (SAND-WP-0009) - ~~`.repo-classification.yaml`~~ — done (SAND-WP-0009)
- fin-hub billing export for metered usage - ~~fin-hub billing export~~ — hook done (SAND-WP-0010); railiance-platform wiring operator
--- ---

54
docs/cloud-adapters.md Normal file
View file

@ -0,0 +1,54 @@
# Cloud adapters (E2B, Modal)
Metered SaaS sandbox backends — SAND-WP-0010.
## Extensions
| Extension | Profile | Provider API |
|-----------|---------|--------------|
| `ext.e2b` | `profile.e2b-burst` | `https://api.e2b.dev` |
| `ext.modal` | `profile.modal-gpu` | `https://api.modal.com` |
| `ext.saas-stub` | `profile.saas-stub` | None (local stub) |
`profile.burst-sandbox` routes: compose-ssh → E2B → Modal → saas-stub.
## BYOK credentials
Resolve keys at provision boundary only — never in Git, workplans, or State Hub.
```bash
warden route find "E2B API key" --json
warden route find "Modal token" --json
```
| Extension | Primary env | secret_ref env fallback |
|-----------|-------------|-------------------------|
| `ext.e2b` | `E2B_API_KEY` | `SANDBOXER_SECRET_E2B_API_KEY` |
| `ext.modal` | `MODAL_TOKEN_ID` | `SANDBOXER_SECRET_MODAL_TOKEN_ID` |
OpenBao custody via railiance-platform; sand-boxer reads env injected by operator.
## Usage
```bash
export E2B_API_KEY=... # operator-injected, not in repo
sandboxer create --profile profile.e2b-burst
sandboxer create --profile profile.burst-sandbox # SaaS when self-hosted unavailable
sandboxer destroy <id>
```
## fin-hub export
On metered destroy, optional POST to `SANDBOXER_FIN_HUB_URL/usage/sandbox`.
Disabled by default. Set `SANDBOXER_NO_FIN_HUB=1` to suppress.
## CI
Unit tests mock HTTP — no live provider calls in `make check`.
Operator smoke (credentials required):
```bash
./scripts/smoke-cloud-adapter.sh e2b
```

View file

@ -33,8 +33,10 @@ Reference implementations:
| Extension | Module | Mode | | Extension | Module | Mode |
|-----------|--------|------| |-----------|--------|------|
| `ext.compose-ssh` | `compose_ssh.py` | Remote compose stack + tar snapshots | | `ext.compose-ssh` | `compose_ssh.py` | Remote compose stack + tar snapshots |
| `ext.vm-packer` | `vm_packer.py` | Attach workspace on pre-built VM | | `ext.vm-packer` | `vm_packer.py` | Attach workspace or Packer build mode |
| `ext.saas-stub` | `saas_stub.py` | Metered stub + metadata snapshots | | `ext.saas-stub` | `saas_stub.py` | Metered stub + metadata snapshots |
| `ext.e2b` | `e2b.py` | E2B cloud adapter |
| `ext.modal` | `modal.py` | Modal cloud adapter |
## Registration ## Registration
@ -80,7 +82,8 @@ Profiles declare semantics; extensions validate required `inputs` keys:
| Extension | Required inputs | Optional | | Extension | Required inputs | Optional |
|-----------|-----------------|----------| |-----------|-----------------|----------|
| compose-ssh | `repo` | `sandbox_id` | | compose-ssh | `repo` | `sandbox_id` |
| vm-packer | `vm` or `ssh_target` | `repo`, `tunnel_port`, `ssh_port`, `workspace_dir` | | vm-packer (attach) | `vm` or `ssh_target` | `repo`, `tunnel_port`, `ssh_port`, `workspace_dir` |
| vm-packer (build) | `packer_template`, `vm_name` | `mode=build`, `packer_var_*` |
Consumer attribution travels on `SandboxCreateRequest.consumer`, not extension inputs. Consumer attribution travels on `SandboxCreateRequest.consumer`, not extension inputs.
@ -106,6 +109,6 @@ Implement `estimate_cost` and `meter_actual` on `SandboxExtension`. Register wit
| Feature | Workplan | | Feature | Workplan |
|---------|----------| |---------|----------|
| Packer build orchestration from `create` | Future WP | | Packer build orchestration from `create` | Future WP |
| E2B / Modal / Daytona cloud adapters | Post SAND-WP-0006 | | Daytona OSS cloud adapter | Future WP |
| fin-hub billing export | Future | | fin-hub billing export | Future |
| Cross-host snapshot transfer | Future | | Cross-host snapshot transfer | Future |

View file

@ -31,6 +31,16 @@ sandboxer create \
| SSH / tunnel reachability setup | glas-harness + ops-bridge | | SSH / tunnel reachability setup | glas-harness + ops-bridge |
| Agent memory and session state | glas-harness | | Agent memory and session state | glas-harness |
## Smoke test
```bash
# Requires sandboxer CLI and SANDBOXER_HOST (or profile placement fallback)
SANDBOXER_HOST=coulombcore ./scripts/smoke-agent-dev.sh
```
Creates `profile.agent-dev`, prints reachability (tunnel metadata + SSH
one-liner), then destroys.
## Out of scope for sand-boxer ## Out of scope for sand-boxer
- Tool schemas and approval flows - Tool schemas and approval flows

View file

@ -30,6 +30,16 @@ sandboxer create \
| Generated code and PR output | snuggle-inventor | | Generated code and PR output | snuggle-inventor |
| Secret resolution at boundary | sand-boxer (via ops-warden / OpenBao) | | Secret resolution at boundary | sand-boxer (via ops-warden / OpenBao) |
## Smoke test
```bash
# Skips live create when SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN is unset
export SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN=<token>
SANDBOXER_VM_TUNNEL_PORT=12222 ./scripts/smoke-build-profile.sh
```
Optional env: `SMOKE_VM` (default `haskell-build`).
## Out of scope for sand-boxer ## Out of scope for sand-boxer
- Code generation prompts and tech specs - Code generation prompts and tech specs

View file

@ -65,7 +65,13 @@ Event `detail` payload (JSON):
"consumer": {"actor": "atm", "project": "wise-validator", "run_id": "..."}, "consumer": {"actor": "atm", "project": "wise-validator", "run_id": "..."},
"actor_type": "atm", "actor_type": "atm",
"state": "ready", "state": "ready",
"reachability": {"ssh": "root@coulombcore", "remote_dir": "/tmp/sandboxer/abc12345"}, "reachability": {
"ssh": "root@coulombcore",
"remote_dir": "/tmp/sandboxer/abc12345",
"tunnel": "localhost:12222",
"tunnel_via": "ops-bridge",
"identity": "ops-warden"
},
"timestamps": {"created_at": "...", "ready_at": "..."} "timestamps": {"created_at": "...", "ready_at": "..."}
} }
``` ```
@ -100,6 +106,36 @@ HTTP surface (optional v0; CLI calls core library directly):
- `POST /v1/sandboxes/{id}/recreate` — recreate - `POST /v1/sandboxes/{id}/recreate` — recreate
- `PATCH /v1/sandboxes/{id}/ttl` — extend TTL - `PATCH /v1/sandboxes/{id}/ttl` — extend TTL
- `POST /v1/sandboxes/expire` — TTL reap (query `apply=true`) - `POST /v1/sandboxes/expire` — TTL reap (query `apply=true`)
- `GET /v1/sandboxes/{id}/reachability` — enriched descriptor + SSH one-liner
---
## Reachability descriptor
When a sandbox reaches `ready`, sand-boxer emits a **reachability** block on
`SandboxStatus`, lifecycle events, and `sandboxer reachability show <id>`.
| Field | Source | Description |
|-------|--------|-------------|
| `ssh` | Extension | SSH target (`user@host`) |
| `remote_dir` | Extension | Workspace root on remote host |
| `host` | Extension | Placement host name |
| `tunnel` | Profile + env | Local port (`localhost:PORT`) or VM alias |
| `tunnel_via` | Profile spec | Route owner (default `ops-bridge`) |
| `identity` | Profile spec | Warden actor hint (default `ops-warden`) |
Tunnel metadata is enriched from profile `reachability` and environment:
- `SANDBOXER_TUNNEL_PORT` / handle `tunnel_port` or `ssh_port`
- `SANDBOXER_TUNNEL_ALIAS` / handle `vm_target`
- `SANDBOXER_TUNNEL_VIA` (optional override)
sand-boxer **does not** bring tunnels up. Consumers use ops-bridge (MCP or
`bridge` CLI) to attach SSH routes; the descriptor is a pointer only.
`secret_refs` from `profile.setup` are resolved at the provision boundary and
passed to the extension handle — they never appear on `SandboxStatus` or State
Hub events.
--- ---

View file

@ -8,7 +8,8 @@ Maps `the-custodian/infra/build-machines/` to sand-boxer `profile.vm-haskell-bui
|-------------------------|---------------| |-------------------------|---------------|
| Packer OVA build | **Unchanged** — operator runs Packer in the-custodian | | Packer OVA build | **Unchanged** — operator runs Packer in the-custodian |
| VM boot + build-agent registration | **Unchanged** — systemd agent on VM | | VM boot + build-agent registration | **Unchanged** — systemd agent on VM |
| `make remote-build PROJECT=` | `sandboxer create` + SSH into `reachability.remote_dir` | | `make remote-build PROJECT=` | `sandboxer create --profile profile.vm-haskell-build` + SSH build (shim in build-machines Makefile) |
| `packer build` in `haskell/` | `sandboxer create --profile profile.vm-packer-build` |
| Isolated workspace `/build/<project>` | `/build/sbx-<sandbox_id>/` per create | | Isolated workspace `/build/<project>` | `/build/sbx-<sandbox_id>/` per create |
| `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) | | `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) |
@ -50,12 +51,36 @@ sandboxer destroy <sandbox_id>
| `repo` | Optional rsync source to workspace | | `repo` | Optional rsync source to workspace |
| `workspace_dir` | Override workspace path on VM | | `workspace_dir` | Override workspace path on VM |
## Packer build mode (SAND-WP-0012)
```bash
sandboxer create \
--profile profile.vm-packer-build \
--input packer_template=~/the-custodian/infra/build-machines/haskell \
--input vm_name=haskell-build \
--host localhost
```
| Input | Purpose |
|-------|---------|
| `mode` | `build` (default for profile.vm-packer-build) or `attach` |
| `packer_template` | Directory containing `*.pkr.hcl` |
| `vm_name` / `vm` | Packer `vm_name` variable |
| `packer_var_*` | Extra Packer `-var` flags (suffix → variable name) |
Runbook: `docs/runbooks/profile-vm-packer-build.md`
## Port registry (read-only pointer)
`the-custodian/infra/build-machines/port-registry.yml` maps tunnel ports
12221–12230 to VM slots. When attaching via tunnel, set
`SANDBOXER_VM_TUNNEL_PORT` or `--input tunnel_port=` to a registered port.
Full ops-bridge automation is deferred — operators bring tunnels up manually.
## Not migrated yet ## Not migrated yet
- Automated Packer `create` trigger from sand-boxer API
- State Hub capability-catalog sync from build-agent (agent unchanged) - State Hub capability-catalog sync from build-agent (agent unchanged)
- Port registry automation (`port-registry.yml`) - Automated port-registry → ops-bridge config generation
- `make remote-build` Makefile targets in the-custodian (add shim in follow-on if needed)
## Runbook ## Runbook

View file

@ -44,6 +44,8 @@ Deferred: Packer orchestration from API, `make remote-build` shim.
| Item | Workplan | | Item | Workplan |
|------|----------| |------|----------|
| ~~SaaS extensions + payments v0~~ | SAND-WP-0006 — stub + routing + credits | | ~~SaaS extensions + payments v0~~ | SAND-WP-0006 — stub + routing + credits |
| E2B / Modal real adapters | Post SAND-WP-0006 | | ~~E2B / Modal real adapters + fin-hub~~ | SAND-WP-0010 — `docs/cloud-adapters.md` |
| Consumer profiles + reachability | **SAND-WP-0011** |
| Packer orchestration + remote-build shim | **SAND-WP-0012** |
| ~~Snapshot / restore~~ | SAND-WP-0007 — `docs/snapshots.md` | | ~~Snapshot / restore~~ | SAND-WP-0007 — `docs/snapshots.md` |
| ~~TTL enforcement + scheduled reap~~ | SAND-WP-0009 — `docs/ttl.md` | | ~~TTL enforcement + scheduled reap~~ | SAND-WP-0009 — `docs/ttl.md` |

View file

@ -36,10 +36,17 @@ Reference: `ext.saas-stub` (no external API).
## BYOK ## BYOK
Provider API keys are resolved at provision boundary via `secret_refs` / OpenBao — Provider API keys resolve at provision boundary — never stored on `SandboxStatus`
not implemented in v0 stub. Set provider env vars per extension when adapters land. or emitted to State Hub.
1. Operator lookup: `warden route find "<provider> API key" --json`
2. Inject env before `sandboxer create` (e.g. `E2B_API_KEY`, `MODAL_TOKEN_ID`)
3. Or map `secret_ref` from extension config to `SANDBOXER_SECRET_<REF>` env
See `docs/cloud-adapters.md`.
## Billing export ## Billing export
sand-boxer meters sandbox consumption only. Domain billing authority (fin-hub) is a On metered destroy, optional fin-hub hook when `SANDBOXER_FIN_HUB_URL` is set.
future export consumer of State Hub meter events — not owned here. Posts `sandbox_id`, `extension_id`, `duration_s`, `actual_usd` to `/usage/sandbox`.
Implementation: `src/sandboxer/payments/billing_export.py`.

View file

@ -10,6 +10,8 @@ route:
strategy: prefer-self-hosted strategy: prefer-self-hosted
extensions: extensions:
- ext.compose-ssh - ext.compose-ssh
- ext.e2b
- ext.modal
- ext.saas-stub - ext.saas-stub
max_cost_per_hour_usd: 1.0 max_cost_per_hour_usd: 1.0
``` ```
@ -19,7 +21,7 @@ route:
| Strategy | Behavior | | Strategy | Behavior |
|----------|----------| |----------|----------|
| `explicit` | Use `profile.extension` (default when no route) | | `explicit` | Use `profile.extension` (default when no route) |
| `prefer-self-hosted` | First self-hosted candidate with resolvable host; else SaaS | | `prefer-self-hosted` | Self-hosted if host available; else credentialed E2B/Modal; else stub |
| `lowest-cost` | Self-hosted if available; else cheapest `estimate_cost` | | `lowest-cost` | Self-hosted if available; else cheapest `estimate_cost` |
| `lowest-latency` | Self-hosted if available; else last candidate (v0) | | `lowest-latency` | Self-hosted if available; else last candidate (v0) |
@ -38,7 +40,12 @@ sandboxer create --profile profile.saas-stub
| Profile | Route | | Profile | Route |
|---------|-------| |---------|-------|
| `profile.burst-sandbox` | compose-ssh → saas-stub fallback | | `profile.burst-sandbox` | compose-ssh → e2b → modal → saas-stub |
| `profile.e2b-burst` | explicit `ext.e2b` |
| `profile.modal-gpu` | explicit `ext.modal` |
| `profile.saas-stub` | explicit `ext.saas-stub` | | `profile.saas-stub` | explicit `ext.saas-stub` |
Cloud adapters require provider credentials (`E2B_API_KEY`, `MODAL_TOKEN_ID`).
See `docs/cloud-adapters.md`.
Resolver: `sandboxer.routing.resolver.resolve_extension`. Resolver: `sandboxer.routing.resolver.resolve_extension`.

View file

@ -0,0 +1,54 @@
# profile.vm-packer-build — Runbook
Trigger a Packer OVA build on the local workstation (build-machines lineage).
## Prerequisites
- **Packer** >= 1.10 (`packer version`)
- **VirtualBox** >= 7.0 (`VBoxManage --version`)
- Template directory from `the-custodian/infra/build-machines/haskell`
- `sandboxer` on PATH
## Build OVA
```bash
sandboxer create \
--profile profile.vm-packer-build \
--input packer_template=~/the-custodian/infra/build-machines/haskell \
--input vm_name=haskell-build \
--host localhost
```
Progress notes emit to State Hub during `packer init` and `packer build`.
On success, `reachability.remote_dir` points at the produced `.ova` file.
## Optional Packer variables
Pass extra `-var` flags via inputs prefixed with `packer_var_`:
```bash
sandboxer create \
--profile profile.vm-packer-build \
--input packer_template=~/the-custodian/infra/build-machines/haskell \
--input vm_name=haskell-build \
--input packer_var_memory=16384 \
--host localhost
```
## Destroy
```bash
sandboxer destroy <sandbox_id>
```
Removes the sandbox record only; the OVA artifact on disk is preserved.
## Attach workflow (post-build)
After import/setup per build-machines README, use `profile.vm-haskell-build`
for workspace attach — see `docs/runbooks/profile-vm-haskell-build.md`.
## Migration reference
`docs/migration-build-machines.md`

19
extensions/ext.e2b.yaml Normal file
View file

@ -0,0 +1,19 @@
id: ext.e2b
title: E2B cloud sandboxes
description: >
Metered E2B Firecracker sandbox adapter. Requires E2B_API_KEY or OpenBao
secret_ref mapping at provision boundary.
handler: sandboxer.extensions.e2b:E2BExtension
capabilities:
isolation_levels: [microvm]
regions: [us, eu]
persistence: true
pricing_model: metered
config:
provider: e2b
api_base: https://api.e2b.dev
api_key_env: E2B_API_KEY
secret_ref: e2b-api-key
template_id: base
rate_usd_per_hour: 0.15
session_fee_usd: 0.02

19
extensions/ext.modal.yaml Normal file
View file

@ -0,0 +1,19 @@
id: ext.modal
title: Modal cloud sandboxes
description: >
Metered Modal serverless sandbox adapter. Requires MODAL_TOKEN_ID or
secret_ref mapping at provision boundary.
handler: sandboxer.extensions.modal:ModalExtension
capabilities:
isolation_levels: [policy]
regions: [us]
persistence: true
pricing_model: metered
config:
provider: modal
api_base: https://api.modal.com
api_key_env: MODAL_TOKEN_ID
secret_ref: modal-token-id
image_ref: modal-default
rate_usd_per_hour: 0.18
session_fee_usd: 0.02

View file

@ -1,9 +1,9 @@
id: ext.vm-packer id: ext.vm-packer
title: VM workspace (Packer lineage) title: VM workspace (Packer lineage)
description: > description: >
Attach an isolated workspace on a pre-built VM (the-custodian build-machines Attach an isolated workspace on a pre-built VM, or run Packer build mode
lineage). v0 supports attach mode via SSH alias or tunnel port; Packer build (the-custodian build-machines lineage). Attach via SSH alias or tunnel port;
orchestration is operator-driven and deferred. build via inputs.mode=build or profile.vm-packer-build.
handler: sandboxer.extensions.vm_packer:VMPackerExtension handler: sandboxer.extensions.vm_packer:VMPackerExtension
capabilities: capabilities:
isolation_levels: [microvm] isolation_levels: [microvm]

View file

@ -160,14 +160,14 @@ some sibling sign-offs sit outside this repo.
| Prio | Gap | Owner | Proposed track | | Prio | Gap | Owner | Proposed track |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| **P1** | TTL enforcement + `extend_ttl` + `expires_at` | sand-boxer | **SAND-WP-0009** | | ~~**P1**~~ | TTL enforcement + `extend_ttl` + `expires_at` | sand-boxer | **SAND-WP-0009** — done |
| **P2** | TTL reap scheduler / activity-core contract | sand-boxer + activity-core | **SAND-WP-0009** | | ~~**P2**~~ | TTL reap / activity-core contract | sand-boxer | **SAND-WP-0009** — `docs/integrations/activity-core.md` |
| **P3** | `.repo-classification.yaml` + registry refresh | sand-boxer | **SAND-WP-0009** | | ~~**P3**~~ | `.repo-classification.yaml` + registry refresh | sand-boxer | **SAND-WP-0009** — done |
| **P4** | HTTP API parity (`recreate`, `extend_ttl`) | sand-boxer | **SAND-WP-0009** | | ~~**P4**~~ | HTTP API parity (`recreate`, `extend_ttl`) | sand-boxer | **SAND-WP-0009** — done |
| **P5** | Real E2B / Modal adapters + BYOK | sand-boxer | SAND-WP-0010 | | **P5** | Real E2B / Modal adapters + BYOK | sand-boxer | **SAND-WP-0010** |
| **P6** | ops-bridge tunnel in reachability descriptor | sand-boxer | SAND-WP-0011 | | **P6** | ops-bridge tunnel in reachability descriptor | sand-boxer | **SAND-WP-0011** |
| **P7** | Consumer profiles (glas-harness, snuggle) | sand-boxer | SAND-WP-0011 | | **P7** | Consumer profiles (glas-harness, snuggle) | sand-boxer | **SAND-WP-0011** |
| **P8** | Packer build orchestration + remote-build shim | sand-boxer | SAND-WP-0012 (WP-0005-T06) | | **P8** | Packer build orchestration + remote-build shim | sand-boxer | **SAND-WP-0012** |
| **P9** | Cross-host snapshot transfer | sand-boxer | Future | | **P9** | Cross-host snapshot transfer | sand-boxer | Future |
| **P10** | fin-hub billing export | sand-boxer + platform | With SAND-WP-0010 | | **P10** | fin-hub billing export | sand-boxer + platform | With SAND-WP-0010 |
| **P11** | sandboxer01 dedicated host | infra / operator | Outside repo | | **P11** | sandboxer01 dedicated host | infra / operator | Outside repo |
@ -175,16 +175,17 @@ some sibling sign-offs sit outside this repo.
--- ---
## 9. Recommended next workplan ## 9. Recommended workplans (2026-06-24)
**SAND-WP-0009 — TTL enforcement and operational hardening** | Workplan | Status | Closes |
| --- | --- | --- |
| SAND-WP-0009 | **finished** | P1–P4 (TTL, hygiene, HTTP parity) |
| SAND-WP-0010 | ready | P5, P10 (E2B/Modal, BYOK, fin-hub) |
| SAND-WP-0011 | ready | P6, P7 (reachability, consumer profiles) |
| SAND-WP-0012 | ready | P8 (Packer orchestration, remote-build shim) |
Closes P1–P4: the largest functional gap (disposable-by-default TTL), platform **Suggested implementation order:** 0010 and 0011 may parallelize; 0012 depends
hygiene (classification, registry), and HTTP parity. Unblocks activity-core on stable vm-packer attach (done). Operator tracks P11/P12 outside repo.
scheduling of expire/reap without implementing activity-core itself.
Subsequent: **SAND-WP-0010** (real cloud adapters), **SAND-WP-0011**
(reachability + consumer profiles), **SAND-WP-0012** (Packer orchestration).
--- ---

View file

@ -0,0 +1,42 @@
id: profile.agent-dev
version: "1.0.0"
extension: ext.compose-ssh
route:
strategy: prefer-self-hosted
extensions:
- ext.compose-ssh
- ext.e2b
- ext.modal
- ext.saas-stub
max_cost_per_hour_usd: 1.0
isolation:
level: container
network:
default: deny
egress: []
workspace:
mode: remote-canonical
access: rw
scope_default: agent
ttl:
default: 8h
max: 24h
idle_reap: 2h
resources:
cpu: null
memory_mb: null
setup:
instructions: >
Agent development sandbox for glas-harness. Prefer self-hosted compose;
burst to cloud when host unavailable. Consumer actor: agt.
secret_refs: []
placement:
prefer: [sandboxer01]
fallback: [coulombcore]
reachability:
tunnel: ops-bridge
identity: ops-warden
metadata:
cost_class: self-hosted
latency_class: standard
observability: none

View file

@ -0,0 +1,35 @@
id: profile.build
version: "1.0.0"
extension: ext.vm-packer
isolation:
level: microvm
network:
default: deny
egress: []
workspace:
mode: remote-canonical
access: rw
scope_default: agent
ttl:
default: 8h
max: 24h
idle_reap: null
resources:
cpu: null
memory_mb: null
setup:
instructions: >
Build sandbox for snuggle-inventor. Attach to pre-built VM via ops-bridge
tunnel (e.g. haskell-build). Secret refs resolved at provision boundary only.
secret_refs:
- build-registry-token
placement:
prefer: [localhost]
fallback: [workstation]
reachability:
tunnel: ops-bridge
identity: ops-warden
metadata:
cost_class: self-hosted
latency_class: standard
observability: none

View file

@ -5,6 +5,8 @@ route:
strategy: prefer-self-hosted strategy: prefer-self-hosted
extensions: extensions:
- ext.compose-ssh - ext.compose-ssh
- ext.e2b
- ext.modal
- ext.saas-stub - ext.saas-stub
max_cost_per_hour_usd: 1.0 max_cost_per_hour_usd: 1.0
isolation: isolation:
@ -25,8 +27,8 @@ resources:
memory_mb: null memory_mb: null
setup: setup:
instructions: > instructions: >
Prefer self-hosted compose on SANDBOXER_HOST; falls back to metered SaaS stub Prefer self-hosted compose on SANDBOXER_HOST; falls back to E2B/Modal when
when host is unavailable or SANDBOXER_FORCE_SAAS=1. credentials are configured, else metered saas-stub. Force SaaS: SANDBOXER_FORCE_SAAS=1.
secret_refs: [] secret_refs: []
placement: placement:
prefer: [sandboxer01] prefer: [sandboxer01]

View file

@ -0,0 +1,32 @@
id: profile.e2b-burst
version: "1.0.0"
extension: ext.e2b
isolation:
level: microvm
network:
default: deny
egress: []
workspace:
mode: remote-canonical
access: rw
scope_default: session
ttl:
default: 2h
max: 8h
idle_reap: null
resources:
cpu: null
memory_mb: null
setup:
instructions: "E2B burst sandbox — requires E2B_API_KEY."
secret_refs: [e2b-api-key]
placement:
prefer: []
fallback: []
reachability:
tunnel: ops-bridge
identity: ops-warden
metadata:
cost_class: saas-metered
latency_class: low
observability: none

View file

@ -0,0 +1,32 @@
id: profile.modal-gpu
version: "1.0.0"
extension: ext.modal
isolation:
level: policy
network:
default: deny
egress: []
workspace:
mode: remote-canonical
access: rw
scope_default: session
ttl:
default: 2h
max: 8h
idle_reap: null
resources:
cpu: null
memory_mb: null
setup:
instructions: "Modal GPU/burst sandbox — requires MODAL_TOKEN_ID."
secret_refs: [modal-token-id]
placement:
prefer: []
fallback: []
reachability:
tunnel: ops-bridge
identity: ops-warden
metadata:
cost_class: saas-metered
latency_class: low
observability: none

View file

@ -0,0 +1,37 @@
id: profile.vm-packer-build
version: "1.0.0"
extension: ext.vm-packer
isolation:
level: microvm
network:
default: deny
egress: []
workspace:
mode: remote-canonical
access: rw
scope_default: session
ttl:
default: 12h
max: 48h
idle_reap: null
resources:
cpu: null
memory_mb: null
setup:
instructions: >
Trigger a Packer OVA build (the-custodian build-machines conventions).
Requires packer and VirtualBox on the placement host. Pass
--input packer_template=~/the-custodian/infra/build-machines/haskell
and --input vm_name=haskell-build (or vm=haskell-build). Attach mode
uses profile.vm-haskell-build instead.
secret_refs: []
placement:
prefer: [localhost]
fallback: [workstation]
reachability:
tunnel: ops-bridge
identity: ops-warden
metadata:
cost_class: self-hosted
latency_class: standard
observability: none

View file

@ -16,27 +16,27 @@ maturity:
Charter (INTENT.md), meta-framework spec, extension SDK, integration docs, Charter (INTENT.md), meta-framework spec, extension SDK, integration docs,
and research synthesis. Capability indexed in registry/. and research synthesis. Capability indexed in registry/.
availability: availability:
current: A4 current: A5
target: A5 target: A5
confidence: high confidence: high
rationale: > rationale: >
CLI v0 (create/destroy/snapshot/TTL), HTTP API, CoulombCore remote smoke. CLI v0 (create/destroy/snapshot/TTL), HTTP API, CoulombCore remote smoke.
SaaS stub + routing + credits shipped (SAND-WP-0006). Cloud adapters E2B/Modal + routing + credits (SAND-WP-0010).
external_evidence: external_evidence:
completeness: completeness:
level: C4 level: C5
name: Substantial name: Mature
confidence: high confidence: high
basis: scope_vs_intent_and_consumer_expectations basis: scope_vs_intent_and_consumer_expectations
satisfied_expectations: satisfied_expectations:
- profile-based create/destroy/snapshot/restore via CLI - profile-based create/destroy/snapshot/restore via CLI
- TTL extend and expire/reap (SAND-WP-0009) - TTL extend and expire/reap (SAND-WP-0009)
- E2B and Modal cloud adapters with BYOK (SAND-WP-0010)
- State Hub lifecycle events on transitions - State Hub lifecycle events on transitions
- wise-validator and the-custodian migration arc complete - wise-validator and the-custodian migration arc complete
- extension SDK with compose-ssh, vm-packer attach, saas-stub - extension SDK with compose-ssh, vm-packer attach, saas-stub, e2b, modal
broken_expectations: broken_expectations:
- Real E2B/Modal adapters not yet built
- sandboxer01 dedicated host not live (CoulombCore interim) - sandboxer01 dedicated host not live (CoulombCore interim)
out_of_scope_expectations: out_of_scope_expectations:
- agent harness and tool orchestration (glas-harness) - agent harness and tool orchestration (glas-harness)

20
scripts/smoke-agent-dev.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Smoke profile.agent-dev — requires SANDBOXER_HOST or defaults via placement.
set -euo pipefail
if [[ -z "${SANDBOXER_HOST:-}" ]]; then
echo "SANDBOXER_HOST not set — using profile placement fallback" >&2
fi
REPO="${SMOKE_REPO:-$(pwd)}"
echo "Smoke: profile.agent-dev repo=$REPO"
STATUS=$(sandboxer create \
--profile profile.agent-dev \
--input "repo=$REPO" \
--actor agt \
--project glas-harness)
ID=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandbox_id'])")
echo "Created: $ID"
sandboxer reachability show "$ID"
sandboxer destroy "$ID"
echo "OK: agent-dev smoke"

23
scripts/smoke-build-profile.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Smoke profile.build — requires VM tunnel and optional build-registry-token secret.
set -euo pipefail
if [[ -z "${SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN:-}" ]]; then
echo "SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN not set — skipping live smoke" >&2
exit 0
fi
VM="${SMOKE_VM:-haskell-build}"
TUNNEL_PORT="${SANDBOXER_VM_TUNNEL_PORT:-12222}"
echo "Smoke: profile.build vm=$VM tunnel=$TUNNEL_PORT"
STATUS=$(sandboxer create \
--profile profile.build \
--input "vm=$VM" \
--input "tunnel_port=$TUNNEL_PORT" \
--actor agt \
--project snuggle-inventor)
ID=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandbox_id'])")
echo "Created: $ID"
sandboxer reachability show "$ID"
sandboxer destroy "$ID"
echo "OK: build profile smoke"

32
scripts/smoke-cloud-adapter.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Operator smoke for cloud adapters — requires provider credentials.
set -euo pipefail
PROVIDER="${1:-e2b}"
case "$PROVIDER" in
e2b)
PROFILE="profile.e2b-burst"
if [[ -z "${E2B_API_KEY:-}" ]]; then
echo "E2B_API_KEY not set — skipping live smoke" >&2
exit 0
fi
;;
modal)
PROFILE="profile.modal-gpu"
if [[ -z "${MODAL_TOKEN_ID:-}" ]]; then
echo "MODAL_TOKEN_ID not set — skipping live smoke" >&2
exit 0
fi
;;
*)
echo "Usage: $0 [e2b|modal]" >&2
exit 1
;;
esac
echo "Smoke: sandboxer create --profile $PROFILE"
STATUS=$(sandboxer create --profile "$PROFILE" --project sand-boxer)
ID=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandbox_id'])")
echo "Created: $ID"
sandboxer destroy "$ID"
echo "Destroyed: $ID"

View file

@ -34,6 +34,14 @@ def get_sandbox(sandbox_id: str) -> SandboxStatus:
return status return status
@app.get("/v1/sandboxes/{sandbox_id}/reachability")
def get_sandbox_reachability(sandbox_id: str) -> dict:
try:
return _manager.reachability_report(sandbox_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/v1/sandboxes", response_model=list[SandboxStatus]) @app.get("/v1/sandboxes", response_model=list[SandboxStatus])
def list_sandboxes() -> list[SandboxStatus]: def list_sandboxes() -> list[SandboxStatus]:
return _manager.list() return _manager.list()

View file

@ -30,6 +30,8 @@ credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
app.add_typer(credits_app, name="credits") app.add_typer(credits_app, name="credits")
snapshots_app = typer.Typer(help="Workspace checkpoint snapshots.") snapshots_app = typer.Typer(help="Workspace checkpoint snapshots.")
app.add_typer(snapshots_app, name="snapshots") app.add_typer(snapshots_app, name="snapshots")
reachability_app = typer.Typer(help="Consumer reachability descriptors.")
app.add_typer(reachability_app, name="reachability")
@app.callback() @app.callback()
@ -92,7 +94,11 @@ def sandbox_create(
host: Annotated[str | None, typer.Option(help="Override placement host")] = None, host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None, ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None,
) -> None: ) -> None:
"""Provision a sandbox. No args → canary self-deploy of sand-boxer.""" """Provision a sandbox. No args → canary self-deploy of sand-boxer.
vm-packer modes: attach (profile.vm-haskell-build) or build
(profile.vm-packer-build / --input mode=build with packer_template, vm_name).
"""
parsed = _parse_inputs(input or []) parsed = _parse_inputs(input or [])
resolved_profile, resolved_inputs = resolve_create_defaults(profile, parsed) resolved_profile, resolved_inputs = resolve_create_defaults(profile, parsed)
request = SandboxCreateRequest( request = SandboxCreateRequest(
@ -112,6 +118,18 @@ def sandbox_create(
_print_telemetry_summary(status.telemetry) _print_telemetry_summary(status.telemetry)
@reachability_app.command("show")
def reachability_show(sandbox_id: str) -> None:
"""Show reachability descriptor and SSH one-liner for a sandbox."""
manager = SandboxManager()
try:
report = manager.reachability_report(sandbox_id)
except KeyError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(code=1) from exc
_print_json(report)
@app.command("get") @app.command("get")
def sandbox_get(sandbox_id: str) -> None: def sandbox_get(sandbox_id: str) -> None:
"""Get sandbox status by id.""" """Get sandbox status by id."""

View file

@ -21,11 +21,14 @@ from sandboxer.models import (
SandboxStatus, SandboxStatus,
SnapshotRecord, SnapshotRecord,
) )
from sandboxer.payments.billing_export import export_meter_usage
from sandboxer.payments.credits import CreditsStore from sandboxer.payments.credits import CreditsStore
from sandboxer.payments.metering import estimate_cost, settle_usage from sandboxer.payments.metering import estimate_cost, settle_usage
from sandboxer.placement import resolve_host from sandboxer.placement import resolve_host
from sandboxer.profiles.loader import load_profile from sandboxer.profiles.loader import load_profile
from sandboxer.reachability.enrich import enrich_reachability
from sandboxer.routing.resolver import resolve_extension from sandboxer.routing.resolver import resolve_extension
from sandboxer.secrets.resolver import resolve_setup_secrets
from sandboxer.snapshots.store import SnapshotStore from sandboxer.snapshots.store import SnapshotStore
from sandboxer.telemetry.export import export_telemetry from sandboxer.telemetry.export import export_telemetry
from sandboxer.telemetry.introspection import ( from sandboxer.telemetry.introspection import (
@ -60,6 +63,8 @@ class SandboxManager:
"vm_target": status.inputs.get("vm_target", ""), "vm_target": status.inputs.get("vm_target", ""),
"vm_host": status.inputs.get("vm_host", ""), "vm_host": status.inputs.get("vm_host", ""),
"endpoint": status.inputs.get("endpoint", ""), "endpoint": status.inputs.get("endpoint", ""),
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
"provider": status.inputs.get("provider", ""),
} }
def _resolved_host(self, profile, extension, host_override: str | None) -> str: def _resolved_host(self, profile, extension, host_override: str | None) -> str:
@ -124,7 +129,21 @@ class SandboxManager:
provision_before = collect_host_snapshot(resolved_host) provision_before = collect_host_snapshot(resolved_host)
try: try:
handle = backend.provision(profile, request.inputs, resolved_host) secret_bundle = resolve_setup_secrets(profile)
provision_inputs = dict(request.inputs)
build_mode = (
provision_inputs.get("mode") == "build"
or profile.id == "profile.vm-packer-build"
)
if build_mode:
emit_lifecycle_event(
status,
summary=f"Packer build starting ({profile.id})",
event_type="note",
)
handle = backend.provision(profile, provision_inputs, resolved_host)
if secret_bundle:
handle["_secret_refs"] = secret_bundle
status.sandbox_id = handle["sandbox_id"] status.sandbox_id = handle["sandbox_id"]
status.inputs["compose_file"] = handle.get("compose_file", "") status.inputs["compose_file"] = handle.get("compose_file", "")
status.inputs["ssh_user"] = handle.get("ssh_user", "") status.inputs["ssh_user"] = handle.get("ssh_user", "")
@ -133,7 +152,10 @@ class SandboxManager:
status.inputs["vm_target"] = handle.get("vm_target", "") status.inputs["vm_target"] = handle.get("vm_target", "")
status.inputs["vm_host"] = handle.get("vm_host", "") status.inputs["vm_host"] = handle.get("vm_host", "")
status.inputs["endpoint"] = handle.get("endpoint", "") status.inputs["endpoint"] = handle.get("endpoint", "")
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
status.inputs["provider"] = handle.get("provider", "")
reach = backend.wait_ready(handle) reach = backend.wait_ready(handle)
reach = enrich_reachability(reach, profile, handle)
status.reachability = Reachability(**reach) status.reachability = Reachability(**reach)
status.state = SandboxState.READY status.state = SandboxState.READY
status.ready_at = utcnow() status.ready_at = utcnow()
@ -173,6 +195,14 @@ class SandboxManager:
def get(self, sandbox_id: str) -> SandboxStatus | None: def get(self, sandbox_id: str) -> SandboxStatus | None:
return self.store.get(sandbox_id) return self.store.get(sandbox_id)
def reachability_report(self, sandbox_id: str) -> dict:
status = self.store.get(sandbox_id)
if not status:
raise KeyError(f"Sandbox not found: {sandbox_id}")
from sandboxer.reachability.enrich import build_reachability_report
return build_reachability_report(status)
def list(self) -> list[SandboxStatus]: def list(self) -> list[SandboxStatus]:
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True) return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
@ -209,6 +239,7 @@ class SandboxManager:
if settled and settled.pricing_model == "metered" and settled.actual_usd: if settled and settled.pricing_model == "metered" and settled.actual_usd:
self.credits.debit(settled.actual_usd) self.credits.debit(settled.actual_usd)
status.meter = settled status.meter = settled
export_meter_usage(status, extension_id=extension.id, meter=settled)
emit_lifecycle_event( emit_lifecycle_event(
status, status,
summary=( summary=(
@ -447,7 +478,11 @@ class SandboxManager:
status.inputs["vm_host"] = handle.get("vm_host", "") status.inputs["vm_host"] = handle.get("vm_host", "")
status.inputs["endpoint"] = handle.get("endpoint", "") status.inputs["endpoint"] = handle.get("endpoint", "")
status.inputs["restored_from"] = record.snapshot_id status.inputs["restored_from"] = record.snapshot_id
secret_bundle = resolve_setup_secrets(profile)
if secret_bundle:
handle["_secret_refs"] = secret_bundle
reach = backend.wait_ready(handle) reach = backend.wait_ready(handle)
reach = enrich_reachability(reach, profile, handle)
status.reachability = Reachability(**reach) status.reachability = Reachability(**reach)
status.state = SandboxState.READY status.state = SandboxState.READY
status.ready_at = utcnow() status.ready_at = utcnow()

View file

@ -0,0 +1,83 @@
"""Shared helpers for metered HTTP cloud sandbox adapters."""
from __future__ import annotations
from typing import Any, Protocol
import httpx
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.credentials import resolve_api_key
from sandboxer.models import MeterQuote, Profile
class HttpClientFactory(Protocol):
def __call__(self) -> httpx.Client: ...
def default_http_client() -> httpx.Client:
return httpx.Client(timeout=60.0)
class CloudMeteredExtension(SandboxExtension):
"""Base for E2B/Modal-style REST sandbox providers."""
extension_id: str = ""
def __init__(
self,
config: dict[str, Any] | None = None,
*,
client_factory: HttpClientFactory | None = None,
) -> None:
super().__init__(config)
self.api_base: str = str(self.config.get("api_base", "")).rstrip("/")
self.api_key_env: str = str(self.config.get("api_key_env", ""))
self.provider: str = self.config.get("provider", self.extension_id)
self.rate_usd_per_hour: float = float(self.config.get("rate_usd_per_hour", 0.15))
self.session_fee_usd: float = float(self.config.get("session_fee_usd", 0.02))
self._client_factory = client_factory or default_http_client
@classmethod
def credentials_available(cls, config: dict[str, Any]) -> bool:
from sandboxer.extensions.credentials import credentials_available as _avail
return _avail(cls.extension_id, config)
def _api_key(self) -> str:
key = resolve_api_key(self.config, extension_id=self.extension_id)
if not key:
raise RuntimeError(
f"{self.extension_id}: API key not configured "
f"(set {self.api_key_env} or secret_ref env mapping)"
)
return key
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key()}",
"Content-Type": "application/json",
}
def _client(self) -> httpx.Client:
return self._client_factory()
def estimate_cost(
self,
profile: Profile,
inputs: dict[str, str],
*,
duration_s: int = 3600,
) -> MeterQuote:
hours = max(duration_s / 3600.0, 1 / 3600)
estimated = round(self.session_fee_usd + hours * self.rate_usd_per_hour, 4)
return MeterQuote(
extension_id=self.extension_id,
estimated_usd=estimated,
unit="per_hour",
duration_s=duration_s,
)
def meter_actual(self, handle: dict[str, str], *, duration_s: float) -> float:
hours = max(duration_s / 3600.0, 1 / 3600)
return round(self.session_fee_usd + hours * self.rate_usd_per_hour, 4)

View file

@ -0,0 +1,48 @@
"""BYOK credential resolution for metered cloud extensions."""
from __future__ import annotations
import os
from typing import Any
def _secret_ref_env(secret_ref: str) -> str:
normalized = secret_ref.upper().replace("-", "_").replace(".", "_")
return f"SANDBOXER_SECRET_{normalized}"
def resolve_api_key(
config: dict[str, Any],
*,
extension_id: str,
) -> str | None:
"""Resolve provider API key from env or secret_ref mapping (never from Git)."""
env_name = config.get("api_key_env")
if env_name:
value = os.environ.get(env_name)
if value:
return value
fallback_env = (
f"SANDBOXER_{extension_id.upper().replace('.', '_').replace('-', '_')}_API_KEY"
)
value = os.environ.get(fallback_env)
if value:
return value
secret_ref = config.get("secret_ref")
if secret_ref:
return os.environ.get(_secret_ref_env(secret_ref))
return None
def credentials_available(
extension_id: str,
config: dict[str, Any],
*,
always_available: bool = False,
) -> bool:
if always_available:
return True
return resolve_api_key(config, extension_id=extension_id) is not None

View file

@ -0,0 +1,77 @@
"""ext.e2b — E2B cloud sandbox adapter."""
from __future__ import annotations
from typing import Any
from sandboxer.extensions.cloud_base import CloudMeteredExtension, default_http_client
from sandboxer.models import Profile
# Re-export for tests
http_client_factory = default_http_client
class E2BExtension(CloudMeteredExtension):
extension_id = "ext.e2b"
def __init__(self, config: dict[str, Any] | None = None, **kwargs) -> None:
super().__init__(config, **kwargs)
self.template_id: str = self.config.get("template_id", "base")
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
sandbox_id = self.new_sandbox_id(inputs)
template = inputs.get("template") or self.template_id
payload = {"templateID": template, "metadata": {"sandboxer_id": sandbox_id}}
with self._client() as client:
response = client.post(
f"{self.api_base}/sandboxes",
json=payload,
headers=self._headers(),
)
if response.status_code >= 400:
raise RuntimeError(f"E2B provision failed: {response.text}")
data = response.json()
provider_sandbox_id = data.get("sandboxID") or data.get("sandbox_id", "")
endpoint = data.get("sandboxURL") or f"https://{provider_sandbox_id}.e2b.dev"
return {
"sandbox_id": sandbox_id,
"provider_sandbox_id": provider_sandbox_id,
"host": self.provider,
"endpoint": endpoint,
"provider": self.provider,
"template_id": template,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
provider_id = handle.get("provider_sandbox_id", "")
with self._client() as client:
response = client.get(
f"{self.api_base}/sandboxes/{provider_id}",
headers=self._headers(),
)
if response.status_code >= 400:
raise RuntimeError(f"E2B wait_ready failed: {response.text}")
return {
"endpoint": handle["endpoint"],
"host": handle.get("host"),
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
provider_id = handle.get("provider_sandbox_id", "")
removed = False
if provider_id:
with self._client() as client:
response = client.delete(
f"{self.api_base}/sandboxes/{provider_id}",
headers=self._headers(),
)
removed = response.status_code < 400
return {
"provider_removed": str(removed).lower(),
"sandbox_id": handle.get("sandbox_id", ""),
"provider_sandbox_id": provider_id,
}

View file

@ -0,0 +1,83 @@
"""ext.modal — Modal cloud sandbox adapter."""
from __future__ import annotations
from typing import Any
from sandboxer.extensions.cloud_base import CloudMeteredExtension, default_http_client
from sandboxer.models import Profile
http_client_factory = default_http_client
class ModalExtension(CloudMeteredExtension):
extension_id = "ext.modal"
def __init__(self, config: dict[str, Any] | None = None, **kwargs) -> None:
super().__init__(config, **kwargs)
self.image_ref: str = self.config.get("image_ref", "modal-default")
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
sandbox_id = self.new_sandbox_id(inputs)
image = inputs.get("image") or self.image_ref
payload = {
"image": image,
"metadata": {"sandboxer_id": sandbox_id},
}
with self._client() as client:
response = client.post(
f"{self.api_base}/v1/sandboxes",
json=payload,
headers=self._headers(),
)
if response.status_code >= 400:
raise RuntimeError(f"Modal provision failed: {response.text}")
data = response.json()
provider_sandbox_id = data.get("sandbox_id") or data.get("id", "")
endpoint = data.get("url") or f"https://modal.run/sandbox/{provider_sandbox_id}"
return {
"sandbox_id": sandbox_id,
"provider_sandbox_id": provider_sandbox_id,
"host": self.provider,
"endpoint": endpoint,
"provider": self.provider,
"image_ref": image,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
provider_id = handle.get("provider_sandbox_id", "")
with self._client() as client:
response = client.get(
f"{self.api_base}/v1/sandboxes/{provider_id}",
headers=self._headers(),
)
if response.status_code >= 400:
raise RuntimeError(f"Modal wait_ready failed: {response.text}")
data = response.json()
state = data.get("status", "ready")
if state not in ("ready", "running"):
raise RuntimeError(f"Modal sandbox not ready: {state}")
return {
"endpoint": handle["endpoint"],
"host": handle.get("host"),
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
provider_id = handle.get("provider_sandbox_id", "")
removed = False
if provider_id:
with self._client() as client:
response = client.delete(
f"{self.api_base}/v1/sandboxes/{provider_id}",
headers=self._headers(),
)
removed = response.status_code < 400
return {
"provider_removed": str(removed).lower(),
"sandbox_id": handle.get("sandbox_id", ""),
"provider_sandbox_id": provider_id,
}

View file

@ -1,32 +1,46 @@
"""ext.vm-packer — attach to pre-built VMs (build-machines lineage). """ext.vm-packer — attach to pre-built VMs or trigger Packer builds.
v0 supports **attach** mode only: connect via SSH to an existing VM (tunnel alias, **attach** mode: connect via SSH to an existing VM (tunnel alias, localhost:port,
localhost:port, or direct host). Creates an isolated workspace directory; teardown or direct host). Creates an isolated workspace directory; teardown removes the
removes the workspace, not the VM. workspace, not the VM.
Full Packer build / OVA import orchestration is deferred — operators still build **build** mode: run `packer init` + `packer build` for a template directory
images via the-custodian/infra/build-machines/ workflows. (the-custodian build-machines conventions). Teardown preserves the OVA artifact.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from sandboxer.extensions.base import SandboxExtension from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.ssh import SSHConfig from sandboxer.extensions.ssh import SSHConfig
from sandboxer.lifecycle.state_hub import emit_progress_note
from sandboxer.models import Profile from sandboxer.models import Profile
BUILD_PROFILE_IDS = frozenset({"profile.vm-packer-build"})
class VMPackerExtension(SandboxExtension): class VMPackerExtension(SandboxExtension):
"""Attach sandbox workspace on a pre-provisioned VM.""" """Attach sandbox workspace on a pre-provisioned VM, or run Packer build."""
def __init__(self, config: dict[str, Any] | None = None) -> None: def __init__(self, config: dict[str, Any] | None = None) -> None:
super().__init__(config) super().__init__(config)
self.workspace_base: str = self.config.get("workspace_base", "/build") self.workspace_base: str = self.config.get("workspace_base", "/build")
self.default_user: str | None = self.config.get("ssh_user") self.default_user: str | None = self.config.get("ssh_user")
self.ready_timeout_s: int = int(self.config.get("ready_timeout_s", 30)) self.ready_timeout_s: int = int(self.config.get("ready_timeout_s", 30))
self.default_packer_template: str | None = self.config.get("default_packer_template")
self.packer_bin: str = self.config.get("packer_bin", "packer")
def _resolve_mode(self, profile: Profile, inputs: dict[str, str]) -> str:
mode = inputs.get("mode")
if mode:
return mode
if profile.id in BUILD_PROFILE_IDS:
return "build"
return "attach"
def _ssh_from_handle(self, handle: dict[str, str]) -> SSHConfig: def _ssh_from_handle(self, handle: dict[str, str]) -> SSHConfig:
port_raw = handle.get("ssh_port") or os.environ.get("SANDBOXER_VM_SSH_PORT") port_raw = handle.get("ssh_port") or os.environ.get("SANDBOXER_VM_SSH_PORT")
@ -53,7 +67,106 @@ class VMPackerExtension(SandboxExtension):
return "localhost", int(env_port) return "localhost", int(env_port)
return inputs.get("vm_host") or placement_host, None return inputs.get("vm_host") or placement_host, None
def provision( def _run_packer(
self,
args: list[str],
*,
cwd: Path,
sandbox_id: str,
profile_id: str,
) -> None:
cmd = [self.packer_bin, *args]
emit_progress_note(
f"Packer: {' '.join(cmd)}",
sandbox_id=sandbox_id,
profile_id=profile_id,
detail={"cwd": str(cwd)},
)
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
msg = result.stderr.strip() or result.stdout.strip() or "unknown error"
raise RuntimeError(f"Packer command failed ({' '.join(cmd)}): {msg}")
def _find_ova_artifact(self, template_dir: Path, vm_name: str) -> Path | None:
candidates = sorted(
template_dir.glob(f"{vm_name}*.ova"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if candidates:
return candidates[0]
all_ova = sorted(
template_dir.glob("*.ova"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
return all_ova[0] if all_ova else None
def _provision_build(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
sandbox_id = self.new_sandbox_id(inputs)
template_raw = (
inputs.get("packer_template")
or os.environ.get("SANDBOXER_PACKER_TEMPLATE")
or self.default_packer_template
)
if not template_raw:
raise ValueError(
"inputs.packer_template is required for build mode "
"(or set SANDBOXER_PACKER_TEMPLATE)"
)
vm_name = inputs.get("vm_name") or inputs.get("vm")
if not vm_name:
raise ValueError("inputs.vm_name or inputs.vm is required for build mode")
template_dir = Path(template_raw).expanduser().resolve()
if not template_dir.is_dir():
raise FileNotFoundError(f"Packer template directory not found: {template_dir}")
pkr_files = list(template_dir.glob("*.pkr.hcl")) + list(template_dir.glob("*.pkr.json"))
if not pkr_files:
raise FileNotFoundError(f"No Packer template (*.pkr.hcl) in {template_dir}")
self._run_packer(
["init", "."],
cwd=template_dir,
sandbox_id=sandbox_id,
profile_id=profile.id,
)
build_args = ["build", "-var", f"vm_name={vm_name}"]
for key, value in inputs.items():
if key.startswith("packer_var_"):
build_args.extend(["-var", f"{key.removeprefix('packer_var_')}={value}"])
self._run_packer(
build_args,
cwd=template_dir,
sandbox_id=sandbox_id,
profile_id=profile.id,
)
artifact = self._find_ova_artifact(template_dir, vm_name)
artifact_path = str(artifact) if artifact else ""
return {
"sandbox_id": sandbox_id,
"host": host,
"mode": "build",
"vm_name": vm_name,
"vm_target": vm_name,
"packer_template": str(template_dir),
"artifact_path": artifact_path,
"remote_dir": artifact_path,
}
def _provision_attach(
self, profile: Profile, inputs: dict[str, str], host: str self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]: ) -> dict[str, str]:
vm_target = inputs.get("vm") or inputs.get("ssh_target") vm_target = inputs.get("vm") or inputs.get("ssh_target")
@ -94,11 +207,29 @@ class VMPackerExtension(SandboxExtension):
"remote_dir": remote_dir, "remote_dir": remote_dir,
"ssh_user": ssh.user or "", "ssh_user": ssh.user or "",
"ssh_port": str(ssh.port) if ssh.port else "", "ssh_port": str(ssh.port) if ssh.port else "",
"mode": inputs.get("mode", "attach"), "mode": "attach",
"repo": repo_path_str, "repo": repo_path_str,
} }
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
mode = self._resolve_mode(profile, inputs)
if mode == "build":
return self._provision_build(profile, inputs, host)
return self._provision_attach(profile, inputs, host)
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]: def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
if handle.get("mode") == "build":
artifact = handle.get("artifact_path") or handle.get("remote_dir")
if not artifact or not Path(artifact).is_file():
raise RuntimeError(f"Packer artifact not found: {artifact}")
return {
"host": handle.get("host"),
"remote_dir": artifact,
"endpoint": artifact,
}
ssh = self._ssh_from_handle(handle) ssh = self._ssh_from_handle(handle)
remote_dir = handle["remote_dir"] remote_dir = handle["remote_dir"]
cmd = f"test -d {remote_dir} && echo ready" cmd = f"test -d {remote_dir} && echo ready"
@ -112,6 +243,15 @@ class VMPackerExtension(SandboxExtension):
} }
def teardown(self, handle: dict[str, str]) -> dict[str, str]: def teardown(self, handle: dict[str, str]) -> dict[str, str]:
if handle.get("mode") == "build":
artifact = handle.get("artifact_path") or handle.get("remote_dir") or ""
return {
"workspace_removed": "false",
"remote_dir": artifact,
"vm_preserved": "true",
"artifact_preserved": "true",
}
remote_dir = handle.get("remote_dir") remote_dir = handle.get("remote_dir")
cleaned_dir = False cleaned_dir = False
if remote_dir: if remote_dir:

View file

@ -59,6 +59,37 @@ def emit_lifecycle_event(
return None return None
def emit_progress_note(
summary: str,
*,
sandbox_id: str,
profile_id: str,
detail: dict[str, Any] | None = None,
author: str = "sandboxer",
) -> dict[str, Any] | None:
"""Emit a progress note during long-running provision (e.g. Packer build)."""
if os.environ.get("SANDBOXER_NO_STATE_HUB", "").lower() in ("1", "true", "yes"):
return None
payload = {
"event_type": "note",
"summary": summary,
"author": author,
"detail": {
"sandbox_id": sandbox_id,
"profile_id": profile_id,
**(detail or {}),
},
}
try:
response = httpx.post(f"{hub_url()}/progress/", json=payload, timeout=10.0)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def event_type_for_state(state: SandboxState) -> str: def event_type_for_state(state: SandboxState) -> str:
if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED): if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED):
return "milestone" return "milestone"

View file

@ -153,6 +153,9 @@ class Reachability(BaseModel):
compose_project: str | None = None compose_project: str | None = None
host: str | None = None host: str | None = None
endpoint: str | None = None endpoint: str | None = None
tunnel: str | None = None
tunnel_via: str | None = None
identity: str | None = None
class SandboxStatus(BaseModel): class SandboxStatus(BaseModel):

View file

@ -0,0 +1,49 @@
"""Optional fin-hub billing export for metered sandbox usage."""
from __future__ import annotations
import os
from typing import Any
import httpx
from sandboxer.models import MeterRecord, SandboxStatus
def fin_hub_url() -> str | None:
return os.environ.get("SANDBOXER_FIN_HUB_URL") or None
def export_meter_usage(
status: SandboxStatus,
*,
extension_id: str,
meter: MeterRecord,
) -> dict[str, Any] | None:
"""POST usage record to fin-hub when SANDBOXER_FIN_HUB_URL is set."""
if os.environ.get("SANDBOXER_NO_FIN_HUB", "").lower() in ("1", "true", "yes"):
return None
if meter.pricing_model != "metered" or not meter.actual_usd:
return None
base = fin_hub_url()
if not base:
return None
payload = {
"sandbox_id": status.sandbox_id,
"extension_id": extension_id,
"profile_id": status.profile_id,
"consumer": status.consumer.model_dump(),
"duration_s": meter.duration_s,
"actual_usd": meter.actual_usd,
"estimate_usd": meter.estimate_usd,
"currency": meter.currency,
}
try:
response = httpx.post(f"{base.rstrip('/')}/usage/sandbox", json=payload, timeout=10.0)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None

View file

@ -0,0 +1,5 @@
"""Reachability descriptor enrichment."""
from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability
__all__ = ["enrich_reachability", "build_reachability_report"]

View file

@ -0,0 +1,68 @@
"""Merge profile reachability spec and env into consumer descriptors."""
from __future__ import annotations
import os
from typing import Any
from sandboxer.models import Profile, Reachability, SandboxStatus
OPS_BRIDGE_DOC = "ops-bridge MCP or `bridge` CLI — sand-boxer does not manage tunnels"
def enrich_reachability(
reach: dict[str, str],
profile: Profile,
handle: dict[str, str],
) -> dict[str, str]:
"""Add tunnel/identity metadata from profile spec and environment."""
enriched = dict(reach)
spec = profile.reachability
if spec.tunnel:
enriched.setdefault("tunnel_via", spec.tunnel)
if spec.identity:
enriched["identity"] = spec.identity
tunnel_port = (
os.environ.get("SANDBOXER_TUNNEL_PORT")
or handle.get("tunnel_port")
or handle.get("ssh_port")
)
tunnel_alias = os.environ.get("SANDBOXER_TUNNEL_ALIAS") or handle.get("vm_target")
if tunnel_port:
enriched["tunnel"] = f"localhost:{tunnel_port}"
elif tunnel_alias:
enriched["tunnel"] = tunnel_alias
tunnel_via = os.environ.get("SANDBOXER_TUNNEL_VIA")
if tunnel_via:
enriched["tunnel_via"] = tunnel_via
return enriched
def ssh_one_liner(reach: Reachability) -> str | None:
if reach.ssh and reach.remote_dir:
return f"ssh {reach.ssh} 'cd {reach.remote_dir} && exec $SHELL'"
if reach.ssh:
return f"ssh {reach.ssh}"
return None
def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
"""Consumer-facing reachability report with ops-bridge pointer."""
reach = status.reachability
payload: dict[str, Any] = {
"sandbox_id": status.sandbox_id,
"profile_id": status.profile_id,
"host": status.host,
"reachability": reach.model_dump(mode="json") if reach else None,
"ops_bridge": {
"doc": OPS_BRIDGE_DOC,
"note": "Bring tunnels up via ops-bridge; sand-boxer emits descriptor only",
},
}
if reach:
payload["ssh_one_liner"] = ssh_one_liner(reach)
return payload

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import os import os
from sandboxer.extensions.credentials import credentials_available
from sandboxer.extensions.registry import load_extension from sandboxer.extensions.registry import load_extension
from sandboxer.models import Extension, Profile, RouteStrategy from sandboxer.models import Extension, Profile, RouteStrategy
from sandboxer.payments.metering import estimate_cost from sandboxer.payments.metering import estimate_cost
@ -20,6 +21,49 @@ def _is_metered(ext: Extension) -> bool:
return ext.capabilities.pricing_model == "metered" return ext.capabilities.pricing_model == "metered"
def _metered_available(ext: Extension) -> bool:
if ext.id == "ext.saas-stub":
return True
return credentials_available(ext.id, ext.config)
def _select_metered_fallback(
loaded: list[Extension],
profile: Profile,
inputs: dict[str, str],
*,
duration_s: int,
) -> Extension | None:
"""Pick cheapest credentialed metered extension; stub is always last resort."""
available = [ext for ext in loaded if _is_metered(ext) and _metered_available(ext)]
if not available:
return None
strategy = profile.route.strategy if profile.route else RouteStrategy.EXPLICIT
if strategy == RouteStrategy.LOWEST_COST:
best: Extension | None = None
best_cost: float | None = None
for ext in available:
if not _metered_available(ext):
continue
cost = _quote_cost(ext, profile, inputs, duration_s)
if cost is None:
continue
max_hour = profile.route.max_cost_per_hour_usd if profile.route else None
if max_hour is not None and cost > max_hour:
continue
if best is None or cost < (best_cost or float("inf")):
best, best_cost = ext, cost
if best:
return best
for ext_id in ("ext.e2b", "ext.modal", "ext.saas-stub"):
for ext in available:
if ext.id == ext_id:
return ext
return available[0]
def _self_hosted_available(profile: Profile, ext: Extension, host_override: str | None) -> bool: def _self_hosted_available(profile: Profile, ext: Extension, host_override: str | None) -> bool:
if _is_metered(ext): if _is_metered(ext):
return True return True
@ -65,6 +109,11 @@ def resolve_extension(
for ext in loaded: for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override): if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext return ext
fallback = _select_metered_fallback(
loaded, profile, inputs, duration_s=duration_s
)
if fallback:
return fallback
for ext in loaded: for ext in loaded:
if _is_metered(ext): if _is_metered(ext):
return ext return ext
@ -76,6 +125,8 @@ def resolve_extension(
for ext in loaded: for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override): if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext return ext
if not _metered_available(ext):
continue
cost = _quote_cost(ext, profile, inputs, duration_s) cost = _quote_cost(ext, profile, inputs, duration_s)
if cost is None: if cost is None:
continue continue
@ -92,6 +143,11 @@ def resolve_extension(
for ext in loaded: for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override): if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext return ext
fallback = _select_metered_fallback(
loaded, profile, inputs, duration_s=duration_s
)
if fallback:
return fallback
return loaded[-1] return loaded[-1]
return load_extension(profile.extension) return load_extension(profile.extension)

View file

@ -0,0 +1,5 @@
"""Setup secret resolution at provision boundary."""
from sandboxer.secrets.resolver import resolve_setup_secrets
__all__ = ["resolve_setup_secrets"]

View file

@ -0,0 +1,41 @@
"""Resolve profile.setup.secret_refs from operator-injected env (BYOK boundary)."""
from __future__ import annotations
import os
from sandboxer.models import Profile
def _secret_env_name(ref: str) -> str:
normalized = ref.upper().replace("-", "_").replace(".", "_")
return f"SANDBOXER_SECRET_{normalized}"
def resolve_secret_ref(ref: str) -> str | None:
"""Resolve a single secret ref from env. OpenBao injection is operator-owned."""
return os.environ.get(_secret_env_name(ref))
def resolve_setup_secrets(profile: Profile) -> dict[str, str]:
"""Resolve all profile secret_refs or raise if any are missing."""
refs = profile.setup.secret_refs
if not refs:
return {}
resolved: dict[str, str] = {}
missing: list[str] = []
for ref in refs:
value = resolve_secret_ref(ref)
if value:
resolved[ref] = value
else:
missing.append(ref)
if missing:
env_hints = ", ".join(_secret_env_name(r) for r in missing)
raise ValueError(
f"Unresolved secret_refs for {profile.id}: {missing}. "
f"Set env ({env_hints}) or use warden route find for OpenBao path."
)
return resolved

View file

@ -0,0 +1,50 @@
"""fin-hub billing export tests."""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
from sandboxer.models import ActorType, Consumer, MeterRecord, SandboxState, SandboxStatus
from sandboxer.payments.billing_export import export_meter_usage
def test_export_skipped_when_url_unset() -> None:
now = datetime.now(UTC)
status = SandboxStatus(
sandbox_id="s1",
profile_id="profile.e2b-burst",
extension_id="ext.e2b",
state=SandboxState.DESTROYED,
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
created_at=now,
updated_at=now,
)
meter = MeterRecord(pricing_model="metered", actual_usd=0.5, duration_s=100.0)
assert export_meter_usage(status, extension_id="ext.e2b", meter=meter) is None
def test_export_posts_when_configured(monkeypatch) -> None:
monkeypatch.setenv("SANDBOXER_FIN_HUB_URL", "http://fin-hub.test")
now = datetime.now(UTC)
status = SandboxStatus(
sandbox_id="s1",
profile_id="profile.e2b-burst",
extension_id="ext.e2b",
state=SandboxState.DESTROYED,
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
created_at=now,
updated_at=now,
)
meter = MeterRecord(pricing_model="metered", actual_usd=0.5, duration_s=100.0)
mock_response = MagicMock()
mock_response.json.return_value = {"ok": True}
with patch("sandboxer.payments.billing_export.httpx.post", return_value=mock_response) as post:
result = export_meter_usage(status, extension_id="ext.e2b", meter=meter)
assert result == {"ok": True}
post.assert_called_once()
payload = post.call_args.kwargs["json"]
assert payload["sandbox_id"] == "s1"
assert payload["actual_usd"] == 0.5

View file

@ -0,0 +1,20 @@
"""Consumer profile loader smoke tests."""
from sandboxer.profiles.loader import load_profile
def test_profile_agent_dev_loads() -> None:
profile = load_profile("profile.agent-dev")
assert profile.id == "profile.agent-dev"
assert profile.extension == "ext.compose-ssh"
assert profile.scope_default == "agent"
assert profile.route is not None
assert profile.ttl.default == "8h"
def test_profile_build_loads() -> None:
profile = load_profile("profile.build")
assert profile.id == "profile.build"
assert profile.extension == "ext.vm-packer"
assert "build-registry-token" in profile.setup.secret_refs
assert profile.reachability.tunnel == "ops-bridge"

72
tests/test_e2b.py Normal file
View file

@ -0,0 +1,72 @@
"""E2B cloud adapter tests."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from sandboxer.extensions.credentials import credentials_available, resolve_api_key
from sandboxer.extensions.e2b import E2BExtension
from sandboxer.models import Profile
def _profile() -> Profile:
return Profile.model_validate(
{
"id": "profile.e2b-burst",
"version": "1.0.0",
"extension": "ext.e2b",
}
)
def test_credentials_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("E2B_API_KEY", "test-key")
config = {"api_key_env": "E2B_API_KEY", "secret_ref": "e2b-api-key"}
assert resolve_api_key(config, extension_id="ext.e2b") == "test-key"
assert credentials_available("ext.e2b", config)
def test_provision_and_teardown_with_mock_client(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("E2B_API_KEY", "test-key")
ext = E2BExtension(
{
"api_base": "https://api.e2b.dev",
"api_key_env": "E2B_API_KEY",
"provider": "e2b",
"template_id": "base",
}
)
mock_client = MagicMock()
create_resp = MagicMock()
create_resp.status_code = 200
create_resp.json.return_value = {
"sandboxID": "e2b-prov-1",
"sandboxURL": "https://e2b-prov-1.e2b.dev",
}
ready_resp = MagicMock()
ready_resp.status_code = 200
delete_resp = MagicMock()
delete_resp.status_code = 204
mock_client.post.return_value = create_resp
mock_client.get.return_value = ready_resp
mock_client.delete.return_value = delete_resp
mock_client.__enter__.return_value = mock_client
mock_client.__exit__.return_value = None
with patch.object(ext, "_client_factory", return_value=mock_client):
handle = ext.provision(_profile(), {}, "e2b")
assert handle["provider_sandbox_id"] == "e2b-prov-1"
reach = ext.wait_ready(handle)
assert "e2b-prov-1" in reach["endpoint"]
report = ext.teardown(handle)
assert report["provider_removed"] == "true"
def test_estimate_cost() -> None:
ext = E2BExtension({"rate_usd_per_hour": 0.15, "session_fee_usd": 0.02})
quote = ext.estimate_cost(_profile(), {}, duration_s=3600)
assert quote.extension_id == "ext.e2b"
assert quote.estimated_usd > 0

63
tests/test_modal.py Normal file
View file

@ -0,0 +1,63 @@
"""Modal cloud adapter tests."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from sandboxer.extensions.modal import ModalExtension
from sandboxer.models import Profile
def _profile() -> Profile:
return Profile.model_validate(
{
"id": "profile.modal-gpu",
"version": "1.0.0",
"extension": "ext.modal",
}
)
def test_provision_and_teardown_with_mock_client(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MODAL_TOKEN_ID", "modal-token")
ext = ModalExtension(
{
"api_base": "https://api.modal.com",
"api_key_env": "MODAL_TOKEN_ID",
"provider": "modal",
}
)
mock_client = MagicMock()
create_resp = MagicMock()
create_resp.status_code = 200
create_resp.json.return_value = {
"sandbox_id": "modal-prov-1",
"url": "https://modal.run/sandbox/modal-prov-1",
"status": "ready",
}
ready_resp = MagicMock()
ready_resp.status_code = 200
ready_resp.json.return_value = {"status": "ready"}
delete_resp = MagicMock()
delete_resp.status_code = 200
mock_client.post.return_value = create_resp
mock_client.get.return_value = ready_resp
mock_client.delete.return_value = delete_resp
mock_client.__enter__.return_value = mock_client
mock_client.__exit__.return_value = None
with patch.object(ext, "_client_factory", return_value=mock_client):
handle = ext.provision(_profile(), {}, "modal")
assert handle["provider_sandbox_id"] == "modal-prov-1"
ext.wait_ready(handle)
report = ext.teardown(handle)
assert report["provider_removed"] == "true"
def test_provision_without_credentials_raises() -> None:
ext = ModalExtension({"api_key_env": "MODAL_TOKEN_ID"})
with pytest.raises(RuntimeError, match="API key"):
ext.provision(_profile(), {}, "modal")

View file

@ -0,0 +1,78 @@
"""Reachability enrichment tests."""
from __future__ import annotations
from datetime import UTC, datetime
from sandboxer.models import (
ActorType,
Consumer,
Profile,
Reachability,
ReachabilitySpec,
SandboxState,
SandboxStatus,
)
from sandboxer.reachability.enrich import (
build_reachability_report,
enrich_reachability,
ssh_one_liner,
)
def _profile() -> Profile:
return Profile.model_validate(
{
"id": "profile.agent-dev",
"version": "1.0.0",
"extension": "ext.compose-ssh",
"reachability": ReachabilitySpec(
tunnel="ops-bridge", identity="ops-warden"
).model_dump(),
}
)
def test_enrich_adds_tunnel_and_identity(monkeypatch) -> None:
monkeypatch.setenv("SANDBOXER_TUNNEL_PORT", "12222")
reach = enrich_reachability(
{"ssh": "build@localhost", "remote_dir": "/build/sbx-1", "host": "localhost"},
_profile(),
{"ssh_port": "12222"},
)
assert reach["identity"] == "ops-warden"
assert reach["tunnel"] == "localhost:12222"
assert reach["tunnel_via"] == "ops-bridge"
def test_ssh_one_liner() -> None:
reach = Reachability(ssh="user@host", remote_dir="/tmp/ws")
line = ssh_one_liner(reach)
assert line is not None
assert "user@host" in line
assert "/tmp/ws" in line
def test_build_reachability_report() -> None:
now = datetime.now(UTC)
status = SandboxStatus(
sandbox_id="abc12345",
profile_id="profile.agent-dev",
extension_id="ext.compose-ssh",
state=SandboxState.READY,
consumer=Consumer(actor=ActorType.AGT, project="glas-harness"),
host="coulombcore",
reachability=Reachability(
ssh="root@coulombcore",
remote_dir="/tmp/sandboxer/abc12345",
tunnel="localhost:22",
tunnel_via="ops-bridge",
identity="ops-warden",
),
created_at=now,
updated_at=now,
)
report = build_reachability_report(status)
assert report["sandbox_id"] == "abc12345"
assert report["ssh_one_liner"] is not None
assert "ops_bridge" in report

View file

@ -16,7 +16,12 @@ def _burst_profile() -> Profile:
"extension": "ext.compose-ssh", "extension": "ext.compose-ssh",
"route": { "route": {
"strategy": "prefer-self-hosted", "strategy": "prefer-self-hosted",
"extensions": ["ext.compose-ssh", "ext.saas-stub"], "extensions": [
"ext.compose-ssh",
"ext.e2b",
"ext.modal",
"ext.saas-stub",
],
}, },
} }
) )
@ -42,12 +47,23 @@ def test_prefer_self_hosted_when_host_set(monkeypatch: pytest.MonkeyPatch) -> No
def test_prefer_self_hosted_falls_back_to_saas(monkeypatch: pytest.MonkeyPatch) -> None: def test_prefer_self_hosted_falls_back_to_saas(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SANDBOXER_HOST", raising=False) monkeypatch.delenv("SANDBOXER_HOST", raising=False)
monkeypatch.delenv("E2B_API_KEY", raising=False)
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
monkeypatch.setenv("SANDBOXER_FORCE_SAAS", "1") monkeypatch.setenv("SANDBOXER_FORCE_SAAS", "1")
ext = resolve_extension(_burst_profile(), {}, host_override=None) ext = resolve_extension(_burst_profile(), {}, host_override=None)
assert ext.id == "ext.saas-stub" assert ext.id == "ext.saas-stub"
assert ext.capabilities.pricing_model == "metered" assert ext.capabilities.pricing_model == "metered"
def test_prefer_self_hosted_falls_back_to_e2b_when_credentialed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SANDBOXER_FORCE_SAAS", "1")
monkeypatch.setenv("E2B_API_KEY", "test-key")
ext = resolve_extension(_burst_profile(), {}, host_override=None)
assert ext.id == "ext.e2b"
def test_lowest_cost_picks_metered_when_forced(monkeypatch: pytest.MonkeyPatch) -> None: def test_lowest_cost_picks_metered_when_forced(monkeypatch: pytest.MonkeyPatch) -> None:
profile = _burst_profile() profile = _burst_profile()
profile.route = RouteSpec( profile.route = RouteSpec(

51
tests/test_secrets.py Normal file
View file

@ -0,0 +1,51 @@
"""Setup secret resolution tests."""
from __future__ import annotations
import pytest
from sandboxer.models import Profile, SetupSpec
from sandboxer.secrets.resolver import resolve_secret_ref, resolve_setup_secrets
def test_resolve_secret_ref_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN", "tok123")
assert resolve_secret_ref("build-registry-token") == "tok123"
def test_resolve_setup_secrets_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN", "tok123")
profile = Profile.model_validate(
{
"id": "profile.build",
"version": "1.0.0",
"extension": "ext.vm-packer",
"setup": SetupSpec(secret_refs=["build-registry-token"]).model_dump(),
}
)
secrets = resolve_setup_secrets(profile)
assert secrets["build-registry-token"] == "tok123"
def test_resolve_setup_secrets_missing_raises() -> None:
profile = Profile.model_validate(
{
"id": "profile.build",
"version": "1.0.0",
"extension": "ext.vm-packer",
"setup": SetupSpec(secret_refs=["missing-ref"]).model_dump(),
}
)
with pytest.raises(ValueError, match="Unresolved secret_refs"):
resolve_setup_secrets(profile)
def test_empty_secret_refs() -> None:
profile = Profile.model_validate(
{
"id": "profile.compose-e2e",
"version": "1.0.0",
"extension": "ext.compose-ssh",
}
)
assert resolve_setup_secrets(profile) == {}

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from subprocess import CompletedProcess
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -21,6 +22,16 @@ def _profile() -> Profile:
) )
def _build_profile() -> Profile:
return Profile.model_validate(
{
"id": "profile.vm-packer-build",
"version": "1.0.0",
"extension": "ext.vm-packer",
}
)
def test_provision_attach_via_alias(tmp_path: Path) -> None: def test_provision_attach_via_alias(tmp_path: Path) -> None:
repo = tmp_path / "proj" repo = tmp_path / "proj"
repo.mkdir() repo.mkdir()
@ -73,4 +84,66 @@ def test_teardown_preserves_vm() -> None:
with patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "")): with patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "")):
report = ext.teardown(handle) report = ext.teardown(handle)
assert report["vm_preserved"] == "true" assert report["vm_preserved"] == "true"
assert report["workspace_removed"] == "True" assert report["workspace_removed"] == "True"
def test_provision_build_runs_packer(tmp_path: Path) -> None:
template = tmp_path / "haskell"
template.mkdir()
(template / "haskell-build.pkr.hcl").write_text('packer {}')
ova = template / "haskell-build-20260624.ova"
ova.write_bytes(b"ova")
ext = VMPackerExtension()
with (
patch(
"sandboxer.extensions.vm_packer.subprocess.run",
return_value=CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
) as run,
patch("sandboxer.extensions.vm_packer.emit_progress_note"),
):
handle = ext.provision(
_build_profile(),
{"vm_name": "haskell-build", "packer_template": str(template)},
"localhost",
)
assert handle["mode"] == "build"
assert handle["artifact_path"] == str(ova)
assert run.call_count == 2
init_cmd, build_cmd = [c.args[0] for c in run.call_args_list]
assert init_cmd[:2] == ["packer", "init"]
assert build_cmd[:2] == ["packer", "build"]
assert "-var" in build_cmd and "vm_name=haskell-build" in build_cmd
def test_provision_build_requires_template() -> None:
ext = VMPackerExtension()
with pytest.raises(ValueError, match="packer_template"):
ext.provision(_build_profile(), {"vm_name": "haskell-build"}, "localhost")
def test_wait_ready_build_checks_artifact(tmp_path: Path) -> None:
ova = tmp_path / "haskell-build.ova"
ova.write_bytes(b"ova")
ext = VMPackerExtension()
reach = ext.wait_ready(
{
"mode": "build",
"artifact_path": str(ova),
"host": "localhost",
}
)
assert reach["endpoint"] == str(ova)
def test_teardown_build_preserves_artifact() -> None:
ext = VMPackerExtension()
report = ext.teardown(
{
"mode": "build",
"artifact_path": "/tmp/haskell-build.ova",
}
)
assert report["artifact_preserved"] == "true"
assert report["workspace_removed"] == "false"

View file

@ -85,9 +85,9 @@ state_hub_task_id: "7075d8fc-44d1-48fe-b061-a05385de31a8"
```task ```task
id: SAND-WP-0006-T06 id: SAND-WP-0006-T06
status: wait status: cancel
priority: low priority: low
state_hub_task_id: "c514c6b3-2be8-41e4-8e84-88f20ad80f7e" state_hub_task_id: "c514c6b3-2be8-41e4-8e84-88f20ad80f7e"
``` ```
Real `ext.e2b` / `ext.modal` adapters, BYOK via OpenBao, fin-hub export. Superseded by SAND-WP-0010 (E2B/Modal adapters, BYOK, fin-hub export).

View file

@ -21,8 +21,7 @@ registry, HTTP parity).
Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md` Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
**Predecessor:** SAND-WP-0007 (snapshots — finished) **Predecessor:** SAND-WP-0007 (snapshots — finished)
**Follow-on:** SAND-WP-0010 (real cloud adapters), SAND-WP-0011 (reachability + **Follow-on:** SAND-WP-0010, SAND-WP-0011, SAND-WP-0012 (workplans filed 2026-06-24)
consumer profiles), SAND-WP-0012 (Packer orchestration)
--- ---

View file

@ -0,0 +1,114 @@
---
id: SAND-WP-0010
type: workplan
title: "Cloud adapters and billing export"
domain: infotech
repo: sand-boxer
status: finished
owner: codex
topic_slug: custodian
created: "2026-06-24"
updated: "2026-06-24"
state_hub_workstream_id: "a78ea9f1-d076-43a6-8798-36fc333edd25"
---
# Cloud adapters and billing export
Replace `ext.saas-stub` with real metered cloud backends (E2B, Modal) and wire
BYOK credential routing plus fin-hub billing export.
Gap analysis P5/P10: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
Supersedes: SAND-WP-0006-T06
**Predecessor:** SAND-WP-0009 (TTL — finished)
**Follow-on:** SAND-WP-0011 (reachability + consumer profiles)
---
## Credential routing and BYOK
```task
id: SAND-WP-0010-T01
status: done
priority: high
state_hub_task_id: "5aeb6a17-dc5b-4e39-996c-f7f31c2659f4"
```
`src/sandboxer/extensions/credentials.py`; docs in `docs/payments.md` and
`docs/cloud-adapters.md`.
## ext.e2b adapter
```task
id: SAND-WP-0010-T02
status: done
priority: high
state_hub_task_id: "205bc70a-aaa6-4cd7-b0a5-11669490c150"
```
`ext.e2b`, `profile.e2b-burst`, mocked HTTP tests in `tests/test_e2b.py`.
## ext.modal adapter
```task
id: SAND-WP-0010-T03
status: done
priority: high
state_hub_task_id: "b9f104e1-e9d6-4324-b82a-8406be3006e5"
```
`ext.modal`, `profile.modal-gpu`, `tests/test_modal.py`.
## Routing and credits integration
```task
id: SAND-WP-0010-T04
status: done
priority: high
state_hub_task_id: "c11bdaf1-4c25-4c14-a566-0e28b0bd8b1d"
```
`profile.burst-sandbox` routes e2b → modal → stub; credential-aware resolver.
## fin-hub billing export
```task
id: SAND-WP-0010-T05
status: done
priority: medium
state_hub_task_id: "4eb1b0df-c6d7-4fb6-a7a4-e1455d2fac61"
```
`payments/billing_export.py`; hook on metered destroy; `tests/test_billing_export.py`.
## Docs and capability registry
```task
id: SAND-WP-0010-T06
status: done
priority: medium
state_hub_task_id: "d0aba132-b8fa-461b-b722-099868bf1770"
```
`docs/cloud-adapters.md`; registry A5/C5; routing/payments/extension-sdk updates.
## Tests and smoke
```task
id: SAND-WP-0010-T07
status: done
priority: high
state_hub_task_id: "3aebb3be-ae5e-4642-9710-9d80a1e8a582"
```
77 tests; `scripts/smoke-cloud-adapter.sh` (operator, credential-gated).
---
## Acceptance criteria
- E2B and Modal adapters provision/teardown via CLI with mocked CI
- BYOK documented; no secrets in repo or State Hub payloads
- `profile.burst-sandbox` routes to E2B when creds available
- fin-hub export hook callable when `SANDBOXER_FIN_HUB_URL` set
- SAND-WP-0006-T06 cancelled (superseded)

View file

@ -0,0 +1,134 @@
---
id: SAND-WP-0011
type: workplan
title: "Reachability and consumer profiles"
domain: infotech
repo: sand-boxer
status: finished
owner: codex
topic_slug: custodian
created: "2026-06-24"
updated: "2026-06-24"
state_hub_workstream_id: "614a59b5-1b95-4e5d-9014-676c69a99b5f"
---
# Reachability and consumer profiles
Formalize ops-bridge tunnel attachment in reachability descriptors and ship
first-class profiles for glas-harness and snuggle-inventor consumers.
Gap analysis P6/P7: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
**Predecessor:** SAND-WP-0010 (cloud adapters)
**Follow-on:** SAND-WP-0012 (Packer orchestration)
---
## Reachability descriptor enrichment
```task
id: SAND-WP-0011-T01
status: done
priority: high
state_hub_task_id: "ccf21aaf-9439-41e2-9ce3-becc08f734a7"
```
Extend `Reachability` model: optional `tunnel` (local port / alias), `tunnel_via`
(ops-bridge route id), `identity` (warden actor hint). Populate from profile
`reachability` spec + `SANDBOXER_TUNNEL_*` env on compose-ssh / vm-packer.
Document contract in `docs/meta-framework.md`; sand-boxer does not own tunnels.
## ops-bridge integration helper
```task
id: SAND-WP-0011-T02
status: done
priority: medium
state_hub_task_id: "61d41e09-ca21-4fbe-9b56-98f0ffe356c6"
```
`sandboxer reachability show <id>` and `GET /v1/sandboxes/{id}/reachability`
surfacing SSH one-liner and tunnel status pointer (`ops-bridge` MCP / CLI doc
link). No tunnel bring-up in sand-boxer — pointer only.
## profile.agent-dev
```task
id: SAND-WP-0011-T03
status: done
priority: high
state_hub_task_id: "1a10a784-6a7c-4af6-9fbf-48d31e7e22cb"
```
Profile for glas-harness: longer TTL defaults, `actor: agt` examples, route
`prefer-self-hosted`. Extension `ext.compose-ssh`. Updated
`docs/integrations/glas-harness.md` with real profile id.
## profile.build (snuggle-inventor)
```task
id: SAND-WP-0011-T04
status: done
priority: high
state_hub_task_id: "a8142492-32c8-40d4-b882-b555858b44bb"
```
Build sandbox profile binding `ext.vm-packer`; `setup.instructions` placeholder;
`secret_refs` list on profile (resolution v0: env `SANDBOXER_SECRET_*`, inject at
provision boundary only). Updated `docs/integrations/snuggle-inventor.md`.
## Secret boundary v0
```task
id: SAND-WP-0011-T05
status: done
priority: medium
state_hub_task_id: "df4053de-ec74-40a3-ae9b-422c1be973cd"
```
`SetupSpec.secret_refs` resolution in manager pre-provision hook via
`SANDBOXER_SECRET_<REF>` env; pass to extension handle; never store on
`SandboxStatus` or emit to State Hub. Tests with mocked resolver.
## Consumer smoke scripts
```task
id: SAND-WP-0011-T06
status: done
priority: medium
state_hub_task_id: "9d5feebe-16a2-4448-ad0c-3276858341d1"
```
`scripts/smoke-agent-dev.sh`, `scripts/smoke-build-profile.sh` (CoulombCore
gated). Integration section in each consumer doc.
## Tests and docs
```task
id: SAND-WP-0011-T07
status: done
priority: high
state_hub_task_id: "849e0701-fe8f-4c08-ac24-98cdf554c24b"
```
Model tests for reachability fields; profile loader tests; updated `SCOPE.md`
profile catalog. `make check` green.
---
## Out of scope
| Item | Track |
|------|-------|
| glas-harness tool execution | glas-harness repo |
| snuggle code generation | snuggle-inventor repo |
| ops-bridge tunnel automation | ops-bridge repo |
---
## Acceptance criteria
- `profile.agent-dev` and `profile.build` load and create via CLI
- Reachability JSON includes tunnel metadata when profile declares ops-bridge
- secret_refs resolved at boundary; absent from agent-visible status payload
- Consumer integration docs reference real profile ids

View file

@ -0,0 +1,121 @@
---
id: SAND-WP-0012
type: workplan
title: "Packer build orchestration"
domain: infotech
repo: sand-boxer
status: finished
owner: codex
topic_slug: custodian
created: "2026-06-24"
updated: "2026-06-24"
state_hub_workstream_id: "87838886-0f4a-4eae-8d0e-b464933089de"
---
# Packer build orchestration
Trigger Packer builds from `sandboxer create` and ship the-custodian
`make remote-build` shim — completing the build-machines migration arc.
Gap analysis P8: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
Carries forward: SAND-WP-0005-T06 (deferred)
**Predecessor:** SAND-WP-0011 (consumer profiles)
**Follow-on:** reuse-surface federation publish; sandboxer01 operator track
---
## Packer build mode on ext.vm-packer
```task
id: SAND-WP-0012-T01
status: done
priority: high
state_hub_task_id: "9dc30d94-1797-4c35-81a0-e75e5414f6fc"
```
`VMPackerExtension` build mode: inputs `packer_template`, `vm_name` trigger
local Packer run per the-custodian `infra/build-machines/` conventions.
Distinct from attach mode; teardown preserves OVA artifact. Tests mocked subprocess.
## profile.vm-packer-build
```task
id: SAND-WP-0012-T02
status: done
priority: high
state_hub_task_id: "8e30794c-d8b9-48c7-ae93-db84724eedf2"
```
Profile binding build mode with placement and TTL suitable for long builds.
Documented inputs in `docs/migration-build-machines.md`.
## Manager and CLI integration
```task
id: SAND-WP-0012-T03
status: done
priority: high
state_hub_task_id: "685f766c-90ae-4698-87d0-b61535e7491a"
```
`create` selects build vs attach via profile or `inputs.mode=build|attach`.
Progress events to State Hub during long provision. CLI help text updated.
## the-custodian remote-build shim
```task
id: SAND-WP-0012-T04
status: done
priority: medium
state_hub_task_id: "6c4c0f85-5153-4fe9-84e6-26c5c9d33bb1"
```
`make remote-build PROJECT=` in build-machines delegates to
`sandboxer create --profile profile.vm-haskell-build` when CLI present;
legacy rsync path retained with deprecation notice.
`scripts/verify-remote-build-shim.sh` mirrors SAND-WP-0004 pattern.
## Port-registry automation
```task
id: SAND-WP-0012-T05
status: done
priority: low
state_hub_task_id: "701b2640-36ea-4702-b660-7169a4ec72cc"
```
Documented read-only port-registry pointer in `docs/migration-build-machines.md`;
full ops-bridge automation deferred.
## Docs, tests, runbook
```task
id: SAND-WP-0012-T06
status: done
priority: high
state_hub_task_id: "2378cd6a-ac23-47e9-a5d9-0d80b9e9f7af"
```
Updated `docs/migration-build-machines.md`, `docs/extension-sdk.md`, operator
runbook `docs/runbooks/profile-vm-packer-build.md`. Build mode cases in
`tests/test_vm_packer.py`. `make check` green (90 tests).
---
## Out of scope
| Item | Track |
|------|-------|
| OVA import on hypervisor | Operator / build-machines |
| systemd build-agent changes | the-custodian infra |
| sandboxer01 host | Infra operator |
---
## Acceptance criteria
- Build mode provisions via CLI with mocked Packer in CI
- Attach mode unchanged (backward compatible)
- the-custodian shim documented and verified
- SAND-WP-0005-T06 superseded when complete