# ¿Por qué separar SSH stdout y stderr?

El resultado de un comando SSH debe conservar stdout, stderr y la terminación como hechos distintos. Si los aplanas en una sola cadena, el agente ya no puede distinguir los datos del diagnóstico, el operador no ve por qué falló el comando y una vista de auditoría puede presentar una secuencia que nunca existió.

La solución no consiste en elegir un delimitador más bonito. Hay que mantener separados los dos flujos de bytes, registrar un orden de observación limitado cuando el transporte lo exponga y representar por separado el estado de salida, la señal de salida, el tiempo agotado, la cancelación y el fallo de transporte. Exige algo más de trabajo en el límite de captura y elimina una larga lista de errores de análisis en las capas posteriores.

## SSH ya distingue los dos flujos

SSH transporta los datos normales del canal y stderr mediante mensajes de protocolo diferentes. RFC 4254 los llama `SSH_MSG_CHANNEL_DATA` y `SSH_MSG_CHANNEL_EXTENDED_DATA`; además, asigna el tipo de datos extendidos 1 a `SSH_EXTENDED_DATA_STDERR`. Una biblioteca cliente que ofrece lectores separados expone una distinción que el protocolo conserva de forma intencionada.

Esa distinción tiene significado. Los programas suelen escribir resultados para consumo automático en stdout y diagnósticos en stderr. Un comando puede emitir JSON válido en stdout, imprimir una advertencia en stderr y aun así devolver cero. Otro puede imprimir una salida parcial, explicar un fallo en stderr y devolver un valor distinto de cero. Los bytes por sí solos no indican cuál de esos casos ocurrió.

La combinación durante la captura elimina información que ningún analizador posterior puede recuperar. Los prefijos como `[stderr]` ayudan a una persona, pero modifican el contenido. Los separadores de salto de línea son peores: un fragmento puede terminar sin salto de línea, los datos binarios pueden contener cualquier byte y el separador añadido puede convertir dos fragmentos válidos en un documento inválido.

Trata cada flujo como bytes hasta que un consumidor elija una política de decodificación. UTF-8 es habitual, pero SSH no lo garantiza. Incluso las herramientas que parecen textuales pueden emitir secuencias inválidas por una diferencia de configuración regional, nombres de archivo con bytes arbitrarios o una escritura cortada a mitad de un carácter multibyte. Guarda los bytes originales o una codificación sin pérdidas y ofrece después el texto decodificado como una vista.

El dato del protocolo importa porque cambia la carga de la prueba. Si el tipo de resultado solo tiene `output: string`, el tipo miente sobre lo que entregó SSH. El formato cómodo debe aplicarse después de la captura, donde puede sustituirse sin reescribir el registro de auditoría.

## La identidad del flujo no indica gravedad

Stderr significa descriptor de archivo 2, no fallo. Tratar cada byte de stderr como un error crea agentes ruidosos que repiten comandos correctos, descartan stdout aprovechable o piden aprobación después de advertencias inocuas.

Muchos programas conocidos usan stderr para progreso, trazas detalladas, solicitudes de entrada y advertencias. Un compilador puede reservar stdout para la salida generada e informar del progreso en otro lugar. Un comando también puede fallar en silencio con un estado distinto de cero. La relación aporta pruebas útiles, pero no constituye una regla booleana.

Mantén separados al menos cuatro conceptos:

- `stdout` y `stderr` identifican por dónde llegaron los bytes.
- `exit_status` o `exit_signal` describe cómo terminó el programa remoto.
- `transport_error` indica si la operación SSH llegó a completarse.
- `timed_out` y `cancelled` describen una intervención local.

Así se evita un error habitual del analizador: convertir `stderr != empty` en `success = false`. Por norma general, éxito debe significar que el comando arrancó, el canal terminó y el estado remoto fue cero. La aplicación puede aplicar una regla más estricta a un comando concreto, pero esa regla corresponde al adaptador del comando, no al ejecutor SSH genérico.

El error inverso causa el mismo daño. Algunos envoltorios devuelven solo stdout cuando todo va bien y sustituyen el resultado completo por una excepción cuando algo falla. La excepción puede contener un sufijo recortado de stderr mientras desaparece la salida parcial de stdout. El agente recibe menos pruebas justo cuando más las necesita.

