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_heredoc sigue ahí, pero los backends nuevos deberían declarar el modo payload por 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 update los 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.