Читать 6 мин

Частичные сбои SSH-команд: не давайте агентам повторять работу

Частичные сбои SSH-команд требуют сопоставления состояния, а не слепых повторов. Сохраняйте коды выхода, контрольные точки, квитанции и наблюдаемое состояние хоста для AI-агентов.

Частичные сбои SSH-команд: не давайте агентам повторять работу

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

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

После сбоя SSH остаются три разных неизвестных

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

Представим удалённый скрипт развёртывания, который выполняет операции по порядку:

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

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

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

Это меняет следующее действие агента:

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

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

Код выхода описывает процесс, а не транзакцию

Код выхода SSH даёт полезные сведения, но не является записью о фиксации в базе данных. В руководстве OpenSSH сказано, что ssh завершает работу с кодом удалённой команды или с кодом 255, если произошла ошибка. Это важная граница: удалённый код описывает команду в момент, когда SSH его получил, а 255 относится к собственному пути ошибки SSH.

Даже нулевой код требует толкования. В POSIX shell статус простого последовательного списка обычно берётся у последней команды. Этот скрипт может сообщить об успехе после существенной ошибки:

install -m 0644 app.conf /etc/myapp/app.conf
systemctl restart myapp
logger -t deploy "deployment finished"

Если install завершится с ошибкой, а systemctl restart и logger вернут ноль, итоговый статус скрипта будет нулевым. Агент увидит успех и ошибочно заявит, что конфигурация изменилась. Ту же ложную уверенность создаёт финальный echo done.

Конвейеры добавляют ещё один путь к потере ошибки. В Bash статус конвейера по умолчанию равен статусу последней команды, если не включён pipefail. Используйте явный интерпретатор и заранее объявляйте нужное поведение:

#!/usr/bin/env bash
set -Eeuo pipefail

curl --fail --silent --show-error "$archive_url" | tar -xz -C "$release_dir"

-e просит Bash остановиться при многих необработанных ошибках, -u отклоняет неустановленные переменные, а pipefail сохраняет ошибку ранних элементов конвейера. Опция E позволяет ловушке ERR работать внутри функций и подстановок команд. Эти настройки улучшают отчётность об ошибках, но не делают последовательность атомарной.

Это важно. set -e срабатывает после того, как операция вернула ошибку. Он не может удалить каталог, созданный предыдущей командой, или вернуть службу в прежнее состояние после перезапуска. У него также есть исключения: команды, проверяемые через if, команды слева от && или || и некоторые составные конструкции не всегда приводят к выходу. Для действий, от которых зависит восстановление, пишите явные проверки.

Определите границу операции до написания команды

Агент не сможет восстановить расплывчатую инструкцию вроде «разверни службу». У удалённой команды должна быть именованная операция с постусловием, которое можно проверить наблюдением.

Для смены релиза операция может звучать так: «Установить /srv/myapp/current на релиз 2025-04-18.3, затем подтвердить, что активная служба сообщает об этом релизе». Для изменения базы данных: «Применить миграцию add_invoice_index ровно один раз и подтвердить наличие записи о ней». Текст команды здесь вторичен. Возможность восстановления определяют операция и её постусловие.

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

Не следуйте популярному, но ошибочному совету сделать каждую удалённую команду «идемпотентной», а затем бесконечно повторять её. Идемпотентность относится к конкретной операции и конкретному желаемому состоянию. mkdir -p /srv/app можно повторять. useradd deploy безопасен для повтора только после проверки UID, группы, домашнего каталога и оболочки существующей учётной записи. ALTER TABLE может завершиться ошибкой при втором запуске, а небрежно написанная миграция способна применить связанное изменение дважды.

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

До выдачи доступа определите для каждой операции четыре поля:

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

Идентификатор операции не формальность. Если контроллер создаёт новый идентификатор при каждой попытке, цель не отличит продолжение от нового запроса. Так появляются повторные миграции и дублирующее создание ресурсов.

Записывайте квитанцию до и после каждого изменения состояния

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

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

#!/usr/bin/env bash
set -Eeuo pipefail

operation_id=${1:?operation ID required}
release=${2:?release path required}
service=${3:?service name required}
state_dir=/var/lib/agent-ops
receipt="$state_dir/$operation_id.receipt"
tmp="$receipt.$$"

mkdir -p "$state_dir"
chmod 0700 "$state_dir"

write_receipt() {
  cat >"$tmp" <<EOF
operation_id=$operation_id
release=$release
service=$service
checkpoint=$1
updated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
  chmod 0600 "$tmp"
  mv -f "$tmp" "$receipt"
}

fail() {
  status=$?
  write_receipt "failed:$status"
  exit "$status"
}
trap fail ERR

