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

@@ -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>

View File

@@ -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
---

View File

@@ -107,7 +107,8 @@ own tunnels or CAs.
- **Workplans finished:** SAND-WP-00010005, 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)

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`.

View File

@@ -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`.

View 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

View 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

View 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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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(

View File

@@ -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

View 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", ""),
}

View File

@@ -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

View 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"]

View 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

View 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),
)

View File

@@ -0,0 +1,5 @@
"""Extension routing — OpenRouter-style backend selection."""
from sandboxer.routing.resolver import resolve_extension
__all__ = ["resolve_extension"]

View 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)

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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"

View 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.