Configuración¶
Toda la configuración se gestiona con variables de entorno, cargadas desde
backend/.env con pydantic-settings.
Los ajustes se definen en app/core/config.py y se leen a través del objeto
global settings:
Primeros pasos¶
make install crea backend/.env a partir de backend/.env.example cuando no
existe ninguno, y no vuelve a tocarlo nunca más — así que en un checkout nuevo no
hay nada que copiar, y en uno existente no hay nada que perder.
Antes de que nada llegue a una red en la que haya alguien más, fija los valores que el ejemplo trae como marcadores de posición:
openssl rand -hex 32 # SECRET_KEY — signs every access token
openssl rand -hex 32 # VAULT_MASTER_KEY — unwraps every credential stored at rest
SECRET_KEY se distribuye con una cadena publicada
Un VAULT_MASTER_KEY vacío recurre a ella para que un checkout nuevo
arranque siquiera. Ambas valen en un portátil y son toda la seguridad de un
despliegue en cualquier otro sitio. Fijar VAULT_MASTER_KEY de forma
explícita es además lo que permite que los secretos guardados sobrevivan a
una rotación de SECRET_KEY.
La configuración rechaza un VAULT_MASTER_KEY sin fijar fuera de
local/development.
Ajustes del proyecto¶
| Variable | Por defecto | Descripción |
|---|---|---|
PROJECT_NAME |
agenticos |
Nombre visible del proyecto |
API_V1_STR |
/api/v1 |
Prefijo de la versión de la API |
DEBUG |
false |
Activa el modo de depuración (errores detallados, recarga automática) |
ENVIRONMENT |
local |
Uno de: development, local, staging, production |
TIMEZONE |
UTC |
Zona horaria IANA (p. ej. UTC, Europe/Warsaw, America/New_York) |
MODELS_CACHE_DIR |
./models_cache |
Directorio para los modelos de ML cacheados |
MEDIA_DIR |
./media |
Directorio para los archivos subidos |
MAX_UPLOAD_SIZE_MB |
50 |
Tope de un documento de la base de conocimiento, y el número del que se deriva el techo de la petición completa que se describe abajo. Un documento de este tamaño se trocea y se convierte en embeddings, no se guarda de una pieza |
CHAT_MAX_UPLOAD_SIZE_MB |
10 |
Lo que se puede adjuntar en el chat. Tiene su propio ajuste y no el de arriba, porque un adjunto a un agent sin workspace se pega entero en el prompt — así que las dos superficies fallan de forma distinta con el mismo tamaño. Eran 10 MiB fijos que ningún operador podía subir (#498); el contenedor del frontend lee el mismo CHAT_MAX_UPLOAD_SIZE_MB en tiempo de ejecución, así que dale un solo valor a los dos contenedores o el composer rechazará un archivo que el servidor sí aceptaría |
EMBED_MAX_UPLOAD_SIZE_MB |
5 |
Lo que un desconocido puede subir a una página alojada. Un techo por encima de CHAT_MAX_UPLOAD_SIZE_MB, nunca una forma de saltárselo |
MEM0_ALLOWED_HOSTS |
[] (empty) |
Hostnames a los que puede apuntar un servicio de memoria mem0 autoalojado. Un base_url viene del spec de un agent, así que sin una lista de permitidos un Builder que puede vincular (pero no leer) una clave mem0 compartida podría apuntarla a su propio servidor y capturar la clave desde la cabecera de la petición. Vacío rechaza mem0 autoalojado y solo permite la nube gestionada; añade un hostname de confianza para habilitar un despliegue autoalojado. Ver secretos |
FILE_IO_MAX_WORKERS |
8 |
Tamaño del pool de hilos dedicado que ejecuta el trabajo bloqueante con archivos — parsear una subida y leer o escribir sus bytes. Se mantiene fuera del executor por defecto compartido de asyncio, que también ejecuta bcrypt y el DNS de hosts fijados, para que una ráfaga de subidas no deje el inicio de sesión y las peticiones salientes en cola detrás de ella (#1108). Súbelo en una máquina que parsea muchas subidas a la vez. Tiene que ser un entero positivo — un 0 o un valor negativo se rechaza al arrancar |
DEFAULT_ORG_MONTHLY_BUDGET_USD |
100 |
El techo de gasto mensual con el que arranca una organización nueva, en USD, para que no esté a un agent desbocado de una factura sorpresa. Se aplica solo en la creación; las organizaciones existentes no se tocan y a cualquier organización se le puede quitar el tope después. Tiene que ser positivo; déjalo vacío para que las organizaciones empiecen sin tope (la postura anterior, de adhesión voluntaria) |
El tamaño de una petición, frente al tamaño de un archivo¶
Todos los límites de arriba se miden sobre bytes que ya han llegado. FastAPI parsea
un cuerpo multipart para resolver el parámetro UploadFile antes de que el
handler se ejecute, así que cuando uno de esos topes se compara con len(data) el
cuerpo ya se ha volcado a un archivo temporal y se ha leído en memoria. Detrás de
una sesión eso no es gran riesgo; en POST /api/v1/embed/{key}/files, que puede
alcanzar un desconocido con un enlace, sí lo es.
Por eso una petición que declara un Content-Length mayor que
MAX_UPLOAD_SIZE_MB más un margen de 5 MiB para el sobre multipart se responde con
413 antes de leer su cuerpo. No hay ajuste: sigue a MAX_UPLOAD_SIZE_MB,
porque un segundo número que hay que mantener a la par del primero es un número que
acaba por debajo de él.
Es la mitad barata de la respuesta, no la respuesta entera. El Content-Length
lo pone quien llama, y una petición chunked no declara ninguno — esas pasan y
quedan acotadas por los topes por ruta, que miden bytes reales. Un despliegue que
quiera la garantía y no la cortesía fija client_max_body_size (nginx) o el
equivalente en lo que termine sus conexiones; los archivos compose ejecutan uvicorn
sin un límite propio.
Autenticación¶
JWT¶
| Variable | Por defecto | Descripción |
|---|---|---|
SECRET_KEY |
(insecure default) | Clave de firma de los JWT. Tiene que cambiarse en producción. Genérala con: openssl rand -hex 32 |
ACCESS_TOKEN_EXPIRE_MINUTES |
30 |
Vida del access token |
REFRESH_TOKEN_EXPIRE_MINUTES |
10080 |
Vida del refresh token (7 días) |
ALGORITHM |
HS256 |
Algoritmo de firma de los JWT |
Validación en producción: SECRET_KEY tiene que tener al menos 32 caracteres y no
puede usar el valor por defecto con ENVIRONMENT=production.
El vault de secretos¶
Toda credencial que la plataforma guarda en reposo — claves de provider, tokens de
bots de canal, credenciales MCP y secretos de organización — la sella
app/core/vault.py, cuyo sobre se deriva de la clave maestra y del propietario
(una organización, o el miembro al que pertenece una conexión personal). Un
ciphertext es por tanto inútil fuera del tenant para el que se selló.
| Variable | Por defecto | Descripción |
|---|---|---|
VAULT_MASTER_KEY |
(empty, falls back to SECRET_KEY) |
Clave maestra del vault de secretos — abreviatura de la versión 1 de VAULT_MASTER_KEYS. Obligatoria fuera de local/development (salvo que se fije el mapa de abajo), para que un vault de staging no pueda arrancar sellado bajo el SECRET_KEY publicado por defecto. Genérala con: openssl rand -hex 32 |
VAULT_MASTER_KEYS |
{} |
Todas las claves maestras aún en uso, por versión, como JSON — {"1": "<old>", "2": "<new>"}. La versión más alta sella los secretos nuevos; las anteriores mantienen legibles las filas existentes hasta que agenticos cmd vault-rotate las vuelve a envolver. Cuando se fija, es la verdad entera: VAULT_MASTER_KEY tiene que estar entonces vacía. Ver Secretos |
Clave de API¶
| Variable | Por defecto | Descripción |
|---|---|---|
API_KEY |
change-me-in-production |
Clave de API compartida para el acceso programático |
API_KEY_HEADER |
X-API-Key |
Nombre de la cabecera HTTP de la clave de API |
Validación en producción: API_KEY no puede usar el valor por defecto con
ENVIRONMENT=production.
OAuth2 (Google)¶
| Variable | Por defecto | Descripción |
|---|---|---|
GOOGLE_CLIENT_ID |
(empty) | Client ID de Google OAuth2 — el inicio de sesión y el consentimiento del trigger de Gmail |
GOOGLE_CLIENT_SECRET |
(empty) | Client secret de Google OAuth2 |
GOOGLE_REDIRECT_URI |
http://localhost:8000/api/v1/oauth/google/callback |
URL de callback de OAuth2 |
FRONTEND_URL |
http://localhost:3000 |
URL del frontend para las redirecciones de OAuth2 |
Cómo conseguir el par: consola de Google Cloud → APIs & Services → Credentials → Create OAuth client ID → Web application.
La URI de redirección autorizada es el callback del backend, no el del frontend:
http://localhost:8000/api/v1/oauth/google/callback por defecto, y lo que diga
GOOGLE_REDIRECT_URI en un despliegue. Google intercambia el código con la API, que
después envía el navegador a FRONTEND_URL. Registrar en su lugar la URL del
frontend es el error que merece nombrarse: la pantalla de consentimiento funciona, y
el callback da 404.
Al navegador se le envía un código de un solo uso y un minuto de vida, nunca los
tokens de sesión: un token en una URL de redirección llega a la barra de
direcciones, al log de acceso del servidor del frontend y al Referer de la
siguiente petición del mismo origen, y el refresh token vale una semana. El
frontend canjea el código por el par de tokens de servidor a servidor en
POST /api/v1/oauth/exchange, que lo redime exactamente una vez.
Base de datos (PostgreSQL)¶
| Variable | Por defecto | Descripción |
|---|---|---|
POSTGRES_HOST |
localhost |
Host de PostgreSQL |
POSTGRES_PORT |
5432 |
Puerto de PostgreSQL |
POSTGRES_USER |
postgres |
Usuario de PostgreSQL |
POSTGRES_PASSWORD |
(empty) | Contraseña de PostgreSQL |
POSTGRES_DB |
agenticos |
Nombre de la base de datos |
POSTGRES_SSLMODE |
(empty) | Cifra la conexión: require, verify-ca o verify-full. Vacío es texto plano. Ver Conexiones cifradas |
DB_POOL_SIZE |
5 |
Tamaño del pool de conexiones |
DB_MAX_OVERFLOW |
10 |
Máximo de conexiones de desbordamiento |
DB_POOL_TIMEOUT |
30 |
Tiempo de espera del pool, en segundos |
Propiedades calculadas:
- DATABASE_URL -- cadena de conexión asíncrona (postgresql+asyncpg://...)
- DATABASE_URL_SYNC -- cadena de conexión síncrona, para Alembic
Redis¶
Los archivos compose ejecutan Valkey (valkey/valkey:8-alpine), el fork
BSD-3-Clause de Redis 7.2, en lugar del propio Redis, que desde 7.4.0 está bajo
RSALv2 o SSPL-1.0 — ninguna de las dos es una licencia de código abierto
(licencias). Habla el mismo protocolo en el mismo puerto, así que
los ajustes de abajo, el esquema redis:// y el nombre de servicio redis no
cambian, y un despliegue que en su lugar apunte todo esto a un Redis, un Valkey o
un Elasticache gestionados funciona exactamente igual que antes.
| Variable | Por defecto | Descripción |
|---|---|---|
REDIS_HOST |
localhost |
Host de Redis |
REDIS_PORT |
6379 |
Puerto de Redis |
REDIS_PASSWORD |
(none) | Contraseña de Redis (opcional) |
REDIS_DB |
0 |
Número de base de datos de Redis |
REDIS_SSL |
false |
Cifra la conexión (rediss://). Ver Conexiones cifradas |
Conexiones cifradas (TLS)¶
Los dos almacenes se conectan en texto plano por defecto. En una sola máquina con Postgres y Redis en la misma red de Docker eso está bien, y es lo que ejecutan los archivos compose que se distribuyen. Con un Postgres gestionado, o un Redis en otro nodo, cifrar la conexión es el control de seguridad en la transmisión que un auditor pide primero (HIPAA §164.312(e), SOC 2 CC6.7).
Fijar POSTGRES_SSLMODE construye la URL que entiende cada driver — ?ssl=<mode>
para el asyncpg de la aplicación, ?sslmode=<mode> para el psycopg2 de Alembic — y
REDIS_SSL cambia el esquema de Redis a rediss://. require cifra la conexión;
verify-ca y verify-full además comprueban el certificado del servidor.
REDIS_SSL pide también una cadena de certificados válida y un hostname que
coincida en la propia URL, en vez de dejar ambas cosas a los valores por defecto de
redis-py.
Una CA privada es un archivo que leen los drivers, no el almacén de confianza del sistema
Ninguno de los dos drivers consulta el almacén de confianza del contenedor, y
la imagen se ejecuta como un usuario no root sin un entrypoint que pudiera
reconstruirlo. asyncpg y libpq leen ambos el archivo de CA que nombra
PGSSLROOTCERT; redis-py confía en el bundle al que apunte OpenSSL, que
SSL_CERT_FILE sobrescribe. Monta la CA una vez y apunta las dos variables a
ella: verify-ca y verify-full fallan sin la primera, porque asyncpg busca
entonces ~/.postgresql/root.crt y no encuentra nada.
Todo servicio que abra una conexión a un almacén necesita el cambio
app, migrate y prefect-runner se conectan cada uno a Postgres y a Redis,
y los archivos compose que se distribuyen fijan POSTGRES_HOST=db y
REDIS_HOST=redis en el environment de cada uno, lo que gana a un archivo
de entorno. Así que un almacén gestionado es un archivo de override que llega
a los tres, no una línea en .env.
# docker-compose.managed.yml - a managed Postgres and Redis, verified against a
# private CA. Run with `docker compose -f docker-compose.yml -f docker-compose.managed.yml up -d`.
x-managed: &managed
environment:
POSTGRES_HOST: db.internal.example.com
POSTGRES_SSLMODE: verify-full
PGSSLROOTCERT: /run/tls/managed-ca.crt
REDIS_HOST: redis.internal.example.com
REDIS_SSL: "true"
SSL_CERT_FILE: /run/tls/managed-ca.crt
volumes:
- ./ca/managed-ca.crt:/run/tls/managed-ca.crt:ro
services:
app: *managed
migrate: *managed
prefect-runner: *managed
Los servicios db y redis incluidos siguen arrancando, sin usarse; agenticos cmd
doctor muestra a qué almacén llegó realmente cada conexión y si iba cifrada
(postgres: tls=on/off, redis: tls=on/off, a partir de pg_stat_ssl y del
esquema de la URL).
Correo (SMTP)¶
El despliegue envía correo a través de un servidor SMTP, y uno que no tenga ninguno configurado no falla: se ejecuta, y todos los flujos que dependen del correo se detienen en silencio, sin que ninguno se anuncie:
- inicio de sesión sin contraseña y restablecimientos de contraseña — los correos con el enlace mágico y el de restablecimiento son las vías de autoservicio para entrar en una cuenta;
- invitaciones — a una dirección invitada nunca se le escribe (la consola ahora lo dice, en vez de afirmar que envió algo, #1484);
- notificaciones — un budget superado, una solicitud de aprobación, un informe de uso, el aviso que se envía cuando un administrador actúa como otra cuenta.
| Variable | Por defecto | Descripción |
|---|---|---|
SMTP_HOST |
localhost |
Host del servidor SMTP |
SMTP_PORT |
587 |
Puerto del servidor SMTP. 587 y 25 negocian STARTTLS; 465 abre TLS desde el principio |
SMTP_USER |
(empty) | Usuario con el que se autentica el relay, junto a SMTP_PASSWORD. Déjalo vacío para un relay sin autenticación |
SMTP_PASSWORD |
(empty) | Contraseña de ese usuario |
SMTP_TLS |
true |
Si se cifra la conexión. El puerto elige el esquema — STARTTLS en 587, TLS implícito en 465 — salvo que SMTP_TLS_MODE diga otra cosa. Ponlo en false solo para un relay sin cifrar, como un servidor local en el 25 |
SMTP_TLS_MODE |
auto |
Cómo se abre la conexión cifrada. auto deja elegir al puerto; implicit abre TLS desde el primer byte y starttls negocia la mejora, sea cual sea el puerto. Se ignora con SMTP_TLS=false |
EMAIL_FROM |
noreply@agenticos.com |
La dirección From de cada mensaje |
EMAIL_FROM_NAME |
agenticos |
El nombre visible que se muestra junto a esa dirección |
Cómo se cifra la conexión
SMTP_TLS es el interruptor; el puerto elige el esquema. El valor por defecto
que se distribuye — 587 con SMTP_TLS=true — negocia STARTTLS, que es lo que
espera un servidor de envío conforme al estándar. Usa 465 para un servidor
que quiera TLS implícito, y SMTP_TLS=false en el 25 para un relay en texto
plano.
Un servidor que habla TLS implícito en un puerto que no sea el 465 — el
8465, pongamos — necesita SMTP_TLS_MODE=implicit, porque auto le
ofrecería un saludo en texto plano y todos los envíos fallarían. starttls es
el caso espejo.
Trabajo en segundo plano (Prefect)¶
| Variable | Por defecto | Descripción |
|---|---|---|
PREFECT_API_URL |
http://localhost:4200/api |
El servidor autoalojado, o la URL de un workspace de Prefect Cloud |
PREFECT_API_KEY |
(none) | Solo para Prefect Cloud |
PREFECT_RUNNER_LIMIT |
5 |
Cuántas ejecuciones de flow corren a la vez; el resto espera en cola |
PREFECT_RUNNER_SERVER_HOST |
127.0.0.1 in compose |
Interfaz en la que el runner sirve su propio endpoint de salud |
PREFECT_RUNNER_SERVER_PORT |
8080 |
Puerto para lo mismo |
PREFECT_RUNNER_LIMIT es un techo de memoria, no un mando de rendimiento. Cada
ejecución es un proceso aparte que importa la aplicación entera — unos 120 MB — y el
número que importa no es el estado estable sino el reinicio: el runner arranca,
encuentra todas las ejecuciones programadas mientras estuvo caído y arranca tantas
como permita el límite. Sin tope, tres días de caída fueron 71 procesos y 6 GiB.
Súbelo si la ingesta se acumula detrás de las sincronizaciones en una máquina con
memoria de sobra; bájalo en una máquina pequeña.
Las dos variables PREFECT_RUNNER_SERVER_* son de Prefect, y los archivos compose
las fijan para que el contenedor del runner tenga un estado de salud con sentido. El
runner arranca el webserver del runner de Prefect, cuyo GET /health responde 503 en
cuanto pierde dos sondeos de la API de Prefect — así, un proceso vivo que ya no
recoge trabajo aparece como unhealthy y no como sano. Va atado al loopback porque
ese mismo webserver expone además POST /shutdown; la sonda corre dentro del
contenedor y nada de fuera alcanza ninguno de los dos. Mover el puerto obliga a mover
con él la sonda en los archivos compose.
No hay HEALTHCHECK en backend/Dockerfile. La imagen se arranca como dos procesos
distintos — la API y este runner — y una sonda para uno es una falsa alarma
permanente para el otro, así que cada definición de servicio lleva la suya.
Caducidad de las aprobaciones¶
| Variable | Por defecto | Descripción |
|---|---|---|
APPROVAL_EXPIRY_HOURS |
72 |
Cuánto espera una llamada a herramienta aparcada antes de que el barrido horario la deniegue por tiempo |
Tres días porque tiene que abarcar un fin de semana: la aprobación que llega el viernes por la tarde es la que nadie decide, y caducarla el sábado sería caducarla por haberse pedido a mala hora. Acórtalo donde la cola se vigila en horario laboral y una petición rancia es peor que una lenta; alárgalo donde las aprobaciones son un ritual semanal. Caducar una llamada además termina su run — ver Gobernanza para lo que eso zanja y lo que deja a propósito en paz.
Recogida de runs estancados¶
| Variable | Por defecto | Descripción |
|---|---|---|
STALE_RUN_REAPED_AFTER_HOURS |
6 |
Cuánto puede quedarse un run en running antes de que el barrido horario decida que su proceso murió y lo termine como failed. Cero o menos apaga el barrido |
La fila de un run se confirma antes de llamar a su modelo, así que un worker muerto a
media ejecución la deja en running sin nada que la termine. El techo no tiene que
ser exacto — un run vivo que el barrido marque igualmente lo devuelve su propia
escritura terminal — así que ponlo bastante por encima de tu run legítimo más largo y
no más cerca. Ver Gobernanza.
Modelos de IA — se configuran en la aplicación, no aquí¶
Los modelos de chat no son variables de entorno. Cada organización guarda sus propias
claves de provider en el vault (Settings → Models), y el spec de cada agent nombra el
perfil de modelo con el que se ejecuta. AI_MODEL, AI_TEMPERATURE,
AI_THINKING_ENABLED, AI_THINKING_EFFORT, AI_AVAILABLE_MODELS, AI_FRAMEWORK y
LLM_PROVIDER se eliminaron junto con el asistente general de la plantilla; fijarlas
ahora no hace nada.
La única credencial de modelo que se queda en el entorno es la de embeddings — ver RAG más abajo.
Observabilidad (Logfire)¶
| Variable | Por defecto | Descripción |
|---|---|---|
LOGFIRE_TOKEN |
(none) | Token de Pydantic Logfire. Consigue uno en https://logfire.pydantic.dev |
LOGFIRE_SERVICE_NAME |
agenticos |
Nombre del servicio en el dashboard de Logfire |
LOGFIRE_ENVIRONMENT |
development |
Etiqueta de entorno |
LOGFIRE_ORGANIZATION |
(none) | Slug de la organización, para construir un enlace hacia dentro de una traza guardada. El token es una credencial de escritura y no lleva ninguno de los dos slugs |
LOGFIRE_PROJECT |
(none) | Slug del proyecto, junto al de la organización. Con cualquiera de los dos sin fijar, el logfire_trace_id de un run se sigue registrando y no se ofrece enlace |
LOGFIRE_BASE_URL |
https://logfire-us.pydantic.dev |
A qué despliegue de Logfire pertenecen esos slugs. logfire-eu es otro host, y un enlace construido para el equivocado da 404 |
Búsqueda web¶
| Variable | Por defecto | Descripción |
|---|---|---|
RAG (Retrieval Augmented Generation)¶
Base de datos vectorial¶
pgvector usa la conexión de PostgreSQL que ya existe. No hace falta configuración
adicional — pero la imagen tiene que ser pgvector/pgvector:pg16, que es lo que
fija cada archivo compose de aquí.
\"Vector store: unconfigured\" en un despliegue recién instalado no es un fallo
La extensión se crea la primera vez que se escribe en una colección, así que
antes del primer documento está genuinamente ausente y tanto la página System de
administración como agenticos cmd doctor lo dicen. Se resuelve solo en la
primera ingesta.
Lo que sí es un fallo es un unhealthy ahí, y dice cuál de los tres: la imagen
no trae pgvector; el rol que se conecta no puede crearla; o el directorio de
datos arrastra la fila de la extensión mientras la imagen sobre la que ahora
corre ha perdido la biblioteca. Los tres hacen fallar una subida después de
aceptar los bytes, y los tres se leían igual que un primer día sano
(#1504).
Embeddings¶
Nada aquí. Cada colección nombra el provider a través del cual genera sus
embeddings, el modelo, y o bien la clave del vault de la organización que lo
paga o bien — para el provider sin clave ollama — un servicio local, una
fila bajo Knowledge → Integrations que dice dónde responde el Ollama del
deployment o de la organización. Consulta Procesamiento de archivos.
Antes había aquí dos variables. EMBEDDING_MODEL preseleccionaba un modelo para
las colecciones nuevas y ha desaparecido: el formulario ofrece los modelos que
sirve el provider elegido. EMBEDDING_OLLAMA_BASE_URL nombraba un único Ollama
para todo el deployment y ahora es un servicio local, por organización o de todo
el deployment.
Parseo de documentos — se configura por colección, no aquí¶
El parser, el OCR, el tamaño de chunk, el solape de chunks, la estrategia de troceado
y el modelo de descripción de imágenes no son variables de entorno. Se guardan en
cada base de conocimiento (knowledge_bases.ingestion_config) y se editan en /rag,
y cualquiera de ellos puede además sobrescribirse para una sola subida.
La razón es que un único valor para toda la instalación hacía que el mismo formulario
produjera colecciones distintas en dos despliegues, sin que nada en el producto
dijera cuál — y un archivo de contratos escaneados y una carpeta de notas en Markdown
quieren respuestas distintas en el mismo despliegue. PDF_PARSER, CHAT_PDF_PARSER,
LLAMAPARSE_TIER, LITEPARSE_OCR_LANGUAGE, LITEPARSE_TIMEOUT_SECONDS,
RAG_ENABLE_OCR, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP y RAG_CHUNKING_STRATEGY se
eliminaron; fijarlas ahora no hace nada.
Dos cosas que antes se quedaban aquí son ahora filas en el producto. Una clave
de LlamaParse es una entrada del vault que nombra la configuración de ingesta de
la colección (llamaparse_secret_id), y una colección en LlamaParse sin ella se
rechaza ya en el formulario: LLAMAPARSE_API_KEY ha desaparecido. Un servidor de
OCR al que LiteParse envía páginas es un servicio local de tipo ocr bajo
Knowledge → Integrations, elegido por colección (ocr_endpoint_id), registrado
por el operador de una organización o — para todas las organizaciones — por el
administrador del deployment; LITEPARSE_OCR_SERVER_URL también ha
desaparecido. Ninguna de las dos era visible para el tenant sobre cuyos
documentos decidía.
Los adjuntos del chat se leen con PyMuPDF y no son configurables: un adjunto no pertenece a ninguna colección, así que no hay configuración guardada que leer.
Sincronización con Google Drive¶
| Variable | Por defecto | Descripción |
|---|---|---|
GOOGLE_DRIVE_CREDENTIALS_FILE |
credentials/google-drive-sa.json |
Ruta a las credenciales de la cuenta de servicio de Google, solo para rag-sync-gdrive |
Esta es la credencial de la CLI, no una reserva para una fuente de sincronización.
Una fuente de sincronización gdrive nombra un secreto gcp_service_account en el
vault de su organización y se ejecuta con ese o no se ejecuta: una clave para todo el
despliegue supliendo a una que falta hacía que el folder_id de un tenant eligiera
qué se listaba bajo la cuenta de servicio del operador. La credencial de la fuente no
es un ajuste ni un campo de configuración — ver Secretos y el vault.
El archivo es la clave de una cuenta de servicio: consola de Cloud → crea una cuenta de servicio → Keys → Add key → JSON. Después comparte la carpeta de Drive con la propia dirección de correo de la cuenta de servicio: es un principal como cualquier otro, y una carpeta que nadie ha compartido con él se lista como vacía en vez de como rechazada.
Sincronización con S3/MinIO¶
| Variable | Por defecto | Descripción |
|---|---|---|
S3_RAG_ENDPOINT |
(none) | URL del endpoint de S3/MinIO. Una fuente de sincronización puede sobrescribirla |
S3_RAG_ACCESS_KEY |
(empty) | Access key, solo para el comando rag-sync-s3 de la CLI |
S3_RAG_SECRET_KEY |
(empty) | Secret key, igual |
S3_RAG_BUCKET |
agenticos-rag |
Nombre del bucket |
S3_RAG_REGION |
us-east-1 |
Región de AWS. La región propia de una credencial gana donde la tenga |
El par de claves de aquí es el de la CLI, no el de una fuente de sincronización.
Una fuente s3 nombra un secreto aws_credentials en el vault de su organización,
igual que una gdrive nombra una cuenta de servicio. El endpoint y la región siguen
recurriendo a estos ajustes porque ninguno nombra a un principal: dicen dónde está el
almacén, no quién pregunta.
Workspaces de los agents¶
El workspace state no necesita nada de aquí. Se guarda en esta base de datos,
funciona en todos los despliegues y es lo que un agent recibe por defecto — así que
los ajustes de abajo son solo para uno respaldado por contenedor.
| Variable | Por defecto | Notas |
|---|---|---|
SANDBOX_STATE_MAX_BYTES |
4 MiB | Por workspace guardado. Pasado ese punto una escritura se rechaza con un mensaje que el modelo lee |
SANDBOX_INLINE_IMAGE_MAX_BYTES |
5 MiB | Por encima de esto una imagen adjunta se escribe en el workspace y no se envía además en línea |
El porcentaje del chat son dos techos distintos, y dice cuál es. Un workspace
guardado se llena contra SANDBOX_STATE_MAX_BYTES, arriba — bytes, y agotarlos
rechaza una escritura. Un contenedor informa de la memoria residente contra el
techo que su host fijó para ese runtime, que es 1g salvo que la lista de permitidos
diga otra cosa, y agotar eso es una muerte por OOM y no un rechazo. Por eso la tira
dice workspace 12% full para lo primero y sandbox memory 12% full para lo
segundo; informar de uno como del otro nombraría un límite que no se aplica.
Dónde se ejecutan las sandboxes no es un ajuste. Es una fila por organización
—Sandboxes en la aplicación, sandbox_connections en la base de datos— con el token
de servicio en el vault. Dos razones, y ninguna se puede expresar en una variable de
entorno: un despliegue puede tener más de un host, y una dirección por despliegue le
daba la misma a todas las organizaciones; y el token autoriza abrir una sesión, que
ejecuta comandos en el host que sostiene el socket de Docker, así que pertenece al
sitio donde vive cualquier otra credencial en reposo.
Un operador registra una conexión con un nombre, una dirección y una clave del vault. Un agent nombra una por id, exactamente igual que nombra un perfil de modelo, o no nombra ninguna y toma la de la organización por defecto — así que mudarse a otro host es una edición y no volver a publicar todos los agents.
El token de servicio vale lo que vale el socket de Docker. El servicio sostiene
ese socket, el socket es una API sin autenticar para root en la máquina, y el token
es lo que abre una sesión sobre él. Nunca en un navegador, nunca en un log, nunca
versionado — por eso la pantalla del operador solo muestra que hay una credencial
adjunta, y por eso GET /policy se sirve a través de esta API en vez de que lo pida
el navegador. El dashboard propio del servicio (SANDBOXD_UI_ENABLED) está apagado en
todos los archivos compose que se distribuyen por la misma razón: pide a una persona
que pegue este valor en un navegador.
SANDBOXD_TOKEN en backend/.env es el del servicio: lo que el daemon del archivo
compose aceptará.
make sandbox-token lo genera, y el formulario de conexión guarda por ti ese mismo
valor en el vault. La API lee este ajuste con un único propósito: ofrecerlo al vault.
Pedirle a alguien que copie un secreto de un archivo que su propio stack ya está
leyendo es fricción sin nada detrás.
Nunca se usa para alcanzar un host: resolver una conexión abre la entrada del vault que esa conexión nombra, y ese sigue siendo el único camino. Así que un despliegue que lo deje sin fijar pierde un botón y nada más, y pega el token a mano.
El mismo formulario pregunta si ya hay un servicio respondiendo, en vez de obligar al
operador a saber que el servicio de sandbox de make dev vive en
http://sandboxd:8080. Esa dirección no es configuración, y a propósito — es una
fila, porque un despliegue puede tener varios hosts —, así que la API sondea el
/healthz sin autenticar en la dirección que usa el archivo compose de este proyecto
y rellena lo que haya respondido. Preguntar no decide nada: sin servicio el campo
queda vacío, y si ya hay una conexión apuntando ahí se nombra para que nadie registre
dos veces el mismo host.
La dirección la pide esta API, así que se valida como tal. Registrar o sondear
una conexión hace que el contenedor de la API emita un GET autenticado y devuelva
el cuerpo JSON, lo que es una primitiva de falsificación de peticiones si la
dirección se acepta a ciegas. Así que base_url rechaza todo lo que no sea
http(s) con un host, y rechaza de plano las direcciones link-local y los hostnames
de metadatos de instancia: 169.254.169.254 y metadata.google.internal nunca son
un servicio de sandbox.
Las direcciones privadas siguen permitidas, y tienen que estarlo: http://sandboxd:8080
dentro de compose y http://localhost:8080 para quien ejecuta la API en su propia
máquina son ambas privadas, así que una lista de denegación del rango privado
rechazaría el despliegue que describe esta página. El validador estrecha el agujero
en vez de cerrarlo — un hostname que resuelve a algo interno sigue resolviendo. El
límite que de verdad aguanta es connections:manage más la política de salida del
contenedor de la API: de quien puede registrar un host se fía uno, y un despliegue
en una red con APIs internas sin autenticar debería decirlo en la red y no aquí.
Qué entornos puede pedir un agent¶
Se distribuye un runtime — workbench (1,93 GB): Python 3.12, Node 24, LibreOffice y
las bibliotecas que un agent necesita para leer, escribir, convertir y graficar los
archivos de los que trata una conversación, incluido liteparse con OCR. Está definido
en backend/app/core/catalog/sandbox_runtimes.json. Añadir uno es una edición ahí y
make sandbox-runtimes, que escribe SANDBOXD_RUNTIMES en los tres archivos
compose; esa variable es el único canal por el que el servicio acepta runtimes, y
PUT /policy rechaza a propósito la composición de la lista.
sandbox.md#which-environments-an-agent-may-ask-for tiene el formato campo por
campo, las tres trampas (la primera entrada es la de por defecto, network_mode no
se hereda, una build se paga al arrancar con prewarm) y por qué la copia generada
en los archivos compose no puede desviarse del catálogo.
Los ajustes del propio servicio¶
Cada campo de la configuración del servicio es SANDBOXD_ más su nombre, así que
esto es un subconjunto y no un vocabulario. Estos son los que fijan los archivos
compose que se distribuyen, o los que deciden si los archivos sobreviven:
| Variable | Valor distribuido | Qué decide |
|---|---|---|
SANDBOXD_WORKSPACE_ROOT |
una ruta del host | Dónde vive el directorio de trabajo de cada sesión, montado desde el host. Sin fijar, los archivos existen solo dentro de un contenedor en marcha: una recogida por inactividad los descarta y la siguiente petición abre un workspace vacío, sin nada en un log. Es además lo que hace posible navegarlos: leer un workspace nunca arranca un contenedor |
SANDBOXD_SANDBOX_UID |
10001 |
El usuario sin privilegios con el que corre una sandbox, en vez de root — una fuga de contenedor arranca desde quienquiera que ejecute el contenedor, y todo archivo que escribe un agent pertenece a este uid en el host. Tiene que ser el uid del propio servicio: abrir una sesión hace chown del workspace a este usuario, cosa que un servicio sin privilegios solo puede hacer para sí mismo. Se aplica a un runtime que el despliegue construye, ya que una imagen hecha no tiene tal cuenta y un agent dentro de una no podría instalar nada |
SANDBOXD_CONTAINER_TTL |
86400s | Cuánto se conserva un contenedor persistido ya parado. Recupera lo que instaló una sesión —la build, las wheels, node_modules— y deja el workspace intacto, porque los archivos son el trabajo. Sin fijar, se conservan para siempre |
SANDBOXD_PERSIST_CONTAINERS |
true |
El contenedor de una sesión cerrada se conserva en vez de eliminarse, así que la siguiente sesión sobre ese workspace arranca sin una build. Cuesta un contenedor parado por workspace; SANDBOXD_CONTAINER_TTL lo acota |
SANDBOXD_MAX_SESSIONS_PER_TENANT |
5 |
Una organización no puede quedarse con el pool. SANDBOXD_MAX_SESSIONS (20) es el pool |
SANDBOXD_NETWORK_MODE |
none |
La red por defecto de una sandbox. none es no tener red en absoluto; un runtime puede nombrar bridge para sí mismo |
SANDBOXD_UI_ENABLED |
0 |
El dashboard propio del servicio. Apagado porque pide a una persona que pegue en un navegador un token equivalente a root |
SANDBOXD_IDLE_TIMEOUT |
1800s | Cuánto vive una sesión inactiva antes de cerrarse y recogerse |
SANDBOXD_MEM_LIMIT |
1g |
El techo de memoria por defecto, y por tanto el número del que el porcentaje sandbox memory del chat es una parte |
Ejecutar el servicio en otra máquina¶
Nada de una conexión da por hecha una dirección local: es una fila con una URL y una credencial del vault, y el formulario sondea lo que se le dé. Una máquina en otro sitio necesita tres cosas y nada de código:
- El socket de Docker, porque el servicio arranca contenedores. Eso es root en esa máquina, que es por lo que el token de abajo vale lo que vale.
SANDBOXD_WORKSPACE_ROOTen disco real, montado en la misma ruta en ambos lados. El servicio crea el directorio y luego pide al daemon que lo monte, y el daemon resuelve la ruta en el host — así que un volumen con nombre, o una ruta que solo existe dentro del contenedor del servicio, se rechaza conmounts denied.- TLS y un token que no comparta nadie. Dentro de compose la dirección es
http://sandboxd:8080en una red privada; a través de internet es un servicio que ejecutará comandos para quien tenga el token, así que va detrás de HTTPS con un valor propio.
Después regístrala en Sandboxes como cualquier otra y apunta un agent a ella por nombre. El servicio de compose es un despliegue más de la misma imagen.
Cuándo hay una sesión abierta¶
La pestaña Running lista las sesiones que sostiene el servicio, con refresco cada diez segundos, y una sesión es un workspace en un host. Tres estados, y solo aparecen los dos primeros:
- running — el contenedor existe y está residente. Lo abre la primera llamada a herramienta de un agent en una conversación, no el inicio de la conversación.
- hibernated — la fila existe y el contenedor no. Una sesión inactiva más allá de
SANDBOXD_EVICT_IDLE_AFTERse hiberna para liberar una plaza, y su siguiente petición la despierta. Esto necesitaWORKSPACE_ROOT, o despertar una abriría un workspace vacío, y el servicio rechaza la combinación en vez de hacer eso. - gone — pasado
SANDBOXD_IDLE_TIMEOUTla sesión se cierra y se recoge. ConPERSIST_CONTAINERSel contenedor sobrevive a eso, así que la siguiente sesión sobre el mismo workspace arranca sin una build.
Así que una pestaña Running vacía significa que ningún agent ha usado una shell hace poco, no que no haya nada configurado — y un workspace con archivos dentro y sin sesión es el estado de reposo normal.
El servicio corre bajo el profile sandbox de compose, que está encendido por
defecto en desarrollo local y apagado en el resto hasta que un operador lo active:
montar el socket de Docker en una máquina compartida es un acto deliberado.
COMPOSE_DEV_PROFILES en el Makefile es el único sitio donde cambiarlo.
uv run agenticos cmd doctor sondea cada conexión registrada: si responde, si acepta
su credencial, y si permite algún runtime. No tener ninguna conexión registrada es un
aviso, no un fallo — el workspace state no necesita ninguna.
Navegar por lo que los agents han guardado. Workspaces es una pantalla propia, no parte de Sandboxes, que trata de hosts.
Cada fila nombra el agent, la conversación a la que pertenecen los archivos (o cuántos chats llegan a ellos, para un workspace que no es de una sola conversación), quién puede verlos, cuánto ocupa y cuándo se usó por última vez.
Open lleva a la página propia de ese workspace, con la forma que usa el editor de skills: el árbol a la izquierda —carpetas recorridas de una en una, con una caja de búsqueda sobre todo el árbol y no sobre la carpeta en pantalla— y el archivo renderizado al lado. Así que leer tres archivos son tres clics, y la lista no se cierra nunca.
La descarga está en la fila y no junto al lector, porque seleccionar un archivo lo lee y de un archivo grande alguien quiere una copia sin pagar por eso.
Una segunda vista sobre el listado aplana en una sola rejilla todos los archivos que el lector puede ver: la pregunta de "quién tiene una copia de ese CSV" que la página por workspace no responde.
Al hacer clic en un archivo se abre en un visor, y es el mismo visor del panel del
chat. Una imagen es una imagen, un PDF es la vista PDF propia del navegador,
markdown ofrece Preview y Source —ambos son el archivo, y un # que se convirtió
en silencio en letra grande es como alguien no llega a notar que su agent escribe
markdown dentro de algo que nada lee como markdown— y cualquier otra cosa es su
texto. La descarga está siempre ahí, también para lo que no se puede mostrar en
absoluto. Un solo componente, porque que "abrir este archivo" signifique dos cosas
distintas en dos pantallas es como a la segunda acaba faltándole un caso.
Los bytes vienen de GET /sandbox-workspaces/{id}/raw?path=…, o de
GET /conversations/{id}/workspace/raw?path=… para el panel junto a un chat.
Dos rutas y no una porque autorizan a llamantes distintos —a la ruta de la conversación se llega obteniendo la conversación, así que alguien con quien se compartió un chat conserva el acceso— y un solo módulo decide qué se puede mostrar, para que la respuesta no pueda variar según la superficie.
Casi todo se sirve como adjunto. Las imágenes ráster y los PDF se sirven para mostrarse: la ráster porque no puede ejecutarse, el PDF porque el navegador lo renderiza en su propio visor, que nunca recibe el DOM de la página.
SVG y HTML se pueden descargar y nunca mostrar
Un SVG servido en línea desde este origen es cross-site scripting almacenado escrito por lo que el agent decidiera guardar, y "lo escribió el agent" no es un límite de confianza.
Todo lo demás se tipa como application/octet-stream con
X-Content-Type-Options: nosniff, para que un navegador no pueda decidir que ese
cuerpo era HTML después de todo. El nombre del archivo viaja solo como filename*,
porque una ruta de workspace puede contener cualquier UTF-8 y la forma simple no
tiene manera de decirlo.
Solo un workspace guardado puede servir bytes arbitrarios. Uno respaldado por contenedor se lee a través del archivo comprimido del workspace, cuyo único lector es textual, así que un archivo de texto se sirve codificándolo y cualquier otra cosa se rechaza en vez de estropearse en silencio — el navegador ofrece la descarga junto al rechazo, para que la respuesta nunca sea un callejón sin salida.
Los archivos se leen solo cuando se abre un workspace, o cuando se activa la vista plana: un despliegue puede tener uno por conversación caliente, así que leer cada uno para renderizar la tabla sería una petición por fila de una página a la que nadie le ha preguntado nada todavía. La vista plana está acotada por la misma razón, y lo dice: cuántos workspaces leyó, cuántos no pudo, y si hay más. Una lista más corta es, si no, indistinguible de menos archivos.
Quién ve qué workspace se decide por lector, en la consulta. Quien tiene
connections:manage ve los de la organización — el listón honesto para un listado que
cruza chats que no son suyos. El resto ve los workspaces de los que forma parte: sus
propios archivos con alcance user, los workspaces de sus propias conversaciones y el
workspace compartido de un agent con el que ha hablado. "Ha hablado" y no "podría
abrir", a propósito: el alcance agent comparte un workspace entre los usuarios de un
agent y el panel del chat ya muestra esos archivos a cualquiera en una conversación con
él, así que poder abrir el agent es una pretensión más amplia que la de este listado.
El alcance channel es visible solo para un operador, lo cual es correcto y no un
descuido: está indexado por un chat de Slack o de Telegram, así que a quienes lo
comparten los identifica esa plataforma y no una fila en users.
Un workspace obtenido por id aplica los mismos tres predicados y responde not found en vez de prohibido cuando fallan: un id no debe servir para descubrir qué workspaces existen en la conversación de un colega. Nada de aquí cruza una organización — un app admin navegando por los archivos de otro tenant sería precisamente la lectura que esta plataforma rechaza, así que cambia de organización como cualquiera.
Un workspace respaldado por contenedor se lee del volumen del host, que hace falta
tener. El servicio de sandbox sirve esos archivos desde
SANDBOXD_WORKSPACE_ROOT, y eso es lo que permite que una conversación del mes pasado
liste sus archivos después de que su sesión fuera recogida: no se arranca ningún
contenedor para responder. Un servicio configurado sin uno no guarda nada en disco,
así que sus archivos existen solo mientras una sandbox está en marcha y no se pueden
leer sin arrancar una: el panel Files solo podría decirlo, para un archivo que el
agent acababa demostrablemente de escribir.
Por eso todos los archivos compose fijan uno, sobrescribible con
SANDBOX_WORKSPACE_ROOT — una variable de entorno donde compose la interpola, así que
la raíz del proyecto y no backend/.env, salvo en los objetivos dev y prod, que
pasan ese archivo de forma explícita.
Una sola ruta del host, montada en la misma ubicación en ambos lados, porque el
servicio crea el directorio y luego pide al daemon que lo monte — y el daemon
resuelve la ruta en el host. Un volumen con nombre, o cualquier ruta que solo exista
dentro del contenedor del servicio, se rechaza con mounts denied.
| Por defecto | ||
|---|---|---|
| Desarrollo local | /tmp/agenticos-sandbox-workspaces |
Docker Desktop la comparte y cualquiera puede escribir en ella, así que un portátil no necesita preparación |
| Los archivos del servidor | /var/lib/agenticos/sandbox-workspaces |
Tiene que existir y ser escribible por el uid 10001 — sudo mkdir -p <path> && sudo chown 10001:10001 <path>, una vez. No install -d -o 10001: install resuelve el propietario a través de la base de datos de passwd y rechaza un uid que no tiene cuenta. Va en almacenamiento del que alguien haga copia de seguridad |
Un reinicio barre /tmp, que es la única razón para no apuntar ahí un despliegue de
verdad.
Eso se informa en vez de lanzarse como error. Todo listado lleva
unreadable_reason, y un cliente lo muestra como una explicación y no como un error
— porque ninguna de las dos causas es un fallo: un servicio que no guarda nada en
disco es una configuración con un arreglo de una línea que el mensaje nombra, y un
host caído estará levantado más tarde.
Lanzar un error lo convertía en un 500, que un navegador solo podía renderizar como "algo ha ido mal", junto a una lista vacía, que se lee como "no hay archivos". Dos respuestas equivocadas a la vez.
Leer un archivo de un host así se rechaza con la misma frase, en vez de informarse como "no existe tal archivo", lo que diría que el archivo falta cuando no es así.
Lo que está en marcha también se lee del servicio.
La pantalla Sandboxes lo mantiene en una pestaña propia, aparte de la tabla de conexiones, y lista las sandboxes abiertas de esta organización en el host que nombra — la conexión por defecto hasta que el operador elija otra.
Cada fila lleva el runtime, qué comparte esa sandbox, su tiempo inactivo y su memoria frente a su propio techo cuando se pide. Se puede ordenar por tiempo inactivo y por memoria. Junto a ellas está el registro de actividad por sandbox: qué rutas se leyeron, qué comandos se ejecutaron y cómo fue cada uno.
Ni el contenido de los archivos ni la salida de los comandos los registra el servicio, que es lo que impide que un rastro de auditoría se convierta en una forma de leer el trabajo de otro agent.
El dashboard responde a las mismas tres preguntas en su propia sección, para quien
tenga connections:manage. La memoria está detrás de un interruptor ahí por la misma
razón que en la pantalla: el servicio muestrea cada sandbox por separado para eso.
Ahora los tres techos dividen.
El listado de sesiones está filtrado a la organización de quien llama, pero lleva
SANDBOXD_MAX_SESSIONS y SANDBOXD_MAX_OPEN_SESSIONS tal cual desde el servicio —
así que esos dos cuentan a todos los tenants del host mientras las filas cuentan uno.
len(sessions) divide únicamente contra SANDBOXD_MAX_SESSIONS_PER_TENANT.
Así que la respuesta lleva dos numeradores de todo el host para el otro par, tomados de la lista sin filtrar antes de que el filtro la estreche:
host_session_count— las sandboxes residentes que el servicio marca comostate == "running", frente alimit;host_open_count— todas las sesiones que existen, residentes o hibernadas, frente aopen_limit.
Ahora la tarjeta de capacidad puede decir por qué se rechazó una sesión mientras esta organización está lejos de su propio techo: el host mismo está lleno del trabajo de otro.
Que esos dos abarquen todo el host es una divulgación deliberada y estrecha —dos
enteros agregados que no nombran nada, muy lejos de las filas de sesión que el filtro
retiene— y el listado está protegido por connections:view, la autoridad para vigilar
un host más que la de un miembro cualquiera.
Son None en una conexión de Daytona, que no impone ningún techo nuestro que dividir.
Ese listado está filtrado, no reenviado. Un solo sandboxd responde a todas las
organizaciones que registraron una conexión en su dirección, así que pasar su respuesta
tal cual mostraría a un tenant los contenedores de otro. Las sesiones se emparejan por
la etiqueta tenant que esta plataforma pone al abrir una, y se nombran desde
agent_workspaces y no descodificando el id de la sesión: el id codifica la clave del
alcance, y parsearlo de vuelta convertiría ese formato en un esquema.
Lo que el servicio permite se lee del servicio. La lista de runtimes permitidos y
el techo detrás de cada alias (SANDBOXD_RUNTIMES, SANDBOXD_MEM_LIMIT,
SANDBOXD_NETWORK_MODE, SANDBOXD_MAX_SESSIONS_PER_TENANT y el resto) son su propia
configuración de arranque, y a propósito no hay endpoint para escribirlos: un navegador
que pudiera reconfigurar el proceso que sostiene el socket de Docker sería dueño del
host. La pantalla Sandboxes y la tarjeta de runtimes del dashboard los leen ambas
para que se vea qué está en vigor, y el Builder ofrece a un agent solo los alias que el
servicio va a aceptar de verdad.
Ninguna de las dos vistas le pregunta nada de esto a una conexión de Daytona. No publica una lista de permitidos propia ni sostiene ninguna de nuestras sesiones que enumerar: lo que permite es un ajuste de esa cuenta, y lo que corre ahí se ve en su propio dashboard.
Canales de mensajería¶
| Variable | Por defecto | Descripción |
|---|---|---|
Las credenciales de los bots no se configuran aquí: cada bot se registra en la
aplicación con su token sellado en el vault, y un bot de Slack lleva además el
signing secret de su propia app y su token xapp- (SLACK_BOT_TOKEN,
SLACK_SIGNING_SECRET y SLACK_APP_TOKEN se eliminaron: ahora cada bot es su propia
app de Slack). Las URL de webhook de Telegram se construyen a partir de
PUBLIC_BASE_URL (TELEGRAM_WEBHOOK_BASE_URL se eliminó), los perfiles de modelo
pueden apuntar a endpoints locales como Ollama sin ninguna bandera
(ALLOW_INTERNAL_MODEL_ENDPOINTS se eliminó), y los límites de la sandbox de
run_python son configuración de la capability por agent
(CODE_EXECUTION_TIMEOUT_SECS / CODE_EXECUTION_MAX_MEMORY_MB se eliminaron).
CORS¶
| Variable | Por defecto | Descripción |
|---|---|---|
CORS_ORIGINS |
["http://localhost:3000","http://localhost:8080"] |
Orígenes permitidos (array JSON) |
CORS_ALLOW_CREDENTIALS |
true |
Permite credenciales (cookies) |
CORS_ALLOW_METHODS |
["*"] |
Métodos HTTP permitidos |
CORS_ALLOW_HEADERS |
["*"] |
Cabeceras HTTP permitidas |
Validación en producción: CORS_ORIGINS no puede contener "*" con
ENVIRONMENT=production.
Límites de tasa¶
Se aplican a las superficies que un desconocido puede alcanzar, y solo a ellas: la API pública de runs, el script del widget, su configuración, el handshake del socket de cualquiera de las dos superficies, la configuración y el logo de una página alojada, y la subida de un visitante. Las rutas propias de la consola están detrás de una sesión y no se miden — si toda la API debería llevar un techo es una decisión aparte, no esta.
| Variable | Por defecto | Descripción |
|---|---|---|
RATE_LIMIT_RUN_PER_MINUTE |
30 |
POST /api/v1/agents/{id}/run, por llamante |
RATE_LIMIT_AUTH_PER_MINUTE |
10 |
Todas las rutas de auth.py — login, registro, refresh y las rutas de petición y verificación del restablecimiento y del enlace mágico. Se cuenta por IP y, donde el cuerpo lleve una, por dirección enviada. Ver más abajo |
RATE_LIMIT_EMBED_PER_MINUTE |
20 |
Por dirección, y dos contadores separados de este tamaño: uno para widget.js, otro para la admisión — el /config del widget más el handshake del socket de cualquiera de las dos superficies. Ver más abajo |
RATE_LIMIT_HOSTED_PAGE_PER_MINUTE |
240 |
La configuración de una página alojada, por página — y su logo, en un contador propio. Ver más abajo |
RATE_LIMIT_EMBED_UPLOAD_PER_MINUTE |
5 |
Archivos que un visitante puede guardar en una página alojada. Se cuenta por dirección y por clave de visitante, y las dos tienen que permitirlo — la clave la acuña el navegador, así que contar solo esa no acota nada |
RATE_LIMIT_TRUST_FORWARDED_FOR |
false |
Si X-Forwarded-For nombra a quien llama |
Lo que recibe un llamante rechazado es el sobre de error propio de esta API con
code: "RATE_LIMIT_EXCEEDED", el intervalo en
error.details.retry_after_seconds, y ese mismo intervalo en la cabecera
Retry-After, que es la que un envoltorio de fetch o una CDN respeta de verdad. El
handshake del socket es la excepción, porque un WebSocket no tiene estado con el que
responder: cierra con 4029 (ver canales).
Dos contadores, no uno, y la razón es aritmética.
Cargar una página con un widget cuesta tres peticiones a esta API: el script, la
configuración y el socket. Contadas juntas, 20 compraba unas siete cargas de página
para un navegador frío en vez de veinte admisiones — y un límite equivocado por un
factor de tres es peor que ningún límite, porque se lee como el número que fijaste.
Por eso widget.js tiene su propio cubo. Es cacheable, y un rechazo ahí rompe el
widget del todo en vez de retrasar un mensaje.
La configuración y el handshake se quedan juntos, porque juntos son una admisión: un navegador que leyó una configuración y no abrió ningún socket no entró.
Las cuentas viven en el Redis del despliegue, así que valen entre workers — producción ejecuta cuatro, y una cuenta por proceso dejaría pasar cuatro veces lo que dice. Si no se puede llegar a Redis el límite no se aplica y se registra un aviso: negarle a un visitante su respuesta porque una caché tuvo un tropiezo es el peor de los dos fallos.
Lo que un visitante puede decir una vez admitido es otro número, fijado por widget en
el Builder (rate_limit_per_minute) y contado por visitante. Estos dos son el techo
para entrar.
RATE_LIMIT_HOSTED_PAGE_PER_MINUTE, y por qué no es por dirección¶
La configuración de una página alojada se pide del lado del servidor, por el
frontend, para que la página se pinte con su marca en el primer fotograma. Eso
significa que la dirección de la petición es la del contenedor del frontend y no la
del visitante — así que contarla metía cada carga de página alojada del despliegue en
un solo cubo, y al visitante que lo disparaba se le servía un 404 sin nada que dijera
por qué. RATE_LIMIT_TRUST_FORWARDED_FOR no puede ayudar: un fetch del lado del
servidor no envía tal cabecera para que nadie confíe en ella.
Por eso este se cuenta por clave pública. Acota una sola página en vez de racionar a un
visitante, y por eso el valor por defecto es amplio — no es lo que limita el gasto.
El gasto empieza en el socket que la página abre después, que lo hace el navegador, y
que se cuenta por dirección bajo RATE_LIMIT_EMBED_PER_MINUTE. Y adivinar una clave no
es una estrategia contra 192 bits de secrets.token_urlsafe.
RATE_LIMIT_AUTH_PER_MINUTE, y por qué la superficie de auth tiene el suyo¶
Todas las rutas de auth.py llevan este límite, contado por IP y —donde el cuerpo
lleve una dirección (login, registro, las peticiones de restablecimiento y de enlace
mágico)— también por dirección enviada, ambas contra esta misma asignación. Las dos
frenan ataques distintos: la IP acota una avalancha desde un origen, la dirección acota
una fuerza bruta contra una cuenta.
Es independiente de la asignación de runs, y más baja, porque lo que defiende es el
coste de un solo intento. verify_password es bcrypt, unos 170 ms sin punto de
suspensión, así que una avalancha sin medir contra /login para cualquier dirección
que tenga cuenta satura el bucle de eventos de un worker sin necesidad de credenciales.
Otras dos cosas cierran el resto de esa superficie, y no necesitan configuración:
- bcrypt corre en un hilo, así que nunca bloquea el bucle;
- una dirección sin cuenta se verifica contra un hash falso en vez de saltársela, así que una dirección conocida y una desconocida tardan lo mismo en rechazarse y los tiempos ya no dicen qué direcciones existen.
RATE_LIMIT_TRUST_FORWARDED_FOR, y por qué está apagada¶
Los límites por dirección cuentan request.client.host. Detrás de un proxy o de una
CDN esa es la dirección del proxy, no la del visitante — todos los visitantes
comparten un cubo, así que un sitio concurrido detrás de Cloudflare agota las veinte
admisiones por minuto del widget para todos a la vez. Encender esto lee en su lugar el
salto más a la derecha de X-Forwarded-For: la dirección que añadió el propio
proxy de confianza.
Está apagada por defecto porque la cabecera la pone quien llama. Confiada sin condiciones, un límite por dirección se convierte en un límite por cabecera que cualquiera esquiva variando una cadena.
El salto más a la derecha se lee en vez del de más a la izquierda por la misma
razón: X-Forwarded-For es una lista que empieza el cliente y a la que añade cada
proxy, así que la cabeza es lo que escribió el cliente y solo la cola es lo que escribió
un proxy que tú controlas.
La superficie de auth también necesita esto, y el frontend ahora lo hace posible.
Las peticiones de auth llegan a la API del lado del servidor a través de las rutas
/api/auth/* del propio frontend, así que sin ayuda la dirección en ellas es la del
contenedor del frontend y la mitad por IP de RATE_LIMIT_AUTH_PER_MINUTE mete todo el
despliegue en un cubo — unos once inicios de sesión y todo el mundo queda fuera durante
un minuto, y un cubo de refresh agotado cierra las sesiones. A diferencia de la petición
de configuración de una página alojada, esas rutas reenvían el X-Forwarded-For de
quien llama (#1047), así que con
este ajuste encendido el límite se indexa por el cliente real. Enciéndelo para el límite
de auth bajo la misma regla que para todo lo demás —un proxy que controlas delante,
añadiendo al cliente como el salto más a la derecha—, que es la decisión de despliegue
que este ajuste es; apagado, el límite sigue siendo seguro pero compartido.
Enciéndela solo cuando un único proxy que controlas sea lo único que puede alcanzar la API
Si el puerto del contenedor también está publicado, quien llama puede poner la cabecera él mismo y el límite deja de significar nada.
El puerto del frontend cuenta aquí como el de la API. Sus rutas
/api/auth/* reenvían el X-Forwarded-For que se les dé, así que quien pueda
alcanzar el puerto 3000 saltándose el proxy elige contra qué dirección se
cuentan sus intentos de inicio de sesión igual de bien que quien pueda alcanzar
el puerto 8000 — y cada intento aceptado contra una dirección que no tiene dueño
sigue costando un bcrypt. Por eso tanto docker-compose-prod.yml como
docker-compose-prod.frontend.yml publican en 127.0.0.1 por defecto, donde el
proxy inverso del host los alcanza y nada más lo hace. BIND_HOST=0.0.0.0 los
vuelve a abrir, para un proxy que de verdad corra en otro sitio — con la red de
ese proxy como lo que mantiene la promesa.
Con dos proxies delante, colapsa la cabecera a un solo salto en tu borde: solo el último salto es fiable.
Un worker cuyo bucle de eventos ha dejado de girar¶
| Variable | Por defecto | Descripción |
|---|---|---|
EVENT_LOOP_WEDGED_AFTER |
15 |
Segundos que el bucle de eventos puede dejar de girar antes de que el worker se mate y se reemplace. 0 o menos apaga la comprobación |
Un worker que está vivo pero no responde —bloqueado en un lock, dando vueltas en una
llamada síncrona, esperando en un socket que nunca responde— no tiene código de salida,
así que todos los caminos de recuperación de todos los stacks lo leían como sano
mientras las peticiones expiraban. El contenedor pasa a unhealthy, y un estado no es
un mecanismo.
Por eso el worker juzga su propio bucle de eventos. Un callback de temporizador marca
el bucle una vez por segundo; un hilo lee la marca, y si el bucle no ha girado durante
EVENT_LOOP_WEDGED_AFTER en dos comprobaciones seguidas termina el proceso —
SIGKILL, o os._exit(137) donde el worker es el PID 1, porque el kernel no entrega a
la init de un namespace una señal para la que esa init no tiene handler. De un modo u
otro docker inspect informa de 137, y "atascado", que nada gestionaba, se convierte
en "muerto", que todos los stacks ya gestionan:
| Stack | Qué reemplaza al worker |
|---|---|
docker-compose.yml |
el supervisor de recarga, en su siguiente sondeo |
docker-compose-dev.yml |
el PID 1 es el servidor, así que el contenedor sale y actúa restart: unless-stopped |
docker-compose-prod.yml |
el Multiprocess de uvicorn, en medio segundo aproximadamente; los otros tres workers siguen sirviendo |
Dos propiedades son la razón del diseño, y las dos merecen conocerse antes de cambiar el número:
- Mide la vivacidad, no la disponibilidad. La marca es un callback de temporizador, no una petición, así que una base de datos lenta o un provider de modelos que tarda veinte segundos no son un atasco: el bucle está girando, está esperando. Una sonda HTTP habría tenido menos piezas móviles y habría metido en un bucle de reinicios a un servidor sano por culpa de una dependencia rota.
- Dos comprobaciones, no una.
docker pause, un cgroup congelado y un portátil despertando del sueño paran el vigilante tan a fondo como el bucle, así que la primera comprobación después de una lee una marca vieja que no dice nada.
El supervisor de recarga del stack local lee la misma variable para el juicio que hace desde fuera del worker, así que un número cubre los dos.
Ponla a 0 mientras depuras
Un breakpoint bloquea el bucle de eventos y nada puede distinguir eso de un interbloqueo, así que un worker parado en uno se mata bajo tus pies.
No puede ver un proceso que no está corriendo en absoluto —kill -STOP, un cgroup
congelado— porque un vigilante dentro de un proceso parado está parado también. Ese
caso es el que ya cubren los supervisores: el latido del supervisor de recarga se
queda viejo y el ping por tubería de producción se queda sin respuesta.
Docker / producción¶
| Variable | Por defecto | Descripción |
|---|---|---|
DOMAIN |
example.com |
Dominio de producción (para Traefik) |
ACME_EMAIL |
admin@example.com |
Correo de Let's Encrypt para los certificados SSL |
REDIS_PASSWORD |
change-me-in-production |
Contraseña de Redis para producción |
Lista de comprobación para producción¶
Todas y cada una se distribuyen con un valor por defecto que está mal en producción
Un despliegue alcanzable desde cualquier otro sitio tiene las nueve fijadas a conciencia.
- [ ]
SECRET_KEY— una clave hexadecimal única de 64 caracteres:openssl rand -hex 32 - [ ]
API_KEY— una clave única:openssl rand -hex 32 - [ ]
VAULT_MASTER_KEY— una clave única:openssl rand -hex 32. La configuración rechaza una vacía fuera delocal/development - [ ]
ENVIRONMENT—production - [ ]
DEBUG—false - [ ]
POSTGRES_PASSWORD— una contraseña fuerte y única - [ ]
REDIS_PASSWORD— una contraseña fuerte - [ ]
CORS_ORIGINS— solo el dominio o los dominios reales de tu frontend
El correo a propósito no está en esta lista: un despliegue funciona sin él. Pero
las invitaciones, los restablecimientos de contraseña y las notificaciones se quedan
todos sin enviar, en silencio, hasta que SMTP_HOST y el resto de
Correo (SMTP) apunten a un servidor de verdad — así que un despliegue
que se lo salte debería saltárselo a sabiendas.