# SSH TTY 分配何时会改变命令的行为？

在 SSH 命令中加入 `-t`，作用远不只是让提示出现。它会用终端替换远端三个彼此独立的数据流，启用终端线路规程，给会话分配控制终端，并促使程序按照有人正在观看的方式运行。这样也许能修好一次交互式 `sudo` 执行，却会在不易察觉的情况下破坏解析器、管道或取消操作。

对于无人值守的工作，默认不应分配 PTY。只有远端程序确实需要终端语义时才申请 PTY，并把这项选择视为命令接口的一部分。我见过太多部署脚本在紧急处理时加上 `-tt`，此后多年都没有移除，直到再也没人记得它最初掩盖了什么故障。

## PTY 会改变进程环境

SSH TTY 分配改变的是远端进程收到的文件描述符，不只是输出的外观。没有 PTY 时，OpenSSH 通过管道或套接字对连接标准输入、标准输出和标准错误。使用 PTY 时，OpenSSH 会把 PTY 设为控制终端，并把同一个终端描述符复制到三个标准数据流。

这个实现细节会产生直接可见的后果。程序可以调用 `isatty()`，然后选择交互式执行路径。终端驱动可以处理特殊输入字符、回显输入、转换换行符，并跟踪前台进程组。标准错误不再有独立的远端通道，因为两个输出描述符都指向同一个终端。

RFC 4254 在协议层把这些概念分开。会话可以先请求 `pty-req`，再请求 shell 或 `exec` 命令。PTY 请求包含终端类型、尺寸和编码后的终端模式。这个请求不会把 `exec` 请求变成登录 shell。

OpenSSH 提供了三种实用的选择方式：

- `ssh -T host command` 明确禁止分配 PTY。
- `ssh -t host command` 在本地客户端拥有终端时请求 PTY。
- `ssh -tt host command` 即使本地输入来自管道或其他非终端来源，也会强制请求 PTY。

两个 `-t` 并不会获得一个功能更强的远端终端。它只是绕过本地的安全检查。这一点在构建运行器、代理进程或 `printf ... | ssh` 中非常重要，因为单个 `-t` 可能打印“Pseudo-terminal will not be allocated because stdin is not a terminal”，然后在没有预期终端语义的情况下继续执行。

不使用 PTY 还能保留透明的字节通道。OpenSSH 的 `ssh(1)` 手册称这种模式适合可靠传输二进制数据。PTY 是受处理规则约束的字符设备，因此不适合传输归档、数据库转储或必须保持字节完全一致的机器可读输出。

同一个选择也可能隐藏在配置里。OpenSSH 的客户端设置 `RequestTTY` 接受 `no`、`yes`、`force` 或 `auto`，对应人们通常通过 `-T`、`-t` 和默认行为获得的几种模式。如果命令行没有请求终端却出现了终端，请运行 `ssh -G host.example` 检查最终生效的客户端配置。主机配置块和被包含的文件，可能让两条看起来相同的命令进入不同的会话。

服务器也有决定权。`PermitTTY` 可以拒绝分配，authorized key 条目也可以带有 `no-pty` 限制。强制命令只有在客户端请求且服务器允许时才会在 PTY 上运行。应把拒绝视为真正的接口不匹配，而不是不断增加 `-t` 重试的理由。

## 先测量两种模式再做修改

一个小型探针比继续猜 SSH 选项更能说明问题。请针对生产环境使用的同一账户、主机配置和命令路径运行它：

```sh
probe='
for fd in 0 1 2; do
    if test -t "$fd"; then kind=tty; else kind=not-tty; fi
    printf "fd%s=%s\n" "$fd" "$kind"
done
printf "term=%s\n" "${TERM-unset}"
printf "stdout-marker\n"
printf "stderr-marker\n" >&2
exit 23
'

ssh -T host.example "$probe" >no-pty.out 2>no-pty.err
printf 'no-pty ssh status=%s\n' "$?"

ssh -tt host.example "$probe" >pty.out 2>pty.err
printf 'pty ssh status=%s\n' "$?"
```

非终端运行应当把三行 `fdN=not-tty`、`term=unset` 和 `stdout-marker` 写入 `no-pty.out`，并且只把 `stderr-marker` 写入 `no-pty.err`。实际的 `TERM` 结果可能不同，因为不同客户端和服务器接受环境设置的方式不同，所以应记录真实路径提供的值，不要依赖示例。

