# Зачем разделять SSH stdout и stderr?

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

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

## SSH уже различает эти потоки

SSH передает обычные данные канала и stderr разными сообщениями протокола. RFC 4254 называет их `SSH_MSG_CHANNEL_DATA` и `SSH_MSG_CHANNEL_EXTENDED_DATA`, а расширенному типу данных 1 назначает значение `SSH_EXTENDED_DATA_STDERR`. Когда клиентская библиотека отдает отдельные средства чтения, она раскрывает различие, которое протокол сохранил намеренно.

У этого различия есть смысл. Программы обычно пишут результаты для машинной обработки в stdout, а диагностические сообщения в stderr. Команда может вывести корректный JSON в stdout, напечатать предупреждение в stderr и вернуть ноль. Другая команда может вывести часть результата, объяснить сбой в stderr и вернуть ненулевой статус. Сами байты не говорят, какой случай произошел.

Слияние во время захвата уничтожает сведения, которые потом не восстановит ни один парсер. Префиксы вроде `[stderr]` помогают человеку, но меняют содержимое. Разделители строк еще хуже: фрагмент может заканчиваться без перевода строки, двоичные данные могут содержать любой байт, а добавленный разделитель способен превратить два корректных фрагмента в некорректный документ.

Считайте каждый поток байтами, пока потребитель не выберет правила декодирования. UTF-8 встречается часто, но SSH его не гарантирует. Даже текстовые на вид инструменты могут выдать некорректную последовательность из-за другой локали, имени файла с произвольными байтами или записи, оборванной в середине многобайтового символа. Храните исходные байты или представление без потерь, а декодированный текст показывайте как отдельное представление.

Этот факт протокола меняет требования к модели. Если тип результата содержит только `output: string`, он неверно описывает то, что доставил SSH. Удобное форматирование должно идти после захвата, чтобы его можно было заменить без переписывания аудиторской записи.

## Идентификатор потока не означает серьезность

Stderr означает файловый дескриптор 2, а не сбой. Если считать ошибкой каждый байт stderr, агенты начнут повторять успешные команды, выбрасывать пригодный stdout и запрашивать подтверждение после безобидных предупреждений.

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

Разделяйте как минимум четыре понятия:

- `stdout` и `stderr` показывают, откуда пришли байты.
- `exit_status` или `exit_signal` описывает, как завершилась удаленная программа.
- `transport_error` сообщает, закончилась ли сама SSH-операция.
- `timed_out` и `cancelled` описывают локальное вмешательство.

Так вы избежите распространенной ошибки, когда парсер превращает `stderr != empty` в `success = false`. Обычно успех означает, что команда запустилась, канал завершился, а удаленный процесс вернул ноль. Приложение может установить более строгое правило для конкретной команды, но ему место в адаптере этой команды, а не в общем SSH-исполнителе.

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

Не складывайте удаленную диагностику, ошибки соединения, тайм-ауты и ошибки парсера в одно поле `error`. Для этих ситуаций нужны разные решения о повторе. Ошибку DNS иногда стоит повторить. Статус 2 из-за неверного вызова обычно повторять незачем. При некорректном JSON в stdout сохраните исходные байты, чтобы разработчик мог определить, ошиблась команда или парсер.

## Контракт результата сначала хранит факты

Надежный объект результата содержит исходные данные и явно отмечает неизвестные состояния. Он не заставляет каждого клиента восстанавливать факты из отформатированной расшифровки.

Этот контракт намеренно прост:

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

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

Поле `termination.kind` должно как минимум поддерживать `exit`, `signal`, `timeout`, `cancelled`, `transport_error` и `unknown`. Используйте поля со значением null вместо условных чисел. Отсутствующий SSH-статус не равен нулю, а локальный тайм-аут не равен статусу 124, если только shell или утилита timeout на удаленном хосте действительно не вернула 124.

Отмечайте обрезку отдельно для каждого потока. Общий флаг `truncated` не скажет парсеру, сохранился ли полный JSON в stdout или потерян только конец подробного stderr. Записывайте число захваченных и отброшенных байтов, когда оно известно. Если вы храните только начало и конец, моделируйте их как отдельные сегменты, а не склеивайте так, будто середины не существовало.

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

Добавьте версию контракта до того, как от него начнут зависеть клиенты. Новые поля обычно не ломают совместимость, но изменение смысла `events.seq` с порядка прибытия на порядок отображения нарушает семантику, даже если форма JSON не изменилась.

