diff --git a/.custodian-brief.md b/.custodian-brief.md index b0f42c1..1964705 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — sand-boxer **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)* ## Active Workstreams diff --git a/.forgejo/workflows/ci-smoke.yaml b/.forgejo/workflows/ci-smoke.yaml new file mode 100644 index 0000000..bd44c56 --- /dev/null +++ b/.forgejo/workflows/ci-smoke.yaml @@ -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}" \ No newline at end of file diff --git a/SCOPE.md b/SCOPE.md index 725184a..18c2385 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -116,24 +116,24 @@ own tunnels or CAs. - **Status:** v0 operational — self-hosted compose path proven on CoulombCore; routing, payments stub, and snapshots shipped -- **Workplans finished:** SAND-WP-0001–0008 (all workplans in `workplans/`; - 0003/0004 delivered in sibling repos wise-validator / the-custodian) +- **Workplans finished:** SAND-WP-0001–0012 (0003/0004 in sibling repos) +- **Workplans ready:** none (reuse-surface publish / sandboxer01 operator track) - **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments, snapshots, telemetry, HTTP API -- **Profiles:** `profile.compose-e2e`, `profile.compose-checkpoint`, - `profile.sandbox-canary`, `profile.vm-haskell-build`, `profile.saas-stub`, - `profile.burst-sandbox` -- **Extensions:** `ext.compose-ssh` (compose + tar snapshots), - `ext.vm-packer` (attach), `ext.saas-stub` (metered stub + metadata snapshots) +- **Profiles:** compose e2e/checkpoint, canary, vm-haskell-build, saas-stub, + burst-sandbox, e2b-burst, modal-gpu, agent-dev, build, vm-packer-build +- **Extensions:** `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub`, + `ext.e2b`, `ext.modal` - **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`, `payments`, `snapshots`, `migration-gaps`, `migration-build-machines` - **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 `make e2e REPO=` shim (SAND-WP-0004) 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 credits show / credits add sandboxer inspect host / inspect stale / reap-stale [--apply] +sandboxer reachability show +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) # Full e2e validation (wise-validator, separate install): @@ -172,14 +176,15 @@ cd ~/the-custodian && make e2e REPO=activity-core ## What Is Not Possible Yet - ~~TTL auto-expiry / `extend_ttl` enforcement~~ — done (SAND-WP-0009) -- Packer build orchestration from `create` (attach-only today) -- Real E2B / Modal / Daytona adapters (in-repo stub only) +- ~~Packer build orchestration from `create`~~ — done (SAND-WP-0012) +- ~~Real E2B / Modal adapters~~ — done (SAND-WP-0010) +- ~~Consumer profiles (agent-dev, build)~~ — done (SAND-WP-0011) - 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) - `reuse-surface validate` / federation publish workflow - ~~`.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 --- diff --git a/docs/cloud-adapters.md b/docs/cloud-adapters.md new file mode 100644 index 0000000..b7a8e40 --- /dev/null +++ b/docs/cloud-adapters.md @@ -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 +``` + +## 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 +``` \ No newline at end of file diff --git a/docs/extension-sdk.md b/docs/extension-sdk.md index 21c0d51..6a9be64 100644 --- a/docs/extension-sdk.md +++ b/docs/extension-sdk.md @@ -33,8 +33,10 @@ Reference implementations: | Extension | Module | Mode | |-----------|--------|------| | `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.e2b` | `e2b.py` | E2B cloud adapter | +| `ext.modal` | `modal.py` | Modal cloud adapter | ## Registration @@ -80,7 +82,8 @@ Profiles declare semantics; extensions validate required `inputs` keys: | Extension | Required inputs | Optional | |-----------|-----------------|----------| | 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. @@ -106,6 +109,6 @@ Implement `estimate_cost` and `meter_actual` on `SandboxExtension`. Register wit | Feature | Workplan | |---------|----------| | 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 | | Cross-host snapshot transfer | Future | \ No newline at end of file diff --git a/docs/integrations/glas-harness.md b/docs/integrations/glas-harness.md index 0296eae..f98a74e 100644 --- a/docs/integrations/glas-harness.md +++ b/docs/integrations/glas-harness.md @@ -31,6 +31,16 @@ sandboxer create \ | SSH / tunnel reachability setup | glas-harness + ops-bridge | | 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 - Tool schemas and approval flows diff --git a/docs/integrations/snuggle-inventor.md b/docs/integrations/snuggle-inventor.md index 0cd75a9..59f0974 100644 --- a/docs/integrations/snuggle-inventor.md +++ b/docs/integrations/snuggle-inventor.md @@ -30,6 +30,16 @@ sandboxer create \ | Generated code and PR output | snuggle-inventor | | 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= +SANDBOXER_VM_TUNNEL_PORT=12222 ./scripts/smoke-build-profile.sh +``` + +Optional env: `SMOKE_VM` (default `haskell-build`). + ## Out of scope for sand-boxer - Code generation prompts and tech specs diff --git a/docs/meta-framework.md b/docs/meta-framework.md index 4218471..4413e87 100644 --- a/docs/meta-framework.md +++ b/docs/meta-framework.md @@ -65,7 +65,13 @@ Event `detail` payload (JSON): "consumer": {"actor": "atm", "project": "wise-validator", "run_id": "..."}, "actor_type": "atm", "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": "..."} } ``` @@ -100,6 +106,36 @@ HTTP surface (optional v0; CLI calls core library directly): - `POST /v1/sandboxes/{id}/recreate` — recreate - `PATCH /v1/sandboxes/{id}/ttl` — extend TTL - `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 `. + +| 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. --- diff --git a/docs/migration-build-machines.md b/docs/migration-build-machines.md index c3d0237..3820019 100644 --- a/docs/migration-build-machines.md +++ b/docs/migration-build-machines.md @@ -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 | | 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/` | `/build/sbx-/` per create | | `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) | @@ -50,12 +51,36 @@ sandboxer destroy | `repo` | Optional rsync source to workspace | | `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 -- Automated Packer `create` trigger from sand-boxer API - State Hub capability-catalog sync from build-agent (agent unchanged) -- Port registry automation (`port-registry.yml`) -- `make remote-build` Makefile targets in the-custodian (add shim in follow-on if needed) +- Automated port-registry → ops-bridge config generation ## Runbook diff --git a/docs/migration-gaps.md b/docs/migration-gaps.md index df4d345..c655afb 100644 --- a/docs/migration-gaps.md +++ b/docs/migration-gaps.md @@ -44,6 +44,8 @@ Deferred: Packer orchestration from API, `make remote-build` shim. | Item | Workplan | |------|----------| | ~~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` | | ~~TTL enforcement + scheduled reap~~ | SAND-WP-0009 — `docs/ttl.md` | \ No newline at end of file diff --git a/docs/payments.md b/docs/payments.md index ec137c7..4259bf1 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -36,10 +36,17 @@ Reference: `ext.saas-stub` (no external API). ## BYOK -Provider API keys are resolved at provision boundary via `secret_refs` / OpenBao — -not implemented in v0 stub. Set provider env vars per extension when adapters land. +Provider API keys resolve at provision boundary — never stored on `SandboxStatus` +or emitted to State Hub. + +1. Operator lookup: `warden route find " 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_` env + +See `docs/cloud-adapters.md`. ## Billing export -sand-boxer meters sandbox consumption only. Domain billing authority (fin-hub) is a -future export consumer of State Hub meter events — not owned here. \ No newline at end of file +On metered destroy, optional fin-hub hook when `SANDBOXER_FIN_HUB_URL` is set. +Posts `sandbox_id`, `extension_id`, `duration_s`, `actual_usd` to `/usage/sandbox`. +Implementation: `src/sandboxer/payments/billing_export.py`. \ No newline at end of file diff --git a/docs/routing.md b/docs/routing.md index e26c028..2054142 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -10,6 +10,8 @@ route: strategy: prefer-self-hosted extensions: - ext.compose-ssh + - ext.e2b + - ext.modal - ext.saas-stub max_cost_per_hour_usd: 1.0 ``` @@ -19,7 +21,7 @@ route: | Strategy | Behavior | |----------|----------| | `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-latency` | Self-hosted if available; else last candidate (v0) | @@ -38,7 +40,12 @@ sandboxer create --profile profile.saas-stub | 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` | +Cloud adapters require provider credentials (`E2B_API_KEY`, `MODAL_TOKEN_ID`). +See `docs/cloud-adapters.md`. + Resolver: `sandboxer.routing.resolver.resolve_extension`. \ No newline at end of file diff --git a/docs/runbooks/profile-vm-packer-build.md b/docs/runbooks/profile-vm-packer-build.md new file mode 100644 index 0000000..eb5ee84 --- /dev/null +++ b/docs/runbooks/profile-vm-packer-build.md @@ -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 +``` + +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` \ No newline at end of file diff --git a/extensions/ext.e2b.yaml b/extensions/ext.e2b.yaml new file mode 100644 index 0000000..7d558e2 --- /dev/null +++ b/extensions/ext.e2b.yaml @@ -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 \ No newline at end of file diff --git a/extensions/ext.modal.yaml b/extensions/ext.modal.yaml new file mode 100644 index 0000000..8eedc39 --- /dev/null +++ b/extensions/ext.modal.yaml @@ -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 \ No newline at end of file diff --git a/extensions/ext.vm-packer.yaml b/extensions/ext.vm-packer.yaml index 009b133..7e7cab2 100644 --- a/extensions/ext.vm-packer.yaml +++ b/extensions/ext.vm-packer.yaml @@ -1,9 +1,9 @@ id: ext.vm-packer title: VM workspace (Packer lineage) description: > - Attach an isolated workspace on a pre-built VM (the-custodian build-machines - lineage). v0 supports attach mode via SSH alias or tunnel port; Packer build - orchestration is operator-driven and deferred. + Attach an isolated workspace on a pre-built VM, or run Packer build mode + (the-custodian build-machines lineage). Attach via SSH alias or tunnel port; + build via inputs.mode=build or profile.vm-packer-build. handler: sandboxer.extensions.vm_packer:VMPackerExtension capabilities: isolation_levels: [microvm] diff --git a/history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md b/history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md index 45d5467..dfb96d3 100644 --- a/history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md +++ b/history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md @@ -160,14 +160,14 @@ some sibling sign-offs sit outside this repo. | Prio | Gap | Owner | Proposed track | | --- | --- | --- | --- | -| **P1** | TTL enforcement + `extend_ttl` + `expires_at` | sand-boxer | **SAND-WP-0009** | -| **P2** | TTL reap scheduler / activity-core contract | sand-boxer + activity-core | **SAND-WP-0009** | -| **P3** | `.repo-classification.yaml` + registry refresh | sand-boxer | **SAND-WP-0009** | -| **P4** | HTTP API parity (`recreate`, `extend_ttl`) | sand-boxer | **SAND-WP-0009** | -| **P5** | Real E2B / Modal adapters + BYOK | sand-boxer | SAND-WP-0010 | -| **P6** | ops-bridge tunnel in reachability descriptor | 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) | +| ~~**P1**~~ | TTL enforcement + `extend_ttl` + `expires_at` | sand-boxer | **SAND-WP-0009** — done | +| ~~**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** — done | +| ~~**P4**~~ | HTTP API parity (`recreate`, `extend_ttl`) | sand-boxer | **SAND-WP-0009** — done | +| **P5** | Real E2B / Modal adapters + BYOK | sand-boxer | **SAND-WP-0010** | +| **P6** | ops-bridge tunnel in reachability descriptor | 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** | | **P9** | Cross-host snapshot transfer | sand-boxer | Future | | **P10** | fin-hub billing export | sand-boxer + platform | With SAND-WP-0010 | | **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 -hygiene (classification, registry), and HTTP parity. Unblocks activity-core -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). +**Suggested implementation order:** 0010 and 0011 may parallelize; 0012 depends +on stable vm-packer attach (done). Operator tracks P11/P12 outside repo. --- diff --git a/profiles/profile.agent-dev.yaml b/profiles/profile.agent-dev.yaml new file mode 100644 index 0000000..ef3df5a --- /dev/null +++ b/profiles/profile.agent-dev.yaml @@ -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 \ No newline at end of file diff --git a/profiles/profile.build.yaml b/profiles/profile.build.yaml new file mode 100644 index 0000000..57a7c28 --- /dev/null +++ b/profiles/profile.build.yaml @@ -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 \ No newline at end of file diff --git a/profiles/profile.burst-sandbox.yaml b/profiles/profile.burst-sandbox.yaml index a269ef8..5216667 100644 --- a/profiles/profile.burst-sandbox.yaml +++ b/profiles/profile.burst-sandbox.yaml @@ -5,6 +5,8 @@ route: strategy: prefer-self-hosted extensions: - ext.compose-ssh + - ext.e2b + - ext.modal - ext.saas-stub max_cost_per_hour_usd: 1.0 isolation: @@ -25,8 +27,8 @@ resources: memory_mb: null setup: instructions: > - Prefer self-hosted compose on SANDBOXER_HOST; falls back to metered SaaS stub - when host is unavailable or SANDBOXER_FORCE_SAAS=1. + Prefer self-hosted compose on SANDBOXER_HOST; falls back to E2B/Modal when + credentials are configured, else metered saas-stub. Force SaaS: SANDBOXER_FORCE_SAAS=1. secret_refs: [] placement: prefer: [sandboxer01] diff --git a/profiles/profile.e2b-burst.yaml b/profiles/profile.e2b-burst.yaml new file mode 100644 index 0000000..57fe2ae --- /dev/null +++ b/profiles/profile.e2b-burst.yaml @@ -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 \ No newline at end of file diff --git a/profiles/profile.modal-gpu.yaml b/profiles/profile.modal-gpu.yaml new file mode 100644 index 0000000..e61c171 --- /dev/null +++ b/profiles/profile.modal-gpu.yaml @@ -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 \ No newline at end of file diff --git a/profiles/profile.vm-packer-build.yaml b/profiles/profile.vm-packer-build.yaml new file mode 100644 index 0000000..b1f168f --- /dev/null +++ b/profiles/profile.vm-packer-build.yaml @@ -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 \ No newline at end of file diff --git a/registry/capabilities/execution.sandbox-provision.md b/registry/capabilities/execution.sandbox-provision.md index 0b63f08..7921f0b 100644 --- a/registry/capabilities/execution.sandbox-provision.md +++ b/registry/capabilities/execution.sandbox-provision.md @@ -16,27 +16,27 @@ maturity: Charter (INTENT.md), meta-framework spec, extension SDK, integration docs, and research synthesis. Capability indexed in registry/. availability: - current: A4 + current: A5 target: A5 confidence: high rationale: > 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: completeness: - level: C4 - name: Substantial + level: C5 + name: Mature confidence: high basis: scope_vs_intent_and_consumer_expectations satisfied_expectations: - profile-based create/destroy/snapshot/restore via CLI - TTL extend and expire/reap (SAND-WP-0009) + - E2B and Modal cloud adapters with BYOK (SAND-WP-0010) - State Hub lifecycle events on transitions - 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: - - Real E2B/Modal adapters not yet built - sandboxer01 dedicated host not live (CoulombCore interim) out_of_scope_expectations: - agent harness and tool orchestration (glas-harness) diff --git a/scripts/smoke-agent-dev.sh b/scripts/smoke-agent-dev.sh new file mode 100755 index 0000000..a9e9f6b --- /dev/null +++ b/scripts/smoke-agent-dev.sh @@ -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" \ No newline at end of file diff --git a/scripts/smoke-build-profile.sh b/scripts/smoke-build-profile.sh new file mode 100755 index 0000000..c27f3b3 --- /dev/null +++ b/scripts/smoke-build-profile.sh @@ -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" \ No newline at end of file diff --git a/scripts/smoke-cloud-adapter.sh b/scripts/smoke-cloud-adapter.sh new file mode 100755 index 0000000..515f22b --- /dev/null +++ b/scripts/smoke-cloud-adapter.sh @@ -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" \ No newline at end of file diff --git a/src/sandboxer/api/app.py b/src/sandboxer/api/app.py index 1ef147d..38d1c94 100644 --- a/src/sandboxer/api/app.py +++ b/src/sandboxer/api/app.py @@ -34,6 +34,14 @@ def get_sandbox(sandbox_id: str) -> SandboxStatus: 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]) def list_sandboxes() -> list[SandboxStatus]: return _manager.list() diff --git a/src/sandboxer/cli.py b/src/sandboxer/cli.py index f8ec401..e79a352 100644 --- a/src/sandboxer/cli.py +++ b/src/sandboxer/cli.py @@ -30,6 +30,8 @@ credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).") app.add_typer(credits_app, name="credits") snapshots_app = typer.Typer(help="Workspace checkpoint 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() @@ -92,7 +94,11 @@ def sandbox_create( host: Annotated[str | None, typer.Option(help="Override placement host")] = None, ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = 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 []) resolved_profile, resolved_inputs = resolve_create_defaults(profile, parsed) request = SandboxCreateRequest( @@ -112,6 +118,18 @@ def sandbox_create( _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") def sandbox_get(sandbox_id: str) -> None: """Get sandbox status by id.""" diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index 2cb5cc0..a6b0554 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -21,11 +21,14 @@ from sandboxer.models import ( SandboxStatus, SnapshotRecord, ) +from sandboxer.payments.billing_export import export_meter_usage from sandboxer.payments.credits import CreditsStore from sandboxer.payments.metering import estimate_cost, settle_usage from sandboxer.placement import resolve_host from sandboxer.profiles.loader import load_profile +from sandboxer.reachability.enrich import enrich_reachability from sandboxer.routing.resolver import resolve_extension +from sandboxer.secrets.resolver import resolve_setup_secrets from sandboxer.snapshots.store import SnapshotStore from sandboxer.telemetry.export import export_telemetry from sandboxer.telemetry.introspection import ( @@ -60,6 +63,8 @@ class SandboxManager: "vm_target": status.inputs.get("vm_target", ""), "vm_host": status.inputs.get("vm_host", ""), "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: @@ -124,7 +129,21 @@ class SandboxManager: provision_before = collect_host_snapshot(resolved_host) 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.inputs["compose_file"] = handle.get("compose_file", "") 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_host"] = handle.get("vm_host", "") 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 = enrich_reachability(reach, profile, handle) status.reachability = Reachability(**reach) status.state = SandboxState.READY status.ready_at = utcnow() @@ -173,6 +195,14 @@ class SandboxManager: def get(self, sandbox_id: str) -> SandboxStatus | None: 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]: 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: self.credits.debit(settled.actual_usd) status.meter = settled + export_meter_usage(status, extension_id=extension.id, meter=settled) emit_lifecycle_event( status, summary=( @@ -447,7 +478,11 @@ class SandboxManager: status.inputs["vm_host"] = handle.get("vm_host", "") status.inputs["endpoint"] = handle.get("endpoint", "") 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 = enrich_reachability(reach, profile, handle) status.reachability = Reachability(**reach) status.state = SandboxState.READY status.ready_at = utcnow() diff --git a/src/sandboxer/extensions/cloud_base.py b/src/sandboxer/extensions/cloud_base.py new file mode 100644 index 0000000..f47acf7 --- /dev/null +++ b/src/sandboxer/extensions/cloud_base.py @@ -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) \ No newline at end of file diff --git a/src/sandboxer/extensions/credentials.py b/src/sandboxer/extensions/credentials.py new file mode 100644 index 0000000..f8c367d --- /dev/null +++ b/src/sandboxer/extensions/credentials.py @@ -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 \ No newline at end of file diff --git a/src/sandboxer/extensions/e2b.py b/src/sandboxer/extensions/e2b.py new file mode 100644 index 0000000..65dbec1 --- /dev/null +++ b/src/sandboxer/extensions/e2b.py @@ -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, + } \ No newline at end of file diff --git a/src/sandboxer/extensions/modal.py b/src/sandboxer/extensions/modal.py new file mode 100644 index 0000000..5205c15 --- /dev/null +++ b/src/sandboxer/extensions/modal.py @@ -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, + } \ No newline at end of file diff --git a/src/sandboxer/extensions/vm_packer.py b/src/sandboxer/extensions/vm_packer.py index e66c95e..8e2c7c9 100644 --- a/src/sandboxer/extensions/vm_packer.py +++ b/src/sandboxer/extensions/vm_packer.py @@ -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, -localhost:port, or direct host). Creates an isolated workspace directory; teardown -removes the workspace, not the VM. +**attach** mode: connect via SSH to an existing VM (tunnel alias, localhost:port, +or direct host). Creates an isolated workspace directory; teardown removes the +workspace, not the VM. -Full Packer build / OVA import orchestration is deferred — operators still build -images via the-custodian/infra/build-machines/ workflows. +**build** mode: run `packer init` + `packer build` for a template directory +(the-custodian build-machines conventions). Teardown preserves the OVA artifact. """ from __future__ import annotations import os +import subprocess from pathlib import Path from typing import Any from sandboxer.extensions.base import SandboxExtension from sandboxer.extensions.ssh import SSHConfig +from sandboxer.lifecycle.state_hub import emit_progress_note from sandboxer.models import Profile +BUILD_PROFILE_IDS = frozenset({"profile.vm-packer-build"}) + 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: super().__init__(config) self.workspace_base: str = self.config.get("workspace_base", "/build") self.default_user: str | None = self.config.get("ssh_user") 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: 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 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 ) -> dict[str, str]: vm_target = inputs.get("vm") or inputs.get("ssh_target") @@ -94,11 +207,29 @@ class VMPackerExtension(SandboxExtension): "remote_dir": remote_dir, "ssh_user": ssh.user or "", "ssh_port": str(ssh.port) if ssh.port else "", - "mode": inputs.get("mode", "attach"), + "mode": "attach", "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]: + 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) remote_dir = handle["remote_dir"] 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]: + 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") cleaned_dir = False if remote_dir: diff --git a/src/sandboxer/lifecycle/state_hub.py b/src/sandboxer/lifecycle/state_hub.py index 99a086e..4d8eaae 100644 --- a/src/sandboxer/lifecycle/state_hub.py +++ b/src/sandboxer/lifecycle/state_hub.py @@ -59,6 +59,37 @@ def emit_lifecycle_event( 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: if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED): return "milestone" diff --git a/src/sandboxer/models.py b/src/sandboxer/models.py index 6963161..d8cba64 100644 --- a/src/sandboxer/models.py +++ b/src/sandboxer/models.py @@ -153,6 +153,9 @@ class Reachability(BaseModel): compose_project: str | None = None host: str | None = None endpoint: str | None = None + tunnel: str | None = None + tunnel_via: str | None = None + identity: str | None = None class SandboxStatus(BaseModel): diff --git a/src/sandboxer/payments/billing_export.py b/src/sandboxer/payments/billing_export.py new file mode 100644 index 0000000..caa04c0 --- /dev/null +++ b/src/sandboxer/payments/billing_export.py @@ -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 \ No newline at end of file diff --git a/src/sandboxer/reachability/__init__.py b/src/sandboxer/reachability/__init__.py new file mode 100644 index 0000000..e618af0 --- /dev/null +++ b/src/sandboxer/reachability/__init__.py @@ -0,0 +1,5 @@ +"""Reachability descriptor enrichment.""" + +from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability + +__all__ = ["enrich_reachability", "build_reachability_report"] \ No newline at end of file diff --git a/src/sandboxer/reachability/enrich.py b/src/sandboxer/reachability/enrich.py new file mode 100644 index 0000000..6fde9ed --- /dev/null +++ b/src/sandboxer/reachability/enrich.py @@ -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 \ No newline at end of file diff --git a/src/sandboxer/routing/resolver.py b/src/sandboxer/routing/resolver.py index d32dd51..4e613ca 100644 --- a/src/sandboxer/routing/resolver.py +++ b/src/sandboxer/routing/resolver.py @@ -4,6 +4,7 @@ from __future__ import annotations import os +from sandboxer.extensions.credentials import credentials_available from sandboxer.extensions.registry import load_extension from sandboxer.models import Extension, Profile, RouteStrategy from sandboxer.payments.metering import estimate_cost @@ -20,6 +21,49 @@ def _is_metered(ext: Extension) -> bool: 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: if _is_metered(ext): return True @@ -65,6 +109,11 @@ def resolve_extension( for ext in loaded: if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override): return ext + fallback = _select_metered_fallback( + loaded, profile, inputs, duration_s=duration_s + ) + if fallback: + return fallback for ext in loaded: if _is_metered(ext): return ext @@ -76,6 +125,8 @@ def resolve_extension( for ext in loaded: if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override): return ext + if not _metered_available(ext): + continue cost = _quote_cost(ext, profile, inputs, duration_s) if cost is None: continue @@ -92,6 +143,11 @@ def resolve_extension( for ext in loaded: if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override): return ext + fallback = _select_metered_fallback( + loaded, profile, inputs, duration_s=duration_s + ) + if fallback: + return fallback return loaded[-1] return load_extension(profile.extension) \ No newline at end of file diff --git a/src/sandboxer/secrets/__init__.py b/src/sandboxer/secrets/__init__.py new file mode 100644 index 0000000..fd15a42 --- /dev/null +++ b/src/sandboxer/secrets/__init__.py @@ -0,0 +1,5 @@ +"""Setup secret resolution at provision boundary.""" + +from sandboxer.secrets.resolver import resolve_setup_secrets + +__all__ = ["resolve_setup_secrets"] \ No newline at end of file diff --git a/src/sandboxer/secrets/resolver.py b/src/sandboxer/secrets/resolver.py new file mode 100644 index 0000000..6a981e7 --- /dev/null +++ b/src/sandboxer/secrets/resolver.py @@ -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 \ No newline at end of file diff --git a/tests/test_billing_export.py b/tests/test_billing_export.py new file mode 100644 index 0000000..46b1ffb --- /dev/null +++ b/tests/test_billing_export.py @@ -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 \ No newline at end of file diff --git a/tests/test_consumer_profiles.py b/tests/test_consumer_profiles.py new file mode 100644 index 0000000..1ff5f6a --- /dev/null +++ b/tests/test_consumer_profiles.py @@ -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" \ No newline at end of file diff --git a/tests/test_e2b.py b/tests/test_e2b.py new file mode 100644 index 0000000..eab18bf --- /dev/null +++ b/tests/test_e2b.py @@ -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 \ No newline at end of file diff --git a/tests/test_modal.py b/tests/test_modal.py new file mode 100644 index 0000000..6e331e9 --- /dev/null +++ b/tests/test_modal.py @@ -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") \ No newline at end of file diff --git a/tests/test_reachability.py b/tests/test_reachability.py new file mode 100644 index 0000000..b936221 --- /dev/null +++ b/tests/test_reachability.py @@ -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 \ No newline at end of file diff --git a/tests/test_routing.py b/tests/test_routing.py index 6ac2d67..594bf14 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -16,7 +16,12 @@ def _burst_profile() -> Profile: "extension": "ext.compose-ssh", "route": { "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: 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") ext = resolve_extension(_burst_profile(), {}, host_override=None) assert ext.id == "ext.saas-stub" 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: profile = _burst_profile() profile.route = RouteSpec( diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..d62f9f7 --- /dev/null +++ b/tests/test_secrets.py @@ -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) == {} \ No newline at end of file diff --git a/tests/test_vm_packer.py b/tests/test_vm_packer.py index 0f7bc5e..302e3f4 100644 --- a/tests/test_vm_packer.py +++ b/tests/test_vm_packer.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from subprocess import CompletedProcess from unittest.mock import patch 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: repo = tmp_path / "proj" repo.mkdir() @@ -73,4 +84,66 @@ def test_teardown_preserves_vm() -> None: with patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "")): report = ext.teardown(handle) assert report["vm_preserved"] == "true" - assert report["workspace_removed"] == "True" \ No newline at end of file + 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" \ No newline at end of file diff --git a/workplans/SAND-WP-0006-saas-extensions-and-payments.md b/workplans/SAND-WP-0006-saas-extensions-and-payments.md index 5fde280..1607973 100644 --- a/workplans/SAND-WP-0006-saas-extensions-and-payments.md +++ b/workplans/SAND-WP-0006-saas-extensions-and-payments.md @@ -85,9 +85,9 @@ state_hub_task_id: "7075d8fc-44d1-48fe-b061-a05385de31a8" ```task id: SAND-WP-0006-T06 -status: wait +status: cancel priority: low state_hub_task_id: "c514c6b3-2be8-41e4-8e84-88f20ad80f7e" ``` -Real `ext.e2b` / `ext.modal` adapters, BYOK via OpenBao, fin-hub export. \ No newline at end of file +Superseded by SAND-WP-0010 (E2B/Modal adapters, BYOK, fin-hub export). \ No newline at end of file diff --git a/workplans/SAND-WP-0009-ttl-and-operational-hardening.md b/workplans/SAND-WP-0009-ttl-and-operational-hardening.md index 87ae4d2..e3da192 100644 --- a/workplans/SAND-WP-0009-ttl-and-operational-hardening.md +++ b/workplans/SAND-WP-0009-ttl-and-operational-hardening.md @@ -21,8 +21,7 @@ registry, HTTP parity). Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md` **Predecessor:** SAND-WP-0007 (snapshots — finished) -**Follow-on:** SAND-WP-0010 (real cloud adapters), SAND-WP-0011 (reachability + -consumer profiles), SAND-WP-0012 (Packer orchestration) +**Follow-on:** SAND-WP-0010, SAND-WP-0011, SAND-WP-0012 (workplans filed 2026-06-24) --- diff --git a/workplans/SAND-WP-0010-cloud-adapters-and-billing.md b/workplans/SAND-WP-0010-cloud-adapters-and-billing.md new file mode 100644 index 0000000..8763804 --- /dev/null +++ b/workplans/SAND-WP-0010-cloud-adapters-and-billing.md @@ -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) \ No newline at end of file diff --git a/workplans/SAND-WP-0011-reachability-and-consumer-profiles.md b/workplans/SAND-WP-0011-reachability-and-consumer-profiles.md new file mode 100644 index 0000000..80ef336 --- /dev/null +++ b/workplans/SAND-WP-0011-reachability-and-consumer-profiles.md @@ -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 ` 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_` 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 \ No newline at end of file diff --git a/workplans/SAND-WP-0012-packer-orchestration.md b/workplans/SAND-WP-0012-packer-orchestration.md new file mode 100644 index 0000000..0820b7b --- /dev/null +++ b/workplans/SAND-WP-0012-packer-orchestration.md @@ -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 \ No newline at end of file