PTY 运行应当把三个描述符都报告为 `tty`。两个标记通常都会进入 `pty.out`，行尾往往是回车加换行。`pty.err` 只会包含本地 SSH 诊断信息，如果有的话；它无法把远端程序原本的标准错误恢复成独立数据流。两条 SSH 命令都应返回 23，因为单独分配 PTY 不会丢弃远端退出状态。

在修改作业前，把真实可执行程序的检查加入探针。加入 `command -V tool`、`pwd`、经过筛选的环境输出，以及程序不会泄露秘密的诊断选项。如果输出发生变化，应确认是哪个观测结果触发了分支：终端检测、`TERM`、终端宽度、合并的数据流、启动文件或提示。“加 `-t` 就能运行”只是一份症状报告。

还要通过同一个启动器运行探针。粘贴进 shell 的终端命令拥有本地 TTY，而代理或持续集成工作进程通常没有。因此，在键盘前用 `-t` 做的实验可能与自动执行不一致，直到你测试 `-tt`；强制分配 PTY 也可能让作业接触到它从未预期的输入。

输入和输出要分别测试。`ssh -n` 会把客户端的标准输入重定向到 `/dev/null`，`StdinNull` 设置也能通过配置实现相同行为。当远端命令绝不能读取调用方输入时，这很有用，但它不会自行禁止 PTY 分配。强制 PTY 即使配合空输入，仍会改变终端检测、数据流合并和挂断行为。

在循环中运行 SSH 时要格外小心。如果没有 `-n`，第一条远端命令可能读取原本留给循环的行，即便远端进程并不打算消费它们。使用 PTY 时，终端回显还可能把这些字节送回执行记录。先明确标准输入归谁所有，再解释两种运行方式之间的其他差异。

## sudo 提示需要明确的接口

`sudo` 需要一种认证方式，而 PTY 给了它读取密码的终端。sudo 手册说明，它通常从用户终端读取密码。如果没有终端，当操作需要密码时就会失败，除非存在 askpass 辅助程序，或调用者选择了其他输入方式。

这解释了常见的修复方法：

```sh
ssh -t host.example 'sudo systemctl restart example.service'
```

对于准备输入密码并观察命令的人，这种做法可以接受。对于无人值守执行，它是很差的修复。作业可能一直等待输入，提示可能污染预期输出，最终还可能有人把密码通过管道送进与命令数据相同的通道。

自动化应使用非交互 sudo：

```sh
ssh -T host.example \
  'sudo -n /usr/bin/systemctl restart example.service'
```

`-n` 选项要求 sudo 不要提示。如果缓存的凭据或 sudoers 策略不能在无交互情况下授权操作，sudo 就会以错误退出。这种失败很有用：调用者会得到确定的状态，而不是隐藏的提示。真正需要特权自动化时，应为精确的命令和参数配置范围狭窄的 sudoers 规则。允许无密码启动任意 shell，只是把可用性问题换成了更大的权限问题。

无人值守作业不应依赖缓存的 sudo 时间戳。缓存属于某个认证上下文，其终端和会话细节会随策略变化。一个作业如果只因管理员一分钟前使用过 sudo 而成功，却在安静的周末失败，它没有真正的授权设计，只是在碰运气。

`sudo -S` 从标准输入读取密码。它能让无 PTY 命令执行，但也会把秘密混入数据通道，并把密码处理责任交给调用者。代理和常规自动化应避免这种做法。askpass 辅助程序可能适合图形化的人工作流，但它仍属于交互认证，不能临时塞进一个号称无人值守的作业。

这里经常会混淆两个 PTY。SSH 可以在启动 sudo 前分配远端 PTY。另一方面，现代 sudo 可以为了 I/O 日志和隔离，在自己的 PTY 中运行已经授权的命令；sudo 1.9.14 默认启用 `use_pty` 设置。当 SSH 会话没有终端时，内部 PTY 不能提供外层密码提示。它只会在 sudo 已有足够信息运行命令之后出现。