No sobrecargues un único campo `error` con diagnósticos remotos, fallos de conexión, tiempos agotados y errores del analizador. Esas condiciones requieren decisiones de reintento diferentes. Un fallo de DNS puede justificar otro intento. Un estado 2 debido a un error de uso normalmente no. Si stdout contiene JSON inválido, hay que conservar los bytes originales para que un desarrollador pueda decidir si falló el comando o el analizador.

## El contrato debe conservar hechos antes de interpretarlos

Un objeto de resultado duradero contiene las pruebas originales y hace explícitos los estados desconocidos. No debe obligar a cada consumidor a reconstruir los hechos a partir de una transcripción formateada.

Este contrato es aburrido a propósito:

```json
{
  "stdout": {"encoding": "base64", "data": "Li4u", "truncated": false},
  "stderr": {"encoding": "base64", "data": "Li4u", "truncated": false},
  "events": [
    {"seq": 1, "stream": "stdout", "offset": 0, "length": 48},
    {"seq": 2, "stream": "stderr", "offset": 0, "length": 19}
  ],
  "termination": {
    "kind": "exit",
    "exit_status": 0,
    "exit_signal": null,
    "core_dumped": null
  },
  "transport_error": null,
  "started_at": "2026-07-24T10:20:30.123Z",
  "finished_at": "2026-07-24T10:20:31.456Z"
}
```

Los dos objetos de flujo contienen el contenido autorizado. Cada evento hace referencia a un intervalo de bytes en vez de copiar el texto, de modo que una vista puede construir una transcripción sin duplicar la carga. `seq` solo representa el orden de observación durante la captura. No afirma que las escrituras remotas ocurrieran exactamente en ese orden.

El campo `termination.kind` debe cubrir al menos `exit`, `signal`, `timeout`, `cancelled`, `transport_error` y `unknown`. Utiliza campos que admitan null en vez de códigos mágicos. Que falte el estado de salida SSH no significa cero, y un tiempo agotado local no equivale al estado 124 salvo que un shell o la utilidad timeout lo haya producido realmente en el host remoto.

Incluye el truncamiento por separado para cada flujo. Un indicador global `truncated` no permite saber si el analizador aún tiene JSON completo en stdout o si solo perdió el final de un stderr detallado. Registra la cantidad de bytes capturados y descartados cuando se conozca. Si solo conservas un prefijo y un sufijo, represéntalos como segmentos distintos en vez de unirlos como si el centro nunca hubiera existido.

Las marcas de tiempo ayudan a medir latencia y a investigar, pero no deben ordenar fragmentos mediante la hora de pared. El reloj puede saltar y dos lectores concurrentes pueden recibir la misma marca con la resolución elegida. Asigna el contador de secuencia en un único punto de serialización. Conserva por separado una duración monotónica si el entorno de ejecución la proporciona.

Versiona el contrato antes de que los clientes dependan de él. Añadir campos suele ser seguro, pero cambiar `events.seq` de orden de llegada a orden de presentación rompe la semántica aunque la forma del JSON siga igual.

## El orden entre flujos tiene un límite estricto

Puedes conservar el orden en que la pila SSH observó los mensajes del canal, pero normalmente no puedes demostrar el orden de las escrituras del programa remoto entre stdout y stderr. Esa limitación debe aparecer en el modelo de datos y en el texto de la interfaz.

Dentro de un flujo, los bytes mantienen el orden. Entre dos flujos interviene el almacenamiento en búfer de varias capas: el entorno de ejecución del lenguaje remoto, libc, las tuberías, el servidor SSH, los paquetes de transporte, la biblioteca cliente y tus propias tareas de lectura. Stdout puede usar un búfer por bloques cuando no está conectado a un terminal, mientras stderr puede vaciarse antes. Por eso una escritura posterior en stderr puede hacerse visible antes que una escritura anterior en stdout.

RFC 4254 conserva la secuencia de mensajes de canal que envía la implementación SSH. Es útil, y una devolución de llamada de la biblioteca que exponga esos mensajes puede asignar una secuencia fiel de recepción. Cuando una biblioteca separa los datos en lectores independientes de stdout y stderr, dos goroutines o callbacks asíncronos compiten para comunicar que hay datos listos. El orden en que los ejecuta el planificador es una observación de entrega local, no una reconstrucción del orden del código fuente remoto.

