Читать 5 мин

Может ли код завершения конвейера SSH скрыть ошибку команды?

Код завершения конвейера SSH может скрыть ошибку удаленной команды. Сохраняйте PIPESTATUS Bash, правильно обрабатывайте pipefail и возвращайте агентам честные результаты.

Может ли код завершения конвейера SSH скрыть ошибку команды?

Удаленная команда может завершиться ошибкой, форматтер может вывести убедительный результат, а агент все равно объявит об успехе. Это не загадка SSH. Так работает обычная семантика оболочки, когда она пересекает сетевую границу, не передавая достаточно сведений о результате.

Решение не сводится к тому, чтобы добавить set -o pipefail в каждый скрипт. pipefail меняет один итоговый результат. Агенту, который выполняет важную работу через SSH, нужны статусы всех этапов конвейера, четкое правило для ожидаемых ненулевых кодов и итоговый код удаленной команды, который нельзя принять за успех. Сразу сохраните вектор статусов, дайте ему имя и поручите обертке решить, что считать успехом.

Успешная последняя команда может скрыть ошибку первой

По умолчанию оболочка сообщает в конвейере код завершения последней команды. Поэтому в задачах развертывания, миграции, резервного копирования и восстановления опасной может оказаться такая строка:

build_manifest | sign_manifest | tee /var/tmp/manifest.json

Предположим, build_manifest завершилась ошибкой, потому что не смогла прочитать обязательный файл. sign_manifest может получить непригодные входные данные и тоже завершиться ошибкой или создать пустой результат. tee все равно может создать файл, записать ноль байт и завершиться с кодом 0. Оболочка сообщит 0 для всего конвейера. Вызывающая программа, которая проверяет только $?, увидит успех.

В GNU Bash Reference Manual это сформулировано прямо: конвейер использует код завершения последней команды, если не включен pipefail. Bash дожидается всех команд в синхронном конвейере, но ожидание не означает сохранение их результатов.

Человек у интерактивного терминала иногда замечает отсутствие данных или сообщение об ошибке. У агента часто меньше контекста. Он может получить обрезанный протокол, отформатированное резюме или только итоговый результат команды. Если скрипт возвращает ноль, у агента есть основания сказать, что действие завершилось успешно, даже когда требуемое действие не выполнено.

Различие, которое команды часто размывают, простое:

  • Код завершения конвейера это одно значение, принимающее решение.
  • Статусы его команд это свидетельства, на которых основано решение.

Нужны оба. Значение решения определяет, вернет ли удаленная команда успех. Свидетельства показывают рецензенту, журналу или контролирующему агенту, где произошла ошибка.

Особенно важно это в случаях, когда первый этап меняет состояние системы. Представьте удаленный экспорт, который читает данные из production, сжимает их, шифрует и загружает. Клиент загрузки может завершиться с кодом 0 после отправки пустого потока. В протоколе могут появиться успокаивающие слова вроде «завершено», потому что последующая программа выполнила свою узкую задачу. Такой результат нельзя превращать в ложное утверждение, что экспорт прошел успешно.

SSH возвращает то, что выбрала удаленная оболочка

OpenSSH не проверяет команды внутри удаленного конвейера. Он возвращает статус удаленной команды или 255, если ошибка произошла на стороне самого SSH.

Это правильное и полезное поведение. SSH не может знать, является ли переданный удаленный текст конвейером, функцией оболочки, скриптом или приложением, которое использует коды завершения по-своему:

ssh deploy@host 'generate | transform | tee result.txt'

Удаленная login shell разбирает эту команду. Если ее правила конвейера сообщают статус последнего tee, SSH передает этот статус локальной машине. После того как удаленная оболочка отбросила предыдущие результаты, локальный вызывающий процесс уже не может их восстановить.

Локальный set -o pipefail не исправляет конвейер, который выполняется удаленно. Эта команда меняет только правила статуса локального конвейера:

set -o pipefail
ssh deploy@host 'generate | transform | tee result.txt'

Конвейером generate | transform | tee result.txt по-прежнему управляет удаленная оболочка. Ей нужны собственная явная оболочка и собственная обработка ошибок.

Есть и вторая ловушка. Следующая локальная команда создает еще один конвейер после возврата SSH:

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

Теперь существуют два разных конвейера:

  1. Внутри remote command у удаленной оболочки может быть свой конвейер.
  2. У локальной оболочки есть ssh | tee session.log.

