generated from coulomb/repo-seed
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:
@@ -39,6 +39,8 @@ Sandbox CLI (v0):
|
||||
sandboxer create # canary self-deploy (profile.sandbox-canary)
|
||||
sandboxer create --profile profile.compose-e2e --input repo=/path/to/repo
|
||||
sandboxer create --profile profile.vm-haskell-build --input vm=haskell-build --input repo=/path
|
||||
sandboxer create --profile profile.saas-stub # metered SaaS stub
|
||||
sandboxer credits show / credits add 10.00
|
||||
sandboxer get <id>
|
||||
sandboxer list
|
||||
sandboxer destroy <id>
|
||||
|
||||
@@ -357,6 +357,7 @@ follows evidence.
|
||||
5. ~~**Registry entry**~~ — `capability.execution.sandbox-provision`
|
||||
6. ~~**Sibling integration notes**~~ — `docs/integrations/`
|
||||
7. ~~**Extension SDK sketch**~~ — done (`docs/extension-sdk.md`, `ext.vm-packer` attach mode)
|
||||
9. ~~**SaaS extensions + payments v0**~~ — done (`ext.saas-stub`, routing, credits — SAND-WP-0006)
|
||||
8. ~~**wise-validator**~~ — sibling repo (SAND-WP-0003); not a sand-boxer dependency
|
||||
|
||||
---
|
||||
|
||||
5
SCOPE.md
5
SCOPE.md
@@ -107,7 +107,8 @@ own tunnels or CAs.
|
||||
- **Workplans finished:** SAND-WP-0001–0005, 0008 (see `workplans/`)
|
||||
- **Package:** `src/sandboxer/` — CLI, manager, extensions, telemetry, HTTP API
|
||||
- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary`, `profile.vm-haskell-build`
|
||||
- **Extensions:** `ext.compose-ssh`, `ext.vm-packer` (attach mode)
|
||||
- **Extensions:** `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub` (metered)
|
||||
- **Routing + payments:** `docs/routing.md`, `docs/payments.md`, `sandboxer credits`
|
||||
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
||||
- **Tests:** 26 pytest cases; `make check` green
|
||||
- **Sibling:** wise-validator ships `validate run` (SAND-WP-0003)
|
||||
@@ -144,7 +145,7 @@ cd ~/the-custodian && make e2e REPO=activity-core
|
||||
- ~~`make e2e REPO=` shim~~ — done (SAND-WP-0004; delegates to `validate run`)
|
||||
- TTL auto-expiry / `extend_ttl` enforcement
|
||||
- ~~`ext.vm-packer` attach mode~~ — done (SAND-WP-0005); Packer build orchestration deferred
|
||||
- SaaS extensions (E2B, Modal) or payments layer (SAND-WP-0006)
|
||||
- Real E2B / Modal adapters (stub + payments v0 done in SAND-WP-0006)
|
||||
- Snapshot / restore / checkpoint profiles (SAND-WP-0007)
|
||||
- Formal ops-bridge tunnel attachment in reachability descriptor
|
||||
- Dedicated sandboxer01 host (CoulombCore interim only today)
|
||||
|
||||
@@ -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 |
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
45
docs/payments.md
Normal 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
44
docs/routing.md
Normal 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`.
|
||||
@@ -46,4 +46,14 @@ Attach mode for pre-built VMs (`the-custodian/infra/build-machines/` lineage).
|
||||
|
||||
**Profile:** `profile.vm-haskell-build` — see `docs/runbooks/profile-vm-haskell-build.md`.
|
||||
|
||||
Packer build / OVA import remains operator-driven (not triggered by `create`).
|
||||
Packer build / OVA import remains operator-driven (not triggered by `create`).
|
||||
|
||||
## ext.saas-stub
|
||||
|
||||
Metered SaaS stub for payments and routing v0 (SAND-WP-0006). No external API.
|
||||
|
||||
**estimate_cost / meter_actual:** credits check on create; debit on destroy.
|
||||
|
||||
**Profile:** `profile.saas-stub` (explicit), `profile.burst-sandbox` (self-hosted fallback).
|
||||
|
||||
See `docs/payments.md` and `docs/routing.md`.
|
||||
15
extensions/ext.saas-stub.yaml
Normal file
15
extensions/ext.saas-stub.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
id: ext.saas-stub
|
||||
title: SaaS sandbox stub
|
||||
description: >
|
||||
Metered SaaS extension stub for payments and routing v0. No external API;
|
||||
use for burst fallback testing before E2B/Modal adapters land.
|
||||
handler: sandboxer.extensions.saas_stub:SaaSStubExtension
|
||||
capabilities:
|
||||
isolation_levels: [microvm]
|
||||
regions: [us, eu]
|
||||
persistence: true
|
||||
pricing_model: metered
|
||||
config:
|
||||
provider: saas-stub
|
||||
rate_usd_per_hour: 0.12
|
||||
session_fee_usd: 0.01
|
||||
40
profiles/profile.burst-sandbox.yaml
Normal file
40
profiles/profile.burst-sandbox.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
id: profile.burst-sandbox
|
||||
version: "1.0.0"
|
||||
extension: ext.compose-ssh
|
||||
route:
|
||||
strategy: prefer-self-hosted
|
||||
extensions:
|
||||
- ext.compose-ssh
|
||||
- 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: session
|
||||
ttl:
|
||||
default: 2h
|
||||
max: 8h
|
||||
idle_reap: null
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: >
|
||||
Prefer self-hosted compose on SANDBOXER_HOST; falls back to metered SaaS stub
|
||||
when host is unavailable or SANDBOXER_FORCE_SAAS=1.
|
||||
secret_refs: []
|
||||
placement:
|
||||
prefer: [sandboxer01]
|
||||
fallback: [coulombcore]
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: saas-metered
|
||||
latency_class: standard
|
||||
observability: none
|
||||
32
profiles/profile.saas-stub.yaml
Normal file
32
profiles/profile.saas-stub.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
id: profile.saas-stub
|
||||
version: "1.0.0"
|
||||
extension: ext.saas-stub
|
||||
isolation:
|
||||
level: microvm
|
||||
network:
|
||||
default: deny
|
||||
egress: []
|
||||
workspace:
|
||||
mode: remote-canonical
|
||||
access: rw
|
||||
scope_default: session
|
||||
ttl:
|
||||
default: 1h
|
||||
max: 4h
|
||||
idle_reap: null
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: "Metered SaaS stub — no external provider API."
|
||||
secret_refs: []
|
||||
placement:
|
||||
prefer: []
|
||||
fallback: []
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: saas-metered
|
||||
latency_class: standard
|
||||
observability: none
|
||||
@@ -33,7 +33,7 @@ external_evidence:
|
||||
- profile-based create/destroy via CLI
|
||||
- State Hub lifecycle events on transitions
|
||||
broken_expectations:
|
||||
- SaaS extensions and payments layer not yet built
|
||||
- Real E2B/Modal adapters not yet built (saas-stub + credits v0 done)
|
||||
- wise-validator migration not complete
|
||||
out_of_scope_expectations:
|
||||
- agent harness and tool orchestration (glas-harness)
|
||||
|
||||
@@ -11,6 +11,7 @@ from sandboxer import __version__
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.defaults import resolve_create_defaults
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
from sandboxer.telemetry.export import export_telemetry
|
||||
@@ -25,6 +26,8 @@ app = typer.Typer(
|
||||
)
|
||||
inspect_app = typer.Typer(help="Host introspection without provisioning.")
|
||||
app.add_typer(inspect_app, name="inspect")
|
||||
credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
|
||||
app.add_typer(credits_app, name="credits")
|
||||
|
||||
|
||||
@app.callback()
|
||||
@@ -216,5 +219,22 @@ def reap_stale_cmd(
|
||||
_print_json([r.model_dump(mode="json") for r in results])
|
||||
|
||||
|
||||
@credits_app.command("show")
|
||||
def credits_show() -> None:
|
||||
"""Show current credit balance."""
|
||||
store = CreditsStore()
|
||||
_print_json({"balance_usd": store.balance(), "currency": "USD"})
|
||||
|
||||
|
||||
@credits_app.command("add")
|
||||
def credits_add(
|
||||
amount: Annotated[float, typer.Argument(help="USD amount to add")],
|
||||
) -> None:
|
||||
"""Add credits to the workspace balance."""
|
||||
store = CreditsStore()
|
||||
new_balance = store.add(amount)
|
||||
typer.echo(f"Balance: {new_balance:.4f} USD")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -6,13 +6,17 @@ from sandboxer.extensions.registry import load_extension, resolve_backend
|
||||
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
|
||||
from sandboxer.lifecycle.store import SandboxStore, utcnow
|
||||
from sandboxer.models import (
|
||||
MeterRecord,
|
||||
Reachability,
|
||||
SandboxCreateRequest,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
)
|
||||
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.routing.resolver import resolve_extension
|
||||
from sandboxer.telemetry.export import export_telemetry
|
||||
from sandboxer.telemetry.introspection import (
|
||||
build_introspection_report,
|
||||
@@ -22,17 +26,40 @@ from sandboxer.telemetry.introspection import (
|
||||
|
||||
|
||||
class SandboxManager:
|
||||
def __init__(self, store: SandboxStore | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
store: SandboxStore | None = None,
|
||||
credits: CreditsStore | None = None,
|
||||
) -> None:
|
||||
self.store = store or SandboxStore()
|
||||
self.credits = credits or CreditsStore()
|
||||
|
||||
def _resolved_host(self, profile, extension, host_override: str | None) -> str:
|
||||
if extension.capabilities.pricing_model == "metered":
|
||||
return extension.config.get("provider", "saas")
|
||||
return resolve_host(profile, override=host_override)
|
||||
|
||||
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
|
||||
profile = load_profile(request.profile)
|
||||
extension = load_extension(profile.extension)
|
||||
extension = resolve_extension(profile, request.inputs, host_override=host)
|
||||
backend = resolve_backend(extension)
|
||||
resolved_host = resolve_host(profile, override=host)
|
||||
resolved_host = self._resolved_host(profile, extension, host)
|
||||
wants_telemetry = profile_wants_telemetry(profile)
|
||||
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
||||
|
||||
quote = estimate_cost(extension, profile, request.inputs)
|
||||
meter_record: MeterRecord | None = None
|
||||
if quote:
|
||||
if not self.credits.can_afford(quote.estimated_usd):
|
||||
raise RuntimeError(
|
||||
f"Insufficient credits: need {quote.estimated_usd:.4f} USD, "
|
||||
f"balance {self.credits.balance():.4f} USD"
|
||||
)
|
||||
meter_record = MeterRecord(
|
||||
pricing_model="metered",
|
||||
estimate_usd=quote.estimated_usd,
|
||||
)
|
||||
|
||||
now = utcnow()
|
||||
status = SandboxStatus(
|
||||
sandbox_id="pending",
|
||||
@@ -42,6 +69,7 @@ class SandboxManager:
|
||||
consumer=request.consumer,
|
||||
host=resolved_host,
|
||||
inputs=dict(request.inputs),
|
||||
meter=meter_record,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -52,7 +80,7 @@ class SandboxManager:
|
||||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||
|
||||
provision_before = None
|
||||
if wants_telemetry:
|
||||
if wants_telemetry and extension.capabilities.pricing_model != "metered":
|
||||
provision_before = collect_host_snapshot(resolved_host)
|
||||
|
||||
try:
|
||||
@@ -64,6 +92,7 @@ class SandboxManager:
|
||||
status.inputs["ssh_port"] = handle.get("ssh_port", "")
|
||||
status.inputs["vm_target"] = handle.get("vm_target", "")
|
||||
status.inputs["vm_host"] = handle.get("vm_host", "")
|
||||
status.inputs["endpoint"] = handle.get("endpoint", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
@@ -114,13 +143,13 @@ class SandboxManager:
|
||||
return status
|
||||
|
||||
profile = load_profile(status.profile_id)
|
||||
extension = load_extension(profile.extension)
|
||||
extension = load_extension(status.extension_id)
|
||||
backend = resolve_backend(extension)
|
||||
wants_telemetry = profile_wants_telemetry(profile)
|
||||
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
||||
|
||||
destroy_before = None
|
||||
if wants_telemetry and status.host:
|
||||
if wants_telemetry and status.host and extension.capabilities.pricing_model != "metered":
|
||||
destroy_before = collect_host_snapshot(status.host)
|
||||
|
||||
status.state = SandboxState.DESTROYING
|
||||
@@ -139,6 +168,7 @@ class SandboxManager:
|
||||
"ssh_port": status.inputs.get("ssh_port", ""),
|
||||
"vm_target": status.inputs.get("vm_target", ""),
|
||||
"vm_host": status.inputs.get("vm_host", ""),
|
||||
"endpoint": status.inputs.get("endpoint", ""),
|
||||
}
|
||||
backend.teardown(handle)
|
||||
|
||||
@@ -146,6 +176,19 @@ class SandboxManager:
|
||||
status.destroyed_at = utcnow()
|
||||
status.updated_at = status.destroyed_at
|
||||
|
||||
settled = settle_usage(status, extension, handle, destroyed_at=status.destroyed_at)
|
||||
if settled and settled.pricing_model == "metered" and settled.actual_usd:
|
||||
self.credits.debit(settled.actual_usd)
|
||||
status.meter = settled
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=(
|
||||
f"Sandbox metered: {settled.actual_usd:.4f} USD "
|
||||
f"({settled.duration_s:.0f}s, ext={extension.id})"
|
||||
),
|
||||
event_type="note",
|
||||
)
|
||||
|
||||
if wants_telemetry and destroy_before and status.host:
|
||||
destroy_after = collect_host_snapshot(status.host)
|
||||
report = build_introspection_report(
|
||||
|
||||
@@ -6,7 +6,7 @@ import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.models import Profile
|
||||
from sandboxer.models import MeterQuote, Profile
|
||||
|
||||
|
||||
class SandboxExtension(ABC):
|
||||
@@ -31,4 +31,18 @@ class SandboxExtension(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
"""Release sandbox resources. Returns cleanup report fields."""
|
||||
"""Release sandbox resources. Returns cleanup report fields."""
|
||||
|
||||
def estimate_cost(
|
||||
self,
|
||||
profile: Profile,
|
||||
inputs: dict[str, str],
|
||||
*,
|
||||
duration_s: int = 3600,
|
||||
) -> MeterQuote | None:
|
||||
"""Optional pre-create cost quote (metered SaaS extensions)."""
|
||||
return None
|
||||
|
||||
def meter_actual(self, handle: dict[str, str], *, duration_s: float) -> float | None:
|
||||
"""Optional post-destroy actual cost in USD."""
|
||||
return None
|
||||
66
src/sandboxer/extensions/saas_stub.py
Normal file
66
src/sandboxer/extensions/saas_stub.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""ext.saas-stub — metered SaaS extension stub for routing and payments v0.
|
||||
|
||||
No external provider API. Exercises estimate_cost, credits debit, and routing
|
||||
fallback without E2B/Modal credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.extensions.base import SandboxExtension
|
||||
from sandboxer.models import MeterQuote, Profile
|
||||
|
||||
|
||||
class SaaSStubExtension(SandboxExtension):
|
||||
"""Simulated metered SaaS sandbox backend."""
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(config)
|
||||
self.rate_usd_per_hour: float = float(self.config.get("rate_usd_per_hour", 0.12))
|
||||
self.session_fee_usd: float = float(self.config.get("session_fee_usd", 0.01))
|
||||
self.provider: str = self.config.get("provider", "saas-stub")
|
||||
|
||||
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="ext.saas-stub",
|
||||
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)
|
||||
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
sandbox_id = self.new_sandbox_id(inputs)
|
||||
endpoint = f"https://stub.sandboxer.local/{sandbox_id}"
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
"host": self.provider,
|
||||
"endpoint": endpoint,
|
||||
"provider": self.provider,
|
||||
}
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
"endpoint": handle["endpoint"],
|
||||
"host": handle.get("host"),
|
||||
}
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
"provider_removed": "true",
|
||||
"sandbox_id": handle.get("sandbox_id", ""),
|
||||
}
|
||||
@@ -86,10 +86,32 @@ class ProfileMetadata(BaseModel):
|
||||
observability: Literal["none", "canary"] = "none"
|
||||
|
||||
|
||||
class RouteSpec(BaseModel):
|
||||
strategy: RouteStrategy = RouteStrategy.EXPLICIT
|
||||
extensions: list[str] = Field(default_factory=list)
|
||||
max_cost_per_hour_usd: float | None = None
|
||||
|
||||
|
||||
class MeterQuote(BaseModel):
|
||||
extension_id: str
|
||||
estimated_usd: float
|
||||
unit: Literal["per_hour", "per_session"] = "per_hour"
|
||||
duration_s: int = 3600
|
||||
|
||||
|
||||
class MeterRecord(BaseModel):
|
||||
pricing_model: Literal["self-hosted", "metered"] = "self-hosted"
|
||||
estimate_usd: float | None = None
|
||||
actual_usd: float | None = None
|
||||
duration_s: float | None = None
|
||||
currency: str = "USD"
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
id: str
|
||||
version: str
|
||||
extension: str
|
||||
route: RouteSpec | None = None
|
||||
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
|
||||
network: NetworkSpec = Field(default_factory=NetworkSpec)
|
||||
workspace: WorkspaceSpec = Field(default_factory=WorkspaceSpec)
|
||||
@@ -130,6 +152,7 @@ class Reachability(BaseModel):
|
||||
remote_dir: str | None = None
|
||||
compose_project: str | None = None
|
||||
host: str | None = None
|
||||
endpoint: str | None = None
|
||||
|
||||
|
||||
class SandboxStatus(BaseModel):
|
||||
@@ -142,6 +165,7 @@ class SandboxStatus(BaseModel):
|
||||
reachability: Reachability | None = None
|
||||
inputs: dict[str, str] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
meter: MeterRecord | None = None
|
||||
telemetry: dict | None = None # IntrospectionReport JSON when canary
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
6
src/sandboxer/payments/__init__.py
Normal file
6
src/sandboxer/payments/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Payments and metering for SaaS sandbox extensions."""
|
||||
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||
|
||||
__all__ = ["CreditsStore", "estimate_cost", "settle_usage"]
|
||||
48
src/sandboxer/payments/credits.py
Normal file
48
src/sandboxer/payments/credits.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Org/workspace credits for metered sandbox consumption."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _default_credits_path() -> Path:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||
return base / "sandboxer" / "credits.json"
|
||||
|
||||
|
||||
class CreditsStore:
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or _default_credits_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read(self) -> dict:
|
||||
if not self.path.exists():
|
||||
default = float(os.environ.get("SANDBOXER_DEFAULT_CREDITS", "10.0"))
|
||||
return {"balance_usd": default, "currency": "USD"}
|
||||
return json.loads(self.path.read_text())
|
||||
|
||||
def _write(self, data: dict) -> None:
|
||||
self.path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def balance(self) -> float:
|
||||
return float(self._read().get("balance_usd", 0.0))
|
||||
|
||||
def can_afford(self, amount_usd: float) -> bool:
|
||||
return self.balance() >= amount_usd
|
||||
|
||||
def add(self, amount_usd: float) -> float:
|
||||
data = self._read()
|
||||
data["balance_usd"] = round(float(data.get("balance_usd", 0.0)) + amount_usd, 4)
|
||||
self._write(data)
|
||||
return data["balance_usd"]
|
||||
|
||||
def debit(self, amount_usd: float) -> float:
|
||||
data = self._read()
|
||||
new_balance = round(float(data.get("balance_usd", 0.0)) - amount_usd, 4)
|
||||
if new_balance < 0:
|
||||
raise ValueError(f"Insufficient credits: need {amount_usd:.4f} USD")
|
||||
data["balance_usd"] = new_balance
|
||||
self._write(data)
|
||||
return new_balance
|
||||
66
src/sandboxer/payments/metering.py
Normal file
66
src/sandboxer/payments/metering.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Cost estimation and usage settlement for metered extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sandboxer.extensions.registry import resolve_backend
|
||||
from sandboxer.models import Extension, MeterQuote, MeterRecord, Profile, SandboxStatus
|
||||
|
||||
|
||||
def _duration_seconds(ready_at: datetime | None, destroyed_at: datetime) -> float:
|
||||
if not ready_at:
|
||||
return 0.0
|
||||
return max(0.0, (destroyed_at - ready_at).total_seconds())
|
||||
|
||||
|
||||
def estimate_cost(
|
||||
extension: Extension,
|
||||
profile: Profile,
|
||||
inputs: dict[str, str],
|
||||
*,
|
||||
duration_s: int = 3600,
|
||||
) -> MeterQuote | None:
|
||||
if extension.capabilities.pricing_model != "metered":
|
||||
return None
|
||||
backend = resolve_backend(extension)
|
||||
if not hasattr(backend, "estimate_cost"):
|
||||
return None
|
||||
quote = backend.estimate_cost(profile, inputs, duration_s=duration_s)
|
||||
if quote is None:
|
||||
return None
|
||||
if isinstance(quote, MeterQuote):
|
||||
return quote
|
||||
if isinstance(quote, dict):
|
||||
return MeterQuote.model_validate(quote)
|
||||
return None
|
||||
|
||||
|
||||
def settle_usage(
|
||||
status: SandboxStatus,
|
||||
extension: Extension,
|
||||
handle: dict[str, str],
|
||||
*,
|
||||
destroyed_at: datetime,
|
||||
) -> MeterRecord | None:
|
||||
if extension.capabilities.pricing_model != "metered":
|
||||
return MeterRecord(pricing_model="self-hosted")
|
||||
|
||||
duration_s = _duration_seconds(status.ready_at, destroyed_at)
|
||||
backend = resolve_backend(extension)
|
||||
actual_usd: float | None = None
|
||||
|
||||
if hasattr(backend, "meter_actual"):
|
||||
actual_usd = backend.meter_actual(handle, duration_s=duration_s)
|
||||
|
||||
if actual_usd is None and status.meter and status.meter.estimate_usd is not None:
|
||||
hours = duration_s / 3600.0
|
||||
actual_usd = round(status.meter.estimate_usd * max(hours, 1 / 3600), 4)
|
||||
|
||||
estimate = status.meter.estimate_usd if status.meter else None
|
||||
return MeterRecord(
|
||||
pricing_model="metered",
|
||||
estimate_usd=estimate,
|
||||
actual_usd=actual_usd,
|
||||
duration_s=round(duration_s, 1),
|
||||
)
|
||||
5
src/sandboxer/routing/__init__.py
Normal file
5
src/sandboxer/routing/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Extension routing — OpenRouter-style backend selection."""
|
||||
|
||||
from sandboxer.routing.resolver import resolve_extension
|
||||
|
||||
__all__ = ["resolve_extension"]
|
||||
97
src/sandboxer/routing/resolver.py
Normal file
97
src/sandboxer/routing/resolver.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Select extension backend from profile route policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from sandboxer.extensions.registry import load_extension
|
||||
from sandboxer.models import Extension, Profile, RouteStrategy
|
||||
from sandboxer.payments.metering import estimate_cost
|
||||
from sandboxer.placement import resolve_host
|
||||
|
||||
|
||||
def _candidates(profile: Profile) -> list[str]:
|
||||
if profile.route and profile.route.extensions:
|
||||
return profile.route.extensions
|
||||
return [profile.extension]
|
||||
|
||||
|
||||
def _is_metered(ext: Extension) -> bool:
|
||||
return ext.capabilities.pricing_model == "metered"
|
||||
|
||||
|
||||
def _self_hosted_available(profile: Profile, ext: Extension, host_override: str | None) -> bool:
|
||||
if _is_metered(ext):
|
||||
return True
|
||||
if os.environ.get("SANDBOXER_FORCE_SAAS") == "1":
|
||||
return False
|
||||
try:
|
||||
resolve_host(profile, override=host_override)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _quote_cost(
|
||||
ext: Extension, profile: Profile, inputs: dict[str, str], duration_s: int
|
||||
) -> float | None:
|
||||
quote = estimate_cost(ext, profile, inputs, duration_s=duration_s)
|
||||
return quote.estimated_usd if quote else None
|
||||
|
||||
|
||||
def resolve_extension(
|
||||
profile: Profile,
|
||||
inputs: dict[str, str],
|
||||
*,
|
||||
host_override: str | None = None,
|
||||
duration_s: int = 3600,
|
||||
) -> Extension:
|
||||
"""Pick extension per route strategy. Raises if no candidate qualifies."""
|
||||
strategy = (
|
||||
profile.route.strategy
|
||||
if profile.route
|
||||
else RouteStrategy.EXPLICIT
|
||||
)
|
||||
ids = _candidates(profile)
|
||||
loaded = [load_extension(ext_id) for ext_id in ids]
|
||||
|
||||
if strategy == RouteStrategy.EXPLICIT or len(loaded) == 1:
|
||||
chosen = load_extension(profile.extension)
|
||||
if chosen.id not in {e.id for e in loaded}:
|
||||
chosen = loaded[0]
|
||||
return chosen
|
||||
|
||||
if strategy == RouteStrategy.PREFER_SELF_HOSTED:
|
||||
for ext in loaded:
|
||||
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||
return ext
|
||||
for ext in loaded:
|
||||
if _is_metered(ext):
|
||||
return ext
|
||||
return loaded[0]
|
||||
|
||||
if strategy == RouteStrategy.LOWEST_COST:
|
||||
best: Extension | None = None
|
||||
best_cost: float | None = None
|
||||
for ext in loaded:
|
||||
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||
return ext
|
||||
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
|
||||
return loaded[-1]
|
||||
|
||||
if strategy == RouteStrategy.LOWEST_LATENCY:
|
||||
for ext in loaded:
|
||||
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||
return ext
|
||||
return loaded[-1]
|
||||
|
||||
return load_extension(profile.extension)
|
||||
@@ -12,4 +12,5 @@ def test_load_vm_packer_extension() -> None:
|
||||
def test_load_all_includes_vm_packer() -> None:
|
||||
extensions = load_all_extensions()
|
||||
assert "ext.compose-ssh" in extensions
|
||||
assert "ext.vm-packer" in extensions
|
||||
assert "ext.vm-packer" in extensions
|
||||
assert "ext.saas-stub" in extensions
|
||||
@@ -53,8 +53,9 @@ def test_create_and_destroy(store: SandboxStore) -> None:
|
||||
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||
)
|
||||
|
||||
fake = FakeBackend()
|
||||
with (
|
||||
patch("sandboxer.core.manager.resolve_backend", return_value=FakeBackend()),
|
||||
patch("sandboxer.core.manager.resolve_backend", return_value=fake),
|
||||
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
|
||||
):
|
||||
|
||||
98
tests/test_payments.py
Normal file
98
tests/test_payments.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Payments and metering integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.lifecycle.store import SandboxStore
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxState
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
|
||||
|
||||
class SaaSBackend:
|
||||
def provision(self, profile, inputs, host):
|
||||
return {"sandbox_id": "saas1234", "host": "saas-stub", "endpoint": "https://x"}
|
||||
|
||||
def wait_ready(self, handle):
|
||||
return {"endpoint": handle["endpoint"], "host": handle["host"]}
|
||||
|
||||
def teardown(self, handle):
|
||||
return {"provider_removed": "true"}
|
||||
|
||||
def estimate_cost(self, profile, inputs, *, duration_s=3600):
|
||||
from sandboxer.models import MeterQuote
|
||||
|
||||
return MeterQuote(
|
||||
extension_id="ext.saas-stub",
|
||||
estimated_usd=0.5,
|
||||
duration_s=duration_s,
|
||||
)
|
||||
|
||||
def meter_actual(self, handle, *, duration_s: float) -> float:
|
||||
return 0.25
|
||||
|
||||
|
||||
def _set_balance(credits: CreditsStore, amount: float) -> None:
|
||||
credits._write({"balance_usd": amount, "currency": "USD"})
|
||||
|
||||
|
||||
def test_saas_create_destroy_debits_credits(tmp_path: Path) -> None:
|
||||
store = SandboxStore(path=tmp_path / "sandboxes.json")
|
||||
credits = CreditsStore(path=tmp_path / "credits.json")
|
||||
_set_balance(credits, 5.0)
|
||||
manager = SandboxManager(store=store, credits=credits)
|
||||
|
||||
request = SandboxCreateRequest(
|
||||
profile="profile.saas-stub",
|
||||
inputs={},
|
||||
consumer=Consumer(actor=ActorType.ADM, project="test"),
|
||||
)
|
||||
|
||||
backend = SaaSBackend()
|
||||
with (
|
||||
patch("sandboxer.core.manager.resolve_extension") as resolve_ext,
|
||||
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
|
||||
patch("sandboxer.payments.metering.resolve_backend", return_value=backend),
|
||||
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||
):
|
||||
from sandboxer.extensions.registry import load_extension
|
||||
|
||||
resolve_ext.return_value = load_extension("ext.saas-stub")
|
||||
status = manager.create(request)
|
||||
assert status.state == SandboxState.READY
|
||||
assert status.meter and status.meter.estimate_usd == 0.5
|
||||
|
||||
destroyed = manager.destroy(status.sandbox_id)
|
||||
assert destroyed.state == SandboxState.DESTROYED
|
||||
assert destroyed.meter and destroyed.meter.actual_usd == 0.25
|
||||
|
||||
assert credits.balance() == 4.75
|
||||
|
||||
|
||||
def test_insufficient_credits_blocks_create(tmp_path: Path) -> None:
|
||||
store = SandboxStore(path=tmp_path / "sandboxes.json")
|
||||
credits = CreditsStore(path=tmp_path / "credits.json")
|
||||
_set_balance(credits, 0.01)
|
||||
manager = SandboxManager(store=store, credits=credits)
|
||||
|
||||
request = SandboxCreateRequest(
|
||||
profile="profile.saas-stub",
|
||||
inputs={},
|
||||
consumer=Consumer(actor=ActorType.ADM, project="test"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("sandboxer.core.manager.resolve_extension") as resolve_ext,
|
||||
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||
):
|
||||
from sandboxer.extensions.registry import load_extension
|
||||
|
||||
resolve_ext.return_value = load_extension("ext.saas-stub")
|
||||
with patch("sandboxer.core.manager.resolve_backend", return_value=SaaSBackend()):
|
||||
try:
|
||||
manager.create(request)
|
||||
raise AssertionError("expected RuntimeError")
|
||||
except RuntimeError as exc:
|
||||
assert "Insufficient credits" in str(exc)
|
||||
59
tests/test_routing.py
Normal file
59
tests/test_routing.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Extension routing tests."""
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.extensions.registry import load_extension
|
||||
from sandboxer.models import Profile, RouteSpec, RouteStrategy
|
||||
from sandboxer.routing.resolver import resolve_extension
|
||||
|
||||
|
||||
def _burst_profile() -> Profile:
|
||||
return Profile.model_validate(
|
||||
{
|
||||
"id": "profile.burst-sandbox",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.compose-ssh",
|
||||
"route": {
|
||||
"strategy": "prefer-self-hosted",
|
||||
"extensions": ["ext.compose-ssh", "ext.saas-stub"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_uses_profile_extension() -> None:
|
||||
profile = Profile.model_validate(
|
||||
{
|
||||
"id": "profile.compose-e2e",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.compose-ssh",
|
||||
}
|
||||
)
|
||||
ext = resolve_extension(profile, {}, host_override="coulombcore")
|
||||
assert ext.id == "ext.compose-ssh"
|
||||
|
||||
|
||||
def test_prefer_self_hosted_when_host_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_HOST", "coulombcore")
|
||||
ext = resolve_extension(_burst_profile(), {"repo": "/tmp/x"}, host_override=None)
|
||||
assert ext.id == "ext.compose-ssh"
|
||||
|
||||
|
||||
def test_prefer_self_hosted_falls_back_to_saas(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("SANDBOXER_HOST", 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_lowest_cost_picks_metered_when_forced(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
profile = _burst_profile()
|
||||
profile.route = RouteSpec(
|
||||
strategy=RouteStrategy.LOWEST_COST,
|
||||
extensions=["ext.compose-ssh", "ext.saas-stub"],
|
||||
)
|
||||
monkeypatch.setenv("SANDBOXER_FORCE_SAAS", "1")
|
||||
ext = resolve_extension(profile, {}, host_override=None)
|
||||
assert load_extension(ext.id).capabilities.pricing_model == "metered"
|
||||
30
tests/test_saas_stub.py
Normal file
30
tests/test_saas_stub.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""SaaS stub extension tests."""
|
||||
|
||||
from sandboxer.extensions.saas_stub import SaaSStubExtension
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
def _profile() -> Profile:
|
||||
return Profile.model_validate(
|
||||
{"id": "profile.saas-stub", "version": "1.0.0", "extension": "ext.saas-stub"}
|
||||
)
|
||||
|
||||
|
||||
def test_estimate_and_meter() -> None:
|
||||
ext = SaaSStubExtension({"rate_usd_per_hour": 1.0, "session_fee_usd": 0.05})
|
||||
quote = ext.estimate_cost(_profile(), {}, duration_s=3600)
|
||||
assert quote is not None
|
||||
assert quote.estimated_usd == 1.05
|
||||
|
||||
handle = ext.provision(_profile(), {}, "saas")
|
||||
actual = ext.meter_actual(handle, duration_s=1800)
|
||||
assert actual == 0.55
|
||||
|
||||
|
||||
def test_provision_wait_teardown() -> None:
|
||||
ext = SaaSStubExtension()
|
||||
handle = ext.provision(_profile(), {}, "saas-stub")
|
||||
reach = ext.wait_ready(handle)
|
||||
assert reach["endpoint"].startswith("https://stub.sandboxer.local/")
|
||||
report = ext.teardown(handle)
|
||||
assert report["provider_removed"] == "true"
|
||||
86
workplans/SAND-WP-0006-saas-extensions-and-payments.md
Normal file
86
workplans/SAND-WP-0006-saas-extensions-and-payments.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: SAND-WP-0006
|
||||
type: workplan
|
||||
title: "SaaS extensions and payments layer"
|
||||
domain: infotech
|
||||
repo: sand-boxer
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-23"
|
||||
updated: "2026-06-23"
|
||||
---
|
||||
|
||||
# SaaS extensions and payments layer
|
||||
|
||||
Deliver INTENT pillar 4 (payments/metering) and routing engine v0 for
|
||||
self-hosted vs SaaS backend selection.
|
||||
|
||||
**Predecessor:** SAND-WP-0005 (extension SDK — finished)
|
||||
**Follow-on:** SAND-WP-0007 (snapshots), real E2B/Modal adapters
|
||||
|
||||
## Payments and credits
|
||||
|
||||
```task
|
||||
id: SAND-WP-0006-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
`CreditsStore`, `estimate_cost` / `settle_usage`, pre-create balance check,
|
||||
post-destroy debit, `SandboxStatus.meter` field. Docs: `docs/payments.md`.
|
||||
CLI: `sandboxer credits show|add`.
|
||||
|
||||
## Routing engine
|
||||
|
||||
```task
|
||||
id: SAND-WP-0006-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
`RouteSpec` on profiles, `resolve_extension` with strategies
|
||||
(`prefer-self-hosted`, `lowest-cost`, `lowest-latency`, `explicit`).
|
||||
Docs: `docs/routing.md`.
|
||||
|
||||
## ext.saas-stub
|
||||
|
||||
```task
|
||||
id: SAND-WP-0006-T03
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Metered SaaS stub extension — `estimate_cost`, `meter_actual`, no external API.
|
||||
Profiles: `profile.saas-stub`, `profile.burst-sandbox`.
|
||||
|
||||
## Manager integration
|
||||
|
||||
```task
|
||||
id: SAND-WP-0006-T04
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
`SandboxManager` uses routing resolver; destroy loads `status.extension_id`;
|
||||
meter settlement on destroy; meter events to State Hub.
|
||||
|
||||
## Tests
|
||||
|
||||
```task
|
||||
id: SAND-WP-0006-T05
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
`test_routing.py`, `test_payments.py`, `test_saas_stub.py`.
|
||||
|
||||
## Deferred
|
||||
|
||||
```task
|
||||
id: SAND-WP-0006-T06
|
||||
status: wait
|
||||
priority: low
|
||||
```
|
||||
|
||||
Real `ext.e2b` / `ext.modal` adapters, BYOK via OpenBao, fin-hub export.
|
||||
Reference in New Issue
Block a user