Este pequeño comando muestra por qué una prueba no debe exigir una única transcripción combinada universal:

```sh
sh -c 'printf "out-1\n"; printf "err-1\n" >&2; printf "out-2\n"; printf "err-2\n" >&2'
```

Un terminal suele mostrar el aparente orden del código. Si rediriges ambos descriptores a un archivo mediante `>all.log 2>&1`, el shell los apunta al mismo destino, lo que da a ese proceso una única ruta de escritura gestionada por el núcleo. Si los capturas mediante tuberías separadas, el observador puede recibir fragmentos en otro orden. Si añades un entorno de lenguaje con búfer, la diferencia aumenta.

Si la cronología exacta entre flujos es un requisito, cambia el contrato del productor. Haz que el programa remoto escriba registros estructurados con su propio número de secuencia en un solo flujo, o dirige ambos descriptores a un único destino remoto antes de que SSH los vea. Obtendrás un orden definido a cambio de renunciar a flujos independientes en el productor. Un cliente SSH genérico no puede fabricar después el hecho que falta.

El texto de auditoría debe decir `secuencia observada`, no `secuencia de ejecución`. La precisión no es relleno legal. Evita que una persona que investiga interprete la planificación de tareas como causalidad.

## Los límites de fragmento son artefactos del transporte

Una llamada de lectura no equivale a una línea, un registro ni una llamada remota a `write`. Los analizadores que lo suponen funcionan en las pruebas y fallan bajo carga.

Una escritura puede llegar dividida en varios fragmentos. Varias escrituras pueden llegar juntas en uno. Un punto de código UTF-8, una secuencia de escape ANSI o un token JSON puede cruzar el límite. El mismo comando puede producir otra división en la siguiente ejecución sin cambiar su salida.

Construye la capa de captura alrededor de operaciones que añadan bytes. Para cada flujo, añade el fragmento a su búfer o archivo temporal y registra el offset y la longitud resultantes. Si la biblioteca expone mensajes de forma serial, asigna allí `seq`. Si ofrece lectores independientes, envía avisos de fragmento a un único recolector y documenta que la secuencia refleja la recepción del recolector.

La división en líneas corresponde a una vista derivada. Mantén un decodificador incremental y un búfer de línea incompleta para cada flujo. Nunca compartas el búfer de líneas entre stdout y stderr, porque un fragmento de stdout sin terminar seguido de una línea de stderr no debe convertirse en una línea sintética. Cuando se cierre el flujo, muestra la última línea parcial en vez de descartarla en silencio.

El análisis de JSON normalmente debe esperar hasta el final de stdout y hasta conocer la terminación del comando. Un protocolo JSON en streaming es diferente: necesita un encuadre explícito, como JSON delimitado por saltos de línea, un prefijo de longitud o una gramática incremental documentada. Adivinar los límites de los registros a partir de fragmentos no es streaming, es una condición de carrera.

La salida binaria también necesita una ruta explícita. Base64 dentro de JSON es sencillo y portátil, aunque aumenta el tamaño. Una referencia a un blob puede servir para resultados grandes si el sistema de auditoría garantiza la conservación y la integridad. No decodifiques con caracteres de sustitución para luego descartar el original. La sustitución oculta si la corrupción vino de la herramienta remota, del adaptador de transporte o de la vista.

Los límites deben aplicarse mientras se lee, no después de almacenar todo en memoria. Sigue drenando ambos flujos aunque uno supere su límite de conservación, o el proceso remoto puede bloquearse ante una tubería llena. Guarda el prefijo, el sufijo o el archivo externo permitido, cuenta los bytes descartados y continúa leyendo hasta el cierre o la cancelación.

## Un pseudoterminal cambia estructura por comportamiento

No solicites un pseudoterminal para un comando cuyo stdout vaya a analizarse. Un PTY resulta útil en una sesión humana, pero cambia el entorno del programa y a menudo envía stdout y stderr por el mismo dispositivo terminal antes de que el cliente SSH pueda conservar su identidad.

Los programas comprueban si un descriptor apunta a un terminal. Pueden activar colores, dibujar progreso con retornos de carro, ajustar líneas al ancho indicado, pedir entrada o pasar del búfer por bloques al búfer por líneas. Por eso los bytes capturados con un PTY pueden diferir de los que produce el mismo comando sin PTY. Es un cambio observable de comportamiento, no una opción de presentación.

