Implement SAND-WP-0006: SaaS payments, routing, and ext.saas-stub

Add credits store, metering on create/destroy, extension routing resolver,
metered SaaS stub extension, burst/saas profiles, credits CLI, docs, and tests.
This commit is contained in:
2026-06-24 07:52:20 +02:00
parent eee336149e
commit 1415e17230
29 changed files with 878 additions and 18 deletions

View File

@@ -92,11 +92,16 @@ with patch.object(SSHConfig, "run", return_value=(0, "ready")):
handle = ext.provision(profile, {"vm": "haskell-build"}, "localhost")
```
## Metered extensions (SAND-WP-0006)
Implement `estimate_cost` and `meter_actual` on `SandboxExtension`. Register with
`pricing_model: metered`. See `docs/payments.md` and `ext.saas-stub`.
## Deferred
| Feature | Workplan |
|---------|----------|
| Packer build orchestration from `create` | Future WP |
| SaaS adapters + `estimate_cost` | SAND-WP-0006 |
| Multi-backend routing engine | SAND-WP-0006 |
| E2B / Modal / Daytona cloud adapters | Post SAND-WP-0006 |
| fin-hub billing export | Future |
| Snapshot / restore hooks | SAND-WP-0007 |

View File

@@ -153,7 +153,7 @@ When multiple extensions satisfy a profile capability:
| `lowest-latency` | Closest region / host wins |
| `explicit` | Profile names a single `extension`; no auto-routing |
v0 resolves `profile.extension` directly — routing engine deferred to SAND-WP-0006.
Routing engine v0: `sandboxer.routing.resolver` — see `docs/routing.md`.
---

View File

@@ -43,6 +43,7 @@ Deferred: Packer orchestration from API, `make remote-build` shim.
| Item | Workplan |
|------|----------|
| SaaS extensions + payments | SAND-WP-0006 |
| ~~SaaS extensions + payments v0~~ | SAND-WP-0006 — stub + routing + credits |
| E2B / Modal real adapters | Post SAND-WP-0006 |
| Snapshot / restore | SAND-WP-0007 |
| TTL enforcement + scheduled reap | TBD |

45
docs/payments.md Normal file
View File

@@ -0,0 +1,45 @@
# Payments and metering
Version 0.1 — SAND-WP-0006. OpenRouter-style credits for metered SaaS extensions.
## Credits store
Path: `~/.local/share/sandboxer/credits.json` (override via test injection).
| Env | Default |
|-----|---------|
| `SANDBOXER_DEFAULT_CREDITS` | `10.0` USD on first use |
```bash
sandboxer credits show
sandboxer credits add 25.00
```
## Metered lifecycle
1. **create**`estimate_cost` on metered extension; block if balance insufficient
2. **ready**`SandboxStatus.meter.estimate_usd` recorded
3. **destroy**`meter_actual` (or prorated estimate); debit credits; State Hub note event
Self-hosted extensions (`pricing_model: self-hosted`) skip credits.
## Extension contract
Metered extensions implement on `SandboxExtension`:
```python
def estimate_cost(self, profile, inputs, *, duration_s=3600) -> MeterQuote | None
def meter_actual(self, handle, *, duration_s: float) -> float | None
```
Reference: `ext.saas-stub` (no external API).
## BYOK
Provider API keys are resolved at provision boundary via `secret_refs` / OpenBao —
not implemented in v0 stub. Set provider env vars per extension when adapters land.
## Billing export
sand-boxer meters sandbox consumption only. Domain billing authority (fin-hub) is a
future export consumer of State Hub meter events — not owned here.

44
docs/routing.md Normal file
View File

@@ -0,0 +1,44 @@
# Extension routing
Version 0.1 — SAND-WP-0006. Select backend when a profile lists multiple extensions.
## Profile route block
```yaml
extension: ext.compose-ssh # default / explicit fallback
route:
strategy: prefer-self-hosted
extensions:
- ext.compose-ssh
- ext.saas-stub
max_cost_per_hour_usd: 1.0
```
## Strategies
| Strategy | Behavior |
|----------|----------|
| `explicit` | Use `profile.extension` (default when no route) |
| `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) |
## Testing SaaS fallback
```bash
# Skip self-hosted when host unavailable:
unset SANDBOXER_HOST
SANDBOXER_FORCE_SAAS=1 sandboxer create --profile profile.burst-sandbox
# Or use metered-only profile:
sandboxer create --profile profile.saas-stub
```
## Reference profiles
| Profile | Route |
|---------|-------|
| `profile.burst-sandbox` | compose-ssh → saas-stub fallback |
| `profile.saas-stub` | explicit `ext.saas-stub` |
Resolver: `sandboxer.routing.resolver.resolve_extension`.