mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[sandbox-ext] Phase 3: parameter surface — schema + project field + verb override + cache-by-features
Migration 072 adds projects.sandbox_extensions (jsonb null): a per-service
extension/module map a venture declares up front (e.g. {"postgres":
["vector","postgis"],"redis":["search"]}). Additive + nullable so
existing opted-in projects stay byte-for-byte bare — no default set, opters
set the extensions they need explicitly (TimescaleDB out unless asked).
Project model validates the map against SANDBOX_ENGINE_FEATURES: unknown
service keys and unallowed features are rejected at the model boundary with
the allowlist named (plpython3u — superuser-RCE — excluded by construction),
empty feature lists drop to bare, order normalized + deduped. The allowlist
is the security containment, not privilege. Mirrors sandbox_services: not on
ProjectCreate, only Project + ProjectUpdate.
request_sandbox gains an extensions arg; _sandbox_features_scope unions a
per-call override with the project's standing set (trusted), bounds it to the
opted set + allowlist, rejects a non-opted service or unallowed feature with
the allowlist named in remediate — scope-first priority preserved by
rej_scope or rej_features. ensure_sandbox threads features through to
provision(); cache-by-features: a cached entry satisfies a new call iff
services are a subset AND every requested feature per service is already
cached — a feature superset re-provisions (rotates creds), mirroring the
services-superset case. available_extensions rides the evidence payload so an
agent doesn't guess what was activated.
Gate: ruff clean, mypy clean (9 modules), 51 tests pass (incl. migration
round-trip).
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
"""Per-project sandbox extensions/modules opt-in column.
|
||||||
|
|
||||||
|
The parameterized sandbox (docs/internal/specs/2026-07-13-sandbox-extensions-
|
||||||
|
on-the-fly.md) lets a venture declare the extensions/modules its sandboxed dev
|
||||||
|
DB should activate (e.g. ``{"postgres": ["vector", "postgis"], "redis":
|
||||||
|
["search"]}``). The provisioner activates them post-ready via ``docker exec``.
|
||||||
|
Additive and nullable: an unset service gets no extensions (bare), so existing
|
||||||
|
opted-in projects stay byte-for-byte unchanged on the bare path. Feature names
|
||||||
|
are allowlist-validated by the Project pydantic model before reaching here
|
||||||
|
(``SANDBOX_ENGINE_FEATURES``), so a ``plpython3u`` can never be persisted.
|
||||||
|
|
||||||
|
Revision ID: 072_project_sandbox_extensions
|
||||||
|
Revises: 071_review_findings
|
||||||
|
Create Date: 2026-07-13
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "072_project_sandbox_extensions"
|
||||||
|
down_revision = "071_review_findings"
|
||||||
|
branch_labels: dict[str, str] | None = None
|
||||||
|
depends_on: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column("sandbox_extensions", sa.JSONB(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("projects", "sandbox_extensions")
|
||||||
@@ -268,7 +268,9 @@ async def do_request_sandbox(
|
|||||||
x_agent_id: _AgentIdHeader,
|
x_agent_id: _AgentIdHeader,
|
||||||
actions: _ContentActionsDep,
|
actions: _ContentActionsDep,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
env = await actions.request_sandbox(agent_id=x_agent_id, services=body.services)
|
env = await actions.request_sandbox(
|
||||||
|
agent_id=x_agent_id, services=body.services, extensions=body.extensions
|
||||||
|
)
|
||||||
return envelope_to_response(env, request)
|
return envelope_to_response(env, request)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class ProjectResponse(BaseModel):
|
|||||||
dep_update_command: str | None = None
|
dep_update_command: str | None = None
|
||||||
dep_update_paths: list[str] | None = None
|
dep_update_paths: list[str] | None = None
|
||||||
sandbox_services: list[str] | None = None
|
sandbox_services: list[str] | None = None
|
||||||
|
sandbox_extensions: dict[str, list[str]] | None = None
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
workspace_path: str | None = None
|
workspace_path: str | None = None
|
||||||
@@ -151,6 +152,7 @@ class ProjectUpdateRequest(BaseModel):
|
|||||||
dep_update_command: str | None = None
|
dep_update_command: str | None = None
|
||||||
dep_update_paths: list[str] | None = None
|
dep_update_paths: list[str] | None = None
|
||||||
sandbox_services: list[str] | None = None
|
sandbox_services: list[str] | None = None
|
||||||
|
sandbox_extensions: dict[str, list[str]] | None = None
|
||||||
|
|
||||||
# State
|
# State
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
@@ -238,6 +240,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
|
|||||||
dep_update_command=project.dep_update_command,
|
dep_update_command=project.dep_update_command,
|
||||||
dep_update_paths=project.dep_update_paths,
|
dep_update_paths=project.dep_update_paths,
|
||||||
sandbox_services=project.sandbox_services,
|
sandbox_services=project.sandbox_services,
|
||||||
|
sandbox_extensions=project.sandbox_extensions,
|
||||||
workspace_path=project.workspace_path,
|
workspace_path=project.workspace_path,
|
||||||
last_synced_at=project.last_synced_at,
|
last_synced_at=project.last_synced_at,
|
||||||
head_commit=project.head_commit,
|
head_commit=project.head_commit,
|
||||||
|
|||||||
@@ -176,9 +176,11 @@ class EvidenceRequest(BaseModel):
|
|||||||
|
|
||||||
class RequestSandboxRequest(BaseModel):
|
class RequestSandboxRequest(BaseModel):
|
||||||
"""On-demand sandbox DB/Redis/Mongo. Omitted `services` = the project's
|
"""On-demand sandbox DB/Redis/Mongo. Omitted `services` = the project's
|
||||||
whole opted-in set."""
|
whole opted-in set. ``extensions`` (per-service extensions/modules) is an
|
||||||
|
additive per-call override, allowlist-validated."""
|
||||||
|
|
||||||
services: list[str] | None = None
|
services: list[str] | None = None
|
||||||
|
extensions: dict[str, list[str]] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProgressRequest(BaseModel):
|
class ProgressRequest(BaseModel):
|
||||||
|
|||||||
@@ -543,6 +543,13 @@ class ProjectTable(Base):
|
|||||||
sandbox_services: Mapped[list[str] | None] = mapped_column(
|
sandbox_services: Mapped[list[str] | None] = mapped_column(
|
||||||
ARRAY(String), nullable=True
|
ARRAY(String), nullable=True
|
||||||
)
|
)
|
||||||
|
# Per-service extensions/modules the sandbox should activate post-ready
|
||||||
|
# (e.g. {"postgres": ["vector", "postgis"], "redis": ["search"]}). Null or a
|
||||||
|
# service absent = bare (no enable step). Feature names are allowlist-
|
||||||
|
# validated by the Project pydantic model (SANDBOX_ENGINE_FEATURES).
|
||||||
|
sandbox_extensions: Mapped[dict[str, Any] | None] = mapped_column(
|
||||||
|
JSONB, nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Access Control
|
# Access Control
|
||||||
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
||||||
|
|||||||
+16
-8
@@ -726,22 +726,30 @@ def evidence(task_id: str) -> dict[str, Any]:
|
|||||||
return _post("/api/v1/do/evidence", {"task_id": task_id})
|
return _post("/api/v1/do/evidence", {"task_id": task_id})
|
||||||
|
|
||||||
|
|
||||||
def request_sandbox(services: list[str] | None = None) -> dict[str, Any]:
|
def request_sandbox(
|
||||||
|
services: list[str] | None = None,
|
||||||
|
extensions: dict[str, list[str]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Provision (or reuse) a throwaway sandbox DB/Redis/Mongo for YOUR active task.
|
"""Provision (or reuse) a throwaway sandbox DB/Redis/Mongo for YOUR active task.
|
||||||
|
|
||||||
On-demand — nothing is provisioned at spawn. Omit ``services`` to get the
|
On-demand — nothing is provisioned at spawn. Omit ``services`` to get the
|
||||||
project's whole opted-in set; requesting a service the project didn't opt
|
project's whole opted-in set; requesting a service the project didn't opt
|
||||||
into is rejected with the allowed set named. Creds come back in
|
into is rejected with the allowed set named. ``extensions`` (e.g.
|
||||||
|
``{"postgres": ["vector", "postgis"]}``) is an additive per-call override
|
||||||
|
unioned with the project's standing ``sandbox_extensions`` and bounded by
|
||||||
|
the opted set + the allowlist — a name outside the allowlist (e.g.
|
||||||
|
``plpython3u``) is rejected with the allowed set named. Creds come back in
|
||||||
``evidence``, one entry per service: ``{host, port, user, password,
|
``evidence``, one entry per service: ``{host, port, user, password,
|
||||||
database, env: {ROBOCO_TEST_*: value}}`` — export the ``env`` values
|
database, env: {ROBOCO_TEST_*: value}, available_extensions?: [...]}`` —
|
||||||
verbatim for gate tooling that reads them. The whole opted-in set is
|
export the ``env`` values verbatim for gate tooling that reads them. The
|
||||||
provisioned on first call, so calling this again for any subset or
|
whole opted-in set is provisioned on first call, so calling this again for
|
||||||
superset of it is a cheap no-op (same creds, no re-provisioning); a
|
any subset or superset of it is a cheap no-op (same creds, no
|
||||||
project that never opted into sandbox services will reject this.
|
re-provisioning); a project that never opted into sandbox services will
|
||||||
|
reject this.
|
||||||
"""
|
"""
|
||||||
return _post(
|
return _post(
|
||||||
"/api/v1/do/request_sandbox",
|
"/api/v1/do/request_sandbox",
|
||||||
{"services": services},
|
{"services": services, "extensions": extensions},
|
||||||
timeout=_SANDBOX_TIMEOUT,
|
timeout=_SANDBOX_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ from uuid import UUID, uuid4
|
|||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from roboco.models.base import RobocoBase, Team, TimestampMixin
|
from roboco.models.base import RobocoBase, Team, TimestampMixin
|
||||||
from roboco.models.sandbox import SANDBOX_ENGINES, VALID_SANDBOX_SERVICES
|
from roboco.models.sandbox import (
|
||||||
|
SANDBOX_ENGINE_FEATURES,
|
||||||
|
SANDBOX_ENGINES,
|
||||||
|
VALID_SANDBOX_SERVICES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BranchReason(StrEnum):
|
class BranchReason(StrEnum):
|
||||||
@@ -45,6 +49,38 @@ def _normalize_sandbox_services(value: list[str] | None) -> list[str] | None:
|
|||||||
return [s for s in SANDBOX_ENGINES if s in value]
|
return [s for s in SANDBOX_ENGINES if s in value]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sandbox_extensions(
|
||||||
|
value: dict[str, list[str]] | None,
|
||||||
|
) -> dict[str, list[str]] | None:
|
||||||
|
"""Allowlist-validate + normalize the per-service extension/module map.
|
||||||
|
|
||||||
|
Each key must be a valid sandbox service; each feature must be in that
|
||||||
|
service's allowlist (``SANDBOX_ENGINE_FEATURES``) — the security containment
|
||||||
|
that keeps a ``plpython3u`` (superuser-RCE) from ever being persisted. A
|
||||||
|
service with an empty feature list is dropped (bare == unset). Returns None
|
||||||
|
for an empty/None input so the column stays null for bare projects.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized: dict[str, list[str]] = {}
|
||||||
|
for svc, feats in value.items():
|
||||||
|
if svc not in SANDBOX_ENGINES:
|
||||||
|
raise ValueError(
|
||||||
|
f"sandbox_extensions key {svc!r} is not a valid service; valid: "
|
||||||
|
f"{sorted(VALID_SANDBOX_SERVICES)}"
|
||||||
|
)
|
||||||
|
allowed = SANDBOX_ENGINE_FEATURES.get(svc, frozenset())
|
||||||
|
bad = sorted(set(feats or []) - allowed)
|
||||||
|
if bad:
|
||||||
|
raise ValueError(
|
||||||
|
f"unallowed {svc} extension(s) {bad}; allowed: {sorted(allowed)}"
|
||||||
|
)
|
||||||
|
ordered = [f for f in sorted(allowed) if f in (feats or [])]
|
||||||
|
if ordered:
|
||||||
|
normalized[svc] = ordered
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
class Project(TimestampMixin):
|
class Project(TimestampMixin):
|
||||||
"""
|
"""
|
||||||
A git repository that agents work on.
|
A git repository that agents work on.
|
||||||
@@ -159,6 +195,25 @@ class Project(TimestampMixin):
|
|||||||
def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None:
|
def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None:
|
||||||
return _normalize_sandbox_services(v)
|
return _normalize_sandbox_services(v)
|
||||||
|
|
||||||
|
# Per-service sandbox extensions/modules to activate post-ready
|
||||||
|
# (e.g. {"postgres": ["vector", "postgis"]}); null/empty = bare. Allowlist-
|
||||||
|
# validated — a plpython3u (superuser-RCE) can never be set here.
|
||||||
|
sandbox_extensions: dict[str, list[str]] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Per-service extensions/modules the sandbox activates post-ready "
|
||||||
|
"(e.g. {'postgres': ['vector', 'postgis'], 'redis': ['search']}); "
|
||||||
|
"null/empty = bare. Allowlist-validated."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("sandbox_extensions")
|
||||||
|
@classmethod
|
||||||
|
def _check_sandbox_extensions(
|
||||||
|
cls, v: dict[str, list[str]] | None
|
||||||
|
) -> dict[str, list[str]] | None:
|
||||||
|
return _normalize_sandbox_extensions(v)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_by: UUID = Field(..., description="PM who registered the project")
|
created_by: UUID = Field(..., description="PM who registered the project")
|
||||||
is_active: bool = Field(default=True, description="Whether project is active")
|
is_active: bool = Field(default=True, description="Whether project is active")
|
||||||
@@ -218,8 +273,16 @@ class ProjectUpdate(RobocoBase):
|
|||||||
dep_update_command: str | None = None
|
dep_update_command: str | None = None
|
||||||
dep_update_paths: list[str] | None = None
|
dep_update_paths: list[str] | None = None
|
||||||
sandbox_services: list[str] | None = None
|
sandbox_services: list[str] | None = None
|
||||||
|
sandbox_extensions: dict[str, list[str]] | None = None
|
||||||
|
|
||||||
@field_validator("sandbox_services")
|
@field_validator("sandbox_services")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None:
|
def _check_sandbox_services(cls, v: list[str] | None) -> list[str] | None:
|
||||||
return _normalize_sandbox_services(v)
|
return _normalize_sandbox_services(v)
|
||||||
|
|
||||||
|
@field_validator("sandbox_extensions")
|
||||||
|
@classmethod
|
||||||
|
def _check_sandbox_extensions(
|
||||||
|
cls, v: dict[str, list[str]] | None
|
||||||
|
) -> dict[str, list[str]] | None:
|
||||||
|
return _normalize_sandbox_extensions(v)
|
||||||
|
|||||||
@@ -2438,7 +2438,11 @@ class AgentOrchestrator:
|
|||||||
return list(project.sandbox_services or []) if project else []
|
return list(project.sandbox_services or []) if project else []
|
||||||
|
|
||||||
async def ensure_sandbox(
|
async def ensure_sandbox(
|
||||||
self, agent_slug: str, requested: list[str], opted: list[str]
|
self,
|
||||||
|
agent_slug: str,
|
||||||
|
requested: list[str],
|
||||||
|
opted: list[str],
|
||||||
|
features: dict[str, list[str]] | None = None,
|
||||||
) -> SandboxInfo:
|
) -> SandboxInfo:
|
||||||
"""Idempotent on-demand provision, called by the `request_sandbox` verb.
|
"""Idempotent on-demand provision, called by the `request_sandbox` verb.
|
||||||
|
|
||||||
@@ -2453,6 +2457,13 @@ class AgentOrchestrator:
|
|||||||
(rather than trusting the caller to always pass the full set) is
|
(rather than trusting the caller to always pass the full set) is
|
||||||
belt-and-suspenders — bounded by the project's own opt-in either way.
|
belt-and-suspenders — bounded by the project's own opt-in either way.
|
||||||
|
|
||||||
|
``features`` (per-service extensions/modules) is the union the verb
|
||||||
|
already computed (project standing union per-call, bounded by the opted
|
||||||
|
set + the allowlist). The cache-hit check extends to it: a cached
|
||||||
|
entry satisfies a new call iff the services are a subset AND every
|
||||||
|
requested feature per service is already cached — a feature superset
|
||||||
|
re-provisions (rotates creds), mirroring the services-superset case.
|
||||||
|
|
||||||
A cache hit is verified live (`SandboxProvisioner.is_live`) before
|
A cache hit is verified live (`SandboxProvisioner.is_live`) before
|
||||||
being trusted: a container OOM-killed or removed out-of-band evicts
|
being trusted: a container OOM-killed or removed out-of-band evicts
|
||||||
the stale entry and falls through to a fresh full-set provision
|
the stale entry and falls through to a fresh full-set provision
|
||||||
@@ -2463,6 +2474,7 @@ class AgentOrchestrator:
|
|||||||
race provision()/teardown() on the same containers.
|
race provision()/teardown() on the same containers.
|
||||||
"""
|
"""
|
||||||
full = sorted(set(requested) | set(opted))
|
full = sorted(set(requested) | set(opted))
|
||||||
|
feat_map = features or {}
|
||||||
# Lazily-allocated (no __init__ statement) to keep AgentOrchestrator's
|
# Lazily-allocated (no __init__ statement) to keep AgentOrchestrator's
|
||||||
# constructor under the statement-count gate; getattr guards bare
|
# constructor under the statement-count gate; getattr guards bare
|
||||||
# __new__() test doubles that never ran __init__ — same convention
|
# __new__() test doubles that never ran __init__ — same convention
|
||||||
@@ -2475,10 +2487,18 @@ class AgentOrchestrator:
|
|||||||
async with lock:
|
async with lock:
|
||||||
cached = self._sandbox_info.get(agent_slug)
|
cached = self._sandbox_info.get(agent_slug)
|
||||||
if cached is not None and set(full) <= set(cached.services):
|
if cached is not None and set(full) <= set(cached.services):
|
||||||
if await self._sandbox.is_live(agent_slug, sorted(cached.services)):
|
features_covered = all(
|
||||||
|
set(feat_map.get(svc, [])) <= set(cached.services[svc].features)
|
||||||
|
for svc in full
|
||||||
|
)
|
||||||
|
if features_covered and await self._sandbox.is_live(
|
||||||
|
agent_slug, sorted(cached.services)
|
||||||
|
):
|
||||||
return cached
|
return cached
|
||||||
self._sandbox_info.pop(agent_slug, None)
|
self._sandbox_info.pop(agent_slug, None)
|
||||||
info = await self._sandbox.provision(agent_slug, full)
|
info = await self._sandbox.provision(
|
||||||
|
agent_slug, full, features=feat_map or None
|
||||||
|
)
|
||||||
self._sandbox_info[agent_slug] = info
|
self._sandbox_info[agent_slug] = info
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|||||||
@@ -1985,29 +1985,90 @@ class ContentActions:
|
|||||||
)
|
)
|
||||||
return requested, None
|
return requested, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sandbox_features_scope(
|
||||||
|
project: Any,
|
||||||
|
extensions: dict[str, list[str]] | None,
|
||||||
|
opted: frozenset[str],
|
||||||
|
) -> tuple[dict[str, list[str]], Envelope | None]:
|
||||||
|
"""request_sandbox's extension guard: the per-service feature map to
|
||||||
|
activate (project standing union per-call, bounded by the opted set +
|
||||||
|
the allowlist), or a clean invalid_state rejection.
|
||||||
|
|
||||||
|
Per-call ``extensions`` is allowlist-validated HERE (not only at the
|
||||||
|
provisioner) so a ``plpython3u`` gets a remediate naming the allowed
|
||||||
|
set, mirroring the unknown-service remediate. The project's standing
|
||||||
|
``sandbox_extensions`` was allowlist-validated at write time, so it is
|
||||||
|
trusted and unioned in; entries for a service no longer opted into are
|
||||||
|
dropped (a venture may deactivate a service without clearing its
|
||||||
|
standing extensions). Returns only services with a non-empty feature
|
||||||
|
list — a service with no features is bare (the provisioner's default).
|
||||||
|
"""
|
||||||
|
from roboco.models.sandbox import SANDBOX_ENGINE_FEATURES
|
||||||
|
|
||||||
|
standing = (project.sandbox_extensions if project else None) or {}
|
||||||
|
# Union per service: standing (trusted) + per-call (validated below).
|
||||||
|
merged: dict[str, set[str]] = {}
|
||||||
|
for svc, feats in standing.items():
|
||||||
|
if svc in opted:
|
||||||
|
merged.setdefault(svc, set()).update(feats or [])
|
||||||
|
for svc, feats in (extensions or {}).items():
|
||||||
|
if svc not in opted:
|
||||||
|
return {}, Envelope.invalid_state(
|
||||||
|
message=(
|
||||||
|
f"extensions given for {svc!r}, which this project has "
|
||||||
|
f"not opted into"
|
||||||
|
),
|
||||||
|
remediate=(
|
||||||
|
f"this project's opted-in set is {sorted(opted)} — "
|
||||||
|
"request extensions only for those services"
|
||||||
|
),
|
||||||
|
context_briefing={},
|
||||||
|
)
|
||||||
|
allowed = SANDBOX_ENGINE_FEATURES.get(svc, frozenset())
|
||||||
|
bad = sorted(set(feats or []) - allowed)
|
||||||
|
if bad:
|
||||||
|
return {}, Envelope.invalid_state(
|
||||||
|
message=f"unallowed {svc} extension(s) {bad}",
|
||||||
|
remediate=(
|
||||||
|
f"the allowlist for {svc} is {sorted(allowed)} — "
|
||||||
|
"request a subset; plpython3u and other superuser-"
|
||||||
|
"language extensions are excluded by construction"
|
||||||
|
),
|
||||||
|
context_briefing={},
|
||||||
|
)
|
||||||
|
merged.setdefault(svc, set()).update(feats or [])
|
||||||
|
return {svc: sorted(f) for svc, f in merged.items() if f}, None
|
||||||
|
|
||||||
async def request_sandbox(
|
async def request_sandbox(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
agent_id: UUID,
|
agent_id: UUID,
|
||||||
services: list[str] | None = None,
|
services: list[str] | None = None,
|
||||||
|
extensions: dict[str, list[str]] | None = None,
|
||||||
) -> Envelope:
|
) -> Envelope:
|
||||||
"""On-demand sandbox DB/Redis/Mongo (dev + QA only, see role_config).
|
"""On-demand sandbox DB/Redis/Mongo (dev + QA only, see role_config).
|
||||||
|
|
||||||
Replaces eager per-spawn provisioning: a sandbox is created only when
|
Replaces eager per-spawn provisioning: a sandbox is created only when
|
||||||
an agent actually asks for one, keyed off the CALLER's authenticated
|
an agent actually asks for one, keyed off the CALLER's authenticated
|
||||||
slug (never another agent's). ``services`` omitted means the
|
slug (never another agent's). ``services`` omitted means the
|
||||||
project's whole opted-in set.
|
project's whole opted-in set. ``extensions`` (per-service
|
||||||
|
extensions/modules, e.g. ``{"postgres": ["vector"]}``) is an additive
|
||||||
|
per-call override unioned with the project's standing
|
||||||
|
``sandbox_extensions`` and bounded by the opted set + the allowlist —
|
||||||
|
a ``plpython3u`` is rejected here with the allowed set named.
|
||||||
|
|
||||||
Guards, in order: flag off; caller has no claimed/active,
|
Guards, in order: flag off; caller has no claimed/active,
|
||||||
project-bound task (`_sandbox_active_task`); project not opted into
|
project-bound task (`_sandbox_active_task`); project not opted into
|
||||||
any sandbox service, or a requested service outside its opted set
|
any sandbox service, or a requested service outside its opted set
|
||||||
(`_sandbox_scope`, names the allowed set); orchestrator handle
|
(`_sandbox_scope`, names the allowed set); per-call extensions for a
|
||||||
unavailable (retryable). `ensure_sandbox` always provisions the
|
non-opted service or outside the allowlist (`_sandbox_features_scope`,
|
||||||
project's whole opted-in set regardless of ``services`` (so a later
|
names the allowed set); orchestrator handle unavailable (retryable).
|
||||||
call can never trigger a mid-session teardown of a live container);
|
`ensure_sandbox` always provisions the project's whole opted-in set
|
||||||
the evidence payload here is filtered back down to what THIS call
|
regardless of ``services`` (so a later call can never trigger a
|
||||||
asked for. Creds come back in the evidence payload, never as
|
mid-session teardown of a live container); the evidence payload here
|
||||||
injected env — see
|
is filtered back down to what THIS call asked for. Creds come back in
|
||||||
|
the evidence payload, never as injected env — see
|
||||||
``docs/internal/specs/2026-07-08-sandbox-on-demand.md`` §4.
|
``docs/internal/specs/2026-07-08-sandbox-on-demand.md`` §4.
|
||||||
"""
|
"""
|
||||||
if not settings.sandbox_db_enabled:
|
if not settings.sandbox_db_enabled:
|
||||||
@@ -2026,10 +2087,16 @@ class ContentActions:
|
|||||||
from roboco.services.project import get_project_service
|
from roboco.services.project import get_project_service
|
||||||
|
|
||||||
project = await get_project_service(self.task.session).get(t.project_id)
|
project = await get_project_service(self.task.session).get(t.project_id)
|
||||||
requested, rejection = self._sandbox_scope(project, services)
|
requested, rej_scope = self._sandbox_scope(project, services)
|
||||||
|
opted = frozenset(project.sandbox_services or []) if project else frozenset()
|
||||||
|
features, rej_features = self._sandbox_features_scope(
|
||||||
|
project, extensions, opted
|
||||||
|
)
|
||||||
|
# Scope before features: an unknown-service rejection wins over a
|
||||||
|
# per-call extension rejection for the same call.
|
||||||
|
rejection = rej_scope or rej_features
|
||||||
if rejection is not None:
|
if rejection is not None:
|
||||||
return rejection
|
return rejection
|
||||||
opted = frozenset(project.sandbox_services or []) if project else frozenset()
|
|
||||||
if self.orchestrator is None:
|
if self.orchestrator is None:
|
||||||
return Envelope.invalid_state(
|
return Envelope.invalid_state(
|
||||||
message="orchestrator handle unavailable — cannot provision a sandbox",
|
message="orchestrator handle unavailable — cannot provision a sandbox",
|
||||||
@@ -2044,7 +2111,7 @@ class ContentActions:
|
|||||||
agent_slug = _resolve_to_slug(str(agent_id))
|
agent_slug = _resolve_to_slug(str(agent_id))
|
||||||
try:
|
try:
|
||||||
info = await self.orchestrator.ensure_sandbox(
|
info = await self.orchestrator.ensure_sandbox(
|
||||||
agent_slug, sorted(requested), sorted(opted)
|
agent_slug, sorted(requested), sorted(opted), features=features or None
|
||||||
)
|
)
|
||||||
except SandboxProvisionError as e:
|
except SandboxProvisionError as e:
|
||||||
return Envelope.invalid_state(
|
return Envelope.invalid_state(
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Per-project sandbox extensions opt-in column (migration 072).
|
||||||
|
|
||||||
|
Migration 072 adds ``projects.sandbox_extensions`` (jsonb null). The real
|
||||||
|
upgrade/downgrade chain is verified separately against a throwaway Postgres;
|
||||||
|
these assertions guard the resulting schema shape and a value round-trip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.db.tables import AgentTable, ProjectTable
|
||||||
|
from roboco.models import AgentRole, AgentStatus, Team
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_project(db_session: AsyncSession) -> ProjectTable:
|
||||||
|
agent = AgentTable(
|
||||||
|
id=uuid4(),
|
||||||
|
name="Dev",
|
||||||
|
slug=f"be-dev-{uuid4().hex[:8]}",
|
||||||
|
role=AgentRole.DEVELOPER,
|
||||||
|
team=Team.BACKEND,
|
||||||
|
status=AgentStatus.ACTIVE,
|
||||||
|
model_config={},
|
||||||
|
system_prompt="dev",
|
||||||
|
capabilities=[],
|
||||||
|
permissions={},
|
||||||
|
metrics={},
|
||||||
|
)
|
||||||
|
db_session.add(agent)
|
||||||
|
await db_session.flush()
|
||||||
|
project = ProjectTable(
|
||||||
|
id=uuid4(),
|
||||||
|
name="B-Proj",
|
||||||
|
slug=f"b-proj-{uuid4().hex[:8]}",
|
||||||
|
git_url="https://example.com/r.git",
|
||||||
|
assigned_cell=Team.BACKEND,
|
||||||
|
created_by=agent.id,
|
||||||
|
)
|
||||||
|
db_session.add(project)
|
||||||
|
await db_session.flush()
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandbox_extensions_column_default_null(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
project = await _seed_project(db_session)
|
||||||
|
assert project.sandbox_extensions is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sandbox_extensions_column_round_trip(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
project = await _seed_project(db_session)
|
||||||
|
project.sandbox_extensions = {
|
||||||
|
"postgres": ["vector", "postgis"],
|
||||||
|
"redis": ["search"],
|
||||||
|
}
|
||||||
|
await db_session.flush()
|
||||||
|
row = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(ProjectTable).where(ProjectTable.id == project.id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert row.sandbox_extensions == {
|
||||||
|
"postgres": ["vector", "postgis"],
|
||||||
|
"redis": ["search"],
|
||||||
|
}
|
||||||
@@ -46,8 +46,12 @@ def _task(project_id: object | None = uuid4()) -> MagicMock:
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
def _stub_project(monkeypatch: pytest.MonkeyPatch, services: list[str] | None) -> None:
|
def _stub_project(
|
||||||
project = MagicMock(sandbox_services=services)
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
services: list[str] | None,
|
||||||
|
extensions: dict[str, list[str]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
project = MagicMock(sandbox_services=services, sandbox_extensions=extensions)
|
||||||
project_service = MagicMock()
|
project_service = MagicMock()
|
||||||
project_service.get = AsyncMock(return_value=project)
|
project_service.get = AsyncMock(return_value=project)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -55,7 +59,9 @@ def _stub_project(monkeypatch: pytest.MonkeyPatch, services: list[str] | None) -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sandbox_info() -> SandboxInfo:
|
def _sandbox_info(
|
||||||
|
features: tuple[str, ...] = (),
|
||||||
|
) -> SandboxInfo:
|
||||||
return SandboxInfo(
|
return SandboxInfo(
|
||||||
services={
|
services={
|
||||||
"postgres": SandboxConnection(
|
"postgres": SandboxConnection(
|
||||||
@@ -64,6 +70,7 @@ def _sandbox_info() -> SandboxInfo:
|
|||||||
password="pw",
|
password="pw",
|
||||||
user="sandbox",
|
user="sandbox",
|
||||||
database="sandbox",
|
database="sandbox",
|
||||||
|
features=features,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -287,3 +294,130 @@ async def test_ensure_sandbox_keyed_off_caller_own_slug(
|
|||||||
assert slugs_called[0] != slugs_called[1]
|
assert slugs_called[0] != slugs_called[1]
|
||||||
assert slugs_called[0] == str(agent_a)
|
assert slugs_called[0] == str(agent_a)
|
||||||
assert slugs_called[1] == str(agent_b)
|
assert slugs_called[1] == str(agent_b)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Extensions — per-service additive override, allowlist-guarded
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extensions_additive_unioned_with_project_standing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Per-call extensions union with the project's standing set (bounded by
|
||||||
|
the opted set + allowlist) and reach ensure_sandbox as the features kwarg."""
|
||||||
|
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
|
||||||
|
_stub_project(
|
||||||
|
monkeypatch,
|
||||||
|
services=["postgres"],
|
||||||
|
extensions={"postgres": ["vector"]},
|
||||||
|
)
|
||||||
|
orch = AsyncMock()
|
||||||
|
orch.ensure_sandbox.return_value = _sandbox_info()
|
||||||
|
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
|
||||||
|
|
||||||
|
await actions.request_sandbox(
|
||||||
|
agent_id=uuid4(), extensions={"postgres": ["postgis"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
features = orch.ensure_sandbox.call_args.kwargs["features"]
|
||||||
|
assert features == {"postgres": ["postgis", "vector"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_standing_extensions_passed_with_no_per_call(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
|
||||||
|
_stub_project(
|
||||||
|
monkeypatch,
|
||||||
|
services=["postgres"],
|
||||||
|
extensions={"postgres": ["vector"]},
|
||||||
|
)
|
||||||
|
orch = AsyncMock()
|
||||||
|
orch.ensure_sandbox.return_value = _sandbox_info()
|
||||||
|
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
|
||||||
|
|
||||||
|
await actions.request_sandbox(agent_id=uuid4())
|
||||||
|
|
||||||
|
assert orch.ensure_sandbox.call_args.kwargs["features"] == {"postgres": ["vector"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_extensions_passes_none_features(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Bare call (no standing, no per-call) → features=None (bare provision)."""
|
||||||
|
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
|
||||||
|
_stub_project(monkeypatch, services=["postgres"])
|
||||||
|
orch = AsyncMock()
|
||||||
|
orch.ensure_sandbox.return_value = _sandbox_info()
|
||||||
|
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
|
||||||
|
|
||||||
|
await actions.request_sandbox(agent_id=uuid4())
|
||||||
|
|
||||||
|
assert orch.ensure_sandbox.call_args.kwargs["features"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extensions_rejects_plpython_names_allowlist(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""plpython3u is rejected at the verb with the allowlist named in remediate
|
||||||
|
(not only at the provisioner), mirroring the unknown-service remediate."""
|
||||||
|
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
|
||||||
|
_stub_project(monkeypatch, services=["postgres"])
|
||||||
|
orch = AsyncMock()
|
||||||
|
orch.ensure_sandbox.return_value = _sandbox_info()
|
||||||
|
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
|
||||||
|
|
||||||
|
env = await actions.request_sandbox(
|
||||||
|
agent_id=uuid4(), extensions={"postgres": ["plpython3u"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert env.error == "invalid_state"
|
||||||
|
remediate = env.remediate or ""
|
||||||
|
assert "vector" in remediate # the allowlist is named
|
||||||
|
orch.ensure_sandbox.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extensions_for_non_opted_service_rejected(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
|
||||||
|
_stub_project(monkeypatch, services=["postgres"])
|
||||||
|
orch = AsyncMock()
|
||||||
|
orch.ensure_sandbox.return_value = _sandbox_info()
|
||||||
|
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
|
||||||
|
|
||||||
|
env = await actions.request_sandbox(
|
||||||
|
agent_id=uuid4(), extensions={"redis": ["search"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert env.error == "invalid_state"
|
||||||
|
orch.ensure_sandbox.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_available_extensions_surfaced_in_evidence(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The evidence payload carries available_extensions so the agent doesn't
|
||||||
|
guess what was activated."""
|
||||||
|
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
|
||||||
|
_stub_project(
|
||||||
|
monkeypatch,
|
||||||
|
services=["postgres"],
|
||||||
|
extensions={"postgres": ["vector", "postgis"]},
|
||||||
|
)
|
||||||
|
orch = AsyncMock()
|
||||||
|
orch.ensure_sandbox.return_value = _sandbox_info(features=("postgis", "vector"))
|
||||||
|
actions, _task_svc = _make_actions(task_obj=_task(), orchestrator=orch)
|
||||||
|
|
||||||
|
env = await actions.request_sandbox(agent_id=uuid4())
|
||||||
|
|
||||||
|
assert env.error is None
|
||||||
|
assert env.evidence is not None
|
||||||
|
assert env.evidence["postgres"]["available_extensions"] == ["postgis", "vector"]
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ from roboco.models.base import Team
|
|||||||
from roboco.models.project import Project, ProjectUpdate
|
from roboco.models.project import Project, ProjectUpdate
|
||||||
|
|
||||||
|
|
||||||
def _project(sandbox_services: list[str] | None = None) -> Project:
|
def _project(
|
||||||
|
sandbox_services: list[str] | None = None,
|
||||||
|
sandbox_extensions: dict[str, list[str]] | None = None,
|
||||||
|
) -> Project:
|
||||||
return Project(
|
return Project(
|
||||||
name="P",
|
name="P",
|
||||||
slug="p",
|
slug="p",
|
||||||
@@ -24,6 +27,7 @@ def _project(sandbox_services: list[str] | None = None) -> Project:
|
|||||||
assigned_cell=Team.BACKEND,
|
assigned_cell=Team.BACKEND,
|
||||||
created_by=uuid4(),
|
created_by=uuid4(),
|
||||||
sandbox_services=sandbox_services,
|
sandbox_services=sandbox_services,
|
||||||
|
sandbox_extensions=sandbox_extensions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,3 +69,63 @@ def test_project_update_rejects_unknown_sandbox_service() -> None:
|
|||||||
def test_project_update_accepts_empty_list() -> None:
|
def test_project_update_accepts_empty_list() -> None:
|
||||||
update = ProjectUpdate(sandbox_services=[])
|
update = ProjectUpdate(sandbox_services=[])
|
||||||
assert update.sandbox_services == []
|
assert update.sandbox_services == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sandbox_extensions — per-service allowlist-validated extension/module map.
|
||||||
|
# The allowlist is the security containment: a plpython3u (superuser-RCE) must
|
||||||
|
# be rejected at the model boundary, never persisted.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_accepts_valid_sandbox_extensions() -> None:
|
||||||
|
project = _project(sandbox_extensions={"postgres": ["vector", "postgis"]})
|
||||||
|
assert project.sandbox_extensions == {"postgres": ["postgis", "vector"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_sandbox_extensions_normalizes_order_and_dedupes() -> None:
|
||||||
|
project = _project(
|
||||||
|
sandbox_extensions={"postgres": ["postgis", "vector", "postgis"]}
|
||||||
|
)
|
||||||
|
assert project.sandbox_extensions == {"postgres": ["postgis", "vector"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_sandbox_extensions_defaults_to_none() -> None:
|
||||||
|
assert _project().sandbox_extensions is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_sandbox_extensions_rejects_plpython() -> None:
|
||||||
|
"""plpython3u is a superuser-RCE vector — the allowlist rejects it."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_project(sandbox_extensions={"postgres": ["plpython3u"]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_sandbox_extensions_rejects_unallowed_redis_module() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_project(sandbox_extensions={"redis": ["not_a_module"]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_sandbox_extensions_rejects_feature_for_unknown_service() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_project(sandbox_extensions={"mysql": ["vector"]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_sandbox_extensions_drops_empty_feature_list() -> None:
|
||||||
|
"""A service with an empty feature list is bare — dropped, not stored."""
|
||||||
|
project = _project(sandbox_extensions={"postgres": []})
|
||||||
|
assert project.sandbox_extensions is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_sandbox_extensions_drops_bare_keeps_others() -> None:
|
||||||
|
project = _project(sandbox_extensions={"postgres": [], "redis": ["search"]})
|
||||||
|
assert project.sandbox_extensions == {"redis": ["search"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_update_accepts_valid_sandbox_extensions() -> None:
|
||||||
|
update = ProjectUpdate(sandbox_extensions={"redis": ["json", "bloom"]})
|
||||||
|
assert update.sandbox_extensions == {"redis": ["bloom", "json"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_update_rejects_plpython() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProjectUpdate(sandbox_extensions={"postgres": ["plpython3u"]})
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ async def test_ensure_sandbox_miss_provisions_and_caches() -> None:
|
|||||||
result = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
|
result = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
|
||||||
|
|
||||||
assert result is info
|
assert result is info
|
||||||
sandbox.provision.assert_awaited_once_with("dev-1", ["postgres"])
|
sandbox.provision.assert_awaited_once_with("dev-1", ["postgres"], features=None)
|
||||||
assert orch._sandbox_info["dev-1"] is info
|
assert orch._sandbox_info["dev-1"] is info
|
||||||
|
|
||||||
|
|
||||||
@@ -198,7 +198,9 @@ async def test_ensure_sandbox_first_subset_request_provisions_full_opted_set() -
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert first is second is info
|
assert first is second is info
|
||||||
sandbox.provision.assert_awaited_once_with("dev-1", ["postgres", "redis"])
|
sandbox.provision.assert_awaited_once_with(
|
||||||
|
"dev-1", ["postgres", "redis"], features=None
|
||||||
|
)
|
||||||
assert orch._sandbox_info["dev-1"] is info
|
assert orch._sandbox_info["dev-1"] is info
|
||||||
|
|
||||||
|
|
||||||
@@ -229,7 +231,9 @@ async def test_ensure_sandbox_concurrent_calls_serialize_on_agent_lock() -> None
|
|||||||
info = _info({"postgres": SandboxConnection(host="h", port=5432, password="pw")})
|
info = _info({"postgres": SandboxConnection(host="h", port=5432, password="pw")})
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
||||||
async def _slow_provision(_agent_id: str, _services: list[str]) -> SandboxInfo:
|
async def _slow_provision(
|
||||||
|
_agent_id: str, _services: list[str], **_kw: object
|
||||||
|
) -> SandboxInfo:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
@@ -270,3 +274,64 @@ async def test_ensure_sandbox_cache_hit_with_dead_container_reprovisions() -> No
|
|||||||
assert sandbox.provision.await_count == expected_provision_calls
|
assert sandbox.provision.await_count == expected_provision_calls
|
||||||
assert orch._sandbox_info["dev-1"] is fresh_info
|
assert orch._sandbox_info["dev-1"] is fresh_info
|
||||||
sandbox.is_live.assert_awaited_once_with("dev-1", ["postgres"])
|
sandbox.is_live.assert_awaited_once_with("dev-1", ["postgres"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache-by-features: a cached entry satisfies a new call iff the services are
|
||||||
|
# a subset AND every requested feature per service is already cached. A feature
|
||||||
|
# superset re-provisions (rotates creds), mirroring the services-superset case.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_sandbox_features_subset_is_cache_hit() -> None:
|
||||||
|
orch, sandbox = _make_orchestrator()
|
||||||
|
info = SandboxInfo(
|
||||||
|
services={
|
||||||
|
"postgres": SandboxConnection(
|
||||||
|
host="h", port=5432, password="pw", features=("postgis", "vector")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sandbox.provision.return_value = info
|
||||||
|
|
||||||
|
first = await orch.ensure_sandbox(
|
||||||
|
"dev-1",
|
||||||
|
["postgres"],
|
||||||
|
["postgres"],
|
||||||
|
features={"postgres": ["postgis", "vector"]},
|
||||||
|
)
|
||||||
|
second = await orch.ensure_sandbox(
|
||||||
|
"dev-1", ["postgres"], ["postgres"], features={"postgres": ["vector"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first is second is info
|
||||||
|
sandbox.provision.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_sandbox_features_superset_reprovisions() -> None:
|
||||||
|
orch, sandbox = _make_orchestrator()
|
||||||
|
info = SandboxInfo(
|
||||||
|
services={
|
||||||
|
"postgres": SandboxConnection(
|
||||||
|
host="h", port=5432, password="pw", features=("vector",)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sandbox.provision.return_value = info
|
||||||
|
|
||||||
|
await orch.ensure_sandbox(
|
||||||
|
"dev-1", ["postgres"], ["postgres"], features={"postgres": ["vector"]}
|
||||||
|
)
|
||||||
|
await orch.ensure_sandbox(
|
||||||
|
"dev-1",
|
||||||
|
["postgres"],
|
||||||
|
["postgres"],
|
||||||
|
features={"postgres": ["postgis", "vector"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_provision_calls = 2
|
||||||
|
assert sandbox.provision.await_count == expected_provision_calls
|
||||||
|
second_features = sandbox.provision.call_args_list[1].kwargs["features"]
|
||||||
|
assert second_features == {"postgres": ["postgis", "vector"]}
|
||||||
|
|||||||
Reference in New Issue
Block a user