La sandbox¶
Un contenedor en el que un agent puede escribir archivos y ejecutar comandos.
Es como un run lee la hoja de cálculo que alguien adjuntó, dibuja una gráfica, clona un repositorio o guarda notas entre mensajes.
Esta es la única parte de la plataforma que ejecuta código que nadie de aquí escribió
Por eso esta página dedica tanto espacio a lo que contiene una sandbox como a lo que hay dentro de una.
Esta página es el cuadro completo: qué se ejecuta dónde, qué es una sesión, qué entornos puede pedir un agent y cómo cambiarlos, qué aísla una organización de otra, y cuánto sobrevive cada cosa.
Configuración es la referencia variable por variable.
Qué se ejecuta dónde¶
Tres procesos, y la disposición es el modelo de seguridad:
flowchart LR
B["app - the API<br/><i>no docker.sock</i>"] -->|HTTP + SANDBOXD_TOKEN| S["sandboxd<br/><i>holds /var/run/docker.sock</i>"]
S -->|start a container| D[["the host's Docker daemon"]]
D --> C["a session's container<br/><i>a sibling of sandboxd, not a child</i>"]
- El contenedor de la API no tiene ningún socket de Docker. Esa es toda la
razón por la que
sandboxdes un servicio aparte y no una llamada a una librería: ejecutar un contenedor requiere el daemon, y alcanzar el daemon equivale a ser root en el host. sandboxdtiene el socket y le pide al daemon del host que arranque un contenedor. Así que una sandbox es un hermano desandboxd, no un contenedor dentro de él. Aquí no hay Docker-in-Docker, nada es--privilegedy no corre ningún daemon anidado.- Por eso el directorio del workspace se monta con bind en la misma ruta en
ambos lados:
sandboxdlo crea, luego pide al daemon que lo monte, y el daemon resuelve esa ruta en el host. Un volumen con nombre, o una ruta que solo existe dentro del contenedorsandboxd, se rechaza conmounts denied.
sandboxd es el servidor de pydantic-ai-backend,
distribuido como ghcr.io/vstorm-co/sandboxd; el backend habla con él por HTTP
con el token que hay en SANDBOXD_TOKEN. Los otros dos backends de sandbox que
un spec puede nombrar —daytona y state— son un servicio alojado y un
documento en Postgres, y ninguno implica nada de lo anterior.
El servicio y el backend cambian juntos¶
El servicio y el cliente del backend salen de la misma versión de
pydantic-ai-backend, y un cliente llama a rutas que solo sirve su propia
versión. Por eso cada archivo compose fija la imagen exactamente en la versión de
backend/uv.lock, y una prueba del backend falla cuando los archivos compose o el
inventario de licencias nombran otra. Sube el lock, los archivos compose y el
inventario en un solo cambio. Un pin nuevo es una etiqueta que el host todavía no
tiene, así que make dev y scripts/deploy.sh la descargan en el siguiente
arranque.
El pin vive en el checkout, no en AGENTICOS_VERSION, así que un host que fija
o revierte el backend a mano hace también checkout de la etiqueta de esa release (Fija una release).
Una instalación sin checkout, como la que prepara scripts/quickstart.sh, descarga
en su lugar el docker-compose.yml de esa release (Instalación).
Una etiqueta móvil ya lo rompió una vez (#2484). Un sandboxd:0.2 en caché
servía una API más antigua que la que hablaba el cliente, y cada comando fallaba
como sandboxd session '…' is gone, aunque la sesión seguía viva. La plataforma
ahora distingue los dos casos. Cuando un 404 trae el {"detail":"Not Found"}
desnudo de FastAPI en lugar del No such session propio del servicio, lee el
índice del servicio en /. Si al índice le falta una ruta que el cliente
necesita, la llamada a la herramienta falla y al agente se le dice:
The sandbox service does not serve /sessions/{session_id}/run,
/sessions/{session_id}/runs/{run_id}/stop - its version does not match this
platform (expected <version>). Run ghcr.io/vstorm-co/sandboxd:<version> and
redeploy it
La misma comprobación trata un 409 al conectar una referencia de sesión guardada
antes de actualizar: un servicio antiguo puede rechazarla aunque la sesión exista.
Solo un endpoint ausente confirmado convierte el conflicto en un error de versión;
los conflictos normales y los índices inaccesibles conservan el error original.
El mensaje omite la dirección del servicio, que los miembros y el modelo quizá no
puedan ver; el backend la registra como sandboxd_version_mismatch. La sesión no
se da por perdida, pero la conversación olvida la referencia que guardaba: un
servicio más antiguo se niega a adjuntarse por referencia a una sesión viva. El
siguiente turno abre la sesión por su nombre, así que tras la actualización la
conversación retoma la misma sesión si el servicio la conservó.
agenticos cmd doctor hace la misma comprobación en cada conexión registrada y
nombra la imagen que hay que ejecutar. Vuelve a desplegar la pila que ejecuta el
servicio: scripts/deploy.sh en producción (arranca el perfil sandbox cuando
SANDBOXD_TOKEN está definido), make dev en un portátil. make prod no toca el
servicio, porque no arranca ese perfil; en una pila arrancada así, descárgalo y
recréalo tú mismo:
docker compose --env-file backend/.env -f docker-compose-prod.yml --profile sandbox pull sandboxd
docker compose --env-file backend/.env -f docker-compose-prod.yml --profile sandbox up -d sandboxd
El token equivale a root
Quien lo tenga puede arrancar contenedores en ese host. Trátalo como el socket de Docker delante del que está.
make sandbox-token genera uno en backend/.env una sola vez y luego lo deja en
paz, porque regenerarlo deja huérfano cada workspace que el servicio esté
guardando en ese momento. Esa es también la razón de que el dashboard propio del
servicio esté apagado (SANDBOXD_UI_ENABLED: 0): esa página pide a una persona
que pegue el token en un navegador.
Una sesión, y qué comparte una¶
Un contenedor por sesión, nunca uno para todos
Lo que la clave de una sesión agrupa —scope, organización, tipo de backend y host— es exactamente lo que decide qué runs comparten un contenedor y un directorio.
Una sesión se identifica por una clave que deriva el backend, y esa clave es lo que decide qué se comparte:
xc-4f2a91c8-7b3e5d10-9c1f… backend · scope · organization · host · subject
^^ `x` a container service, `d` a document; `c` the conversation scope
El scope es un campo del spec del agent: run, conversation, channel,
user o agent. Así que conversation, la elección habitual, significa un
contenedor y un directorio por chat; agent significa que todos los runs de ese
agent comparten uno.
user es de la persona que inició sesión, así que un run que sustituye a alguien
no tiene ninguno: el visitante de un widget y el disparo de un trigger se rechazan
en lugar de recibir el workspace del publicador o del creador — salvo que el
trigger se ejecute como su creador.
La clave es también el nombre de la sesión en el host —el id de una sesión de
sandboxd, el nombre de un sandbox de Daytona—, así que cada run de un scope la
abre por ese nombre: el primero la crea y los demás se conectan a ella, también los
concurrentes, y nada tiene que guardar el id propio de un proveedor antes de que el
segundo run pueda encontrarla.
Una vez abierta una sesión, un run posterior solo se conecta a ella. La fila del
workspace la registra cuando termina el run que la abrió, y el siguiente run pide al
host esa sesión y no el nombre. Una sesión cuyos archivos se purgaron —barridos por
SANDBOXD_WORKSPACE_TTL, un host reconstruido— se notifica entonces al agente como
perdida, en lugar de sustituirse en silencio por una vacía en la que seguiría
trabajando. La fila la olvida al final de ese run, así que el turno siguiente empieza
una sesión nueva.
También se agrupan en la clave: qué tipo de backend y qué host alojan el
workspace. Un documento state y el volumen de un contenedor no son la misma
cosa con distinto nombre, y tampoco lo son dos instalaciones de sandboxd:
registrar un segundo host y marcarlo como el predeterminado de la organización
movía antes cada workspace existente sin que nadie editara un spec.
Lo que separa un tenant de otro:
- un contenedor propio y un directorio propio en el host por sesión;
- las claves de sesión derivan de
uuid4, así que son inadivinables: el prefijo legible de la organización sirve para leer un dashboard, no es la frontera; - la comprobación de organización en cada fila que produce esas claves, que es lo que la frontera realmente es;
- el
tenant(el id de la organización) enviado al abrir la sesión, que el servicio cuenta contraSANDBOXD_MAX_SESSIONS_PER_TENANT(10) dentro de un pool deSANDBOXD_MAX_SESSIONS(20), así que una organización no puede quedarse con la instalación. Por encima del techo el servicio rechaza conalready holds 10 of 10.
Qué entornos puede pedir un agent¶
Se distribuye un runtime, y está definido en este repositorio:
backend/app/core/catalog/sandbox_runtimes.json.
workbench — 1,93 GB, construido en unos 65 s en un host caliente |
|
|---|---|
| Construido sobre | python:3.12-slim |
| Lenguajes | Python 3.12; Node 24.19.0 LTS con npm 11 y tsx para TypeScript |
| Coding agents | Claude Code 2.1.288 y Codex 0.160.0, para el coding agent |
| Herramientas | git, curl, ripgrep, fd, jq, less, procps, unzip, zip, uv, pdftotext/pdfinfo |
| Lectura | liteparse (lit) — PDFs e imágenes a texto o markdown, OCR incluido; poppler-utils para la vía rápida de la capa de texto y un recuento de páginas |
| Documentos | pypdf, python-docx, openpyxl, python-pptx, reportlab; LibreOffice headless para la conversión y los formatos antiguos |
| Datos | pandas, duckdb, tabulate |
| Gráficas e imágenes | matplotlib (Agg), pillow |
| Web | httpx, requests, beautifulsoup4, lxml, markdownify |
| Otros | pyyaml |
| Memoria | 2 GiB |
| Red | sí — el único runtime que la tiene |
Uno en lugar de ocho, y la razón es prewarm: el servicio construye todas las
entradas de su lista de permitidos al arrancar, así que ocho alias son ocho
pip install en un arranque que nadie mira, ocho imágenes cacheadas en el host, y
un agent al que se le pide leer un PDF recibiendo el alias que su spec resultara
nombrar. workbench está construido para ser la respuesta a escribe y ejecuta
algo de código, lee lo que adjuntó el usuario, dibújalo, trae una página.
Hasta #1040 este catálogo era BUILTIN_RUNTIMES, de la librería de la sandbox:
quince recetas, de las cuales un sandboxd arrancado por este proyecto permitía
tres. También significaba que añadir un paquete a una imagen era una publicación
de una dependencia, una subida de versión y un pin.
Qué hay dentro, y qué deliberadamente no¶
Medido sobre python:3.12-slim (205 MB), arm64:
- liteparse viene de
pip, y esa es toda la respuesta. El wheel pesa 13,8 MB, lleva el binario de Rust y la CLIlit, no tiene dependencias de Python y trae OCR incorporado: medido en 1,3 s para un PDF de una página con OCR y 79 ms para un PNG. Así quecargo install(un toolchain de Rust), el paquete de npm (una segunda copia del mismo binario) y la build WASM (para navegadores) no aportan nada aquí. - LibreOffice, a +683 MB, y vale la pena. Compra tres cosas que nada más de
aquí da:
litpuede leer los formatos antiguos.doc,.xlsy.ppt, que es lo que un usuario de negocio adjunta de verdad;soffice --headless --convert-to pdf deck.pptxrenderiza una presentación que el agent construyó conpython-pptx, que es como una presentación se convierte en algo que una persona puede abrir; y--convert-to pngconvierte una diapositiva en una imagen que el agent puede volver a leer y mirar, ya que aquíread_filees multimodal. Alrededor de un segundo por documento después del primero. Writer y Calc son +135 MB de esos 683 y están por una razón: una conversión ofimática que funcionara para presentaciones y no para documentos sería una excepción en el producto y una excepción en el prompt. - Solo funciona porque el runtime se construye. LibreOffice crea un perfil
de usuario en la primera ejecución, así que necesita una cuenta real con un home
escribible, que es la que hace el constructor cuando
SANDBOXD_SANDBOX_UIDestá fijado (useradd --uid 10001 --create-home). Ejecuta la misma imagen como un uid pelado sin entrada en passwd y cada conversión falla conUser installation could not be completed. Esa es también la razón por la que un runtime de tipoimageya hecha no puede añadir LibreOffice sin más: las dos decisiones son una sola decisión. - Node desde nodejs.org, no desde apt. El
nodejs npmde Debian son +398 MB y trae npm 9; el tarball oficial son +239 MB y está al día — y Node 20, que esta receta fijó al principio, está al final de su vida desde abril de 2026. La arquitectura se detecta en el comando, porque el mismo catálogo se construye en amd64 y en arm64. poppler-utils(+67 MB) junto a liteparse, no en su lugar.lites el mejor lector —maquetación, tablas, markdown— ypdftotextes el más rápido en un PDF que ya tiene texto: un libro de 120 páginas en menos de un segundo.pdfinfoes la razón real de que esté aquí, porque un recuento de páginas en milisegundos es lo que convierte «extrae este libro» en un plan.- El OCR es el coste que importa, y está medido.
litsolo hace OCR de las páginas sin capa de texto, así que 120 páginas generadas cuestan una fracción de segundo; pero una página escaneada cuesta 8,8 s, con lo que un escaneo de 300 páginas son unos 44 minutos contra un techo de 300 segundos por comando: matado, sin nada que enseñar.--target-pages 1-40lo acota (tres páginas escaneadas en 1,6 s), y--no-ocrsobre un escaneo tiene éxito y devuelve 179 bytes: una respuesta casi vacía y silenciosa, que es el peor de los dos fallos. Ambos están en el briefing de más abajo, porque esta es exactamente la petición que hace un usuario: «resume este libro». - Sin
build-essential(+94 MB) y sinscikit-learn/scipy(~200 MB). Ambos están a unuv pip installde distancia en un runtime que tiene red. El coste de dejarlos fuera es una instalación la primera vez; el coste de hornearlos dentro lo paga cada host en cada arranque. requestsjunto ahttpx, ytabulatejunto apandas, medio megabyte entre los dos: un modelo escribeimport requestsydf.to_markdown()de memoria muscular, y ninguno merece un script fallido y un reintento.tzdatayfonts-dejavu-coreestán en la capa de apt porquepython:slimno tiene ninguno de los dos, así quezoneinfolanza una excepción yPIL.ImageDraw.textno puede cargar una fuente; ambos verificados antes y después.env_varsen lugar de una costumbre.MPLBACKEND=Agg,PYTHONUTF8=1,PYTHONUNBUFFERED=1,PYTHONDONTWRITEBYTECODE=1son propiedades de la imagen; un run que tiene que acordarse de ellas es un run que no lo hará.
Al modelo se le cuenta todo esto¶
Un contenedor no le sirve de nada a un agent que no sabe qué hay dentro.
Antes de #1040, un agent al que se le pedía una gráfica hacía import plotly, y
uno al que le daban un PDF escribía su propio extractor junto al lit que lo lee,
aprendiendo cada uno por las malas dentro de la petición de alguien.
Y se le cuenta cómo trabajar, no solo qué hay instalado.
La instrucción que se gana su sitio es la que nada más puede enseñar: no leas un
archivo grande para ojearlo. Extráelo una vez a un archivo de texto, rg -n para
encontrar los sitios que importan, sed -n '400,460p' para leer uno.
Un libro son miles de líneas y una respuesta necesita decenas de ellas. Un modelo que se lleva todo eso a su propio contexto gasta el budget del run en páginas por las que nadie preguntó. El mismo párrafo lleva las dos trampas de OCR de arriba, porque un libro escaneado es donde caen las dos a la vez.
Así que cada run sobre un runtime que este despliegue distribuye lleva un párrafo
añadido a sus instrucciones: qué runtime le tocó, la lista de paquetes, la línea
de lit, qué convierte soffice, la única carencia (sin compilador de C) y si
tiene red.
Está compuesto a partir del catálogo, no escrito al lado. runtime_briefing
lee la lista de paquetes de la definición, así que un paquete añadido al archivo
llega al prompt en la misma edición que llega a la imagen. Solo lo que no se puede
derivar es prosa, en la lista briefing de la entrada.
Dos consecuencias que vale la pena conocer:
- Se añade por run, igual que el prompt de un binding de canal, porque qué runtime recibe un run se resuelve desde el spec, la conexión y el host cuando el run arranca. El spec publicado se queda como está.
- Un alias que este despliegue no distribuye no recibe párrafo alguno. Un host arrancado con una lista de permitidos propia no es uno cuyas imágenes podamos describir honestamente, y un prompt que adivina es peor que un prompt callado.
Cambiarlo¶
$EDITOR backend/app/core/catalog/sandbox_runtimes.json
make sandbox-runtimes # writes SANDBOXD_RUNTIMES into all three compose files
docker compose up -d sandboxd # prewarm rebuilds what the list now names
Una entrada tiene una de dos formas, nunca las dos:
{
"alias": "workbench",
"description": "What it is for - shown in the connection dialog",
"base_image": "python:3.12-slim",
"setup_commands": ["apt-get update && apt-get install -y --no-install-recommends git"],
"packages": ["pillow"],
"mem_limit": "2g",
"needs_network": true
}
| Campo | |
|---|---|
alias |
Lo que nombra un spec. Minúsculas, [a-z][a-z0-9-]* |
description |
Se muestra en el Default runtime del diálogo de conexión. Di para qué sirve |
image |
Una imagen ya hecha. Arranca en lo que tarda un pull, y no instala nada |
base_image |
Se construye una vez en el primer uso y se cachea después: la forma que puede instalar |
setup_commands |
Shell en tiempo de build, antes de los paquetes: una capa de apt, un instalador |
packages |
pip, instalados en tiempo de build. Necesita un base_image |
env_vars |
Se fijan en cada contenedor de este runtime — MPLBACKEND, PYTHONUTF8 |
briefing |
Frases que se le cuentan al modelo y que no se pueden derivar de los campos de arriba |
mem_limit |
La sintaxis propia de Docker (2g). Si falta, se aplica SANDBOXD_MEM_LIMIT |
needs_network |
Si una sesión sobre él tiene red. Una build siempre la tiene |
Cuatro cosas que el archivo no te dejará equivocar, o que te morderán si te saltas esta sección:
imageybase_imageson excluyentes, y una listapackagesosetup_commandsen una entrada conimagese rechaza al importar. Aceptada, sería un runtime cuyos paquetes están en el catálogo, en el archivo de compose y no en el contenedor.- La primera entrada es la predeterminada para un agent cuyo spec no nombra ningún runtime, así que el orden del archivo sostiene peso.
network_modeno se hereda.SANDBOXD_NETWORK_MODEes de todo el servicio y cada archivo de compose distribuido lo pone anone, así que una entrada que instala algo en tiempo de ejecución necesita una red propia.needs_networkes esa decisión, tomada una vez donde están los paquetes en lugar de recordada por archivo de compose; si se olvida, el fallo es un agent cuyouv pip installagota el tiempo.- Una entrada malformada detiene el despliegue, deliberadamente: el catálogo se valida al importar y no en el primer uso, porque un selector con un agujero lo descubre un usuario.
Por qué el valor está además en los archivos de compose¶
SANDBOXD_RUNTIMES es el único canal por el que el servicio acepta runtimes.
Un archivo de compose no puede llamar a un comando, así que el valor de ahí es una
copia generada, y la única pregunta que vale la pena responder es si puede
divergir: backend/tests/test_sandbox_runtime_catalog.py falla cuando lo ha
hecho, nombrando el archivo y diciéndote que ejecutes make sandbox-runtimes.
Generado dentro de un archivo versionado en lugar de leído de un archivo aparte
al arrancar, porque docker compose up tiene que funcionar sin generar nada
primero; la alternativa es un despliegue tomando calladamente la lista de
permitidos por defecto de la librería.
Y no es un formulario del producto. PUT /policy cambia techos y tiempos de vida
en ejecución y rechaza deliberadamente la composición de esta lista, junto con
network_mode, oci_runtime, sandbox_uid, work_dir y persist_containers:
nombrar una imagen es una decisión sobre aislamiento, y el token del servicio lo
tiene una aplicación y no quien administre el host. Cambiar la lista es un
reinicio.
Dos listas en el producto, que responden a preguntas distintas¶
El Default runtime del diálogo de conexión ofrece este catálogo —lo que los
archivos de compose le dieron al servicio—, rellenado antes de preguntar a ningún
host y marcado en cuanto uno ha respondido. El Runtime del Builder del agent
ofrece lo que el servicio de esa conexión permite de verdad, leído en vivo de
él, así que un alias que nombre es uno que la siguiente llamada a una tool
aceptará. Donde los dos discrepan, el segundo tiene razón: un host puede haberse
arrancado con otra lista de permitidos, y un despliegue que generó la suya es
justo el caso que no conviene dejar caer.
Por eso se pregunta al host antes de guardar la conexión, y por eso al servicio
que arranca make dev se le puede preguntar sin ninguna clave.
Añadir ese es el camino más común por el diálogo, y no nombra ninguna clave del vault hasta el envío, así que no había nada con lo que probar y un servicio local desactualizado podía registrarse con un runtime predeterminado que su primera llamada a una tool rechaza.
Un sondeo sin clave recurre a SANDBOXD_TOKEN para dos direcciones y solo dos
Las dos que usa el propio archivo de compose de este proyecto. Ese token arranca contenedores en cualquier host que lo acepte, y un sondeo no debe ser nunca una forma de enviarlo a un sitio nuevo.
A cualquier otra dirección se le pregunta con una clave del vault, y solo cuando un operador pulsa el botón.
Cuándo aparece un contenedor¶
En la primera operación sobre el workspace: una tool que el agent llama allí, o un archivo adjunto que se coloca en él. Los skills del agent se escriben en el workspace en ese momento, justo antes de la operación, y no mientras se prepara el run, así que un turno que nunca usa la sandbox no abre sesión ni arranca un contenedor. Un turno que sí la usa encuentra los skills ya en disco. Una lista de sesiones aún puede mostrar una sandbox cuyo registro de actividad solo tiene escrituras: una a la que se adjuntó un archivo y que luego quedó sin usar.
Qué se hizo en una, y dónde vive ese registro¶
En la tabla propia de esta plataforma, sandbox_operations, no en el servicio.
El servicio lleva un registro de actividad propio, y es un búfer circular de 200
entradas en la memoria de ese proceso. Lo que descartaba no se podía consultar,
una conversación trabajada durante todo el día había perdido su mañana, y
reiniciar sandboxd perdía todos los registros del host. Nada fuera de ese
proceso vio nunca las entradas (#1061).
Cada llamada al workspace ya pasa por esta aplicación —el run nos llama a nosotros, nosotros llamamos al servicio—, así que el registro es nuestro.
RecordingWorkspace envuelve el workspace en el que trabaja el run, que es por lo
que añadir una novena tool no puede olvidarse de registrar. Registra lo que llegó
al sandbox —read, write, ls_info, mkdir, remove y execute—, así que una
tool que son varias operaciones aparece como cada una de ellas: un edit_file es
un read y un write; un glob o un grep, el execute del find o del grep
que ejecutó. exists y stat son preguntas y no operaciones, y un registro lleno
de ellas enterraría las escrituras que alguien vino a leer.
Lleva dos hechos que el servicio nunca podría, y son los dos que una auditoría
pide de verdad: qué agent y qué run. Ambos son SET NULL al borrar, porque el
registro de lo que ocurrió tiene que sobrevivir al agent que se borró después.
Una ruta, nunca una carga útil
write registra la ruta y que tuvo éxito. execute registra el comando y
nunca su salida: una salida distinta de cero se registra como un fallo con su
estado numérico (exit 2), el único hecho seguro sobre un comando fallido.
read registra la ruta y un recuento de bytes.
Estas filas las puede leer todo el que ve la sandbox, así que un registro con contenidos sería una forma de leer el trabajo de un agent en lugar de una auditoría de él: la misma línea que traza el servicio, trazada otra vez aquí.
La única línea sobre el resultado la escribimos nosotros, nunca se cita de más abajo: el mensaje de una shell es la salida del comando (#423).
Las filas caen cuando la transacción del run hace commit, porque se escriben en la sesión del propio run y no en una conexión por llamada a una tool. Así que las operaciones de un turno aparecen juntas, un segundo después de que el turno termine.
El marcador en vivo de la fila del dashboard sigue leyendo el búfer del servicio exactamente por esa razón: responde a mitad de turno, donde el registro responde una semana después.
GET /api/v1/sandbox-connections/operations lo pagina, y sus filtros acotan la
consulta: la búsqueda del diálogo, su filtro de operación y su interruptor de
solo fallidas son peticiones, así que un paginador sobre trescientas operaciones
tiene adónde paginar.
Cuándo se paga una build¶
prewarm está activo, así que la lista de permitidos se descarga y se construye
en segundo plano mientras el servicio arranca en lugar de dentro de la primera
petición de alguien: una build son diez segundos para arriba. Las imágenes se
cachean, así que un host paga una vez.
Un despliegue las construye aún antes. scripts/deploy.sh ejecuta el
python -m pydantic_ai_backends.remote.prepare de la biblioteca en un contenedor
de un solo uso de la nueva imagen de sandboxd, antes de reemplazar el servicio
viejo. Así, una versión que cambia un runtime paga la construcción mientras el
servicio viejo sigue atendiendo. Antes, el 18.10.2026, el primer chat con sandbox
tras una versión así esperó minutos, y cada petición de sesión respondió 409 hasta
que terminó la construcción. Una construcción que falla se reintenta tres veces
ahí y dos al arrancar. Un runtime que siga sin construir se construye bajo
demanda, como antes, y el log del despliegue lo nombra.
Mientras una sesión espera una construcción así, el chat lo dice encima del campo de escritura: «Preparando el sandbox». Abrirla espera hasta diez minutos, no los 30 segundos de una petición normal.
Lo que queda por esperar: un runtime que el despliegue no pudo preparar, y un
host cuya caché de imágenes se vació. SANDBOXD_PERSIST_CONTAINERS: true quita
entonces casi todo lo demás: una sesión cerrada conserva su contenedor, así que la
siguiente sesión sobre ese workspace arranca sin build y con lo que el agent
instalara la última vez todavía instalado.
El aislamiento, sin rodeos¶
Lo que se sostiene:
- la sandbox no puede ver el socket de Docker; solo
sandboxdpuede; - ninguna red en absoluto salvo que el runtime la pida, y solo
workbenchlo hace; - 2 CPUs, 512 procesos y un
tmpfsde 64 MiB en/tmppor sandbox, másSANDBOXD_EXECUTE_TIMEOUT(300 s) en cada comando ySANDBOXD_MAX_READ_BYTES(8 MiB) en cada lectura; SANDBOXD_SANDBOX_UID: 10001— una sandbox corre como un usuario sin privilegios y no como root, y cada archivo que un agent escribe pertenece a ese uid en el host. Tiene que ser el uid del propio servicio: abrir una sesión hacechowndel workspace a este usuario, y unsandboxdsin privilegios solo puede hacerlo para sí mismo, así que un número distinto falla en la primera sesión y no al arrancar. Se aplica a un runtime que este despliegue construye: una imagen ya hecha no tiene esa cuenta ni un virtualenv, así que un agent dentro de una no podría instalar nada.
Lo que queda, dicho en lugar de dado por supuesto:
- El token del servicio equivale a root. Ver más arriba.
- Escapar del contenedor es escapar al host.
runcnormal;oci_runtimepuede nombrar uno con sandbox (elrunscde gVisor) allí donde un despliegue quiera ese trato. - Un runtime con red puede alcanzar los puertos publicados en el host.
docker-compose.ymlpublica Postgres y Redis para el desarrollo local, conpostgres/postgres;docker-compose-prod.ymlno publica ninguno.
Una salvedad de portátil, pero compruébala antes de copiar el archivo de compose local
docker-compose.yml publica Postgres y Redis con postgres/postgres, y
workbench es el único runtime con red. En un host compartido eso es
alcanzable desde dentro de una sandbox; docker-compose-prod.yml no publica
ninguno.
Qué muestra el navegador de archivos, y qué deja fuera¶
Documents (/documents) lista lo que un agent guarda para una persona, que no es el mismo
conjunto que lo que hay en el volumen. Tres prefijos se descartan de cada listado
que lee una persona —la lista de todos los archivos, los archivos de una carpeta,
el panel de una conversación y los recuentos de archivos—:
skills/— el cuerpo de un skill y sus recursos, escritos al principio de cada run que tiene skills y workspace. Ahí hacen falta: un recurso es un script que la shell ejecuta, ycollect_changescompara estos archivos para hacer una propuesta que alguien acepta. Se quitaron una vez porque el listado era casi solo eso, que era la queja correcta sobre la cosa equivocada (#1064).- el directorio de desbordamiento, donde se escribió la salida desbordada de una tool.
.agenticos/— los eventos de una tarea del coding agent y la sesión que continúa la siguiente.
También las carpetas que llena una toolchain, estén donde estén: .venv, venv,
node_modules, __pycache__, .pytest_cache, .mypy_cache, .ruff_cache,
.tox, .git, .next y .cache. Un coding agent que montó una app de FastAPI
puso mil en la página, entre la docena de archivos que escribió.
Un recuento también tiene que descartarlos, o un workspace que informa de cuatro archivos donde se ve uno es un recuento que nadie puede comprobar. Todos los archivos agrupa lo que queda por el workspace que lo guarda, lo más reciente primero, bajo la conversación a la que pertenece: archivos sueltos y adjuntos como filas, cada carpeta de primer nivel como una fila con sus primeros archivos, y un enlace a la carpeta para el resto. Los archivos más recientes se muestran en vista previa sobre los grupos, y una búsqueda lista sus resultados planos.
De quién es el workspace, y quién más puede verlo¶
Dos respuestas, y la tabla lleva las dos.
access_label es el scope en palabras: «todo el que habla con este agent»,
«quien esté en esa conversación». No nombra a nadie, que es exactamente la
pregunta que un operador tiene sobre un workspace con scope de agent que comparten
seis personas.
Así que la fila lleva además owner_name: el correo de una cuenta, o el id de
plataforma de un dueño que llegó por un canal y no tiene cuenta aquí. Se dibuja
como palabras y nunca como un enlace, porque la mitad de ellos no son cuentas a
las que enlazar. Y es nulo para todos los scopes menos user, el único que
registra un dueño siquiera: tres de los cuatro honestamente no tienen ninguno, y
la columna lo dice en lugar de repetir el scope.
Un archivo dice quién lo puso ahí¶
uploads/ es donde cae un adjunto, así que una ruta bajo él es un archivo que
adjuntó una persona, y cualquier otra cosa es trabajo propio del agent. Se
ofrece como filtro y se dice en la tarjeta.
Es la única señal disponible: un host no registra autor, y el documento de estado
tampoco. Su límite viene de ahí: un agent que escribe dentro del propio uploads/
es indistinguible de una persona, y nada se lo impide.
Listar un contenedor cuesta viajes de ida y vuelta, así que dos cosas están acotadas¶
El ls del archivo lee un directorio, así que el listado pide al host su tree:
una petición, recorrida en anchura sobre el propio disco del host, seis niveles de
profundidad como mucho, parando a las 2.000 entradas, porque un host con un
node_modules no debe convertir un workspace en diez mil filas.
Las dos cotas se informan. La página de un workspace dice claramente que esto no es todo archivo, porque un árbol que se detiene sin decirlo es uno que alguien lee como todo lo que el agent guarda.
Un directorio que no responde se salta, y el listado avisa de que está incompleto. Solo si se niega la raíz el workspace se vuelve ilegible, porque una carpeta a la que el agent quitó los permisos no es un host que nadie pueda leer.
Y la miniatura de una imagen es un read_bytes para ese archivo: el sufijo y el
tamaño se comprueban en la entrada del listado antes de traer nada, y una petición
dibuja 24 como mucho. Pasado eso una tarjeta se queda con el glifo.
Un workspace almacenado no paga ninguna de las dos cosas: sus archivos y sus bytes son una columna de la fila que el listado ya leyó.
El panel Files del chat y el listado propio de un workspace dibujan sus mosaicos como All files: las primeras líneas de un archivo de texto almacenado, y una imagen como miniatura dentro del mismo límite.
Cuánto sobrevive cada cosa¶
Los archivos viven en el host, en
{SANDBOXD_WORKSPACE_ROOT}/{session_id}/workspace —
/tmp/agenticos-sandbox-workspaces en local,
/var/lib/agenticos/sandbox-workspaces en el servidor de desarrollo y en
producción. Ese bind mount es también lo que hace posible el panel de Files del
producto: leer un workspace nunca arranca un contenedor.
| Ajuste | Qué ocurre | |
|---|---|---|
| Una sesión inactiva | SANDBOXD_IDLE_TIMEOUT 1800 s |
El contenedor se cierra y se recoge. Los archivos se quedan |
| Un contenedor parado | SANDBOXD_CONTAINER_TTL 86400 s |
Lo que la sesión instaló —la build, los wheels, node_modules— se reclama. El workspace queda intacto |
| El directorio del workspace | SANDBOXD_WORKSPACE_TTL sin fijar |
Se conserva indefinidamente |
| El registro de lo que se hizo | OPERATION_RETENTION_DAYS 30 |
El sandbox-log-sweep diario borra las filas. Los archivos quedan intactos |
La última fila es el valor por defecto de la librería y es deliberado: las notas y los scripts son el trabajo, y el usuario de un agent espera tenerlos la semana que viene.
El uso de disco solo crece
Nada barre un workspace cuya conversación nadie volverá a abrir. Pon
SANDBOXD_WORKSPACE_TTL a lo que diga tu política de retención y los archivos
más viejos que eso se van.
/tmp lo limpia un reinicio en un portátil; /var/lib no.
Borrar una conversación purga su workspace a través del producto, así que esto va sobre lo que nadie borra y no sobre lo que sí borran.
Esa purga no depende de que el proveedor responda en ese momento. El borrado
escribe, en la misma transacción, una lápida por cada sandbox remoto, y un destroy
la quita cuando el proveedor lo confirma. Si el host del sandbox está caído, una
clave de Daytona se rechaza o el worker se reinicia entre medias, el sweep
sandbox-destroy-retry del worker lo vuelve a intentar. La pausa se duplica cada
vez, de 2 minutos a 6 horas, hasta que el destroy sale bien. Un sandbox de Daytona
no tiene TTL detrás, así que solo esto lo termina.
A una conexión apagada se le siguen eliminando sus sandboxes, porque apagarla detiene el trabajo nuevo y no la factura. Una conexión a la que le quedan tombstones no se puede borrar: se irían con ella, y son el único registro de lo que sigue en marcha en el proveedor. Cuando el barrido no puede tener éxito nunca, porque la credencial de la conexión se borró o se revocó, borra los sandboxes en la consola del proveedor. Después vuelve a borrar la conexión y confirma que lo hiciste: sus tombstones se olvidan, y cuántos eran queda en el registro de auditoría.
Resumen¶
- El contenedor de la API no tiene ningún socket de Docker.
sandboxdsí, y una sandbox es su hermano en lugar de un contenedor dentro de él. - Una clave de sesión agrupa scope, organización, tipo de backend y host, que es precisamente lo que decide quién comparte un contenedor.
- Se distribuye un runtime,
workbench, y al modelo se le cuenta qué hay dentro, compuesto a partir del mismo catálogo que lo construyó. - El registro de lo que hizo un agent vive en la tabla de esta plataforma, lleva una ruta y nunca una carga útil, y sobrevive al agent.
- Los archivos se conservan indefinidamente salvo que fijes
SANDBOXD_WORKSPACE_TTL. El uso de disco solo crece.
El backend state incluye archivos y directorios vacíos en el límite de almacenamiento, también al fusionar cambios simultáneos. Si los documentos aceptados por separado no caben juntos, el documento compartido no cambia y el estado posterior se confirma como copia de recuperación en Documents. La ejecución registra un error con el identificador de la copia; una modificación del proyecto devuelve un conflicto en lugar de éxito. Las copias de proyectos requieren permisos de visualización y uso. Una clave foránea elimina la copia junto con su origen, incluso durante una eliminación de datos simultánea. Reducir el límite por debajo del tamaño de un documento existente permite seguir reduciendo ese documento.
Las instrucciones de workbench explican que los comandos usan POSIX /bin/sh, no Bash. Por ejemplo, mkdir -p sources inbox notes crea tres carpetas; esta shell no expande {sources,inbox,notes}. Los archivos del proyecto pertenecen a /workspace; el directorio personal (~) queda fuera de los archivos compartidos. Cada comando inicia un proceso nuevo: las variables y los cambios de directorio no pasan a la siguiente llamada. Se indica al agente que revise los fallos y verifique los archivos antes de anunciar el éxito.