From 6336e8208294887d82e3a35f44a177a2cbb93c9f Mon Sep 17 00:00:00 2001
From: Renn F
Date: Mon, 13 Jul 2026 16:57:32 +0200
Subject: [PATCH] [sandbox-ext] Phase 4: panel extension picker + allowlist
docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Project edit dialog (Sandbox section) exposes a per-service extension
picker — Switches from the allowlist grouped under each enabled service
(postgres: pgvector/PostGIS/pg_trgm/citext/uuid-ossp; redis: RediSearch/
RedisJSON/RedisBloom; mongo has none), mirroring the backend
SANDBOX_ENGINE_FEATURES allowlist. State holds a per-service Set; payload
builds sandbox_extensions only for enabled services with non-empty picks
(empty {} clears the column, mirroring sandbox_services' always-send —
exclude_unset + no exclude_none means an explicit {} writes NULL). The
picker renders only for opted-in services with activatable features.
Types: Project.sandbox_extensions (Record | null),
ProjectUpdate.sandbox_extensions? (not on ProjectCreate, mirroring
sandbox_services). Mock create seeds null.
Docs name the allowlist (the security containment — no plpython3u), the
no-default-set rule (opters set explicitly, existing opters stay bare), the
standing-vs-per-call union, cache-by-features, kitchen-sink image selection,
and the recommendation to set the full set in project settings so agents
request subsets. sandbox-db.md gains an Extensions section; task-tools.md
and config-reference.md updated; CLAUDE.md sandbox paragraph extended.
Gate: panel typecheck + lint + prettier clean, 516 tests pass.
---
CLAUDE.md | 2 +-
docs/rag/architecture/config-reference.md | 2 +-
docs/rag/architecture/sandbox-db.md | 26 +++++-
docs/rag/tools/task-tools.md | 2 +-
.../projects/edit-project-dialog.tsx | 81 +++++++++++++++++++
panel/src/lib/api/projects.ts | 1 +
panel/src/types/index.ts | 2 +
7 files changed, 110 insertions(+), 6 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 4449108d..d599647c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -392,7 +392,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Organizational memory loop (default-off).** Closes the learn→reuse loop so agents stop cold-respawning blind. Three parts, all gated by `ROBOCO_ORG_MEMORY_ENABLED`: ① **capture** — at task completion `TaskService._completion_learnings_for` distills ONE high-signal lesson (Problem→Approach→Gotcha, ≤120 words) via the local model (`MemoryDistiller`, `roboco/services/memory_distiller.py`) instead of the noisy raw-notes/duration capture (flag-off keeps the legacy capture); journal indexing excludes `is_private` reflections from the shared corpus. ② **retrieve (keystone)** — on claim, `_briefing_for` injects `context_briefing["institutional_memory"]`: top-K (`ROBOCO_ORG_MEMORY_TOP_K`) relevance-floored (`ROBOCO_ORG_MEMORY_MIN_SCORE`) lessons + approved playbooks from a role-shaped query (`EvidenceRepo.similar_memory` over the LEARNINGS + PLAYBOOKS pgvector indexes); below the floor nothing is injected (no briefing bloat). ③ **playbooks** — a first-class curated procedure store: `PlaybookTable` (migration 050), the `PLAYBOOKS` OptimalService index, the `draft_playbook` content verb (delivery roles), Auditor `approve_playbook`/`reject_playbook`/`archive_playbook` curation (approval indexes it), and the panel review queue (`playbook-review-queue.tsx`; `/api/playbooks` Auditor/CEO routes). Distillation runs on the local model only — never a cloud LLM in the hot path; every step is best-effort (a failure never blocks completion or the briefing).
-**Sandboxed dev DB/Redis/Mongo (default-off).** Per-project opt-in (`projects.sandbox_services`, migration 057); when armed (`ROBOCO_SANDBOX_DB_ENABLED`), provisioning is **on-demand (2026-07-08)**, not eager at spawn: a developer or QA agent calls the `request_sandbox` do-verb (role-scoped to `_DEV_DO`/`_QA_DO` in `role_config.py`; `services` omitted means the project's whole opted-in set) and `ContentActions.request_sandbox` (`roboco/services/gateway/content_actions.py`) walks a guard chain — flag off; no active project-bound task; project not opted into any service; a requested service outside the opted set (remediate names the allowed set); orchestrator handle unavailable (the one **retryable** guard) — before calling `AgentOrchestrator.ensure_sandbox`, which always provisions the project's whole opted-in set regardless of the requested subset (so a later subset/superset request within that set is a guaranteed cache hit and can never trigger a mid-session teardown of a live container the agent is using), verifies a cache hit is still live before trusting it (evicting + re-provisioning on a dead container), serializes concurrent calls for one agent behind a per-slug `asyncio.Lock`, and caches the result in-memory per agent slug (`_sandbox_info`) — the verb filters the returned creds back down to what this call actually asked for. Sibling containers get random per-sandbox creds, tmpfs data dir, memory/cpu caps, labeled `roboco.sandbox=1`; creds return in the verb's envelope `evidence` (`SandboxInfo.as_payload()`), one entry per service including a ready-to-`export` `env` sub-dict (`ROBOCO_TEST_DB_*` / `ROBOCO_TEST_REDIS_*` / `ROBOCO_TEST_MONGO_*`) — never injected as container env, so no spawn-time creds delivery exists at all. Spawn itself only injects a cheap marker env `ROBOCO_SANDBOX_SERVICES_AVAILABLE=` (never creds) for an opted-in project, plus a briefing line naming `request_sandbox()` explicitly, **in place of** the legacy prod-creds gate-env injection (`_append_gate_env`, which points agents at RoboCo's own production Postgres under `ROBOCO_TOOLCHAIN_MATCH_ENABLED`) — sandbox replaces, never coexists with, prod creds. A provisioning failure now surfaces as a retryable envelope on the verb, never a spawn refusal — sandbox trouble can no longer block dispatch. A sandbox is torn down at end-of-engagement, not just at container removal: `AgentOrchestrator.release_sandbox(agent_slug)` is called (best-effort, never failing the verb; a fast cache-check no-op when nothing was ever requested) by the Choreographer on the SUCCESSFUL exit of `i_am_done` / `unclaim` / `i_am_idle` / `pass_review` / `fail_review` / `i_documented`, so a sidecar doesn't outlive the work that requested it. Lifetime still tracks the agent container 1:1 as the backstop: teardown at every removal path plus an orphan janitor at startup + each reaper tick (grace-windowed so a sweep can't reap a sandbox whose request is still mid-flight; the pre-spawn stale-clear likewise spares a just-requested sandbox) also evicts the `_sandbox_info` cache entry. **Known ceiling:** the cache is in-memory only — an orchestrator restart forgets live sandboxes, so the next `request_sandbox` call re-provisions (the pre-clear tears down any still-running stale container) and returns fresh creds. Docker-in-agent stays structurally absent throughout. The service set is a **pluggable engine registry** (`roboco/models/sandbox.py`): each engine declares its image, run args, readiness probe, and `ROBOCO_TEST_*` env; `VALID_SANDBOX_SERVICES` is derived from the registry, and the provisioner + the verb's payload builder iterate it, so adding an engine (e.g. mongo) is one class + one registry line — no branch edited in the provisioner or the env emitter.
+**Sandboxed dev DB/Redis/Mongo (default-off).** Per-project opt-in (`projects.sandbox_services`, migration 057); when armed (`ROBOCO_SANDBOX_DB_ENABLED`), provisioning is **on-demand (2026-07-08)**, not eager at spawn: a developer or QA agent calls the `request_sandbox` do-verb (role-scoped to `_DEV_DO`/`_QA_DO` in `role_config.py`; `services` omitted means the project's whole opted-in set) and `ContentActions.request_sandbox` (`roboco/services/gateway/content_actions.py`) walks a guard chain — flag off; no active project-bound task; project not opted into any service; a requested service outside the opted set (remediate names the allowed set); orchestrator handle unavailable (the one **retryable** guard) — before calling `AgentOrchestrator.ensure_sandbox`, which always provisions the project's whole opted-in set regardless of the requested subset (so a later subset/superset request within that set is a guaranteed cache hit and can never trigger a mid-session teardown of a live container the agent is using), verifies a cache hit is still live before trusting it (evicting + re-provisioning on a dead container), serializes concurrent calls for one agent behind a per-slug `asyncio.Lock`, and caches the result in-memory per agent slug (`_sandbox_info`) — the verb filters the returned creds back down to what this call actually asked for. Sibling containers get random per-sandbox creds, tmpfs data dir, memory/cpu caps, labeled `roboco.sandbox=1`; creds return in the verb's envelope `evidence` (`SandboxInfo.as_payload()`), one entry per service including a ready-to-`export` `env` sub-dict (`ROBOCO_TEST_DB_*` / `ROBOCO_TEST_REDIS_*` / `ROBOCO_TEST_MONGO_*`) — never injected as container env, so no spawn-time creds delivery exists at all. Spawn itself only injects a cheap marker env `ROBOCO_SANDBOX_SERVICES_AVAILABLE=` (never creds) for an opted-in project, plus a briefing line naming `request_sandbox()` explicitly, **in place of** the legacy prod-creds gate-env injection (`_append_gate_env`, which points agents at RoboCo's own production Postgres under `ROBOCO_TOOLCHAIN_MATCH_ENABLED`) — sandbox replaces, never coexists with, prod creds. A provisioning failure now surfaces as a retryable envelope on the verb, never a spawn refusal — sandbox trouble can no longer block dispatch. A sandbox is torn down at end-of-engagement, not just at container removal: `AgentOrchestrator.release_sandbox(agent_slug)` is called (best-effort, never failing the verb; a fast cache-check no-op when nothing was ever requested) by the Choreographer on the SUCCESSFUL exit of `i_am_done` / `unclaim` / `i_am_idle` / `pass_review` / `fail_review` / `i_documented`, so a sidecar doesn't outlive the work that requested it. Lifetime still tracks the agent container 1:1 as the backstop: teardown at every removal path plus an orphan janitor at startup + each reaper tick (grace-windowed so a sweep can't reap a sandbox whose request is still mid-flight; the pre-spawn stale-clear likewise spares a just-requested sandbox) also evicts the `_sandbox_info` cache entry. **Known ceiling:** the cache is in-memory only — an orchestrator restart forgets live sandboxes, so the next `request_sandbox` call re-provisions (the pre-clear tears down any still-running stale container) and returns fresh creds. Docker-in-agent stays structurally absent throughout. The service set is a **pluggable engine registry** (`roboco/models/sandbox.py`): each engine declares its image, run args, readiness probe, and `ROBOCO_TEST_*` env; `VALID_SANDBOX_SERVICES` is derived from the registry, and the provisioner + the verb's payload builder iterate it, so adding an engine (e.g. mongo) is one class + one registry line — no branch edited in the provisioner or the env emitter. **Extensions/modules on the fly (2026-07-13):** a project may declare `sandbox_extensions` (migration 072, jsonb null) — a per-service extension/module map (e.g. `{"postgres": ["vector", "postgis"], "redis": ["search"]}`) activated post-ready via `docker exec` (`CREATE EXTENSION IF NOT EXISTS` / `MODULE LOAD`) then verified; `request_sandbox(extensions=...)` unions a per-call override with the project's standing set, bounded to the opted set + a fixed allowlist (`SANDBOX_PG_EXTENSIONS` = vector/postgis/pg_trgm/citext/uuid-ossp, `SANDBOX_REDIS_MODULES` = search/json/bloom — `plpython3u` excluded by construction; mongo has none). No default set — opters set extensions explicitly, existing opters stay bare. A bare request uses the light upstream image; features pull a kitchen-sink image (`image_for(features)`), so the pgvector+postgis intersection just works. Cache-by-features: a cached entry satisfies a new call iff services are a subset AND per-service requested features are a subset of cached features; a superset re-provisions (rotates creds). The evidence entry carries `available_extensions`. Set the full set in project settings so agents request subsets.
**Cloud auth via FastAPI Users (default-off).** Lets the panel/API be safely exposed beyond localhost without touching the CEO's local no-login flow while off. Gated by `ROBOCO_CLOUD_AUTH_ENABLED` (+ `ROBOCO_CLOUD_AUTH_EMAIL` / `_PASSWORD` / `_SECRET` / `_COOKIE_MAX_AGE`; `Settings` fails loud at startup if the flag is on with no secret). Off: `get_agent_context` (`roboco/api/deps.py`) and the WS `_require_panel_token` gate (`roboco/api/websocket.py`) are byte-for-byte unchanged (header-trust). On: header-trust is dead for humans — any agent-role claim (`ceo` OR a privileged `main_pm`/`cell_pm`/board role) with no valid HMAC token or session cookie is 401, closing the header-spoof hole on the host-published `:8000` port for every role, not just `ceo` (real agents always carry a signed token, so they're unaffected); the agent-fleet HMAC path (and the orchestrator's `system` self-PATCH) keeps working unmodified in both modes; a valid session cookie authenticates as the single seeded CEO user. New `users` table (migration 058, `UserTable` in `roboco/db/tables.py`) backs FastAPI Users' `SQLAlchemyUserDatabase`; no registration router — `roboco/api/auth/seed.py` idempotently upserts exactly one row from env at startup (by primary key, so an email change renames the row instead of duplicating it). `roboco/api/auth/backend.py` wires a **cookie** transport (httponly, secure, samesite=lax) + a `JWTStrategy` subclass that binds each token to a fingerprint of the current `hashed_password`, so rotating the seeded password invalidates every prior session. Session lifetime is **sliding**: every authenticated request through `get_agent_context` re-mints + re-sets the cookie (`_slide_session_cookie`), so an active session never expires — only genuine inactivity past `cloud_auth_cookie_max_age` (default 30 days) logs out. `GET /api/auth/status` is always mounted (public); `/api/auth/login` + `/api/auth/logout` mount only when armed (`roboco/api/auth/routes.py`, mirroring `apply_guard`'s conditional mount). Panel: `(auth)/login/page.tsx` + `proxy.ts` (the Next 16 rename of `middleware.ts`; probes `/auth/status` over the docker-internal orchestrator URL, not through nginx, and fails open to "off" on any probe error/timeout) gate the `(dashboard)` group; `client.ts` adds `withCredentials` + a 401→`/login` redirect. nginx needs no changes (`/api/auth/*` rides the existing `/api/` proxy location) — but its own static `X-Agent-Token` injection (`ROBOCO_PANEL_AGENT_TOKEN`) is itself a valid HMAC credential that bypasses login when present, so a deployment arming cloud auth for real public exposure should leave that token unset (the two are alternative human-auth tiers, not layered).
diff --git a/docs/rag/architecture/config-reference.md b/docs/rag/architecture/config-reference.md
index 5ec12254..02a31680 100644
--- a/docs/rag/architecture/config-reference.md
+++ b/docs/rag/architecture/config-reference.md
@@ -156,7 +156,7 @@ Per-agent-spawn throwaway Postgres/Redis/Mongo, replacing (never coexisting with
| Variable | Default | Description |
|----------|---------|-------------|
-| `ROBOCO_SANDBOX_DB_ENABLED` | `false` | Master switch. Off = spawning behaves exactly as today (the legacy `_append_gate_env` prod-creds injection, itself gated by `ROBOCO_TOOLCHAIN_MATCH_ENABLED`). Only projects with their `sandbox_services` column set (migration `057`) participate even when on. The valid service set is `VALID_SANDBOX_SERVICES` in `roboco/models/sandbox.py` (registry-derived: postgres / redis / mongo); adding an engine is one class + one registry line, no orchestrator edit. Env injected per engine: `ROBOCO_TEST_DB_*`, `ROBOCO_TEST_REDIS_*`, `ROBOCO_TEST_MONGO_*` (incl. `ROBOCO_TEST_MONGO_AUTH_DB=admin`). |
+| `ROBOCO_SANDBOX_DB_ENABLED` | `false` | Master switch. Off = spawning behaves exactly as today (the legacy `_append_gate_env` prod-creds injection, itself gated by `ROBOCO_TOOLCHAIN_MATCH_ENABLED`). Only projects with their `sandbox_services` column set (migration `057`) participate even when on. The valid service set is `VALID_SANDBOX_SERVICES` in `roboco/models/sandbox.py` (registry-derived: postgres / redis / mongo); adding an engine is one class + one registry line, no orchestrator edit. Env injected per engine: `ROBOCO_TEST_DB_*`, `ROBOCO_TEST_REDIS_*`, `ROBOCO_TEST_MONGO_*` (incl. `ROBOCO_TEST_MONGO_AUTH_DB=admin`). A project may also declare `sandbox_extensions` (migration `072`) — a per-service extension/module map activated post-ready via `docker exec`, bounded by a fixed allowlist (`SANDBOX_PG_EXTENSIONS` / `SANDBOX_REDIS_MODULES`; no `plpython3u`). See `docs/rag/architecture/sandbox-db.md`. |
## X (Twitter) Engine
diff --git a/docs/rag/architecture/sandbox-db.md b/docs/rag/architecture/sandbox-db.md
index f9988a5f..01d0ba79 100644
--- a/docs/rag/architecture/sandbox-db.md
+++ b/docs/rag/architecture/sandbox-db.md
@@ -31,16 +31,20 @@ A second, per-project gate applies even when the flag is on: only a project with
Provisioning is **on-demand**: nothing is provisioned at spawn. A developer or QA agent calls the `request_sandbox` content tool (on `roboco-do`) when it actually needs a sandboxed DB, and the orchestrator provisions it inline. Wiring: `roboco/api/schemas/v1/do.py` `RequestSandboxRequest` → `POST /api/v1/do/request_sandbox` → `ContentActions.request_sandbox` (`roboco/services/gateway/content_actions.py`) → `AgentOrchestrator.ensure_sandbox` → `roboco/mcp/do_server.py`'s `request_sandbox()` tool (1080s timeout — `ensure_sandbox` always provisions the project's full opted-in set on first call, so an all-three-engines-cold first request is the norm the timeout must cover, not a rare edge case).
```python
-request_sandbox(services: list[str] | None = None)
+request_sandbox(
+ services: list[str] | None = None,
+ extensions: dict[str, list[str]] | None = None,
+)
```
-`services` omitted means the project's whole opted-in set. Guards fire in order, each with a clean `invalid_state` envelope + `remediate`:
+`services` omitted means the project's whole opted-in set. `extensions` is an optional per-service map of extensions/modules to activate (see "Extensions and modules (on the fly)" below); it unions with the project's standing set. Guards fire in order, each with a clean `invalid_state` envelope + `remediate`:
1. `ROBOCO_SANDBOX_DB_ENABLED` off → refused before any DB lookup.
2. No active, project-bound task (agent hasn't `give_me_work`'d) → refused.
3. Project has no `sandbox_services` opted in → refused.
4. A requested service outside the project's opted set → refused, remediate **names the allowed set**.
-5. Orchestrator handle unavailable (e.g. mid-restart) → refused, but **retryable** — the one guard that isn't a permanent no.
+5. A requested extension outside the per-service allowlist, or for a service not opted in → refused, remediate **names the allowed extensions**.
+6. Orchestrator handle unavailable (e.g. mid-restart) → refused, but **retryable** — the one guard that isn't a permanent no.
Only past all five does it call `ensure_sandbox` and provision. A genuine provisioning failure (image pull, readiness timeout) also surfaces as a retryable `invalid_state`, never a spawn refusal — sandbox trouble can no longer block a spawn or an agent's turn.
@@ -71,6 +75,22 @@ The `env` sub-dict (`SandboxInfo.as_payload()`, `roboco/models/sandbox.py`) carr
Only `developer` and `qa` roles carry `request_sandbox` in their spawn manifest (`roboco/services/gateway/role_config.py` `_DEV_DO` / `_QA_DO`) — the DB-needing gate roles. It is carried unconditionally on those manifests (declarative); the real gating is the project opt-in check inside the verb itself.
+## Extensions and modules (on the fly)
+
+A sandboxed DB/Redis can be built to a venture's declared extensions on the fly — "need a db? ok, extensions?" — instead of a fixed flavor. A project declares a per-service extension/module map in `projects.sandbox_extensions` (migration `072`, jsonb null), e.g. `{"postgres": ["vector", "postgis"], "redis": ["search"]}`. The provisioner activates them **post-ready** via `docker exec` (`CREATE EXTENSION IF NOT EXISTS ` for pg, `MODULE LOAD ` for redis) then verifies presence, so a missing extension file fails loudly at first provision rather than silently. Design spec: `docs/internal/specs/2026-07-13-sandbox-extensions-on-the-fly.md`.
+
+**The allowlist is the security containment.** `SANDBOX_PG_EXTENSIONS = {vector, postgis, pg_trgm, citext, uuid-ossp}` and `SANDBOX_REDIS_MODULES = {search, json, bloom}` (`roboco/models/sandbox.py` `SANDBOX_ENGINE_FEATURES`) are the only extensions/modules the system will ever activate. A `plpython3u` (superuser-RCE) is excluded by construction — rejected at the Project pydantic boundary before it can be persisted, and again at the verb. Mongo has no activatable features and is intentionally absent from the map. The Project model normalizes order, drops empty feature lists (a service with `[]` is bare), and rejects unknown service keys or unallowed features with the allowlist named.
+
+**No default set.** Opters set the extensions they need explicitly; an unset service is bare. Existing opted-in projects stay byte-for-byte unchanged on the bare path — no behavior change unless a venture declares a set.
+
+**Standing set + per-call override.** The project field is the trusted standing set; a `request_sandbox(extensions=…)` call unions a per-call override with it, bounded to the opted-in service set and the allowlist. The union reaches `ensure_sandbox` as the `features` kwarg. **Recommendation: set the full set the venture needs in project settings, so agents can request subsets at runtime** — a subset request is a cache hit, a feature superset re-provisions (rotates creds), mirroring the services-superset case.
+
+**Cache-by-features.** A cached `SandboxInfo` satisfies a new `ensure_sandbox` call iff the services are a subset AND every requested feature per service is already cached. A feature superset re-provisions with fresh creds (the pre-clear teardown removes the now-too-small container), exactly as a services superset does.
+
+**Feature-aware image selection.** A bare request (no features) uses the light upstream image (`postgres:16-alpine` / `redis:8-alpine`); a request with features pulls a kitchen-sink image that carries all extension/module files (`roboco-sandbox-pg:latest` — `pgvector/pgvector:pg16` + postgis; `redis/redis-stack-server:latest`). So a bare project never pays for a heavier image, and the intersection case (pgvector AND postgis in one DB) just works — flavors are inadequate because the intersection isn't a flavor. `SandboxEngine.image_for(features)` picks; the provisioner pulls whichever it resolves to.
+
+**Delivery.** The ok-envelope's per-service evidence entry carries `available_extensions` (the sorted list actually activated) so an agent doesn't guess what was turned on. The project's standing set is edited in the panel's project edit dialog (Sandbox section) — checkboxes from the allowlist grouped under each enabled service.
+
## Provisioning
`ensure_sandbox(agent_slug, requested, opted)` provisions `requested | opted` (in practice the project's whole opted-in set) through a single generic `_provision_engine` (no per-engine branch): it generates a random 32-hex-char password (`secrets.token_hex(16)`), pre-pulls the image, `docker run`s the sibling container, and polls the engine's readiness probe up to its deadline.
diff --git a/docs/rag/tools/task-tools.md b/docs/rag/tools/task-tools.md
index e3cda51c..e8d06772 100644
--- a/docs/rag/tools/task-tools.md
+++ b/docs/rag/tools/task-tools.md
@@ -165,4 +165,4 @@ Your plan's steps are the progress checklist; the percentage is derived from com
## Sandbox DB/Redis/Mongo (Developer + QA)
-`request_sandbox(services=None)` — a **content tool** on `roboco-do`, not a flow verb — provisions a throwaway sandbox Postgres/Redis/Mongo on demand, for a project that opted in (`projects.sandbox_services`). Only `developer` and `qa` carry it. Omit `services` for the project's whole opted-in set; requesting one outside it is rejected naming the allowed set. Creds come back in the envelope's `evidence`, one entry per service, including ready-to-`export` `ROBOCO_TEST_*` values for gate tooling. Calling it again is a cheap no-op (same creds). See `docs/rag/architecture/sandbox-db.md`.
+`request_sandbox(services=None, extensions=None)` — a **content tool** on `roboco-do`, not a flow verb — provisions a throwaway sandbox Postgres/Redis/Mongo on demand, for a project that opted in (`projects.sandbox_services`). Only `developer` and `qa` carry it. Omit `services` for the project's whole opted-in set; requesting one outside it is rejected naming the allowed set. `extensions` is an optional per-service map of extensions/modules to activate (e.g. `{"postgres": ["vector"]}`), unioned with the project's standing `sandbox_extensions` set and bounded by a fixed allowlist (pg: vector/postgis/pg_trgm/citext/uuid-ossp; redis: search/json/bloom — no `plpython3u`); an unallowed feature or a feature for a non-opted service is rejected naming the allowed set. Creds come back in the envelope's `evidence`, one entry per service, including ready-to-`export` `ROBOCO_TEST_*` values for gate tooling and an `available_extensions` list of what was activated. Calling it again is a cheap no-op (same creds) as long as the requested features are a subset of the cached set. See `docs/rag/architecture/sandbox-db.md`.
diff --git a/panel/src/components/projects/edit-project-dialog.tsx b/panel/src/components/projects/edit-project-dialog.tsx
index a8c8e88a..68a7c828 100644
--- a/panel/src/components/projects/edit-project-dialog.tsx
+++ b/panel/src/components/projects/edit-project-dialog.tsx
@@ -40,6 +40,25 @@ const SANDBOX_SERVICES = [
{ id: "mongo", label: "MongoDB" },
] as const;
+// Activatable extensions/modules per service, mirroring the backend allowlist
+// (roboco/models/sandbox.py SANDBOX_ENGINE_FEATURES). The allowlist is the
+// security containment — a plpython3u (superuser-RCE) is absent by design.
+// Mongo has no activatable features and is intentionally absent here.
+const SANDBOX_EXTENSIONS: Record = {
+ postgres: [
+ { id: "vector", label: "pgvector" },
+ { id: "postgis", label: "PostGIS" },
+ { id: "pg_trgm", label: "pg_trgm" },
+ { id: "citext", label: "citext" },
+ { id: "uuid-ossp", label: "uuid-ossp" },
+ ],
+ redis: [
+ { id: "search", label: "RediSearch" },
+ { id: "json", label: "RedisJSON" },
+ { id: "bloom", label: "RedisBloom" },
+ ],
+};
+
interface EditProjectDialogProps {
projectId: string;
open: boolean;
@@ -95,6 +114,28 @@ function EditProjectForm({
const [sandboxSet, setSandboxSet] = useState>(
new Set(sandboxServices),
);
+ // Per-service extension picks (only meaningful for services in sandboxSet).
+ const [sandboxExtensions, setSandboxExtensions] = useState<
+ Record>
+ >(() => {
+ const init: Record> = {};
+ for (const [svc, feats] of Object.entries(
+ project.sandbox_extensions || {},
+ )) {
+ init[svc] = new Set(feats);
+ }
+ return init;
+ });
+ const toggleExtension = (svc: string, feat: string, checked: boolean) => {
+ setSandboxExtensions((prev) => {
+ const next = { ...prev };
+ const set = new Set(next[svc] ?? []);
+ if (checked) set.add(feat);
+ else set.delete(feat);
+ next[svc] = set;
+ return next;
+ });
+ };
// Token handling
const [newToken, setNewToken] = useState("");
@@ -135,6 +176,14 @@ function EditProjectForm({
.filter(Boolean)
: undefined,
sandbox_services: [...sandboxSet],
+ sandbox_extensions: (() => {
+ const extObj: Record = {};
+ for (const svc of sandboxSet) {
+ const feats = sandboxExtensions[svc];
+ if (feats && feats.size > 0) extObj[svc] = [...feats].sort();
+ }
+ return extObj;
+ })(),
};
// Handle token update
@@ -486,6 +535,38 @@ function EditProjectForm({
project instead of the production credentials.