某些 sudoers 配置还会把终端作为策略要求。较旧的企业配置常用 `requiretty`，当前系统也可能继续保留它。应确认实际生效的 sudoers 策略，不要假定每一条“terminal is required”消息都表示密码提示。

## PTY 不会选择 shell 启动文件

带 PTY 的 SSH 命令仍然由账户配置的 shell 通过 `-c` 执行。OpenSSH 的 `sshd(8)` 手册明确说明了请求命令的执行方式，服务器源码也把命令交给用户的 shell。PTY 分配只改变 shell 周围的描述符，不会加入 `-i`、`-l` 或登录选项。

当 `PATH`、语言版本管理器或别名在交互提示中存在，而自动化中不存在时，这一点很重要。加入 `-t` 似乎能修好路径，可能是因为启动文件包含终端测试，也可能是因为被调用工具检测了终端。它也可能完全不起作用。依赖这种偶然行为，会让作业受开发者为自己终端编辑的点文件影响。

Bash 有一个特殊规则，让这些传言更难梳理。它的手册说明，交互式登录 shell 读取 profile 文件，交互式非登录 shell 读取 `.bashrc`，非交互式 shell 使用 `BASH_ENV`。Bash 还会尝试识别自己是否由远程 shell 守护进程启动，即使不是交互模式也可能读取 `.bashrc`，但作为 `sh` 调用时除外。这是 Bash 的行为，不是 SSH 的承诺，也不能移植到所有账户 shell。

确实需要特定 shell 模式时，应直接选择：

```sh
# A predictable noninteractive Bash command with explicit inputs
ssh -T host.example \
  'PATH=/usr/local/bin:/usr/bin:/bin /bin/bash -c '\''command -v deploy && deploy'\'''

# A login environment, requested because the command truly depends on it
ssh -T host.example \
  '/bin/bash -lc '\''command -v deploy && deploy'\'''
```

第二种形式会有意读取登录启动文件，也会继承其中的风险：profile 打印的输出可能破坏协议，profile 也可能在没有部署审查的情况下改变行为。对于生产作业，使用绝对可执行路径和少量明确的环境变量通常更好。

别名也是一个陷阱。非交互式 Bash 不会展开别名，除非启用了 `expand_aliases`；shell 函数也只有在启动机制定义或导入后才存在。如果 `ssh host deploy` 只有加入 `-t` 才能工作，应确认 `deploy` 是真实的可执行文件。远端账户能否找到程序，不应由终端决定。

还要记住，SSH 发送的是命令字符串，不是最终可执行程序的参数数组。本地 shell 已经完成自己的引号处理后，远端账户 shell 会再次解析这个字符串。PTY 分配不会改变任何一次解析。当值中可能包含空格或 shell 元字符时，应发送带固定输入的脚本，使用定义了数据格式的远端辅助程序，或认真为每一层加引号。终端无法修复被本地 shell 过早展开的参数。

## 只有使用 PTY 时 Ctrl-C 才遵循终端规则

使用 PTY 时，Ctrl-C 通常是由远端终端驱动解释的输入字节。POSIX 把它称为 `INTR` 字符。当终端的 `ISIG` 标志启用时，驱动会丢弃该字节，并向终端前台进程组中的每个进程发送 `SIGINT`。这就是远端前台管道可以作为一个作业整体停止的原因。

本地 SSH 客户端把本地终端设成合适的模式，并通过通道发送按键。远端 PTY 负责规范输入、回显、特殊字符和窗口尺寸。全屏编辑器等程序可能修改这些模式，异常断开也可能让本地显示状态看起来不正常，直到重置本地终端。

没有 PTY 时，就没有远端终端驱动来解释 Ctrl-C，也没有终端前台组。RFC 4254 定义了独立的 SSH `signal` 通道请求，但也允许不实现信号的系统忽略它。因此，客户端、服务器、包装脚本和被调用程序组合起来的取消行为，可能与交互终端不同。

Ctrl-Z 和作业控制能更快暴露这种差异。在终端中，`SUSP` 字符可以向前台组发送 `SIGTSTP`，交互式 shell 随后可以恢复该作业。一条一次性的远端命令即使拥有 PTY，也没有实用的交互式作业控制会话。挂起它可能让 SSH 客户端一直等待已停止的远端进程组，却没有 shell 提示可供运行 `fg`。