## У межпоточного порядка есть жесткий предел

Можно сохранить порядок, в котором SSH-стек увидел сообщения канала, но обычно нельзя доказать порядок записей удаленной программы между stdout и stderr. Это ограничение должно быть видно и в модели данных, и в тексте интерфейса.

Внутри одного потока байты сохраняют порядок. Между двумя потоками работают буферы на нескольких уровнях: среда языка на удаленной стороне, libc, каналы, SSH-сервер, транспортные пакеты, клиентская библиотека и ваши задачи чтения. Stdout без терминала может буферизоваться блоками, а stderr сбрасываться раньше. Поэтому более поздняя запись в stderr может стать видимой раньше предыдущей записи в stdout.

RFC 4254 сохраняет последовательность сообщений канала, которую отправляет реализация SSH. Это полезно: callback библиотеки, который раскрывает такие сообщения, может присвоить точную последовательность приема. Как только библиотека делит данные между независимыми читателями stdout и stderr, две goroutine или два асинхронных callback начинают конкурировать за уведомление о готовности. Порядок, в котором планировщик их запускает, отражает локальную доставку, а не порядок строк удаленного исходного кода.

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

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

Терминал часто показывает порядок, похожий на порядок в исходном коде. Если перенаправить оба дескриптора в файл через `>all.log 2>&1`, shell направит их в одно место и создаст для процесса единый путь записи под управлением ядра. При захвате через отдельные каналы наблюдатель может получить фрагменты в другом порядке. Добавьте среду языка с буферизацией, и расхождение увеличится.

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

В журнале пишите «наблюдаемый порядок», а не «порядок выполнения». Это не юридическая перестраховка. Такая формулировка не дает расследующему принять работу планировщика за причинно-следственную связь.

## Границы фрагментов создает транспорт

Один callback чтения не равен строке, записи или одному удаленному вызову `write`. Парсеры с таким предположением работают в тестах и ломаются под нагрузкой.

Одна запись может прийти несколькими фрагментами. Несколько записей могут прийти одним фрагментом. Граница может пройти через кодовую точку UTF-8, управляющую последовательность ANSI или токен JSON. При следующем запуске та же команда способна разделить неизменный вывод иначе.

Стройте слой захвата вокруг добавления байтов. Для каждого потока добавляйте фрагмент в буфер или временный файл и записывайте получившиеся offset и length. Если библиотека последовательно отдает сообщения, назначайте `seq` там. Если она дает независимые средства чтения, отправляйте уведомления о фрагментах одному сборщику и укажите, что последовательность отражает порядок их приема сборщиком.

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

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

Для двоичного вывода тоже нужен явный путь. Base64 внутри JSON прост и переносим, хотя увеличивает размер. Для больших результатов подойдет ссылка на blob, если аудиторская система гарантирует срок хранения и целостность. Не декодируйте с символами замены и не выбрасывайте оригинал. Иначе станет непонятно, где возникло повреждение: в удаленном инструменте, транспортном адаптере или просмотрщике.

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

## Псевдотерминал меняет структуру на поведение

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

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

Параметры клиента OpenSSH отражают различие: `-T` запрещает выделение псевдотерминала, `-t` запрашивает его, а повторный `-t` может назначить его принудительно. Автоматизация по умолчанию должна работать без PTY. Запрашивайте его только тогда, когда удаленной программе нужна семантика терминала, а контракт результата явно сообщает, что потоки разделить невозможно.

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

Это различие объясняет целый класс упорных ошибок. Разработчик проверяет команду в shell и видит аккуратный цветной прогресс в разумном порядке. Агент запускает тот же текст без PTY, stdout начинает буферизоваться блоками, stderr появляется первым, а машинный вывод без управляющих кодов приходит к парсеру позже. Затем кто-то принудительно включает PTY, чтобы расшифровка была похожа на ручной тест, и разбор JSON ломается из-за кодов цвета или запросов ввода в потоке.

Считайте интерактивный и структурированный запуск разными режимами API. Структурированный режим должен обещать отдельные потоки и стабильный захват без эмуляции терминала. Интерактивный режим должен возвращать терминальную расшифровку, размеры терминала и явный признак того, что исходная принадлежность stdout и stderr потеряна. Скрытого `pty: true` в параметрах запроса недостаточно, если ответ выглядит как структурированный результат.

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

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

