¿Puede el estado de salida de una canalización SSH ocultar un comando fallido?
El estado de salida de una canalización SSH puede ocultar un comando remoto fallido. Captura Bash PIPESTATUS, gestiona pipefail y devuelve resultados fiables a los agentes.

Un comando remoto puede fallar, un formateador puede mostrar una salida convincente y un agente puede anunciar que todo salió bien. No es un misterio de SSH. Es la semántica normal del shell atravesando un límite de red sin suficiente evidencia.
La solución no consiste simplemente en añadir set -o pipefail a todos los scripts. pipefail cambia un único resultado agregado. Un agente que ejecuta trabajo SSH importante necesita el estado de cada etapa de la canalización, una regla definida para los estados distintos de cero esperados y un código de salida remoto final que no pueda confundirse con un éxito. Captura el vector inmediatamente, asígnale un nombre y deja que el wrapper decida qué significa tener éxito.
Un comando final correcto puede ocultar que el primero falló
De forma predeterminada, un shell informa del estado de salida de la última orden de una canalización. Eso vuelve peligrosa esta línea en tareas de despliegue, migración, copias de seguridad y reparación:
build_manifest | sign_manifest | tee /var/tmp/manifest.json
Supón que build_manifest falla porque no puede leer un archivo necesario. sign_manifest puede recibir una entrada inutilizable y fallar también, o producir un resultado vacío. tee aun así puede crear un archivo, escribir cero bytes y terminar con estado cero. El shell informa de cero para toda la canalización. Un proceso que solo compruebe $? verá un éxito.
El GNU Bash Reference Manual lo explica claramente: una canalización usa el estado de su último comando salvo que se active pipefail. Bash espera a que terminen todos los comandos de una canalización síncrona, pero esperar no equivale a conservar sus resultados.
Una persona ante un terminal interactivo quizá note que faltan datos o que aparece un mensaje de error. Un agente suele tener una visión más limitada. Puede recibir una transcripción truncada, un resumen formateado o únicamente el resultado final del comando. Si el script devuelve cero, el agente tiene motivos para decir que la acción terminó bien aunque no hiciera lo solicitado.
La distinción que los equipos suelen mezclar es sencilla:
- El estado de salida de una canalización es un único valor de decisión.
- Los estados de sus comandos son la evidencia que sustenta ese valor.
Necesitas ambos. El valor de decisión controla si el comando remoto devuelve éxito. La evidencia indica a un revisor, un registro o un agente supervisor dónde falló.
Esto importa especialmente cuando la primera etapa cambia el mundo. Imagina una exportación remota que lee datos de producción, los comprime, cifra y sube. El cliente de subida puede terminar con código cero después de subir un flujo vacío. La transcripción puede contener palabras tranquilizadoras como «completado» porque un programa posterior terminó su tarea concreta. Ese resultado no debe convertirse en una afirmación falsa de que la exportación tuvo éxito.
SSH devuelve lo que el shell remoto decide devolver
OpenSSH no inspecciona los comandos que hay dentro de una canalización del shell remoto. Devuelve el estado del comando remoto, o 255 cuando SSH encuentra un error.
Ese comportamiento es correcto y útil. SSH no puede saber si este texto remoto es una canalización, una función del shell, un script o una aplicación que usa los códigos de salida a su manera:
ssh deploy@host 'generate | transform | tee result.txt'
El shell de inicio de sesión remoto interpreta ese comando. Si su semántica de canalizaciones informa del estado del último tee, SSH devuelve ese estado al equipo local. El proceso local no puede reconstruir los resultados anteriores después de que el shell remoto los haya descartado.
Añadir set -o pipefail al shell local no arregla una canalización que se ejecuta remotamente. Este comando solo cambia las reglas de estado de la canalización local:
set -o pipefail
ssh deploy@host 'generate | transform | tee result.txt'
El shell remoto sigue siendo el dueño de generate | transform | tee result.txt. Necesita su propio shell explícito y su propio tratamiento de errores.
Hay otra trampa. Este comando local crea una canalización adicional después de que SSH devuelva el resultado:
ssh deploy@host 'remote command' 2>&1 | tee session.log
Ahora existen dos canalizaciones distintas:
- El shell remoto puede contener una canalización dentro de
remote command. - El shell local contiene
ssh | tee session.log.
Un tee local correcto puede ocultar un fallo de transporte de SSH o un estado distinto de cero del wrapper remoto. Debes inspeccionar la canalización remota en el host y la canalización local alrededor de SSH. Tratar toda la línea como un comando opaco permite que los resultados falsamente correctos superen las revisiones.
Pipefail detecta un fallo, pero no lo explica
set -o pipefail cambia el resultado agregado de Bash. Cuando está activado, Bash devuelve el estado de la orden situada más a la derecha que terminó con un valor distinto de cero, o cero si todas tuvieron éxito.
Para muchos scripts, esto supone una mejora real:
set -o pipefail
produce_data | validate_data | publish_data
printf 'pipeline status: %s\n' "$?"
Si produce_data termina con 17 y los comandos posteriores terminan con cero, la canalización devuelve 17. Si validate_data termina con 4 y publish_data con cero, devuelve 4. El proceso que llama recibe un fallo en lugar de una mentira.
Pero pipefail pierde información cuando falla más de una etapa. Supón que los estados son 17 4 0. El resultado de la canalización es 4, porque es el fallo situado más a la derecha. Eso indica que hubo un fallo, pero no demuestra si el validador causó el fallo del productor, reaccionó ante él o falló de forma independiente.
Por eso pipefail es una barrera de seguridad, no un formato de informe. Úsalo cuando quieras que una canalización falle como unidad. Usa PIPESTATUS cuando después necesites responder a estas preguntas:
- ¿Qué etapa devolvió un código distinto de cero?
- ¿Se ejecutó y terminó bien una etapa posterior después de que fallara una anterior?
- ¿El proceso recibió una señal en lugar de devolver su propio error?
- ¿Se espera un código distinto de cero para este comando concreto?
No ocultes la diferencia con || true:
produce_data | validate_data | publish_data || true
Este patrón es popular porque permite que el script continúe. También borra la única señal que tenía el proceso que lo invoca. Si una etapa puede devolver legítimamente un valor distinto de cero, registra el estado permitido para esa etapa después de capturar el vector real. No anules el fallo de toda la canalización.
PIPESTATUS desaparece si esperas siquiera un comando
Bash expone el código de salida de cada etapa en el array PIPESTATUS. El array es frágil por diseño: describe la última canalización en primer plano ejecutada y el siguiente comando puede reemplazarlo.
Esto parece razonable, pero es incorrecto:
source_data | normalize | upload
pipeline_rc=$?
printf 'pipeline result: %s\n' "$pipeline_rc"
statuses=("${PIPESTATUS[@]}")
Cuando Bash llega a la asignación final, ya se han ejecutado la asignación pipeline_rc=$? y printf. PIPESTATUS ya no describe source_data | normalize | upload.
Copia el array primero, antes de hacer cualquier otra cosa:
source_data | normalize | upload
statuses=("${PIPESTATUS[@]}")
Después inspecciónalo sin depender del código agregado de la canalización:
printf 'source_data=%s normalize=%s upload=%s\n' \
"${statuses[0]}" "${statuses[1]}" "${statuses[2]}"
Por eso un set -e usado de forma despreocupada también puede empeorar el diagnóstico. Con pipefail activo, una canalización fallida puede hacer que Bash salga antes de que la siguiente línea copie PIPESTATUS. El tratamiento de errores del shell tiene varias excepciones contextuales, y los scripts que dependen solo de set -e suelen producir menos evidencia justo cuando falla un comando.
Cuando importan los estados de una canalización, desactiva errexit durante las pocas líneas necesarias para ejecutarla y hacer una instantánea. Después toma una decisión explícita. Es más código que una opción mágica del shell, pero puedes leerlo durante un incidente.
Ejecuta el programa remoto con el shell que necesitas
PIPESTATUS es un array de Bash. No es sintaxis POSIX sh, y POSIX tampoco exige pipefail. Un comando remoto invocado mediante SSH puede ejecutarse con un shell de inicio de sesión que no has elegido. En un host puede ser Bash; en otro, dash, zsh o un shell restringido.
No envíes sintaxis de Bash a un shell remoto sin especificar y esperes que la máquina coincida contigo. Inicia Bash de forma explícita:
ssh deploy@host 'bash -s' <<'REMOTE_SCRIPT'
printf 'alpha\n' | grep 'beta' | tee /var/tmp/example.out
statuses=("${PIPESTATUS[@]}")
printf 'stages=%s,%s,%s\n' \
"${statuses[0]}" "${statuses[1]}" "${statuses[2]}" >&2
REMOTE_SCRIPT
El delimitador entre comillas del heredoc es importante. <<'REMOTE_SCRIPT' impide que el shell local expanda variables, sustituciones de comandos y barras invertidas antes de enviar el script. El proceso Bash remoto recibe el texto tal como lo escribiste.
En macOS, el Bash del sistema es antiguo, pero admite arrays indexados, PIPESTATUS y set -o pipefail. Eso no significa que /bin/sh sea Bash. Un script con #!/bin/bash solo ayuda cuando ejecutas directamente ese archivo. Si pasas una orden de una línea a ssh host '...', el shell de inicio de sesión remoto la interpreta salvo que inicies Bash explícitamente.
Para una ruta de automatización mantenida, coloca el wrapper remoto en un script versionado e invoca su ruta absoluta. Para trabajos breves de un agente, bash -s con un heredoc entre comillas suele ser más fácil de auditar porque el programa remoto completo aparece en la solicitud de acción local.
Un wrapper debe nombrar las etapas y devolver un resultado honesto
Un wrapper remoto útil hace cuatro cosas. Ejecuta la canalización, copia inmediatamente el vector de estados, emite un registro legible por máquinas y termina con un valor distinto de cero cuando falla una etapa obligatoria.
Este ejemplo usa una transferencia de datos de tres etapas. Sustituye los comandos, pero conserva el flujo de control. Deliberadamente no depende de set -e para decidir qué ocurre después de la canalización.
#!/usr/bin/env bash
set -uo pipefail
run_export() {
local -a status
local stage
local -a names=("collect" "compress" "send")
set +e
collect_records | gzip -c | send_archive --destination daily
status=("${PIPESTATUS[@]}")
set -e
if ((${#status[@]} != ${#names[@]})); then
printf 'agent_pipeline_error pipeline=export reason=status_count expected=%s got=%s\n' \
"${#names[@]}" "${#status[@]}" >&2
return 70
fi
for stage in "${!names[@]}"; do
printf 'agent_pipeline_status pipeline=export stage=%s code=%s\n' \
"${names[$stage]}" "${status[$stage]}" >&2
done
for stage in "${!status[@]}"; do
if (( status[stage] != 0 )); then
printf 'agent_pipeline_result pipeline=export outcome=failed\n' >&2
return "${status[$stage]}"
fi
done
printf 'agent_pipeline_result pipeline=export outcome=ok\n' >&2
return 0
}
run_export
Una recopilación fallida con un compresor y un emisor correctos produce una salida de este tipo:
agent_pipeline_status pipeline=export stage=collect code=23
agent_pipeline_status pipeline=export stage=compress code=0
agent_pipeline_status pipeline=export stage=send code=0
agent_pipeline_result pipeline=export outcome=failed
El wrapper termina con 23. SSH devuelve 23 al proceso local. El agente puede informar de que la exportación falló en collect, aunque send_archive haya mostrado un mensaje de finalización para un flujo vacío.
El código exacto devuelto importa menos que la disciplina. En este wrapper, gana el primer estado distinto de cero según el orden de la canalización. Bash pipefail, en cambio, selecciona el estado distinto de cero situado más a la derecha. Cualquiera de las dos políticas puede funcionar si la declaras y la pruebas. Para operaciones, prefiero la primera etapa fallida porque suele apuntar más cerca de la causa inicial. Conserva el vector completo de estados en el registro de la acción para que nadie tenga que deducir lo ocurrido a partir de un único número.
Los nombres de las etapas no son decoración. 0=23,1=0,2=0 obliga a una persona a volver a abrir el script. collect=23,compress=0,send=0 permite que un supervisor dirija el fallo, añada contexto o decida si es seguro reintentar.
El registro local puede crear un segundo éxito falso
Los operadores quieren una transcripción local. Los agentes también. La forma ingenua de obtenerla es esta:
ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log
Si SSH devuelve 23, pero el tee local escribe la transcripción y devuelve cero, la canalización local devuelve cero de forma predeterminada. Has corregido la mentira remota y has introducido otra local.
Captura también los estados locales:
set +e
ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log
local_status=("${PIPESTATUS[@]}")
set -e
ssh_rc=${local_status[0]}
tee_rc=${local_status[1]}
printf 'ssh=%s tee=%s\n' "$ssh_rc" "$tee_rc" >&2
if (( ssh_rc != 0 )); then
exit "$ssh_rc"
fi
if (( tee_rc != 0 )); then
exit "$tee_rc"
fi
No actives pipefail local y te detengas ahí. Produce un resultado agregado distinto de cero si falla ssh o tee, lo que es mejor que el comportamiento predeterminado. Pero no puede decir al agente si falló la acción remota, la conexión de red o el registro local. Cada caso exige una decisión distinta.
Un estado SSH de 255 requiere un tratamiento especial. OpenSSH lo reserva para un error en la ruta del cliente SSH, no para el resultado de un comando remoto. El wrapper debe informarlo como un fallo de transporte o de ejecución de SSH, no afirmar que una etapa remota con nombre devolvió 255.
También conviene mantener separados los resultados locales y remotos porque una transcripción puede contener varios registros de canalizaciones remotas, advertencias del shell de inicio de sesión y un diagnóstico de SSH. Si un agente busca el último número en texto libre, tarde o temprano elegirá el equivocado. Usa registros reconocibles y vincula el resultado final de la acción al estado real de salida del proceso.
SIGPIPE necesita una excepción escrita, no un perdón general
pipefail muestra un fallo que muchos scripts ignoraban: SIGPIPE. En Bash, un proceso terminado por la señal N recibe el estado 128 + N; SIGPIPE suele aparecer como 141.
Un caso intencionado clásico es:
generate_many_lines | head -n 10
head lee diez líneas y termina correctamente. El generador puede seguir escribiendo, recibir SIGPIPE porque ya no queda ningún lector y terminar con 141. Con pipefail, la canalización puede parecer fallida aunque se haya producido la muestra de diez líneas solicitada.
Eso no hace que 141 sea inofensivo en todas las canalizaciones. Un cliente de red, un compresor o un productor de datos puede recibir SIGPIPE porque un consumidor posterior inesperadamente se bloqueó o rechazó la entrada. Si marcas todos los estados 141 como correctos, ocultas una transferencia rota.
La regla adecuada es limitada: permite un estado derivado de una señal solo para una etapa cuya terminación anticipada forme parte del contrato previsto del comando. Coloca la excepción junto a la etapa, no en una configuración global del shell.
Por ejemplo, un wrapper de una vista previa deliberada puede aceptar generate_many_lines=141 solo cuando head=0:
if (( status[0] == 141 && status[1] == 0 )); then
printf 'agent_pipeline_result pipeline=preview outcome=ok reason=expected_sigpipe\n' >&2
return 0
fi
Cualquier otro resultado distinto de cero sigue siendo un fallo. Esta especificidad evita una corrección excesiva habitual: se activa pipefail, aparece un 141 ruidoso y después se desactiva en todo el sistema de automatización.
Un agente necesita evidencia separada de la salida del comando
El agente no debe determinar el éxito leyendo prosa. Los comandos muestran palabras de éxito antes de fallar, las herramientas mezclan advertencias con resultados y un script remoto puede emitir una línea final después de que una etapa ya haya fallado.
Define el contrato de una acción en dos capas:
- El código de salida del proceso decide si la acción solicitada tuvo éxito.
- Los registros estructurados explican cada etapa relevante de la canalización.
Conserva la salida normal de los comandos para depurar, pero no pidas al agente que deduzca el flujo de control a partir de ella. En el wrapper anterior, stderr lleva registros que empiezan por agent_pipeline_status y agent_pipeline_result. Un programa que llama puede conservar ese flujo, analizar solo esos registros exactos y seguir mostrando el resto a una persona.
No confíes en un marcador solo porque aparezca en una salida no confiable. Si una etapa procesa datos proporcionados por otro usuario o sistema, esos datos pueden contener una línea parecida a tu registro de estado. El patrón más seguro es que el wrapper capture la salida de las etapas y emita los registros después de que termine la canalización. Para trabajos de mayor riesgo, usa un archivo de resultados específico con permisos restrictivos, y haz que el wrapper lo lea y valide antes de emitir un único registro final.
El informe del agente debe incluir el código remoto de salida, el código local de SSH y, cuando estén disponibles, los estados de las etapas remotas con sus nombres. También debe distinguir estos resultados:
- la acción remota se ejecutó y falló una etapa identificada;
- el wrapper remoto no pudo producir un registro de estado completo;
- SSH no pudo establecer o mantener el canal de la acción;
- la captura de la transcripción local falló después de que terminara la acción remota.
Son hechos operativos distintos. Un reintento después de perder la red puede duplicar una mutación remota ya completada. Un reintento después de que falle una etapa de validación puede ser seguro. Un reintento después de que falle el tee local puede no servir de nada porque el trabajo remoto ya ocurrió.
Sallyport puede mantener la credencial SSH fuera del agente mientras ejecuta la acción SSH, pero el comando remoto sigue necesitando este contrato honesto de salida y evidencia.
Prueba las rutas de fallo antes de que llegue el agente
Un wrapper de shell solo merece confianza después de fallar de forma controlada. Probar el camino correcto demuestra la rama menos interesante.
Crea comandos desechables que devuelvan los estados que quieres observar:
fail_23() { printf 'collector failed\n' >&2; return 23; }
pass_through() { cat; }
succeed() { cat >/dev/null; return 0; }
set +e
fail_23 | pass_through | succeed
status=("${PIPESTATUS[@]}")
set -e
printf 'observed=%s,%s,%s\n' "${status[0]}" "${status[1]}" "${status[2]}"
El resultado esperado es 23,0,0. Después ejecuta el mismo patrón mediante la invocación SSH exacta que usa tu agente. No te detengas en una prueba del shell local, porque la selección del shell remoto, las comillas del heredoc, la canalización local de la transcripción y el comportamiento de salida del wrapper quedan fuera de esa primera comprobación.
Prueba al menos estos casos:
- todas las etapas terminan correctamente y el wrapper devuelve cero;
- falla una etapa inicial mientras las posteriores devuelven cero;
- falla una etapa intermedia después de consumir parte de la entrada;
- SSH no puede conectarse o autenticarse;
- el
teelocal no puede escribir la transcripción; - una canalización deliberada con
headactiva la regla prevista para SIGPIPE.
Registra el código de salida esperado y los registros de etapas esperados para cada caso. Si una prueba informa de éxito después de que una etapa anterior devuelva un valor distinto de cero, el wrapper no ha cumplido su función.
El atajo tentador es hacer que el agente inspeccione una transcripción después de cada acción y juzgue si la salida «parece correcta». Esto falla con carga, cuando cambian las palabras de las herramientas o cuando se trunca la salida. Los códigos de salida son el canal de control. Los registros de etapas son el canal de evidencia. Mantén ambos separados, conserva ambos a través de SSH y no permitas que un tee final decida si una acción remota ocurrió.
FAQ
¿SSH devuelve el código de salida de todos los comandos de una canalización remota?
No. OpenSSH devuelve el estado de salida del comando remoto, pero una canalización del shell remoto normalmente informa del estado de su última etapa. Si esa etapa es tee, cat o un formateador que termina con código cero, SSH puede devolver cero aunque haya fallado un comando anterior.
¿Basta con pipefail para automatizar SSH?
set -o pipefail cambia el resultado de la canalización: deja de usar el estado de la última etapa y pasa a usar el de la etapa fallida situada más a la derecha. Informa al proceso que algo falló, pero no identifica todas las etapas fallidas ni conserva un registro etapa por etapa para el agente.
¿Cómo capturo todos los estados de salida de una canalización en Bash?
En Bash, copia el valor inmediatamente después de la canalización: statuses=("${PIPESTATUS[@]}"). Hazlo antes de echo, local, una asignación que lea $? o cualquier otro comando, porque el siguiente comando reemplaza el contenido del array.
¿Bash de macOS admite PIPESTATUS?
macOS incluye Bash 3.2, que admite PIPESTATUS y set -o pipefail. No supongas que /bin/sh es Bash. Ejecuta el programa remoto como bash -s o invoca un script de Bash mediante una ruta explícita.
¿Por qué pipefail devuelve a veces 141?
El estado 141 suele indicar que un proceso recibió SIGPIPE. Esto puede ser normal cuando un comando posterior deja de leer de forma intencionada, como head. Trátalo como esperado solo en una canalización cuya terminación anticipada hayas diseñado y probado; en cualquier otro caso, investígalo como un fallo.
¿También tengo que comprobar una canalización local con tee después de SSH?
No. Una canalización remota y una canalización local como ssh ... | tee log son distintas. El wrapper remoto debe informar de sus propias etapas, y el wrapper local debe capturar el estado tanto de ssh como de tee.
¿Debo usar set -e con pipefail?
set -e tiene excepciones que dependen del contexto, especialmente alrededor de condicionales, sustituciones de comandos y canalizaciones. Puede detener el script antes de que recopiles evidencias útiles. Usa una captura explícita de estados para las canalizaciones de acciones y reserva set -e para estructuras de script más sencillas.
¿Qué debe recibir un agente después de ejecutar una canalización remota?
Usa un registro estable y legible por máquinas que nombre la canalización y cada etapa, y termina con un código distinto de cero si falla una etapa obligatoria. Mantén ese registro separado de la salida destinada a personas para que el agente no confunda una línea final atractiva con una señal de éxito.
¿Puedo ignorar el código de salida distinto de cero de una etapa?
No trates cualquier estado distinto de cero como un fallo genérico. Decide si cada etapa permite estados como el 1 de grep cuando no encuentra coincidencias y codifica esa regla junto a la etapa. Un || true general oculta precisamente los fallos que intentabas detectar.
¿Cómo pruebo que un agente no pueda informar falsamente de un éxito de SSH?
Coloca un productor que falle deliberadamente, una etapa intermedia correcta y una etapa final correcta en un script remoto desechable. Comprueba el vector exacto de estados, la salida distinta de cero del wrapper y el estado local de SSH. Prueba por separado el caso correcto y un caso intencionado de SIGPIPE.