Успешный локальный tee может скрыть сбой транспорта SSH или ненулевой код удаленной обертки. Нужно проверить удаленный конвейер на хосте и локальный конвейер вокруг SSH. Если воспринимать всю строку как одну непрозрачную команду, ложные успешные результаты будут проходить проверку.

Pipefail обнаруживает ошибку, но не объясняет ее

set -o pipefail меняет совокупный результат Bash. Когда эта настройка включена, Bash возвращает код крайней справа команды, завершившейся с ненулевым кодом, или 0, если все команды завершились успешно.

Для многих скриптов это заметное улучшение:

set -o pipefail
produce_data | validate_data | publish_data
printf 'pipeline status: %s\n' "$?"

Если produce_data завершилась с кодом 17, а последующие команды вернули 0, конвейер вернет 17. Если validate_data вернула 4, а publish_data 0, конвейер вернет 4. Вызывающий процесс получит ошибку, а не ложный успех.

Но pipefail теряет детали, когда ошибку возвращают несколько этапов. Допустим, статусы равны 17 4 0. Результатом конвейера будет 4, потому что именно 4 принадлежит крайней справа неисправной команде. Это говорит о наличии ошибки, но не показывает, вызвал ли валидатор сбой производителя, отреагировал на него или сломался независимо.

Поэтому pipefail это защитный барьер, а не формат отчета. Включайте его, когда хотите, чтобы конвейер завершался как единое целое. Используйте PIPESTATUS, когда позже нужно ответить на такие вопросы:

  • Какой этап вернул ненулевой код?
  • Запустился ли последующий этап и завершился ли успешно после сбоя предыдущего?
  • Получил ли процесс сигнал вместо собственного кода ошибки?
  • Ожидаем ли ненулевой код для этой конкретной команды?

Не замалчивайте проблему с помощью || true:

produce_data | validate_data | publish_data || true

Этот прием популярен, потому что позволяет скрипту продолжить работу. Одновременно он стирает единственный сигнал, который был у вызывающей программы. Если этап может законно вернуть ненулевой код, после сохранения фактического вектора явно укажите допустимый статус именно для этого этапа. Не отменяйте ошибку для всего конвейера.

PIPESTATUS исчезает, если подождать хотя бы одну команду

Bash предоставляет код завершения каждого этапа в массиве PIPESTATUS. Массив намеренно хрупкий: он описывает последний выполненный конвейер переднего плана, а следующая команда может его заменить.

На вид следующий вариант разумен, но он неверен:

source_data | normalize | upload
pipeline_rc=$?
printf 'pipeline result: %s\n' "$pipeline_rc"
statuses=("${PIPESTATUS[@]}")

К моменту последнего присваивания Bash уже выполнил присваивание pipeline_rc=$? и printf. PIPESTATUS больше не описывает source_data | normalize | upload.

Сначала скопируйте массив, не выполняя ничего другого:

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

Затем изучите его, не полагаясь на совокупный код конвейера:

printf 'source_data=%s normalize=%s upload=%s\n' \
  "${statuses[0]}" "${statuses[1]}" "${statuses[2]}"

По этой же причине неосторожный set -e может усложнить диагностику. При активном pipefail неисправный конвейер может завершить Bash до того, как следующая строка скопирует PIPESTATUS. У обработки ошибок оболочкой много контекстных исключений, и скрипты, которые полагаются только на set -e, часто дают меньше сведений именно в момент сбоя.

Если статусы конвейера важны, на несколько строк, необходимых для его запуска и сохранения, отключите errexit. Затем явно примите решение. Кода получится больше, чем при использовании магической настройки оболочки, зато его можно прочитать во время инцидента.

Запускайте удаленную программу под нужной оболочкой

Подтверждайте каждый запуск SSH
Подтверждайте новый процесс агента до того, как он начнет выполнять удаленные команды с вашими SSH-учетными данными.

PIPESTATUS это массив Bash. Это не переносимый синтаксис POSIX sh, а pipefail не входит в обязательные возможности POSIX shell. Команда, запущенная через SSH, может выполняться под login shell, которую вы не выбирали. На одном хосте это будет Bash, на другом dash, zsh или ограниченная оболочка.

Не отправляйте синтаксис Bash в неуказанную удаленную оболочку в надежде, что машина случайно вас поймет. Запускайте 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