Las opciones del cliente OpenSSH reflejan la distinción: `-T` desactiva la asignación de pseudoterminal, mientras `-t` la solicita y repetir `-t` puede forzarla. La automatización debe usar por defecto la ejecución sin PTY. Solicítalo solo cuando el programa remoto necesite semántica de terminal y el contrato del resultado diga de forma explícita que la separación de flujos no está disponible.

Un PTY no hace que el orden sea más verdadero. Puede ofrecer un único flujo de bytes de terminal, de modo que el orden mostrado queda definido en ese límite, pero el programa y sus bibliotecas pueden usar otro búfer al detectar el terminal. Has cambiado pruebas separadas por comportamiento interactivo; no has descubierto la cronología de una ejecución sin PTY.

Esta distinción explica una clase de errores persistente. Una persona prueba el comando en un shell y ve un progreso limpio, con color y en un orden razonable. El agente ejecuta el mismo texto sin PTY, stdout pasa a usar bloques, stderr aparece primero y el analizador recibe después la salida automática sin códigos de control. Entonces alguien fuerza un PTY para que la transcripción se parezca a la prueba manual y el JSON deja de analizarse porque los códigos de color o las solicitudes de entrada entran en el flujo.

Trata la ejecución interactiva y la estructurada como modos distintos de la API. El modo estructurado debe prometer flujos separados y captura estable sin emulación de terminal. El modo interactivo debe devolver una transcripción del terminal, sus dimensiones y una indicación explícita de que no se conservó la identidad original de stdout y stderr. Un `pty: true` escondido en las opciones de la solicitud no basta si la respuesta parece idéntica a un resultado estructurado.

Los archivos de inicio remotos introducen otra dificultad. RFC 4254 advierte que la inicialización del shell puede producir salida imprevista al iniciar un subsistema y recomienda una marca reconocible para los protocolos que necesiten distinguirla. La misma lección vale para los adaptadores: invoca la ruta de ejecutable más directa que controles, evita shells interactivos innecesarios y trata los bytes iniciales inesperados como pruebas, en vez de quitar en silencio cualquier cosa que parezca un mensaje de bienvenida.

Si un comando necesita de verdad una contraseña o control de terminal, no finjas que su transcripción está lista para un analizador. Da al agente una herramienta de interacción especializada con entradas limitadas y una transcripción diseñada para la semántica del terminal. Separar esa ruta protege la garantía más sencilla de que las acciones SSH normales devuelven stdout fiel, stderr fiel y un resultado de terminación.

## El estado de salida pertenece al resultado

RFC 4254 define una solicitud de canal `exit-status` y una forma separada `exit-signal`. Recomienda devolver el estado, pero también permite que el cliente lo ignore. Por eso tu API necesita un resultado desconocido explícito en lugar de asumir éxito cuando no llegó ningún estado.

Un estado cero suele indicar éxito, no certeza. RFC 4254 usa esa formulación matizada porque las convenciones de los comandos se encuentran por encima del transporte. Aun así, el estado es la principal señal genérica disponible. Conserva el valor sin signo que entrega el protocolo antes de convertirlo a las convenciones de procesos del lenguaje anfitrión.

La terminación por señal no es un estado de salida negativo. Guarda por separado el nombre de la señal, el indicador de volcado de memoria cuando se reciba y el mensaje explicativo remoto. Si un consumidor quiere un número similar al del shell, como 128 más el valor de la señal, puede derivarlo para mostrarlo. El registro de auditoría debe conservar los hechos de SSH.

Distingue estos resultados en el código y en la interfaz:

- El comando remoto devolvió un estado.
- El extremo remoto informó de una terminación por señal.
- El canal se cerró sin ninguno de los dos informes.
- El cliente falló antes de confirmar el inicio del comando.
- La conexión falló después de recibir salida parcial.

Ese cuarto caso no debe disfrazarse de estado remoto 255 solo porque el cliente de línea de comandos OpenSSH suele usar 255 para sus propios errores. Un error de transporte de biblioteca tiene su propio tipo. Si invocas el ejecutable `ssh` como subproceso, puede que 255 sea todo lo que conoce el envoltorio, por lo que debes conservar su stderr local y etiquetar con franqueza ese límite.