不要假设操作员能在恰当时刻按下 Ctrl-C，就认为远端事务足够安全。应把取消语义写进远端命令。shell 包装脚本可以捕获信号并终止子进程组，服务管理器可以接管进程，远端超时可以限制执行时长。随后还要测试断开连接的情况，而不只是键盘操作。

```sh
ssh -tt host.example '
  trap '\''printf "wrapper got INT\n" >&2; exit 130'\'' INT
  printf "remote shell pid=%s\n" "$$"
  sleep 300
'
```

按下 Ctrl-C 应当触发 PTY 路径并让控制权返回。还要用真实程序重复测试，因为 shell、终端应用和进程管理器会安装不同的处理器。也要测试关闭网络连接。PTY 拆除可能对控制会话产生挂断行为，而保留输出管道的无 PTY 子进程可能让 SSH 通道继续保持打开。在任何模式下，仅仅把进程放到后台都不算可靠的脱离方案。

对于长时间任务，应把它提交给远端服务管理器或作业系统，并返回作业标识符。这样取消操作会有明确的远端目标，SSH 只需报告提交是否成功。短暂的客户端中断也不会决定一项迁移执行到一半后是否继续。

## 退出状态会保留，但包装脚本可能替换它

OpenSSH 在两种模式下都会返回远端命令的退出状态。客户端手册说明，`ssh` 以该状态退出，发生错误时则返回 255。RFC 4254 把 `exit-status` 通道消息描述为 32 位无符号值，并为信号终止定义了独立的 `exit-signal` 消息。

PTY 分配不会让成功状态更可信。shell 的组合方式决定哪个状态成为远端命令状态。下面这条命令报告的是 `printf` 的状态，因此可能隐藏部署失败：

```sh
ssh -tt host.example 'deploy; printf "finished\n"'
```

应明确保留执行结果：

```sh
ssh -T host.example '
  deploy
  rc=$?
  printf "deploy_status=%s\n" "$rc" >&2
  exit "$rc"
'
rc=$?
printf 'ssh_status=%s\n' "$rc"
exit "$rc"
```

本地管道还可能再次隐藏它。在没有管道状态选项的 shell 中，`ssh host command | tee log` 通常返回 `tee` 的状态。可以先把 SSH 输出捕获到文件，使用具有经过测试的管道状态功能的 shell，或读取管道中每条命令的状态。不要假定 `set -e` 能修复所有管道、条件或子 shell。

状态 255 有歧义，因为 OpenSSH 把它保留给客户端错误，而远端程序也可以退出 255。如果必须区分，应让远端包装脚本在受保护的通道上发出结构化完成记录，或把应用状态映射到约定的范围。认证失败、主机验证失败、传输丢失和远端真的返回 255，不应触发同一种重试。

连接建立失败发生在远端包装脚本能够输出任何内容之前。应使用客户端超时和足够的 SSH 诊断信息在本地分类这些失败，同时不要让诊断进入程序的数据文件。服务器一旦启动包装脚本，就应包含运行标识符和最终状态记录，让调用者能区分“从未启动”和“已启动但连接丢失”。这种区别可以防止盲目重试可能已经改变状态的命令。

信号终止也需要同样谨慎。shell 常用 128 加信号编号来表示信号，但 SSH 协议可以直接报告退出信号，客户端行为也不必在所有情况下都像本地 shell 的子进程状态。应定义包装脚本会发出的状态，而不是在故障后猜测所有大于 128 的值。

## PTY 会合并数据流并修改字节

请求 PTY 就意味着放弃远端标准输出和标准错误之间清晰的分离。OpenSSH 的服务器实现把 PTY 从端复制到描述符 0、1 和 2。协议虽然为标准错误提供扩展数据通道，但连接到同一终端的进程已经把两个输出流写进同一个字节流。

这会破坏一种常见的自动化写法：

```sh
ssh -T host.example command >result.json 2>diagnostic.log
```

没有 PTY 时，远端程序的标准错误可以进入 `diagnostic.log`，而 JSON 留在 `result.json`。使用 PTY 时，远端警告、密码提示、横幅、进度显示和 JSON 可能一起进入 `result.json`；本地 SSH 诊断仍可能出现在 `diagnostic.log`。服务器合并字节之后，客户端重定向无法再将它们拆开。