if [[ -f "$receipt" ]]; then
  . "$receipt"
  case "$checkpoint" in
    complete)
      printf 'operation already complete: %s\n' "$operation_id"
      exit 0
      ;;
    switched|restarted)
      printf 'operation requires reconciliation: %s\n' "$checkpoint" >&2
      exit 75
      ;;
  esac
fi

[[ -d "$release" ]]
write_receipt "release_verified"

ln -sfn "$release" /srv/myapp/current
write_receipt "switched"

systemctl restart "$service"
write_receipt "restarted"

active_target=$(readlink -f /srv/myapp/current)
[[ "$active_target" == "$release" ]]
systemctl is-active --quiet "$service"
write_receipt "complete"
printf 'operation complete: %s\n' "$operation_id"

Временный файл и mv важны. В одной файловой системе переименование заменяет квитанцию одной операцией, поэтому читатель получает либо прежнюю полную квитанцию, либо новую, а не половину файла. Каталог состояния должен быть доступен для записи только владельцу операции. Если ненадёжный пользователь может менять квитанции, логика восстановления примет выдумку за доказательство.

Ловушка ERR записывает код выхода, когда Bash обрабатывает ошибку. Она не сработает при отключении питания, необрабатываемом сигнале или резком завершении машины. Поэтому прогресс нужно сохранять после каждого завершённого изменения, а не полагаться только на финальную ловушку.

Не подключайте произвольные форматы квитанций через source, как в этом небольшом примере, если каталог не защищён владельцем и правами. В рабочей системе лучше использовать JSON, разобранный известным парсером, или фиксированный построчный формат, который отклоняет неожиданные поля. Здесь подключается только файл, созданный самим скриптом в защищённом каталоге, чтобы код оставался понятным.

Квитанция должна содержать наблюдаемые факты, а не оптимистичное намерение. checkpoint=switched означает, что команда переключения ссылки завершилась успешно. Это не значит, что служба загрузила новый релиз. Статус complete записывается только после явной проверки постусловий. Такое различие не даёт агенту считать написанную команду завершённой операцией.

Пусть агент запрашивает сопоставление состояния, а не новую команду

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

После ненулевого результата агент должен сохранить исходный идентификатор операции и сначала выполнить проверки только для чтения. Не следует заново генерировать команду развёртывания, слегка меняя формулировки. Новые слова не создают нового перехода состояния.

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

operation_id='release-7f3b'
cat "/var/lib/agent-ops/$operation_id.receipt"
printf 'current='
readlink -f /srv/myapp/current
systemctl is-active myapp
systemctl show myapp --property=ActiveState --property=SubState --no-pager

Вывод должен иметь форму, которую агент может разобрать без попытки считать прозу доказательством:

operation_id=release-7f3b
release=/srv/myapp/releases/2025-04-18.3
service=myapp
checkpoint=restarted
updated_at=2025-04-18T14:05:12Z
current=/srv/myapp/releases/2025-04-18.3
active
ActiveState=active
SubState=running

Здесь ссылка указывает на нужный релиз, а служба активна, хотя квитанция остановилась на restarted. Удалённый процесс мог завершиться после перезапуска службы, но до записи complete. Процедура восстановления может снова выполнить проверки постусловия и, если они успешны, записать квитанцию завершения через узко ограниченную команду восстановления. Начинать развёртывание заново не нужно.

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

{
  "operation_id": "release-7f3b",
  "action": "deploy_release",
  "target": "app-01",
  "arguments": {
    "release": "/srv/myapp/releases/2025-04-18.3",
    "service": "myapp"
  },
  "mode": "reconcile"
}

Цель должна принимать mode: reconcile только для проверки без записи или для заранее подготовленного пути завершения, который проверяет постусловие. Не разрешайте агенту отправлять произвольную shell-строку с пометкой reconcile. Такая метка ничего не значит с точки зрения безопасности, если команда может менять состояние.

Код 75 в примере специально обозначает временный сбой. Точное число менее важно, чем задокументированный контракт: агент должен наблюдать состояние, а не отправлять автоматический повтор. Разделяйте результаты контроллера на completed, reconcile_required, rejected_before_start и transport_unknown. Одно булево поле success уничтожает сведения, необходимые для восстановления.

Тайм-ауты и разрывы требуют доказательства удалённого состояния

Тайм-аут является локальным наблюдением. Локальный клиент перестал ждать, но это не значит, что удалённая команда остановилась. Считать тайм-аут отменой один из самых быстрых способов дважды выполнить удалённое действие.

