# 为什么要分开保留 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。某个命令可以在 stdout 输出有效 JSON，同时在 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 通常不值得。stdout 中的 JSON 无效时，必须保留原始字节，开发人员才能判断是命令错了，还是解析器错了。

## 结果契约应先保留事实，再做解释

耐用的结果对象会保存原始证据，并明确表示未知状态。它不应迫使每个调用方都从格式化转录中反向推断事实。

下面这份契约故意保持朴素：

```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`。使用可空字段，不要使用魔法数字。缺失的 SSH 退出状态并不等于零，本地超时也不等于退出码 124，除非远程主机上的 shell 或 timeout 工具确实产生了 124。

每个流都要单独记录截断状态。全局 `truncated` 标记无法告诉解析器，stdout 里是否仍有完整 JSON，还是只丢掉了详细 stderr 的尾部。知道时，应记录已捕获字节数和丢弃字节数。如果只保留前缀和后缀，就把它们建模为不同片段，不要把两段直接连接，假装中间内容从未存在。

时间戳有助于分析延迟和调查，但不要用墙上时钟排列数据块。时钟可能跳变，两个并发读取器在所选精度下也可能得到同一个时间戳。应在一个串行化点分配序号。如果运行时提供单调时间，则另外保存单调时长。

在客户端依赖契约之前先给它设版本。新增字段通常安全，但把 `events.seq` 的含义从到达顺序改成展示顺序，即使 JSON 形状不变，也属于语义破坏。

## 跨流顺序存在无法突破的限制

你可以保留 SSH 栈观察到通道消息的顺序，但通常无法证明远程程序跨 stdout 和 stderr 的写入顺序。数据模型和界面文字都应明确这项限制。

同一个流内部的字节保持有序。两个流之间则会经过多层缓冲：远程语言运行时、libc、管道、SSH 服务器、传输数据包、客户端库，以及你自己的读取任务。stdout 没有连接终端时可能采用块缓冲，而 stderr 可能更早刷新。因此，较晚的 stderr 写入有可能比更早的 stdout 写入先变得可见。

RFC 4254 保留 SSH 实现发送的通道消息序列。这很有用，库回调若能直接暴露这些消息，就可以分配忠实的接收序号。一旦库把数据拆成独立的 stdout 和 stderr 读取器，两个 goroutine 或异步回调便会竞争报告就绪状态。调度器运行它们的顺序只是对本地交付的观察，不是对远程源代码顺序的重建。

下面这个小命令说明，为什么测试不应要求唯一且固定的合并转录：