终端输出处理还可能把换行转换为回车加换行。输入可能被回显，规范模式可能等待整行，控制字符也可能触发终端功能。这些对终端来说都是正确行为，但对二进制协议来说就是数据损坏。

能够感知终端的程序经常加入颜色、进度条、分页器或提示。它们可能在终端上使用行缓冲，通过管道时使用块缓冲，让无 PTY 执行看起来卡住，即使程序仍在工作。尽量在应用层修复缓冲：选择其纯文本输出选项、无缓冲模式或日志选项。伪造终端会同时改变多个变量，也可能掩盖真正的缓冲问题。

如果命令产生机器数据，应保留 `-T`，并让程序输出非交互格式。如果它确实需要终端，就把全部输出当作终端记录。不要像解析稳定 API 那样解析 PTY 记录。

## 代理执行的 SSH 必须无需对话即可失败

自主调用者无法安全掌控终端对话。帮助人类恢复的提示，可能让代理等待、擅自猜测输入，或把只执行了一部分的操作误判为成功。在凭据、权限或远端状态介入之前，SSH 命令就应声明是否允许交互。

代理执行时，应在每一层同时禁用 PTY 和提示。SSH 主机认证与主机验证需要预先安排好策略。提权应使用 `sudo -n`。远端工具应收到非交互选项、明确超时和通过文件或命名参数传入的输入，而不是对话式提示。标准输出、标准错误和状态应分别捕获。

不要通过向代理暴露密码或私钥来解决密码提示缺失。认证材料与终端行为是两件不同的事。给进程分配 PTY 不会缩小被盗凭据的权限，不分配 PTY 也无法保护已经存在于环境中的凭据。

Sallyport 把凭据边界移出代理进程：支持 MCP 的代理通过内置的无状态 `sp-ssh` 辅助程序请求 SSH 操作，加密保险库提供 SSH 密钥，Activity 日志记录该调用。命令仍需要明确的 PTY 接口，因为秘密隔离无法判断远端程序是否需要终端语义。

批准操作也不同于远端提示。本地批准卡可以在执行前授权一项已经定义的操作；远端终端提示出现在已打开的会话里，而且可能在先前副作用发生后才出现。应把人工授权放在远端字节流之外，让拒绝的含义清楚，也让日志能够识别尝试过的操作。

当代理需要交互式维护工具时，应拆分工作流。让代理准备命令或请求，然后把实时终端会话交给人，或者改用为自动化设计的非交互操作。假装代理是一名打字员，只会产生两个系统中最难测试的组合。

## 把模式选择视为接口的一部分

正确模式取决于远端程序的接口，而不是“PTY 一定好”或“PTY 一定坏”的通用规则。审查时可以使用以下决策清单：

- 对 JSON、归档和精确字节使用 `-T`，因为它能保留透明数据和独立错误输出。
- 对无人值守的特权工作使用 `-T` 和 `sudo -n`，让认证失败而不是等待。
- 当人会输入 sudo 密码或操作终端界面时使用 `-t`。
- 只有在审查过“缺少本地 TTY”这一绕过行为后，才从管道或代理启动器使用 `-tt`。
- 长时间工作应提交给远端管理器，因为 PTY 不提供持久的进程所有权。

在命令旁记录这个选择。测试应断言描述符类型、数据流分离、取消和状态，而不只是几个预期输出词。如果依赖升级后开始要求终端，应让测试失败，调查新出现的提示或检测分支，再考虑加入 `-t`。

应把所选模式作为结构化元数据写入日志，不要根据彩色输出或提示来推断。当一次故障跨越多个主机时，这一个字段就能帮助操作员先把终端行为与认证、网络和应用故障分开，而不必立即重跑任何命令。

服务器策略可以通过 OpenSSH 配置或 authorized key 限制拒绝 PTY。这也是不要把 PTY 变成偶然依赖的原因。为自动化设计的命令应当在 `-T` 下仍可使用；交互式命令无法取得终端时，则应清楚地失败。

任何加入 `-tt` 的提议都应按接口变更处理。重新运行探针，重定向两个本地数据流，发送 Ctrl-C，强制远端命令返回非零状态，并在执行中途断开连接。如果无法准确描述结果，这条命令就还不能无人值守运行。