Уменьшить неопределённость помогает режим single-flight. До изменения удалённый скрипт создаёт эксклюзивную блокировку, связанную с идентификатором операции. Следующая попытка видит блокировку и выбирает: ждать, проверить процесс или сообщить, что восстановление требует решения человека.

Для простой блокировки на уровне хоста часто достаточно flock:

exec 9>/var/lib/agent-ops/deploy.lock
if ! flock -n 9; then
  printf 'another deployment operation is active\n' >&2
  exit 75
fi

Это защищает только процессы, которые используют ту же блокировку. Она не защищает от отдельной процедуры администратора и не исправляет все ошибки проектирования скриптов. Для миграции базы используйте доступную блокировку самой базы. Для API-действия используйте поддерживаемый API токен идемпотентности. Блокировка должна находиться рядом с состоянием, которое защищает.

После восстановления соединения проверяйте всё в таком порядке:

  1. Прочитайте квитанцию исходной операции.
  2. Проверьте, работает ли исходный процесс, если для операции есть надёжный маркер.
  3. Проверьте постусловие операции командами только для чтения.
  4. Выберите явное действие восстановления или передайте решение человеку, если наблюдения расходятся.

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

С мультиплексированием SSH нужна такая же осторожность. Главное соединение может скрыть сбой отдельной команды за общим транспортом, а контроллер может принять закрытый канал за сбой операции. Сохраняйте stdout, stderr, исходный код выхода SSH, время начала и время окончания удалённой команды как одну запись действия. Не теряйте stderr, даже если агент пересказывает его. В нём часто видно, отклонил ли Bash неустановленную переменную, вернула ли удалённая команда 75 или сам SSH завершился с 255.

Для некоторых изменений нужны компенсации, а не повторы

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

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

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

Не записывайте секреты в квитанцию, стандартный вывод или контекст агента. Храните только несекретный идентификатор или отпечаток, если сам идентификатор не даёт доступа. Для восстановления нужно знать, какой объект существует, а не его закрытое значение.

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

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

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

У shell-скрипта должен быть контракт, который агент может проверить

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

Используйте стабильные категории выхода, один объект результата и явные идентификаторы операций. Например, выводите финальную строку JSON только после того, как команда определила результат:

{"operation_id":"release-7f3b","outcome":"reconcile_required","checkpoint":"switched","exit_code":75}

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

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

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

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

Авторизация и аудит должны сохранять историю восстановления

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

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

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

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

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

Хорошая система восстановления делает осторожное действие дешёвым. Дайте агенту команду сопоставления только для чтения, долговечный идентификатор операции и определённый результат эскалации. Тогда разрыв соединения оставит проверяемую запись, а не вторую попытку, снова меняющую систему.

Начните с команды, которую люди уже повторяют

Найдите SSH-команду, которую ваша команда запускает снова после тайм-аута или красного сообщения о развёртывании. Добавьте идентификатор операции, защищённую квитанцию и одну проверку постусловия только для чтения, прежде чем менять что-либо ещё.

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

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

Что на самом деле сообщает AI-агенту код завершения SSH?

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

Когда неудачную SSH-команду безопасно повторять автоматически?

Автоматически повторять команду можно только тогда, когда каждая операция до места сбоя идемпотентна, а её постусловие можно проверить. Обновление метаданных пакетов и перезапуск именованной службы иногда подходят для повтора. Создание учётных записей, изменение правил файрвола и миграция данных обычно требуют сначала сопоставить состояние.

Предотвращает ли set -e частичные сбои SSH-команд?

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

Зачем удалённым скриптам нужен pipefail?

В Bash используйте set -o pipefail, если ошибка любого элемента конвейера должна завершать скрипт с ошибкой. Без него curl | tar может вернуть статус tar, даже когда curl завершился с ошибкой. Агент получит опасно неполную картину выполнения.

Что должно быть в квитанции SSH-команды?

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

Как не дать повтору агента выполнить одно изменение дважды?

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

Что должен делать агент после тайм-аута SSH?

Тайм-аут означает, что контроллер перестал ждать, но не доказывает остановку удалённого процесса. Перед повтором проверьте на целевом хосте долговечную квитанцию, состояние процесса, файл блокировки, состояние службы или запись транзакции.

Что означает код завершения SSH 255?

OpenSSH использует код завершения удалённой команды, если получает его, и обычно возвращает 255 при собственных ошибках. Агент должен сохранить исходный код и stderr, а затем отличить сбой удалённого скрипта от сбоя транспорта, прежде чем выбирать способ восстановления.

Как агенту понять, произошло ли удалённое изменение?

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

Нужны ли AI-агентам отдельные журналы сессий и команд SSH?

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

Sallyport

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

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