Completar también significa haber drenado toda la salida. La documentación de `os/exec` de Go advierte que es incorrecto llamar a `Wait` antes de terminar las lecturas de `StdoutPipe` o `StderrPipe`. Node.js marca un límite parecido: su evento `exit` puede ocurrir mientras stdio sigue abierto, mientras que `close` llega después del cierre de los flujos. Esos manuales describen subprocesos locales, pero la enseñanza se aplica directamente a un ayudante SSH. Publica el resultado final solo después de conocer la terminación y de que ambos lectores hayan alcanzado su estado final.

El tiempo agotado y la cancelación merecen campos propios. Registra quién inició la cancelación cuando el sistema lo sepa, si se solicitó una señal y si el canal llegó a cerrarse. No declares `timed_out: true` para después descartar un informe remoto que llegue tarde; ambos hechos pueden importar en una investigación.

## El analizador debe consumir stdout y guardar lo demás

Un analizador específico de comando debe recibir los bytes de stdout, la terminación y los metadatos del contenido. No debe recibir una transcripción mezclada y adivinar qué líneas son diagnósticos.

Supón que un agente ejecuta un comando remoto de inventario que promete JSON en stdout. El adaptador debe comprobar primero que la operación SSH alcanzó una terminación conocida, aplicar después la política de estado del comando y finalmente decodificar y analizar stdout. Stderr permanece unido al resultado como prueba de apoyo. Una advertencia no entra en el analizador JSON y un error de análisis no borra la advertencia.

Devuelve el fallo del analizador junto con el resultado del comando, no en su lugar. Un error útil puede indicar que el byte 418 de stdout era inválido y conservar stdout, stderr, el estado de salida y los indicadores de truncamiento originales. Con ese conjunto, el agente puede decidir si corrige la invocación, reintenta con una configuración regional estable o entrega las pruebas exactas a una persona.

Evita las API cómodas llamadas `CombinedOutput` en una ruta estructurada para agentes. El manual de Go describe exactamente lo que hace ese método: devuelve la salida estándar y el error estándar combinados. Resulta práctico para un comando de diagnóstico puntual y es incorrecto para un contrato reutilizable, porque después no se pueden inferir las etiquetas perdidas.

Los comandos de texto también exigen decisiones específicas. Un analizador puede tratar stdout como registros delimitados por saltos de línea y presentar stderr como texto diagnóstico normal. Otro puede aceptar un estado cero con stdout vacío como resultado vacío válido. Sitúa estas reglas junto a la definición del comando, con sus pruebas, en vez de incrustarlas en el transporte.

La construcción del prompt debe usar campos estructurados. Indica al modelo `exit status: 2`, ofrece stdout y stderr en bloques etiquetados por separado y señala cuándo se truncó el contenido. No concatenes salida remota no fiable con instrucciones sin marcar límites. La salida puede contener texto que parezca un prompt, así que trátala como datos y escápala para el formato contenedor que uses.

Un agente no debe decidir el éxito a partir de la prosa. Entrégale un campo para máquinas, como `termination.kind` y `exit_status`, y deja que la prosa explique. Esto reduce el uso de tokens e impide que una advertencia con la palabra `error` invalide un estado correcto.

## Las vistas de auditoría necesitan dos representaciones honestas

El registro de auditoría y la transcripción humana tienen trabajos distintos. El registro conserva bytes y metadatos; la transcripción ayuda a leerlos.

Una vista útil empieza con una franja de estado: comando, identidad del host, horas de inicio y fin, tipo de terminación, estado de salida o señal, cantidades de bytes y truncamiento. Debajo, ofrece pestañas separadas de stdout y stderr como vistas autorizadas. Una pestaña combinada puede intercalar intervalos de eventos según la secuencia observada, con una etiqueta de flujo permanente en cada fila.

No codifiques la identidad del flujo solo mediante color. Utiliza etiquetas de texto y ofrece una acción de copia para cada flujo original. La copia de la vista combinada debe incluir etiquetas explícitas o advertir que es una representación, porque la salida pegada sin procedencia vuelve a crear el problema inicial.