```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 客户端无法事后创造已经缺失的事实。

审计文字应写成“观察顺序”，不要写成“执行顺序”。这不是法律式的多余修饰。它可以阻止调查人员把调度时机误读为因果关系。

## 数据块边界只是传输产物

一次读取回调不等于一行记录，也不等于远程的一次 `write` 调用。依赖这种假设的解析器在测试里能工作，在高负载下会失败。

一次调用可能拆成多个数据块。多次写入也可能合并到同一个数据块。UTF-8 码点、ANSI 转义序列或 JSON 词元都可能横跨边界。同一命令下一次运行时，即使输出没有变化，也可能采用不同的分块方式。

捕获层应围绕字节追加操作构建。对每个流，把数据块追加到缓冲区或暂存文件，并记录得到的偏移和长度。如果库按顺序暴露消息，就在那里分配 `seq`。如果库提供独立读取器，则把数据块通知发送给一个收集器，并说明序列表示收集器的接收顺序。

拆行应属于派生视图。每个流都维护自己的增量解码器和未完成行缓冲。绝不能让 stdout 和 stderr 共用一个行缓冲，因为未终止的 stdout 片段接上一行 stderr 后，不应变成一行虚构文本。流关闭时，要暴露最后一行不完整内容，不能默默丢掉。

JSON 解析通常应等到 stdout 到达流末尾，并且命令终止状态已经确定。流式 JSON 协议是另一回事：它需要明确的分帧，例如按换行分隔的 JSON、长度前缀，或已记录的增量语法。根据数据块猜测记录边界不叫流式处理，只是一场竞态。

二进制输出也需要明确路径。在 JSON 中使用 base64 简单且通用，但会增大体积。大结果可以使用二进制对象引用，前提是审计系统保证保存期限和完整性。不要用替换字符解码后丢掉原文。替换会掩盖损坏究竟来自远程工具、传输适配器，还是查看器。

限制必须在读取过程中执行，不能等全部内容进入内存后再处理。即使某个流超过保留上限，也要继续排空两个流，否则远程进程可能因为管道已满而阻塞。保存允许的前缀、后缀或外部暂存内容，统计丢弃字节，并持续读取，直到关闭或取消。

## 伪终端用结构化结果换取终端行为

如果要解析命令的 stdout，就不要申请伪终端。PTY 适合人工会话，但它会改变程序环境，而且常常在 SSH 客户端有机会保留身份之前，就让 stdout 和 stderr 经过同一个终端设备。

程序会检查描述符是否连接终端。它们可能启用颜色、用回车符绘制进度、按终端宽度换行、请求输入，或从块缓冲切换为行缓冲。因此，带 PTY 捕获的字节可能和同一命令在无 PTY 时产生的字节不同。这是可观察的行为变化，不只是显示选项。

OpenSSH 客户端的选项体现了这种区别：`-T` 禁用伪终端分配，`-t` 请求伪终端，重复使用 `-t` 可以强制分配。自动化默认不应使用 PTY。只有远程程序确实需要终端语义，而且结果契约明确说明无法区分两个流时，才申请 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 特意使用了带限制的说法，因为命令约定属于传输层之上的规则。即便如此，状态仍是通用层面最主要的信号。在把它映射到宿主语言的进程约定之前，应保留协议提供的无符号值。

信号终止不是负数退出码。分别保存信号名称、协议提供时的核心转储标记，以及远程解释信息。如果消费者想显示类似 shell 的数字，比如 128 加信号值，可以自行派生。审计记录应保留 SSH 的原始事实。

代码和界面需要区分这些结果：

- 远程命令返回了状态。
- 远程端报告了信号终止。
- 通道关闭，但两种报告都没有收到。
- 客户端在确认命令启动之前失败。
- 连接在收到部分输出之后失败。

第四种情况不能仅仅因为 OpenSSH 命令行客户端常用 255 表示自己的错误，就伪装成远程退出 255。库的传输错误有自己的类型。如果把 `ssh` 可执行文件当作子进程调用，封装层也许只能知道 255，因此要保留它的本地 stderr，并如实标明边界。

完成还意味着所有输出都已排空。Go 的 `os/exec` 文档警告，在 `StdoutPipe` 或 `StderrPipe` 的读取结束前调用 `Wait` 是错误的。Node.js 也划出了相似边界：它的 `exit` 事件发生时，stdio 可能仍然打开，而 `close` 会在流关闭后发生。这些手册描述的是本地子进程，但设计经验同样适用于 SSH 辅助程序。只有在终止状态已知，而且两个输出读取器都进入最终状态后，才能发布完整结果。

超时和取消也应该拥有独立字段。如果系统知道发起方，就记录由谁发起取消、是否请求了信号，以及通道是否真正关闭。不要记录 `timed_out: true` 后就丢弃稍后到达的远程退出报告；调查时两件事都可能有意义。

## 解析器应读取 stdout，同时保留其他证据

特定命令的解析器应接收 stdout 字节、终止结果和内容元数据。它不应接收混合转录，再猜哪些行属于诊断。

假设代理运行一个承诺在 stdout 输出 JSON 的远程清单命令。适配器应先检查 SSH 操作是否得到已知终止结果，再应用该命令的状态规则，最后解码并解析 stdout。stderr 作为辅助证据保留。警告不会进入 JSON 解析器，解析失败也不会抹掉警告。

返回解析失败时，要把它和命令结果放在一起，不能用它替换命令结果。一条有用错误可以说明 stdout 的第 418 个字节无效，同时保留原始 stdout、stderr、退出状态和截断标记。有了这组证据，代理才能决定是修正调用、使用稳定区域设置重试，还是把准确材料交给人处理。

结构化代理路径应避免名为 `CombinedOutput` 的便利 API。Go 手册明确说明这个方法做什么：返回合并的标准输出和标准错误。它很适合一次性的诊断命令，却不适合可复用结果契约，因为丢失的标签之后无法推断。

文本命令也需要针对命令做选择。某个解析器可以把 stdout 视为按换行分隔的记录，把 stderr 作为普通诊断文字展示。另一个解析器也可以接受状态为零且 stdout 为空，把它视为有效空结果。把这些规则和命令定义放在一起并编写测试，不要埋进传输层。

构造提示时应使用结构化字段。告诉模型 `exit status: 2`，把 stdout 和 stderr 放进分别标记的区块，并说明内容何时被截断。不要在没有边界的情况下把不可信远程输出直接拼进指令。输出可能含有看起来像提示的文字，因此要把它当作数据，并根据使用的容器格式转义。

代理不应根据文字说明决定成功与否。给它 `termination.kind` 和 `exit_status` 这样的机器字段，再用文字解释。这样可以减少 token 使用，也能阻止包含单词 `error` 的警告覆盖成功状态。

## 审计界面需要两种诚实的呈现

审计记录和人类转录承担不同任务。记录保存字节和元数据，转录帮助人阅读。

实用的调用界面首先显示状态栏：命令、主机身份、开始和结束时间、终止类型、退出状态或信号、字节数和截断情况。下方提供独立 stdout 和 stderr 标签页，作为权威视图。合并标签页可以按观察序号交错事件范围，但每一行都要持续显示流标签。

不要只用颜色编码流身份。使用文字标签，并为每个原始流提供复制操作。复制合并视图时，要么包含明确标签，要么提醒用户它只是一个渲染结果，因为没有来源信息的粘贴内容会再次制造最初的问题。

长行、回车符和终端控制码需要谨慎渲染。默认转义控制字符。反复写入 `\r` 的进度条不应像真实终端一样覆盖旧审计内容。终端模拟只能作为可选的派生视图，同时保持原始表示可访问。

每个搜索结果都应包含流、字节偏移和事件序号。筛选 stderr 不能改变序号。若内容被截断，应在字节缺失的位置放置明显的空缺标记，并显示记录的数量。绝不能让前缀和后缀紧贴在一起，造成它们原本相邻的假象。

只有捕获层确认两个读取器都在最终化之前关闭，时间线才能把终止放在最后一个观察数据块之后。如果连接中断，要显示最后一个输出事件、传输故障，以及未知的远程结果。把这一切压成红色 `failed` 标记，会抹掉程序失败和证据丢失之间的区别。

Sallyport 通过无状态 `sp-ssh` 辅助程序转发 SSH 操作，并在 Activity 日志中记录每次调用，因此这种分离应发生在辅助程序的结果边界上，早于代理或审计界面对调用进行格式化。这里真正有用的产品行为并不是聪明的转录，而是保留足够证据，让代理和人都能自行得出结论。

## 测试失败形态，而不是一份顺利转录

解析器测试套件应独立改变分块、流时间、终止方式、编码和保留限制。对一个合并字符串做快照，主要测试的是格式化器。

先创建一个可完全控制协议级事件的伪通道源。把同一份 stdout 有效负载在每个可能位置拆分后输入。然后对多字节 UTF-8 样本、ANSI 序列和末尾没有 `\n` 的最后一行做同样测试。无论怎样拆分，保存的字节都必须完全相同。

用已知序号交错 stdout 和 stderr 事件，确认独立缓冲区、范围偏移和合并视图相互一致。对于使用独立读取器的实现，注入调度延迟，只断言每个流内部的字节顺序和收集器的观察顺序。坚持生产者源码顺序的测试，断言的是系统从未提供的保证。

覆盖普通测试夹具容易漏掉的终止组合：状态为零但有 stderr、非零但 stderr 为空、因信号终止且 stdout 不完整、通道关闭但无状态、两个流都产生数据后的传输故障、超时后延迟关闭，以及确认启动前取消。每种情况都应产生不同的结构化结果。

每次只给一个流设置很小的限制。验证 stdout 截断不会把 stderr 标为截断，丢弃字节数正确，读取器持续排空，而且最终状态仍能到达。然后同时填满两个流。这样能抓住一种经典死锁：代码先把 stdout 读完，之后才开始读取 stderr。

属性测试很适合验证字节不变量。生成任意字节切片和分块边界，让它们经过收集器，并要求拼接所有保留事件范围后，能够还原保留的流内容。事件调度和流内容要分别生成，避免测试把分块方式误当成含义。

最后测试每种导出格式。JSON 必须区分 null 和零。文本转录必须标出流。脱敏处理不能在没有记录映射的情况下改变已存偏移，也可以另建一个派生结果。审计格式能让棘手故障保持棘手而且可见，不把它们整理成整齐但虚假的故事，才值得信任。

保留原始流，标明观察顺序，并等待两个流排空且终止状态确定后再发布结果。混合字符串一旦进入代理消息或审计日志，丢失的区别便无法恢复，之后每一层都只能猜。