Кавычки вокруг разделителя heredoc важны. <<'REMOTE_SCRIPT' не дает локальной оболочке подставлять переменные, выполнять подстановки команд и обрабатывать обратные косые черты до отправки скрипта. Удаленный процесс Bash получает написанный вами текст.

В macOS системный Bash старый, но он поддерживает индексированные массивы, PIPESTATUS и set -o pipefail. Это не означает, что /bin/sh является Bash. Скрипт с #!/bin/bash помогает только при прямом запуске файла. Если передать однострочную команду через ssh host '...', ее все равно разбирает удаленная login shell, если вы явно не запустите Bash.

Для постоянно используемого пути автоматизации храните удаленную обертку в версионируемом скрипте и вызывайте ее по абсолютному пути. Для краткосрочной работы агента обычно проще проверять bash -s с heredoc в кавычках: вся удаленная программа видна в локальном запросе действия.

Обертка должна называть этапы и возвращать честный результат

Хорошая удаленная обертка выполняет четыре задачи. Она запускает конвейер, сразу копирует вектор статусов, выводит машиночитаемую запись и завершает работу с ненулевым кодом, если обязательный этап завершился ошибкой.

В этом примере используются три этапа передачи данных. Замените команды, но сохраните структуру управления. Здесь намеренно нет зависимости от set -e при принятии решения о результате конвейера.

#!/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

Если сбор данных завершился ошибкой, а сжатие и отправка прошли успешно, вывод будет выглядеть так:

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

Обертка завершится с кодом 23. SSH вернет 23 локальному процессу. Агент сможет сообщить, что экспорт завершился ошибкой на этапе collect, даже если send_archive вывела сообщение о завершении для пустого потока.

Точный возвращаемый код менее важен, чем сам принцип. В этой обертке побеждает первый ненулевой статус по порядку этапов. Bash pipefail выбирает крайний справа ненулевой статус. Подойдет любая политика, если вы ее явно описали и проверили. В операционных задачах я предпочитаю первый неисправный этап, потому что он обычно ближе к исходной причине. Сохраняйте весь вектор статусов в записи действия, чтобы никто не восстанавливал картину по одному числу.

Названия этапов нужны не для украшения. Запись 0=23,1=0,2=0 заставляет человека снова открывать скрипт. Запись collect=23,compress=0,send=0 позволяет контролирующей системе направить ошибку, добавить контекст или решить, безопасно ли повторить операцию.

Локальное журналирование может создать второй ложный успех

Возвращайте результаты, а не учетные данные
Передавайте агенту результаты команд, а учетные данные для подключения оставляйте в Sallyport.

Операторам нужен локальный протокол. Агенту он тоже нужен. Наивный способ получить его выглядит так:

ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log

Если SSH возвращает 23, а локальный tee записывает протокол и возвращает 0, локальный конвейер по умолчанию вернет 0. Вы исправили ложь на удаленной стороне и создали локальную.

Сохраните локальные статусы тоже:

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

Не включайте локальный pipefail и не останавливайтесь на этом. Он даст ненулевой совокупный результат, если завершился ошибкой ssh или tee, что лучше поведения по умолчанию. Но он не скажет агенту, что именно произошло: сбой удаленного действия, сетевого соединения или локального журналирования. Эти случаи требуют разных решений.

Код SSH 255 требует особого отношения. OpenSSH резервирует его для ошибки на стороне SSH-клиента, а не для результата удаленной команды. Обертка должна сообщать о нем как о сбое транспорта или выполнения SSH, а не утверждать, что именованный этап удаленного конвейера вернул 255.

Есть еще одна практическая причина разделять локальные и удаленные результаты. В протоколе могут быть записи нескольких удаленных конвейеров, предупреждения login shell и диагностика SSH. Если агент разбирает свободный текст и берет последнее встретившееся число, рано или поздно он выберет неправильное. Используйте узнаваемые записи и связывайте итог действия с фактическим кодом завершения процесса.

Для SIGPIPE нужно отдельное правило, а не общее исключение

pipefail показывает ошибку, которую многие скрипты раньше игнорировали: SIGPIPE. В Bash процесс, завершенный сигналом с номером N, получает код 128 + N; SIGPIPE обычно дает 141.

Классический намеренный случай:

generate_many_lines | head -n 10

