[sandbox-ext] Phase 4: panel extension picker + allowlist docs

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<string,string[]> | 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.
This commit is contained in:
Renn F
2026-07-13 20:05:45 +02:00
committed by Renzo F
parent e7d7311636
commit 6336e82082
7 changed files with 110 additions and 6 deletions
@@ -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<string, { id: string; label: string }[]> = {
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<Set<string>>(
new Set(sandboxServices),
);
// Per-service extension picks (only meaningful for services in sandboxSet).
const [sandboxExtensions, setSandboxExtensions] = useState<
Record<string, Set<string>>
>(() => {
const init: Record<string, Set<string>> = {};
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<string, string[]> = {};
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.
</p>
</div>
{SANDBOX_SERVICES.filter(
(svc) => sandboxSet.has(svc.id) && SANDBOX_EXTENSIONS[svc.id],
).map((svc) => (
<div key={`ext_${svc.id}`} className="grid gap-2">
<Label>{svc.label} Extensions</Label>
{SANDBOX_EXTENSIONS[svc.id].map((ext) => (
<div
key={ext.id}
className="flex items-center justify-between"
>
<Label
htmlFor={`ext_${svc.id}_${ext.id}`}
className="text-sm font-normal"
>
{ext.label}
</Label>
<Switch
id={`ext_${svc.id}_${ext.id}`}
checked={sandboxExtensions[svc.id]?.has(ext.id) ?? false}
onCheckedChange={(checked) =>
toggleExtension(svc.id, ext.id, checked)
}
/>
</div>
))}
<p className="text-xs text-muted-foreground">
Activated on-demand in the sandbox {svc.label} container. Set
the full set here so agents can request subsets.
</p>
</div>
))}
</>
)}
</div>
+1
View File
@@ -95,6 +95,7 @@ export const projectsApi = {
dep_update_command: null,
dep_update_paths: null,
sandbox_services: null,
sandbox_extensions: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
+2
View File
@@ -1032,6 +1032,7 @@ export interface Project {
dep_update_command: string | null;
dep_update_paths: string[] | null;
sandbox_services: string[] | null;
sandbox_extensions: Record<string, string[]> | null;
// Runtime state
workspace_path: string | null;
last_synced_at: string | null;
@@ -1081,6 +1082,7 @@ export interface ProjectUpdate {
dep_update_command?: string;
dep_update_paths?: string[];
sandbox_services?: string[];
sandbox_extensions?: Record<string, string[]>;
}
export interface ProjectSummary {