Las líneas largas, los retornos de carro y los códigos de control del terminal requieren una presentación prudente. Escapa los caracteres de control de forma predeterminada. Una barra de progreso que escriba `\r` repetidamente no debe sobrescribir contenido anterior de auditoría como si la vista fuera un terminal. Ofrece la emulación de terminal solo como vista derivada opcional y mantén accesible la representación original.

La búsqueda debe devolver el flujo, el offset de bytes y la secuencia del evento con cada coincidencia. Filtrar por stderr no debe cambiar los números de secuencia. Si el contenido está truncado, coloca una marca visible donde faltan bytes y muestra la cantidad registrada. Nunca juntes el prefijo y el sufijo como si fueran adyacentes en el original.

Una línea temporal puede situar la terminación después del último fragmento observado, pero solo cuando la captura confirma que ambos lectores se cerraron antes de finalizar. Si se rompió la conexión, muestra el último evento de salida, el fallo de transporte y un resultado remoto desconocido. Reducirlo a una insignia roja `failed` borra la diferencia entre un fallo del programa y una pérdida de pruebas.

Sallyport dirige las acciones SSH mediante su ayudante sin estado `sp-ssh` y registra las llamadas individuales en el diario Activity, por lo que esta separación corresponde al límite del resultado del ayudante, antes de que un agente o una vista de auditoría formatee la llamada. El comportamiento útil del producto no es una transcripción ingeniosa, sino conservar pruebas suficientes para que agentes y personas lleguen a sus propias conclusiones.

## Prueba las formas de fallo, no un caso feliz

Un conjunto de pruebas del analizador debe variar de manera independiente la división en fragmentos, el tiempo de los flujos, la terminación, la codificación y los límites de conservación. Una instantánea de una cadena combinada prueba sobre todo el formateador.

Empieza con una fuente de canal falsa que emita eventos de protocolo bajo tu control. Introduce una carga de stdout dividida en cada punto posible. Repite con una muestra UTF-8 multibyte, una secuencia ANSI y una última línea sin `\n`. Los bytes almacenados deben ser idénticos en todas las divisiones.

Intercala eventos de stdout y stderr con números de secuencia conocidos y confirma que los búferes separados, los offsets de intervalo y la vista combinada coinciden. En implementaciones con lectores independientes, introduce retrasos de planificación y comprueba solo el orden de bytes por flujo y el orden observado por el recolector. Una prueba que insiste en el orden del código fuente del productor exige una garantía que el sistema no ofrece.

Cubre combinaciones de terminación que suelen faltar en los datos de prueba: cero con stderr, valor distinto de cero con stderr vacío, señal con stdout parcial, cierre de canal sin estado, fallo de transporte después de que ambos flujos produzcan datos, tiempo agotado seguido de cierre tardío y cancelación antes de confirmar el inicio. Cada caso debe producir un resultado estructurado distinto.

Aplica límites pequeños a un flujo cada vez. Comprueba que truncar stdout no marca stderr como truncado, que la cantidad de bytes descartados es correcta, que los lectores siguen drenando y que el estado final todavía llega. Después llena ambos flujos a la vez. Así se detecta el bloqueo clásico en el que el código drena stdout por completo antes de empezar a leer stderr.

Las pruebas basadas en propiedades funcionan bien para los invariantes de bytes. Genera secuencias arbitrarias de bytes y límites de fragmento, pásalos por el recolector y exige que la concatenación de los intervalos conservados reproduzca el contenido retenido del flujo. Genera los calendarios de eventos por separado del contenido para que la prueba no confunda fragmentación con significado.

Por último, prueba todas las exportaciones. JSON debe conservar la diferencia entre null y cero. La transcripción de texto debe etiquetar los flujos. La ocultación de datos no debe desplazar los offsets guardados sin registrar una correspondencia, o debe producir un artefacto derivado aparte. Un formato de auditoría se gana la confianza cuando los fallos incómodos siguen siendo incómodos y visibles, en vez de normalizarse como un relato ordenado pero falso.

Conserva los flujos originales, etiqueta el orden observado y espera tanto al drenaje como a la terminación antes de publicar un resultado. Cuando una cadena aplanada entra en el mensaje de un agente o en un registro de auditoría, las distinciones perdidas no pueden recuperarse y todas las capas posteriores tienen que adivinar.