## Статус завершения входит в результат

RFC 4254 определяет запрос канала `exit-status` и отдельную форму `exit-signal`. Стандарт рекомендует возвращать статус, но также разрешает клиенту его игнорировать. Поэтому API должен явно представлять неизвестный результат, а не считать отсутствие статуса успехом.

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

Завершение по сигналу не равно отрицательному статусу. Отдельно храните имя сигнала, признак core dump, если он пришел, и пояснение удаленной стороны. Потребитель может для отображения вычислить число в стиле shell, например 128 плюс значение сигнала. В аудиторской записи должны оставаться факты SSH.

Различайте в коде и интерфейсе такие исходы:

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

Четвертый случай нельзя выдавать за удаленный статус 255 лишь потому, что консольный клиент OpenSSH часто использует 255 для собственных ошибок. Транспортная ошибка библиотеки имеет отдельный тип. Если вы запускаете исполняемый файл `ssh` как подпроцесс, обертка может знать только 255, поэтому сохраните ее локальный stderr и честно обозначьте эту границу.

Завершение также означает, что весь вывод прочитан. Документация Go `os/exec` предупреждает: нельзя вызывать `Wait`, пока чтение из `StdoutPipe` или `StderrPipe` не закончено. Node.js проводит похожую границу: событие `exit` может произойти при открытом stdio, а `close` приходит после закрытия потоков. Эти руководства описывают локальные подпроцессы, но вывод напрямую относится к SSH-помощнику. Публикуйте окончательный результат только тогда, когда известно завершение, а оба читателя вывода достигли конечного состояния.

Для тайм-аута и отмены нужны отдельные поля. Если система знает инициатора отмены, запишите его, а также факт запроса сигнала и реального закрытия канала. Не ставьте `timed_out: true`, отбрасывая более позднее сообщение об удаленном завершении: в расследовании могут понадобиться оба события.

## Парсер читает stdout и сохраняет остальное

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

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

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

В структурированном пути агента избегайте удобных API с именем `CombinedOutput`. Руководство Go точно описывает этот метод: он возвращает объединенные стандартный вывод и стандартную ошибку. Для разовой диагностической команды это удобно, но для повторно используемого контракта неверно, потому что потерянные метки потом не вывести.

Текстовым командам тоже нужны собственные правила. Один парсер может считать stdout записями с разделением переводами строк, а stderr показывать как обычный диагностический текст. Другой может принять нулевой статус и пустой stdout как корректный пустой результат. Держите правила рядом с определением команды и тестами, а не встраивайте их в транспорт.

Собирайте запрос к модели из структурированных полей. Сообщите `exit status: 2`, поместите stdout и stderr в отдельные подписанные блоки и отметьте обрезку. Не добавляйте недоверенный удаленный вывод к инструкциям без границ. Вывод может содержать текст, похожий на запрос, поэтому считайте его данными и экранируйте для выбранного контейнерного формата.

Агент не должен определять успех по пояснительному тексту. Дайте ему машинные поля вроде `termination.kind` и `exit_status`, а текст пусть объясняет. Так потребуется меньше токенов, а предупреждение со словом `error` не перекроет успешный статус.

## Аудиту нужны два честных представления

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

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

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

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

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

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

Sallyport проводит SSH-действия через свой помощник `sp-ssh` без состояния и записывает отдельные вызовы в журнал Activity, поэтому разделение нужно выполнять на границе результата помощника, до форматирования вызова агентом или аудиторским интерфейсом. Пользу приносит не хитрая расшифровка, а сохранение достаточных данных, чтобы агенты и люди могли сделать собственные выводы.

## Тестируйте формы сбоев, а не удачный запуск

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

Начните с поддельного источника канала, который выдает контролируемые события уровня протокола. Передайте одну нагрузку stdout, разрезав ее во всех возможных точках. Повторите с многобайтовым образцом UTF-8, последовательностью ANSI и последней строкой без `\n`. Сохраненные байты должны совпадать при любом делении.

Чередуйте события stdout и stderr с известными номерами и проверяйте согласованность отдельных буферов, смещений диапазонов и объединенного представления. В реализации с независимыми читателями добавьте задержки планировщика и проверяйте только порядок байтов внутри каждого потока и порядок наблюдения сборщика. Тест, который требует порядка исходного кода производителя, проверяет гарантию, которой у системы нет.

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

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

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

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

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