为什么要分开保留 SSH stdout 和 stderr?
分开保留 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 无效时,必须保留原始字节,开发人员才能判断是命令错了,还是解析器错了。
结果契约应先保留事实,再做解释
耐用的结果对象会保存原始证据,并明确表示未知状态。它不应迫使每个调用方都从格式化转录中反向推断事实。
下面这份契约故意保持朴素:
{
"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 -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 和零。文本转录必须标出流。脱敏处理不能在没有记录映射的情况下改变已存偏移,也可以另建一个派生结果。审计格式能让棘手故障保持棘手而且可见,不把它们整理成整齐但虚假的故事,才值得信任。
保留原始流,标明观察顺序,并等待两个流排空且终止状态确定后再发布结果。混合字符串一旦进入代理消息或审计日志,丢失的区别便无法恢复,之后每一层都只能猜。
常见问题
SSH 命令只要有 stderr 就应该算失败吗?
不应该。Stderr 只表示写入文件描述符 2 的字节,并不定义命令结果。通用结果应依据 SSH 退出状态或信号,再由特定命令适配器决定某些诊断是否影响接受结果。
SSH 能保留 stdout 和 stderr 的精确顺序吗?
SSH 可以保留客户端观察到的通道消息顺序,但这不能证明远程程序的写入顺序。缓冲和独立读取器会改变字节可见的时机,因此合并转录应标为观察顺序。
stderr 非空时,把 stdout 当作 JSON 解析安全吗?
如果命令契约规定 stdout 包含 JSON,而且终止结果满足该契约,就安全。只解析 stdout,把 stderr 保留为诊断信息,绝不要把混合转录交给 JSON 解析器。
SSH 命令没有返回退出状态时该怎么办?
把结果表示为未知,不要当成零。保留两个流和所有传输错误,因为通道关闭但没有状态,既不能证明成功,也不能证明命令失败。
代理应该收到原始字节还是解码文本?
持久结果应保留字节或无损编码。也可以提供解码文本作为便利视图,但必须记录解码错误,不能在没有保存原文时替换无效字节。
为什么不为每条 SSH 命令都使用伪终端?
伪终端会改变缓冲行为,而且经常破坏结构化解析器需要的 stdout 和 stderr 清晰区别。真正的交互命令可以申请,期望机器可读输出的自动化命令不要申请。
应如何截断很大的 SSH 输出?
分别限制 stdout 和 stderr,记录保留和丢弃字节数,并继续排空两个流。所有缺失区段都要明显显示,不能让前缀和后缀看起来原本相邻。
SSH 结果什么时候才算完整?
终止状态已经确定或明确标为未知,而且两个流读取器都结束时,结果才完整。只有进程退出还不够,因为缓冲的 stdout 或 stderr 可能仍在到达。
合并 SSH 转录应怎样标记数据块?
每个渲染范围都要带可见的 stdout 或 stderr 标签和观察序号。独立流视图应是权威来源,复制合并文字时也要包含标签,让来源信息在粘贴后仍然存在。
测试 stdout 和 stderr 处理的最佳方法是什么?
生成任意字节,改变数据块边界和调度,再断言每个流都能精确重建。还要加入信号、状态缺失、部分输出、超时、传输失败,以及独立截断限制等情况。