diff --git a/.custodian-brief.md b/.custodian-brief.md index 1964705..b0f42c1 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:57 UTC +**Last synced:** 2026-06-24 10:44 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 deleted file mode 100644 index bd44c56..0000000 --- a/.forgejo/workflows/ci-smoke.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# 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 18c2385..725184a 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–0012 (0003/0004 in sibling repos) -- **Workplans ready:** none (reuse-surface publish / sandboxer01 operator track) +- **Workplans finished:** SAND-WP-0001–0008 (all workplans in `workplans/`; + 0003/0004 delivered in sibling repos wise-validator / the-custodian) - **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments, snapshots, telemetry, HTTP API -- **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` +- **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) - **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`, `payments`, `snapshots`, `migration-gaps`, `migration-build-machines` - **Registry:** `capability.execution.sandbox-provision` indexed (draft) -- **Tests:** 90 pytest cases; `make check` green +- **Tests:** 69 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` -Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md` -**Ready workplans:** none — gap analysis items complete; operator tracks remain. +Latest workplan: **SAND-WP-0009** (TTL enforcement — finished). --- @@ -154,10 +154,6 @@ 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): @@ -176,15 +172,14 @@ 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`~~ — done (SAND-WP-0012) -- ~~Real E2B / Modal adapters~~ — done (SAND-WP-0010) -- ~~Consumer profiles (agent-dev, build)~~ — done (SAND-WP-0011) +- Packer build orchestration from `create` (attach-only today) +- Real E2B / Modal / Daytona adapters (in-repo stub only) - Cross-host snapshot transfer -- ~~Formal ops-bridge tunnel attachment~~ — done (SAND-WP-0011; descriptor only) +- Formal ops-bridge tunnel attachment in reachability descriptor - Dedicated sandboxer01 host (CoulombCore interim only today) - `reuse-surface validate` / federation publish workflow - ~~`.repo-classification.yaml`~~ — done (SAND-WP-0009) -- ~~fin-hub billing export~~ — hook done (SAND-WP-0010); railiance-platform wiring operator +- fin-hub billing export for metered usage --- diff --git a/docs/cloud-adapters.md b/docs/cloud-adapters.md deleted file mode 100644 index b7a8e40..0000000 --- a/docs/cloud-adapters.md +++ /dev/null @@ -1,54 +0,0 @@ -# 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 6a9be64..21c0d51 100644 --- a/docs/extension-sdk.md +++ b/docs/extension-sdk.md @@ -33,10 +33,8 @@ Reference implementations: | Extension | Module | Mode | |-----------|--------|------| | `ext.compose-ssh` | `compose_ssh.py` | Remote compose stack + tar snapshots | -| `ext.vm-packer` | `vm_packer.py` | Attach workspace or Packer build mode | +| `ext.vm-packer` | `vm_packer.py` | Attach workspace on pre-built VM | | `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 @@ -82,8 +80,7 @@ Profiles declare semantics; extensions validate required `inputs` keys: | Extension | Required inputs | Optional | |-----------|-----------------|----------| | compose-ssh | `repo` | `sandbox_id` | -| 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_*` | +| vm-packer | `vm` or `ssh_target` | `repo`, `tunnel_port`, `ssh_port`, `workspace_dir` | Consumer attribution travels on `SandboxCreateRequest.consumer`, not extension inputs. @@ -109,6 +106,6 @@ Implement `estimate_cost` and `meter_actual` on `SandboxExtension`. Register wit | Feature | Workplan | |---------|----------| | Packer build orchestration from `create` | Future WP | -| Daytona OSS cloud adapter | Future WP | +| E2B / Modal / Daytona cloud adapters | Post SAND-WP-0006 | | 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 f98a74e..0296eae 100644 --- a/docs/integrations/glas-harness.md +++ b/docs/integrations/glas-harness.md @@ -31,16 +31,6 @@ 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 59f0974..0cd75a9 100644 --- a/docs/integrations/snuggle-inventor.md +++ b/docs/integrations/snuggle-inventor.md @@ -30,16 +30,6 @@ 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 4413e87..4218471 100644 --- a/docs/meta-framework.md +++ b/docs/meta-framework.md @@ -65,13 +65,7 @@ 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", - "tunnel": "localhost:12222", - "tunnel_via": "ops-bridge", - "identity": "ops-warden" - }, + "reachability": {"ssh": "root@coulombcore", "remote_dir": "/tmp/sandboxer/abc12345"}, "timestamps": {"created_at": "...", "ready_at": "..."} } ``` @@ -106,36 +100,6 @@ 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 3820019..c3d0237 100644 --- a/docs/migration-build-machines.md +++ b/docs/migration-build-machines.md @@ -8,8 +8,7 @@ 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 --profile profile.vm-haskell-build` + SSH build (shim in build-machines Makefile) | -| `packer build` in `haskell/` | `sandboxer create --profile profile.vm-packer-build` | +| `make remote-build PROJECT=` | `sandboxer create` + SSH into `reachability.remote_dir` | | Isolated workspace `/build/` | `/build/sbx-/` per create | | `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) | @@ -51,36 +50,12 @@ 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) -- Automated port-registry → ops-bridge config generation +- Port registry automation (`port-registry.yml`) +- `make remote-build` Makefile targets in the-custodian (add shim in follow-on if needed) ## Runbook diff --git a/docs/migration-gaps.md b/docs/migration-gaps.md index c655afb..df4d345 100644 --- a/docs/migration-gaps.md +++ b/docs/migration-gaps.md @@ -44,8 +44,6 @@ 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 + fin-hub~~ | SAND-WP-0010 — `docs/cloud-adapters.md` | -| Consumer profiles + reachability | **SAND-WP-0011** | -| Packer orchestration + remote-build shim | **SAND-WP-0012** | +| E2B / Modal real adapters | Post SAND-WP-0006 | | ~~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 4259bf1..ec137c7 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -36,17 +36,10 @@ Reference: `ext.saas-stub` (no external API). ## BYOK -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`. +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. ## Billing export -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 +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 diff --git a/docs/routing.md b/docs/routing.md index 2054142..e26c028 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -10,8 +10,6 @@ route: strategy: prefer-self-hosted extensions: - ext.compose-ssh - - ext.e2b - - ext.modal - ext.saas-stub max_cost_per_hour_usd: 1.0 ``` @@ -21,7 +19,7 @@ route: | Strategy | Behavior | |----------|----------| | `explicit` | Use `profile.extension` (default when no route) | -| `prefer-self-hosted` | Self-hosted if host available; else credentialed E2B/Modal; else stub | +| `prefer-self-hosted` | First self-hosted candidate with resolvable host; else SaaS | | `lowest-cost` | Self-hosted if available; else cheapest `estimate_cost` | | `lowest-latency` | Self-hosted if available; else last candidate (v0) | @@ -40,12 +38,7 @@ sandboxer create --profile profile.saas-stub | Profile | Route | |---------|-------| -| `profile.burst-sandbox` | compose-ssh → e2b → modal → saas-stub | -| `profile.e2b-burst` | explicit `ext.e2b` | -| `profile.modal-gpu` | explicit `ext.modal` | +| `profile.burst-sandbox` | compose-ssh → saas-stub fallback | | `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 deleted file mode 100644 index eb5ee84..0000000 --- a/docs/runbooks/profile-vm-packer-build.md +++ /dev/null @@ -1,54 +0,0 @@ -# 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 deleted file mode 100644 index 7d558e2..0000000 --- a/extensions/ext.e2b.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 8eedc39..0000000 --- a/extensions/ext.modal.yaml +++ /dev/null @@ -1,19 +0,0 @@ -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 7e7cab2..009b133 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, 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. + 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. 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 dfb96d3..45d5467 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** — 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** | +| **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) | | **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,17 +175,16 @@ some sibling sign-offs sit outside this repo. --- -## 9. Recommended workplans (2026-06-24) +## 9. Recommended next workplan -| 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) | +**SAND-WP-0009 — TTL enforcement and operational hardening** -**Suggested implementation order:** 0010 and 0011 may parallelize; 0012 depends -on stable vm-packer attach (done). Operator tracks P11/P12 outside repo. +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). --- diff --git a/profiles/profile.agent-dev.yaml b/profiles/profile.agent-dev.yaml deleted file mode 100644 index ef3df5a..0000000 --- a/profiles/profile.agent-dev.yaml +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 57a7c28..0000000 --- a/profiles/profile.build.yaml +++ /dev/null @@ -1,35 +0,0 @@ -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 5216667..a269ef8 100644 --- a/profiles/profile.burst-sandbox.yaml +++ b/profiles/profile.burst-sandbox.yaml @@ -5,8 +5,6 @@ route: strategy: prefer-self-hosted extensions: - ext.compose-ssh - - ext.e2b - - ext.modal - ext.saas-stub max_cost_per_hour_usd: 1.0 isolation: @@ -27,8 +25,8 @@ resources: memory_mb: null setup: instructions: > - 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. + Prefer self-hosted compose on SANDBOXER_HOST; falls back to metered SaaS stub + when host is unavailable or SANDBOXER_FORCE_SAAS=1. secret_refs: [] placement: prefer: [sandboxer01] diff --git a/profiles/profile.e2b-burst.yaml b/profiles/profile.e2b-burst.yaml deleted file mode 100644 index 57fe2ae..0000000 --- a/profiles/profile.e2b-burst.yaml +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index e61c171..0000000 --- a/profiles/profile.modal-gpu.yaml +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index b1f168f..0000000 --- a/profiles/profile.vm-packer-build.yaml +++ /dev/null @@ -1,37 +0,0 @@ -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 7921f0b..0b63f08 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: A5 + current: A4 target: A5 confidence: high rationale: > CLI v0 (create/destroy/snapshot/TTL), HTTP API, CoulombCore remote smoke. - Cloud adapters E2B/Modal + routing + credits (SAND-WP-0010). + SaaS stub + routing + credits shipped (SAND-WP-0006). external_evidence: completeness: - level: C5 - name: Mature + level: C4 + name: Substantial 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, e2b, modal + - extension SDK with compose-ssh, vm-packer attach, saas-stub 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 deleted file mode 100755 index a9e9f6b..0000000 --- a/scripts/smoke-agent-dev.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/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 deleted file mode 100755 index c27f3b3..0000000 --- a/scripts/smoke-build-profile.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/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 deleted file mode 100755 index 515f22b..0000000 --- a/scripts/smoke-cloud-adapter.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/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 38d1c94..1ef147d 100644 --- a/src/sandboxer/api/app.py +++ b/src/sandboxer/api/app.py @@ -34,14 +34,6 @@ 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 e79a352..f8ec401 100644 --- a/src/sandboxer/cli.py +++ b/src/sandboxer/cli.py @@ -30,8 +30,6 @@ 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() @@ -94,11 +92,7 @@ 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. - - vm-packer modes: attach (profile.vm-haskell-build) or build - (profile.vm-packer-build / --input mode=build with packer_template, vm_name). - """ + """Provision a sandbox. No args → canary self-deploy of sand-boxer.""" parsed = _parse_inputs(input or []) resolved_profile, resolved_inputs = resolve_create_defaults(profile, parsed) request = SandboxCreateRequest( @@ -118,18 +112,6 @@ 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 a6b0554..2cb5cc0 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -21,14 +21,11 @@ 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 ( @@ -63,8 +60,6 @@ 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: @@ -129,21 +124,7 @@ class SandboxManager: provision_before = collect_host_snapshot(resolved_host) try: - 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 + handle = backend.provision(profile, request.inputs, resolved_host) status.sandbox_id = handle["sandbox_id"] status.inputs["compose_file"] = handle.get("compose_file", "") status.inputs["ssh_user"] = handle.get("ssh_user", "") @@ -152,10 +133,7 @@ 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() @@ -195,14 +173,6 @@ 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) @@ -239,7 +209,6 @@ 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=( @@ -478,11 +447,7 @@ 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 deleted file mode 100644 index f47acf7..0000000 --- a/src/sandboxer/extensions/cloud_base.py +++ /dev/null @@ -1,83 +0,0 @@ -"""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 deleted file mode 100644 index f8c367d..0000000 --- a/src/sandboxer/extensions/credentials.py +++ /dev/null @@ -1,48 +0,0 @@ -"""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 deleted file mode 100644 index 65dbec1..0000000 --- a/src/sandboxer/extensions/e2b.py +++ /dev/null @@ -1,77 +0,0 @@ -"""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 deleted file mode 100644 index 5205c15..0000000 --- a/src/sandboxer/extensions/modal.py +++ /dev/null @@ -1,83 +0,0 @@ -"""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 8e2c7c9..e66c95e 100644 --- a/src/sandboxer/extensions/vm_packer.py +++ b/src/sandboxer/extensions/vm_packer.py @@ -1,46 +1,32 @@ -"""ext.vm-packer — attach to pre-built VMs or trigger Packer builds. +"""ext.vm-packer — attach to pre-built VMs (build-machines lineage). -**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. +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. -**build** mode: run `packer init` + `packer build` for a template directory -(the-custodian build-machines conventions). Teardown preserves the OVA artifact. +Full Packer build / OVA import orchestration is deferred — operators still build +images via the-custodian/infra/build-machines/ workflows. """ 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, or run Packer build.""" + """Attach sandbox workspace on a pre-provisioned VM.""" 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") @@ -67,106 +53,7 @@ class VMPackerExtension(SandboxExtension): return "localhost", int(env_port) return inputs.get("vm_host") or placement_host, None - 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( + def provision( self, profile: Profile, inputs: dict[str, str], host: str ) -> dict[str, str]: vm_target = inputs.get("vm") or inputs.get("ssh_target") @@ -207,29 +94,11 @@ class VMPackerExtension(SandboxExtension): "remote_dir": remote_dir, "ssh_user": ssh.user or "", "ssh_port": str(ssh.port) if ssh.port else "", - "mode": "attach", + "mode": inputs.get("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" @@ -243,15 +112,6 @@ 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 4d8eaae..99a086e 100644 --- a/src/sandboxer/lifecycle/state_hub.py +++ b/src/sandboxer/lifecycle/state_hub.py @@ -59,37 +59,6 @@ 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 d8cba64..6963161 100644 --- a/src/sandboxer/models.py +++ b/src/sandboxer/models.py @@ -153,9 +153,6 @@ 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 deleted file mode 100644 index caa04c0..0000000 --- a/src/sandboxer/payments/billing_export.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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 deleted file mode 100644 index e618af0..0000000 --- a/src/sandboxer/reachability/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""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 deleted file mode 100644 index 6fde9ed..0000000 --- a/src/sandboxer/reachability/enrich.py +++ /dev/null @@ -1,68 +0,0 @@ -"""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 4e613ca..d32dd51 100644 --- a/src/sandboxer/routing/resolver.py +++ b/src/sandboxer/routing/resolver.py @@ -4,7 +4,6 @@ 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 @@ -21,49 +20,6 @@ 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 @@ -109,11 +65,6 @@ 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 @@ -125,8 +76,6 @@ 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 @@ -143,11 +92,6 @@ 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 deleted file mode 100644 index fd15a42..0000000 --- a/src/sandboxer/secrets/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""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 deleted file mode 100644 index 6a981e7..0000000 --- a/src/sandboxer/secrets/resolver.py +++ /dev/null @@ -1,41 +0,0 @@ -"""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 deleted file mode 100644 index 46b1ffb..0000000 --- a/tests/test_billing_export.py +++ /dev/null @@ -1,50 +0,0 @@ -"""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 deleted file mode 100644 index 1ff5f6a..0000000 --- a/tests/test_consumer_profiles.py +++ /dev/null @@ -1,20 +0,0 @@ -"""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 deleted file mode 100644 index eab18bf..0000000 --- a/tests/test_e2b.py +++ /dev/null @@ -1,72 +0,0 @@ -"""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 deleted file mode 100644 index 6e331e9..0000000 --- a/tests/test_modal.py +++ /dev/null @@ -1,63 +0,0 @@ -"""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 deleted file mode 100644 index b936221..0000000 --- a/tests/test_reachability.py +++ /dev/null @@ -1,78 +0,0 @@ -"""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 594bf14..6ac2d67 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -16,12 +16,7 @@ def _burst_profile() -> Profile: "extension": "ext.compose-ssh", "route": { "strategy": "prefer-self-hosted", - "extensions": [ - "ext.compose-ssh", - "ext.e2b", - "ext.modal", - "ext.saas-stub", - ], + "extensions": ["ext.compose-ssh", "ext.saas-stub"], }, } ) @@ -47,23 +42,12 @@ 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 deleted file mode 100644 index d62f9f7..0000000 --- a/tests/test_secrets.py +++ /dev/null @@ -1,51 +0,0 @@ -"""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 302e3f4..0f7bc5e 100644 --- a/tests/test_vm_packer.py +++ b/tests/test_vm_packer.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -from subprocess import CompletedProcess from unittest.mock import patch import pytest @@ -22,16 +21,6 @@ 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() @@ -84,66 +73,4 @@ 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" - - -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 + assert report["workspace_removed"] == "True" \ 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 1607973..5fde280 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: cancel +status: wait priority: low state_hub_task_id: "c514c6b3-2be8-41e4-8e84-88f20ad80f7e" ``` -Superseded by SAND-WP-0010 (E2B/Modal adapters, BYOK, fin-hub export). \ No newline at end of file +Real `ext.e2b` / `ext.modal` adapters, BYOK via OpenBao, 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 e3da192..87ae4d2 100644 --- a/workplans/SAND-WP-0009-ttl-and-operational-hardening.md +++ b/workplans/SAND-WP-0009-ttl-and-operational-hardening.md @@ -21,7 +21,8 @@ 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, SAND-WP-0011, SAND-WP-0012 (workplans filed 2026-06-24) +**Follow-on:** SAND-WP-0010 (real cloud adapters), SAND-WP-0011 (reachability + +consumer profiles), SAND-WP-0012 (Packer orchestration) --- diff --git a/workplans/SAND-WP-0010-cloud-adapters-and-billing.md b/workplans/SAND-WP-0010-cloud-adapters-and-billing.md deleted file mode 100644 index 8763804..0000000 --- a/workplans/SAND-WP-0010-cloud-adapters-and-billing.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -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 deleted file mode 100644 index 80ef336..0000000 --- a/workplans/SAND-WP-0011-reachability-and-consumer-profiles.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -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 deleted file mode 100644 index 0820b7b..0000000 --- a/workplans/SAND-WP-0012-packer-orchestration.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -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