Pasarela de secretos¶
Un agente que llama a una API externa (Anthropic, OpenAI, tu propio backend)
normalmente necesita que su código tenga la credencial: secrets= la
entrega como variable de entorno, legible por cualquier cosa que el agente
ejecute. gateways=/gateways ofrece la alternativa: rayd abre, dentro
del propio sandbox, un listener HTTP en loopback por cada ruta que declares.
El sandbox le manda peticiones sin credencial; la pasarela comprueba una
allowlist de método/ruta y un límite de peticiones por minuto, inyecta la
cabecera real (resuelta de Secrets Manager, nunca guardada en el sandbox) y
reenvía al upstream fijo que declaraste. El código del sandbox puede
usar el secreto; no puede leerlo, salvo que el upstream permitido
lo refleje en su respuesta (ver Lo que la pasarela no puede
impedir).
Desde 0.6.0 (M15, ADR-023).
Coste y activación
- Por defecto: apagado. Sin
gateways=/gateways,raydno abre ningún socket de loopback para esta función y el SDK no construye ningún clientesecretsmanagernuevo ni manda ningúnConfigureSandbox. Ninguna variable de entorno lo enciende (ADR-014 regla 4). - Activa:
Sandbox.create(gateways={"nombre": SecretGateway(...)})opool.take(gateways=...)(Python) /Sandbox.create({ gateways: { nombre: new SecretGateway({...}) } })opool.take({ gateways })(TypeScript). Exige una imagen 0.6.0 o posterior: con una anterior,create()/take()termina el MicroVM recién lanzado o tomado (salvokeep_on_failure/keepOnFailureencreate()) y lanzaUnimplementedError. - Recursos y llamadas AWS:
secretsmanager:GetSecretValueuna vez por cabecera y TTL de laSecretCacheque ya usasecrets=/secrets(un acierto no llama a AWS), y una más por cabecera en cadasbx.gateways.refresh()(siempre relee); ningún recurso nuevo — reutilizainfra/secrets-access.yaml. - Coste aproximado (us-east-1, consultado 2026-09-30,
precios): el de
SecretCache($0,05 por 10 000 llamadas) más el del secreto en sí ($0,40/mes) si no existía ya. - IAM (credenciales de quien llama al SDK, no el execution role: la
pasarela corre dentro de
rayd, nunca necesita el rol de ejecución):RayitoSecretsReaderdeinfra/secrets-access.yaml. - Cómo apagarla: no pases
gateways=/gateways(su valor por defecto).
Qué hace, exactamente¶
Por cada ruta de gateways=/gateways:
raydabre unTcpListeneren127.0.0.1:<puerto elegido por el SO>.- La ruta de la petición pasa una lista de permitidos antes de mirar
la allowlist, o se rechaza con 403: sólo caracteres de ruta de
RFC 3986 salvo
;(letras, dígitos,-._~,:@,!$&'()*+,=y%XXbien formados), ningún segmento vacío (//) salvo el último, y cada segmento, decodificado una sola vez, debe ser UTF-8 válido, sin bytes de control, sin/,\,%ni;, y distinto de.y...raydnunca normaliza la ruta, así que nunca reenvía una que elupstream(o la CDN delante de él) pudiera llevar fuera deallow: ni/v1/../adminni/v1/%2e%2e/admin, ni/v1/..;/admin(Tomcat, Spring o Jetty quitan el parámetro;y ven..), ni/v1/%252e%252e/admin(un salto que decodifica dos veces ve..) pasan por una regla/v1/*./v1/files/a%20bsí pasa. Las rutas deallowsiguen la misma regla: el SDK rechaza una que ninguna petición podría cumplir conInvalidArgumentException/InvalidArgumentErrorantes de cualquier llamada, yraydconinvalid_allow_path. - Una petición entrante se compara contra la allowlist
allow((método, ruta), exacta o con sufijo/*) y el límiterate_per_minute/ratePerMinute(un cubo de tokens;0usa el valor por defecto, 600). Si no pasa ninguna de las dos comprobaciones, la pasarela responde sin abrir ninguna conexión alupstream(403 fuera de la allowlist, 429 por encima del límite). - Si pasa,
raydelimina de la petición cualquier cabecera cuyo nombre coincida con una deheaders(así el sandbox nunca puede suplantar su propia credencial) e inyecta el valor real de cada una. - Reenvía al
upstream(siemprehttps://host, sin ruta, query, usuario ni fragmento: la ruta y la query vienen de cada petición) por un cliente HTTPS compartido que nunca resuelve un host a loopback, link-local o la IMDS del propio guest. La petición y la respuesta se transmiten en flujo: una respuesta en Server-Sent Events o una subida troceada atraviesan la pasarela sin cambios. Si elupstreamno acepta la conexión en 10 s o no envía la cabecera de la respuesta en 600 s, la pasarela responde 504 (upstream_timeout); cualquier otro fallo tras conectar (TLS, conexión cortada, respuesta malformada) es 502 (upstream_error). El cuerpo de una respuesta en flujo no tiene tope. - La respuesta del
upstreamllega al sandbox con su estado y su cuerpo sin cambios. De sus cabeceras,raydelimina las de transporte, las que llevan el nombre de una deheadersy cualquiera cuyo valor contenga uno de los valores inyectados (de 8 caracteres o más).
Lo que la pasarela no puede impedir¶
Un endpoint que refleja la petición entrega el secreto
La pasarela no inspecciona el cuerpo de la respuesta. Si una regla de
allow apunta a un endpoint que devuelve las cabeceras de la petición
(rutas de depuración o de eco del tipo /headers o /anything,
páginas de error verbosas que repiten Authorization o x-api-key),
el código del sandbox lee el secreto en ese cuerpo. La garantía es «el
sandbox puede usar el secreto y no puede leerlo salvo que el
upstream permitido lo refleje»: es un límite que la pasarela no
puede imponer por ti. Lista en allow sólo las rutas que tu agente
necesita y nunca una de depuración o de eco
(SECURITY.md, T24).
Los nombres de headers deben ser un token HTTP válido (RFC 9110), no
pueden repetirse ignorando mayúsculas (X-Api-Key y x-api-key son el
mismo) ni ser una cabecera de transporte que la pasarela fija ella misma
(host, content-length, transfer-encoding, connection, keep-alive,
te, trailer, upgrade, proxy-authorization, ...): el SDK lo rechaza
al construir SecretGateway, y rayd vuelve a comprobarlo.
El valor de cada cabecera vive sólo en la memoria de rayd, nunca en
repr/Debug, nunca en un log: se resuelve justo antes de cada
ConfigureSandbox (el propio RPC nunca se registra) y se expone una única
vez, al construir la petición saliente.
Rotación¶
sbx.gateways.refresh() (await sbx.gateways.arefresh() en
AsyncSandbox) descarta de la SecretCache los secretos de la pasarela,
los vuelve a leer de Secrets Manager (aunque su TTL no haya vencido) y manda
un ConfigureSandbox nuevo. Cada ruta conserva su puerto: un proceso
arrancado con ANTHROPIC_BASE_URL=http://127.0.0.1:<puerto> sigue
funcionando, y su siguiente petición, incluso por una conexión keep-alive
ya abierta, lleva el valor nuevo. Si rayd rechaza la configuración
(por ejemplo un valor rotado con un salto de línea), refresh() lanza la
misma excepción que create() y la pasarela sigue con la configuración
anterior.
Justo después de rotar, refresh() puede empujar el valor anterior
Secrets Manager es de consistencia eventual: durante unos segundos
después de SecretStore.update() (o de una rotación), GetSecretValue
puede devolver todavía la versión anterior, y refresh() manda lo que
lee. Si necesitas que la siguiente petición lleve ya el valor nuevo,
comprueba que lo lleva (por ejemplo, con una petición de prueba a un
endpoint que no gaste) y, si no, repite refresh() tras una pausa
breve, unas pocas veces. Si lo rotas por un horario (una clave que
caduca), basta con hacer el refresh() con algo de margen antes de que
caduque la anterior.
Una ruta que deja de estar en la configuración se cierra de verdad: deja de aceptar conexiones, cierra en el acto las keep-alive inactivas y deja terminar como mucho la petición en curso.
Límites¶
| Valor | Por qué | |
|---|---|---|
| Rutas por sandbox | 8 | cada una abre su propio listener; un límite evita agotar el rango de puertos efímeros del guest |
| Cabeceras por ruta | 16 | — |
Reglas de allow por ruta |
32 | — |
rate_per_minute/ratePerMinute |
0 (= 600) o 1..6000 |
600 = 10 peticiones/s, un tope conservador para un agente llamando a una sola API de forma interactiva |
| Nombre de ruta | 1-64 [a-z0-9-] |
es la clave pública de sbx.gateways["nombre"] |
| Espera a la cabecera de la respuesta | 600 s | basta para una petición larga a un LLM; un upstream atascado acaba en 504 en vez de colgar el comando para siempre |
Conexión al upstream |
10 s | — |
Ejemplos¶
from rayito import (
PoolConfig,
Sandbox,
SandboxPool,
SecretCache,
SecretGateway,
SecretStore,
)
store = SecretStore(region="us-east-1") # no llama a AWS
store.create("anthropic", "sk-ant-...")
sbx = Sandbox.create(
gateways={
"anthropic": SecretGateway(
upstream="https://api.anthropic.com",
headers={"x-api-key": "anthropic"}, # nombre del secreto, no el valor
allow=[("POST", "/v1/messages")],
rate_per_minute=600,
)
},
secret_cache=SecretCache(),
)
url = sbx.gateways["anthropic"].url # "http://127.0.0.1:<puerto>"
sbx.commands.run("python agent.py", envs={"ANTHROPIC_BASE_URL": url})
# rotar la credencial sin recrear el sandbox (mismo puerto, valor nuevo):
store.update("anthropic", "sk-ant-nueva")
sbx.gateways.refresh()
sbx.kill()
# desde un pool: las plazas calientes nunca llevan la pasarela
with SandboxPool(PoolConfig(size=2, template="rayito-base")) as pool:
pooled = pool.take(gateways={"anthropic": SecretGateway(
upstream="https://api.anthropic.com",
headers={"x-api-key": "anthropic"},
allow=[("POST", "/v1/messages")],
)})
pooled.kill()
import { Sandbox, SandboxPool, SecretCache, SecretGateway, SecretStore } from "rayito";
const store = new SecretStore({ region: "us-east-1" }); // no llama a AWS
await store.create("anthropic", "sk-ant-...");
const sbx = await Sandbox.create({
gateways: {
anthropic: new SecretGateway({
upstream: "https://api.anthropic.com",
headers: { "x-api-key": "anthropic" }, // nombre del secreto, no el valor
allow: [["POST", "/v1/messages"]],
ratePerMinute: 600,
}),
},
secretCache: new SecretCache(),
});
const url = sbx.gateways.get("anthropic")!.url; // "http://127.0.0.1:<puerto>"
await sbx.commands.run("python agent.py", { envs: { ANTHROPIC_BASE_URL: url } });
// rotar la credencial sin recrear el sandbox (mismo puerto, valor nuevo):
await store.update("anthropic", "sk-ant-nueva");
await sbx.gateways.refresh();
await sbx.kill();
// desde un pool: las plazas calientes nunca llevan la pasarela
await using pool = await new SandboxPool({ size: 2, template: "rayito-base" }).start();
await using pooled = await pool.take({
gateways: {
anthropic: new SecretGateway({
upstream: "https://api.anthropic.com",
headers: { "x-api-key": "anthropic" },
allow: [["POST", "/v1/messages"]],
}),
},
});
sbx.gateways es un mapa de solo lectura {nombre: GatewayStatus}
(TypeScript: sbx.gateways.get(nombre), .entries() y .size). Cada
GatewayStatus trae port, url (http://127.0.0.1:<port>) y
last_error_class/lastErrorClass (None/undefined salvo que la ruta
haya fallado). Sin gateways=, sbx.gateways está vacío y refresh() no
hace nada.
Pasarelas para el modelo de un agente¶
Para el modelo de un agente en el sandbox,
tres funciones construyen la SecretGateway con el upstream, la cabecera
y un allow ya restringido al proveedor (y, en Bedrock, a los modelos que
elijas). Construirlas no llama a AWS: el coste y la activación son los de
SecretGateway.
| Python | TypeScript | Upstream | Cabecera | allow |
|---|---|---|---|---|
bedrock_gateway(secret, region=, models=) |
bedrockGateway(secret, { region, models }) |
https://bedrock-runtime.<región>.amazonaws.com |
authorization (el secreto guarda Bearer <clave>) |
POST /model/<id>/converse-stream y /converse de cada modelo |
anthropic_gateway(secret) |
anthropicGateway(secret) |
https://api.anthropic.com |
x-api-key |
POST /v1/messages |
openai_compatible_gateway(secret, upstream=, base_path=) |
openaiCompatibleGateway(secret, { upstream, basePath }) |
el upstream que pases |
authorization (Bearer <clave>) |
POST <base_path>/chat/completions |
OpenAI, Gemini, Azure OpenAI, OpenRouter, Groq, Mistral, DeepSeek, xAI y un
proxy de LiteLLM propio tienen su preset (openai_gateway,
gemini_gateway, litellm_gateway…): ver
Proveedores del agente. En las APIs al
estilo de OpenAI el modelo va en el cuerpo y la pasarela no puede
limitarlo: pon el tope de gasto en el proveedor.
Todas aceptan además rate_per_minute=/ratePerMinute. secret es el
nombre del secreto (o un SecretRef), nunca el valor.
from rayito import Sandbox, anthropic_gateway, bedrock_gateway
with Sandbox.create(
gateways={
"bedrock": bedrock_gateway(
"bedrock-key",
region="us-east-1",
models=["us.anthropic.claude-haiku-4-5-20251001-v1:0"],
rate_per_minute=60,
),
"anthropic": anthropic_gateway("anthropic"),
}
) as sbx:
print(sbx.gateways["bedrock"].url)
import { Sandbox, anthropicGateway, bedrockGateway } from "rayito";
await using sbx = await Sandbox.create({
gateways: {
bedrock: bedrockGateway("bedrock-key", {
region: "us-east-1",
models: ["us.anthropic.claude-haiku-4-5-20251001-v1:0"],
ratePerMinute: 60,
}),
anthropic: anthropicGateway("anthropic"),
},
});
console.log(sbx.gateways.get("bedrock")?.url);
bedrock_gateway no admite ARNs en models (la pasarela rechaza un /
codificado en la ruta): usa el id del modelo o del perfil de inferencia.
Cómo conectarla con sbx.agent:
Agente en el sandbox.
Errores¶
- Una
SecretGatewaymal formada (cabecera prohibida o repetida, ruta deallowimposible,upstreamque no eshttps://host, más rutas o reglas que los límites) esInvalidArgumentException/InvalidArgumentErroral construirla o al pasarla acreate()/take(), antes de cualquier llamada a AWS. - Un secreto que no existe es
SecretNotFoundException/SecretNotFoundError: se lee justo antes de configurar el sandbox ya lanzado, quecreate()termina (salvokeep_on_failure/keepOnFailure). - Si
raydrechaza la configuración,create()/take()/refresh()lanzanSandboxException/SandboxErrorcon la clase del fallo en el mensaje (UnimplementedErroren una imagen anterior a 0.6.0). - Lo que la pasarela rechaza por petición nunca es una excepción del
SDK: el proceso del sandbox recibe la respuesta HTTP (403 fuera de
allow, 429 por encima del límite, 502/504 si falla elupstream). Por eso el SDK no tiene una excepción propia de la pasarela.
Divergencias con E2B¶
E2B no tiene un equivalente: no tiene conmutador de proxy interno al
sandbox que inyecte una credencial de un backend externo. rayito.e2b no
añade ningún método para esta función; Sandbox.create(gateways=) es
nativo de Rayito.
Contrato interno (sin AWS; AWS_API_NOTES.md §28 documenta la reutilización
de Secrets Manager, ya cubierta por §19).