Por qué escribir un archivo grande en un sandbox no deja de fallar

Una escritura grande pasa sin problemas en tu portátil. Esa misma escritura contra un sandbox de Modal vuelve con ValueError: embedded null byte. Contra otro backend el error es aún más raro: write_file informa de éxito y el archivo en disco tiene cero bytes. Le echas la culpa a la red, a los permisos, a una caché obsoleta — y el motivo real es aburridamente tonto: el payload entero se pasaba al shell como argumento de línea de comandos, y los argumentos de línea de comandos tienen un límite de longitud y son visibles para cualquier proceso de la máquina. Un arreglo mergeado el 2026-09-26 (PR #124206) acaba con eso.
La causa raíz: datos de stdin en argv
cat file | bash -c '...' es trabajo de shell normal, pero los backends de sandbox tomaron un atajo: el payload destinado al stdin de un comando acababa concatenado dentro del propio argumento de bash -c (el código lo llama la ruta heredoc).
Eso funciona bien hasta que choca con dos límites duros:
| Límite | Cifra | Qué ocurre |
|---|---|---|
| Bytes NUL | Un argumento no puede contener \0 |
ValueError: embedded null byte, la escritura falla directamente |
| Longitud del argumento | MAX_ARG_STRLEN en Linux ronda los 128 KiB; Modal es más estrecho (límite de ~64 KiB por exec-arg) |
Las escrituras mayores fallan directamente en Linux o en Modal |
La reproducción del PR es contundente: un payload de 160 KiB que contiene un NUL lanza embedded null byte en main, y el elemento de argv que construye mide 165003 bytes. Relee la segunda mitad — incluso sin el NUL, ese tamaño ya se pasa de la raya.
Hay un segundo efecto que se escapa fácil: argv es público. Cualquier proceso de la misma máquina puede leer la línea de comandos de otro proceso. Concatenar contenidos de archivo — que pueden contener keys, tokens o datos de usuario — dentro de argv los deja aparcados en la tabla de procesos sin hacer ruido.
El arreglo: cada backend declara cómo entrega stdin
El parche introduce una declaración explícita de modo (tools/environments/base.py):
# Cómo execute() entrega stdin a _run_bash: "pipe" (stdin del proceso, por defecto),
# "payload" (se pasa como stdin_data; el backend lo transporta a través de su
# SDK -- Modal/managed Modal lo hace streaming, Daytona/Vercel preparan un archivo) o
# "heredoc" (embebido en el comando; ningún backend integrado, solo plugins).
_stdin_mode: str = "pipe" # "pipe" | "payload" | "heredoc"
En los tres backends integrados:
| Backend | Qué hace ahora |
|---|---|
| Modal | _stdin_mode = "payload"; stdin viaja por streaming a través del SDK, byte a byte (surrogateescape), por trozos (1 MiB cada uno, quedándose por debajo del tope de 2 MB del servidor legacy) |
| Daytona | El payload se sube a un archivo privado 0600 y luego se le entrega al comando como exec 0< f; rm -f -- f; cmd |
| Vercel | La misma forma: los bytes aterrizan en una ruta privada, se fijan los permisos y stdin se redirige desde ahí |
La versión de Daytona queda todavía un poco más limpia: los bytes van directamente por el SDK sin archivo temporal en el host, y un stdin vacío se salta por completo el staging.
Fíjate bien en el prefijo de redirección — dos acciones, en un orden deliberado:
exec 0< <staged-path> || exit $? # el shell se hace cargo del descriptor
rm -f -- <staged-path> || exit $? # después se desvincula el archivo
<command>
exec antes de rm significa que el archivo desaparece del disco durante toda la duración del comando, mientras los datos siguen siendo legibles a través del descriptor ya abierto. Ambos pasos pasaron a ser helpers compartidos en BaseEnvironment (_staged_stdin_path, _redirect_stdin_from_file); la antigua ruta _embed_stdin_heredoc solo sobrevive para los backends de plugin.
La lógica de cancelación también se reescribió
La limpieza original tenía dos defectos reales: cualquier error del SDK detenía todo el sandbox, y cada cancelación lo detenía dos veces. El parche divide el estado en tres (cancelled / staged / dispatched) y lo mantiene bajo el lock del entorno:
- una cancelación que llega después de la subida pero antes del dispatch limpia el archivo preparado (Vercel lo sobrescribe, Daytona lo borra) y luego detiene una sola vez;
- un error normal del SDK ya no detiene el sandbox;
- un comando cancelado nunca se despacha.
Qué significa para ti
- Ningún cambio de configuración. Cada backend cambió su transporte; quien llama no nota nada.
- Casos que antes fallaban en silencio o a gritos — contenido con bytes NUL, escrituras por encima de unos 128 KiB — ahora aterrizan byte a byte.
- Si mantienes un plugin de backend de sandbox,
_embed_stdin_heredocsigue ahí, pero los backends nuevos deberían declarar el modopayloadpor los motivos de esa tabla. - Los tres PRs se mergearon el 2026-09-26, después del tag de la release v0.21.5, así que están en
main.hermes updatelos trae.
El patrón de fondo
Este trabajo tiene un capítulo anterior: los PRs #122012 / #122011 arreglaron un heredoc ligado al comando equivocado, que es la otra cara del problema de la escritura byte a byte — un archivo que parece escrito y guarda los bytes equivocados. El arreglo de argv que hay aquí cubre «nunca llegó a escribirse» más «otro proceso podía leerlo». Juntos dejan una idea: cuando envías datos a un entorno remoto, la línea de comandos es el último sitio donde deberían estar.
Si dependes de sandboxes remotos, sigue leyendo: cómo salen los archivos multimedia de un sandbox, qué significa un contenedor Docker compartido para agentes en paralelo, y el resto de esta ventana en la página de la release v0.21.5.