# ¿Puede el estado de salida de una canalización SSH ocultar un comando fallido?

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:

```bash
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:

```bash
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:

```bash
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:

```bash
ssh deploy@host 'remote command' 2>&1 | tee session.log
```

Ahora existen dos canalizaciones distintas:

1. El shell remoto puede contener una canalización dentro de `remote command`.
2. 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:

```bash
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`:

```bash
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:

```bash
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:

```bash
source_data | normalize | upload
statuses=("${PIPESTATUS[@]}")
```

Después inspecciónalo sin depender del código agregado de la canalización:

```bash
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:

```bash
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.

```bash
#!/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:

```text
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:

```bash
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:

```bash
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:

```bash
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`:

```bash
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:

1. El código de salida del proceso decide si la acción solicitada tuvo éxito.
2. 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:

```bash
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 `tee` local no puede escribir la transcripción;
- una canalización deliberada con `head` activa 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ó.