head читает десять строк и успешно завершается. Генератор может продолжить запись, получить SIGPIPE, потому что читателя больше нет, и завершиться с кодом 141. При включенном pipefail конвейер может выглядеть неисправным, хотя требуемый образец из десяти строк был создан.

Но это не делает 141 безобидным в любом конвейере. Сетевой клиент, компрессор или производитель данных может получить SIGPIPE, потому что неожиданный следующий процесс завершился или отклонил входные данные. Если объявлять успешными все статусы 141, можно скрыть сломанную передачу.

Правило должно быть узким: разрешайте код, возникший из-за сигнала, только для этапа, раннее завершение которого входит в предусмотренный контракт команды. Размещайте это исключение рядом с этапом, а не в глобальной настройке оболочки.

Например, обертка для намеренного предварительного просмотра может принять generate_many_lines=141 только при 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

Любой другой ненулевой результат остается ошибкой. Такая точность предотвращает распространенную чрезмерную реакцию: кто-то включает pipefail, однажды видит шумный 141, а затем отключает настройку во всей инфраструктуре автоматизации.

Агенту нужны сведения отдельно от вывода команд

Блокируйте удаленные действия в хранилище
Хранилище остается заблокированным за Secure Enclave и Touch ID и не разрешает действия, пока вы его не разблокируете.

Агент не должен определять успех по тексту. Команды могут напечатать слова об успехе до сбоя, инструменты смешивают предупреждения с результатами, а удаленный скрипт может вывести последнюю строку после того, как один из этапов уже завершился ошибкой.

Определите контракт действия из двух уровней:

  1. Код завершения процесса решает, выполнено ли требуемое действие.
  2. Структурированные записи статусов объясняют каждый важный этап конвейера.

Сохраняйте обычный вывод команд для диагностики, но не заставляйте агента восстанавливать ход выполнения по этому тексту. В приведенной выше обертке записи с префиксами agent_pipeline_status и agent_pipeline_result передаются в stderr. Вызывающая программа может сохранить этот поток, разбирать только точные записи и при этом показывать остальное человеку.

Не доверяйте маркеру только потому, что он появился в недоверенном выводе команды. Если этап конвейера обрабатывает данные, переданные другим пользователем или системой, в этих данных может оказаться строка, похожая на вашу запись статуса. Надежнее поручить обертке собрать вывод этапов и самостоятельно вывести записи после завершения конвейера. Для более рискованных операций используйте отдельный файл результатов с ограниченными правами доступа, а затем пусть обертка прочитает и проверит его перед выводом одной итоговой записи.

В отчет агента должны входить код завершения удаленной команды, локальный код SSH и, если они доступны, статусы именованных удаленных этапов. Также нужно различать такие результаты:

  • удаленное действие запустилось, и именованный этап завершился ошибкой;
  • удаленная обертка не смогла создать полную запись статуса;
  • SSH не смог установить или сохранить канал выполнения действия;
  • локальная запись протокола завершилась ошибкой после завершения удаленного действия.

С точки зрения эксплуатации это разные факты. Повтор после потери сети может повторно выполнить уже завершившуюся удаленную операцию. Повтор после ошибки проверки может быть безопасным. Повтор после сбоя локального tee может быть бессмысленным, потому что удаленная работа уже выполнена.

Sallyport может не передавать SSH-учетные данные агенту во время выполнения SSH-действия, но удаленной команде все равно нужен честный контракт по коду завершения и доказательствам результата.

Проверьте пути отказа до того, как до них доберется агент

Доверие к shell-обертке появляется только после контролируемых сбоев. Проверка успешного сценария показывает наименее интересную ветку.

Создайте одноразовые команды, которые возвращают нужные вам статусы:

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]}"

Ожидаемый результат это 23,0,0. Затем пропустите тот же шаблон через точную SSH-команду, которую использует ваш агент. Не ограничивайтесь локальной проверкой: выбор удаленной оболочки, кавычки heredoc, локальный конвейер журналирования и поведение обертки при завершении находятся за пределами первой проверки.

Проверьте как минимум такие случаи:

  • все этапы завершаются успешно, а обертка возвращает ноль;
  • ранний этап завершается ошибкой, а последующие возвращают ноль;
  • промежуточный этап завершается ошибкой после обработки части входных данных;
  • SSH не может подключиться или пройти аутентификацию;
  • локальный tee не может записать протокол;
  • намеренный конвейер с head запускает предусмотренное правило SIGPIPE.

