diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index 6ffaaad..7ae9309 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -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 sandboxer list sandboxer destroy diff --git a/INTENT.md b/INTENT.md index 2c21072..d56dd07 100644 --- a/INTENT.md +++ b/INTENT.md @@ -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 --- diff --git a/SCOPE.md b/SCOPE.md index 193fb26..5b03fdb 100644 --- a/SCOPE.md +++ b/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) diff --git a/docs/extension-sdk.md b/docs/extension-sdk.md index c1c6319..2a4a90f 100644 --- a/docs/extension-sdk.md +++ b/docs/extension-sdk.md @@ -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 | \ No newline at end of file diff --git a/docs/meta-framework.md b/docs/meta-framework.md index 702b2fc..a323c67 100644 --- a/docs/meta-framework.md +++ b/docs/meta-framework.md @@ -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`. --- diff --git a/docs/migration-gaps.md b/docs/migration-gaps.md index a014ba9..ea4be12 100644 --- a/docs/migration-gaps.md +++ b/docs/migration-gaps.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 | \ No newline at end of file diff --git a/docs/payments.md b/docs/payments.md new file mode 100644 index 0000000..ec137c7 --- /dev/null +++ b/docs/payments.md @@ -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. \ No newline at end of file diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 0000000..e26c028 --- /dev/null +++ b/docs/routing.md @@ -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`. \ No newline at end of file diff --git a/extensions/README.md b/extensions/README.md index 778d302..fb2f123 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -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`). \ No newline at end of file +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`. \ No newline at end of file diff --git a/extensions/ext.saas-stub.yaml b/extensions/ext.saas-stub.yaml new file mode 100644 index 0000000..0fbd2b3 --- /dev/null +++ b/extensions/ext.saas-stub.yaml @@ -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 \ No newline at end of file diff --git a/profiles/profile.burst-sandbox.yaml b/profiles/profile.burst-sandbox.yaml new file mode 100644 index 0000000..a269ef8 --- /dev/null +++ b/profiles/profile.burst-sandbox.yaml @@ -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 \ No newline at end of file diff --git a/profiles/profile.saas-stub.yaml b/profiles/profile.saas-stub.yaml new file mode 100644 index 0000000..6946d07 --- /dev/null +++ b/profiles/profile.saas-stub.yaml @@ -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 \ No newline at end of file diff --git a/registry/capabilities/execution.sandbox-provision.md b/registry/capabilities/execution.sandbox-provision.md index 1f0815d..cab453d 100644 --- a/registry/capabilities/execution.sandbox-provision.md +++ b/registry/capabilities/execution.sandbox-provision.md @@ -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) diff --git a/src/sandboxer/cli.py b/src/sandboxer/cli.py index 9780513..f4031b8 100644 --- a/src/sandboxer/cli.py +++ b/src/sandboxer/cli.py @@ -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() \ No newline at end of file diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index ffef014..502e88f 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -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( diff --git a/src/sandboxer/extensions/base.py b/src/sandboxer/extensions/base.py index cab28c5..39ba1e5 100644 --- a/src/sandboxer/extensions/base.py +++ b/src/sandboxer/extensions/base.py @@ -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.""" \ No newline at end of file + """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 \ No newline at end of file diff --git a/src/sandboxer/extensions/saas_stub.py b/src/sandboxer/extensions/saas_stub.py new file mode 100644 index 0000000..6bfd959 --- /dev/null +++ b/src/sandboxer/extensions/saas_stub.py @@ -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", ""), + } \ No newline at end of file diff --git a/src/sandboxer/models.py b/src/sandboxer/models.py index bcdf2b4..20f75d8 100644 --- a/src/sandboxer/models.py +++ b/src/sandboxer/models.py @@ -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 diff --git a/src/sandboxer/payments/__init__.py b/src/sandboxer/payments/__init__.py new file mode 100644 index 0000000..18eeb24 --- /dev/null +++ b/src/sandboxer/payments/__init__.py @@ -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"] \ No newline at end of file diff --git a/src/sandboxer/payments/credits.py b/src/sandboxer/payments/credits.py new file mode 100644 index 0000000..2724a94 --- /dev/null +++ b/src/sandboxer/payments/credits.py @@ -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 \ No newline at end of file diff --git a/src/sandboxer/payments/metering.py b/src/sandboxer/payments/metering.py new file mode 100644 index 0000000..159fe65 --- /dev/null +++ b/src/sandboxer/payments/metering.py @@ -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), + ) \ No newline at end of file diff --git a/src/sandboxer/routing/__init__.py b/src/sandboxer/routing/__init__.py new file mode 100644 index 0000000..97517c2 --- /dev/null +++ b/src/sandboxer/routing/__init__.py @@ -0,0 +1,5 @@ +"""Extension routing — OpenRouter-style backend selection.""" + +from sandboxer.routing.resolver import resolve_extension + +__all__ = ["resolve_extension"] \ No newline at end of file diff --git a/src/sandboxer/routing/resolver.py b/src/sandboxer/routing/resolver.py new file mode 100644 index 0000000..d32dd51 --- /dev/null +++ b/src/sandboxer/routing/resolver.py @@ -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) \ No newline at end of file diff --git a/tests/test_extension_registry.py b/tests/test_extension_registry.py index 7117709..7dd78c5 100644 --- a/tests/test_extension_registry.py +++ b/tests/test_extension_registry.py @@ -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 \ No newline at end of file + assert "ext.vm-packer" in extensions + assert "ext.saas-stub" in extensions \ No newline at end of file diff --git a/tests/test_manager.py b/tests/test_manager.py index adac6f8..43551b1 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -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"), ): diff --git a/tests/test_payments.py b/tests/test_payments.py new file mode 100644 index 0000000..b10d1ef --- /dev/null +++ b/tests/test_payments.py @@ -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) \ No newline at end of file diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..6ac2d67 --- /dev/null +++ b/tests/test_routing.py @@ -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" \ No newline at end of file diff --git a/tests/test_saas_stub.py b/tests/test_saas_stub.py new file mode 100644 index 0000000..cba6805 --- /dev/null +++ b/tests/test_saas_stub.py @@ -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" \ 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 new file mode 100644 index 0000000..6dc7c59 --- /dev/null +++ b/workplans/SAND-WP-0006-saas-extensions-and-payments.md @@ -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. \ No newline at end of file