Для каждого случая запишите ожидаемый код завершения и ожидаемые записи этапов. Если тест сообщает об успехе после ненулевого результата раннего этапа, обертка не выполнила свою задачу.

Соблазнительный короткий путь состоит в том, чтобы после каждого действия просить агента проверить протокол и решить, «похож» ли вывод на правильный. Такой подход ломается под нагрузкой, после изменения формулировок инструментов и при обрезанном выводе. Коды завершения это канал управления. Записи этапов это канал доказательств. Разделяйте их, сохраняйте оба через SSH и не позволяйте последнему tee решать, произошло ли удаленное действие.

Вопросы и ответы

Возвращает ли SSH коды завершения всех команд в удаленном конвейере?

Нет. OpenSSH возвращает код завершения удаленной команды, но удаленная shell обычно сообщает код последнего этапа конвейера. Если этот этап представляет собой tee, cat или форматтер, который завершился с нулевым кодом, SSH может вернуть ноль, хотя предыдущая удаленная команда завершилась ошибкой.

Достаточно ли pipefail для автоматизации через SSH?

set -o pipefail меняет результат конвейера: вместо кода последнего этапа возвращается код крайнего справа этапа с ненулевым результатом. Это сообщает вызывающей программе о сбое, но не показывает все неисправные этапы и не сохраняет для агента подробный статус каждого из них.

Как получить код завершения каждого этапа конвейера в Bash?

В Bash сразу после конвейера скопируйте массив: statuses=("${PIPESTATUS[@]}"). Сделайте это до echo, local, присваивания, которое читает $?, или любой другой команды, потому что следующая команда заменяет содержимое массива.

Поддерживает ли Bash в macOS PIPESTATUS?

В macOS используется Bash 3.2, который поддерживает PIPESTATUS и set -o pipefail. Но не считайте /bin/sh Bash. Запускайте удаленную программу через bash -s или вызывайте Bash-скрипт по явному пути.

Почему pipefail иногда возвращает 141?

Код 141 часто означает, что процесс получил SIGPIPE. Это может быть нормальным, когда следующая команда намеренно перестает читать данные, например при использовании head. Считайте его ожидаемым только в конвейере, где раннее завершение предусмотрено и проверено. В остальных случаях исследуйте его как обычную ошибку.

Нужно ли после SSH проверять локальный конвейер с tee?

Нет. Удаленный конвейер и локальный конвейер ssh ... | tee log существуют отдельно. Удаленная оболочка должна сообщать о своих этапах, а локальная оболочка должна отдельно получать статусы ssh и tee.

Стоит ли использовать set -e вместе с pipefail?

У set -e есть контекстные исключения, особенно в условиях, подстановках команд и конвейерах. Скрипт может завершиться до того, как вы соберете полезные сведения, поэтому для рабочих конвейеров лучше явно сохранять статусы, а set -e оставлять для более простой структуры скрипта.

Что должен получить агент после выполнения удаленного конвейера?

Используйте стабильную машиночитаемую запись с названием конвейера и каждого этапа, а затем возвращайте ненулевой код, если обязательный этап завершился ошибкой. Храните эту запись отдельно от обычного вывода команд, чтобы агент не принял красивую последнюю строку за сигнал успеха.

Можно ли игнорировать ненулевой код одного этапа конвейера?

Не считайте любой ненулевой код общей ошибкой. Определите, какие коды допустимы для каждого этапа, например код 1 у grep при отсутствии совпадений, и зафиксируйте это правило рядом с этапом. Общий || true скрывает ошибки, которые вы пытались обнаружить.

Как проверить, что агент не сможет ошибочно объявить SSH успешным?

Добавьте в одноразовый удаленный скрипт намеренно неисправный источник, успешный промежуточный этап и успешный конечный этап. Проверьте точный вектор статусов, ненулевой код оболочки-обертки и локальный статус SSH. Отдельно проверьте успешный сценарий и намеренный SIGPIPE.

Sallyport

Sallyport выполняет API-вызовы и SSH-команды за вашего ИИ-агента. Ключи остаются в локальном хранилище на вашем Mac; вы подтверждаете каждый запуск, и каждое действие попадает в запечатанный журнал.

© 2026 Sallyport · Открытый код по лицензии Apache-2.0 · Oleg Sotnikov