# SSH 后台任务会在工具调用成功后继续运行

SSH 工具调用返回零，只能证明远程命令报告了成功。它不能证明该命令产生的每个进程都已停止、完成写入，或得到成功结果。如果命令在后台启动了工作，那么工具记录完成时，真正有意义的操作可能才刚刚开始。

我把这个差异看作审计边界，而不是无关紧要的 shell 细节。代理可以运行部署脚本，收到状态 0 后继续执行其他任务，而分离的迁移进程仍在修改数据。工具记录如实反映了 SSH 通道，却很容易让人对远程工作得出错误结论。解决办法是明确你要观察的生命周期，在与代理相同的 shell 和终端条件下测试，并从实际负责长期进程的系统获取完成证据。

## 零状态描述的是远程命令，而不是其后代进程

OpenSSH 返回远程命令提供的退出状态，如果 SSH 客户端本身出错，则返回 255。RFC 4254 的表述更加准确：另一端的命令终止时，服务器可以发送 `exit-status` 通道请求，然后关闭通道。这两份文档都没有说服务器会递归等待该命令的所有后代进程。

每当 shell 执行异步列表时，这个区别都很重要。POSIX 将以 `&` 结尾的命令定义为异步命令：shell 启动它，然后不等待就继续执行。如果 shell 没有其他事情可做，它可以成功退出，而异步子进程仍然存活。SSH 状态属于这个 shell。

人们随口所说的成功至少包含四种结果：

- SSH 连接和身份验证成功。
- 远程 shell 接受并启动了命令。
- 启动的工作负载以状态 0 完成。
- 预期效果已经持久化并且可以观察。

一个整数无法证明全部四项。清晰的审计记录必须说明它由哪个事件产生。我用 `ssh_command_exit_status` 表示通道结果，只把远程工作管理者报告的证据写入 `workload_result`。

远程可执行文件自行守护化时，同样需要这个警告。启动器可能在成功 fork 后返回 0，但其子进程几秒后可能在打开数据库、绑定端口或读取配置文件时失败。启动器履行了自己的契约，审计者却选择了错误的契约。

## 十二秒探针可以暴露这个差异

不需要守护进程、root 权限或特殊 shell 设置，就能复现这种误导性的成功。请针对一次性的 Unix 账户运行下面的测试。显式重定向很重要，因为它们让后台进程释放 SSH 通道，同时继续运行。

```sh
ssh testhost 'rm -f /tmp/ssh-bg.done /tmp/ssh-bg.log; (sleep 12; date -u +%FT%TZ > /tmp/ssh-bg.done) > /tmp/ssh-bg.log 2>&1 < /dev/null & printf "launcher_pid=%s\n" "$!"'
printf 'ssh_status=%s\n' "$?"
ssh testhost 'test -f /tmp/ssh-bg.done; printf "done_status=%s\n" "$?"'
sleep 13
ssh testhost 'cat /tmp/ssh-bg.done'
```

典型的即时结果如下：

```text
launcher_pid=41872
ssh_status=0
done_status=1
2026-07-24T10:14:05Z
```

PID 和时间戳会不同。这个矛盾正是测试重点：`ssh_status=0` 和 `done_status=1` 可以同时成立，因为它们回答的是两个问题。shell 成功启动了异步列表，但标记文件当时还不存在。

不要把这个示例直接用作生产编排。`/tmp` 中的标记文件可能重名、消失，也可能被具有相应权限的其他进程伪造。这个探针的作用是让时序可见。生产环境中的完成记录需要唯一执行标识符、受保护的存储、经过身份验证的写入者和明确定义的失败状态。

通过代理实际使用的路径重复运行探针。直接在终端输入命令、非交互 SSH exec 请求、带伪终端的 SSH 调用和工具网关，可能选择不同的启动文件、shell 和文件描述符配置。跳过这些细节的测试，验证的是另一个相近但不同的系统。

## 打开的文件描述符会让分离任务看似同步

后台执行和通道关闭是两套不同的机制。继承 SSH 通道标准输出或标准错误的子进程，可能在远程 shell 退出后仍让通道保持可读。本地 `ssh` 进程看起来像是在等待子进程，其实是管道尚未到达文件结尾，并不是 SSH 在监督子进程的结果。

比较下面两个调用并测量耗时：

```sh
time ssh testhost 'sleep 12 &'
time ssh testhost 'sleep 12 > /tmp/sleep.log 2>&1 < /dev/null &'
```

在常见的 OpenSSH 和 shell 组合中，第一个调用可能一直保持打开，直到 `sleep` 退出，第二个则会迅速返回。你必须验证这个现象，不能把它当成可移植的保证。shell 实现、服务器行为、伪终端分配以及子程序对描述符的处理都可能改变结果。

这种意外等待是很弱的证据。后台进程可以提前关闭描述符，然后继续工作。它可以 fork 一个关闭描述符的孙进程。它还可以通过套接字发送输出或直接写入存储。反过来，一个仅仅保持 stdout 打开的辅助进程，会在重要工作已经失败后继续让调用看似繁忙。

文件描述符仍值得检查，因为它们能解释许多不一致的测试。在 Linux 上，捕获远程 PID，并在 SSH 调用仍活动时检查描述符：

```sh
pid=$(cat /run/user/$(id -u)/agent-job.pid)
ps -o pid=,ppid=,pgid=,sid=,stat=,etime=,args= -p "$pid"
ls -l "/proc/$pid/fd/0" "/proc/$pid/fd/1" "/proc/$pid/fd/2"
```

记录父 PID、进程组、会话 ID、状态、运行时间、命令，以及描述符 0、1、2 的目标。如果系统没有 `/proc`，请使用操作系统自带的进程和描述符工具。不要把测试简化为 `pgrep name`：名称会冲突，包装器会改变名称，PID 也可能在进程退出后被重复使用。

## nohup 解决挂断问题，而不负责所有权

`nohup` 改变信号处理方式，让被调用命令忽略 SIGHUP。它不会把命令放到后台。GNU Coreutils 手册对此说得很直接，并要求用户添加 `&` 才能异步执行。复制部署片段时，人们经常漏掉这个限定条件。

它的重定向规则在 SSH 场景中也很容易让人意外。GNU `nohup` 只在标准输入是终端时重定向它，只在 stdout 是终端时把标准输出发送到 `nohup.out`，并且通常对标准错误采用同样的选择。非交互 SSH 命令通常使用管道而不是终端，因此 `nohup` 可能让这些描述符继续连接 SSH 通道。

所以，下面两个命令作出的承诺不同：

```sh
ssh testhost 'nohup /opt/jobs/rebuild-index &'
ssh testhost 'nohup /opt/jobs/rebuild-index > /var/log/rebuild-index.log 2>&1 < /dev/null &'
```

第二种写法显式断开标准描述符，但仍然不能说明 `rebuild-index` 是否完成。`nohup` 会报告命令不存在等调用失败；其他情况下，它的状态跟随所调用的命令。shell 把该调用放入后台以后，shell 通常只报告任务已启动，而不是任务最终的状态。

抵抗 SIGHUP 只是生存条件之一。进程仍可能因为登录管理器清理会话、服务管理器杀死会话控制组、内核执行内存不足策略、管理员撤销账户或主机重启而终止。进程也可能一直正常存活，却产生错误结果。`nohup` 不提供身份、重试策略、资源边界、持久状态或可信的完成记录。

对于很小、可丢弃的维护工作，如果结果丢失也可以接受，而且我正盯着主机，我仍会用 `nohup`。我不会用它把代理的 SSH 调用伪装成受管理的生产任务。这个建议流行，是因为片段很短，而且通常能在终端断开后继续运行。如果以后有人必须证明完成了什么，它就是错误的选择。

## 终端加入后，作业控制行为会改变

Shell 作业控制会把进程分组，让交互用户可以暂停、恢复、前台运行和后台运行管道。非交互 shell 通常不启用监控模式，SSH exec 请求默认也没有伪终端，除非客户端明确申请。依赖 `jobs`、`%1`、`disown` 或终端生成信号的脚本，由代理运行时可能表现不同。

POSIX 把作业标识符和已知后台 PID 限定在当前 shell 执行环境中。它的 `wait` 工具可以等待这些已知进程，但在另一个 shell 中启动的 `wait` 没有继承的作业表。下面这种审计方法会失败：

```sh
ssh testhost 'long_task & printf "%s\n" "$!"'
ssh testhost 'wait 41872; printf "wait_status=%s\n" "$?"'
```

第二次调用启动了新的 shell。即使 41872 仍是活进程，这个 shell 也不知道它是自己的子进程。POSIX 规定，传给 `wait` 的未知 PID 返回 127。权限和 PID 重用会让重建这种关系的尝试更加不可靠。

当契约要求同步完成时，应在同一个 shell 中启动并等待：

```sh
ssh testhost 'long_task > /tmp/long-task.log 2>&1 < /dev/null & pid=$!; printf "pid=%s\n" "$pid"; wait "$pid"; rc=$?; printf "workload_status=%s\n" "$rc"; exit "$rc"'
```

这种写法返回子进程状态，并让 SSH 操作保持打开。它适用于始终依附于该 shell 的子进程。如果 `long_task` fork 后原始进程退出，`wait` 仍可能在真正的工作进程之前结束。在接受这种契约之前，要测试真实的可执行文件，而不是替代用的 `sleep`。

伪终端还会带来信号行为和缓冲变化。会话结束时，终端可以发送 SIGHUP；后台进程组尝试从控制终端读取时，可能收到 SIGTTIN 并停止。有些程序检测到终端后会改用行缓冲，或者输出不同内容。除非命令确实需要终端语义，自动化任务不应分配伪终端，并且应该明确设置三个标准描述符。

## setsid 分离进程，却不生成证据

`setsid` 创建新会话和进程组，初始时没有控制终端。与只忽略 SIGHUP 相比，这是更强的终端分离方式。它解释了为什么子进程可以比 shell 活得更久，也解释了为什么终端生成的信号不再跟随它。

它不会让进程受到监督。原始父进程退出后，另一个进程可能收养后代进程。在传统主机上，收养者可能是 PID 1；在容器或服务树中，也可能是子进程回收器。新的父子关系不能说明工作负载是否成功，还可能抹去它与启动操作之间最方便的联系。

有效的分离测试会在 shell 消失前捕获身份：

```sh
ssh testhost 'run_id=agent-probe-20260724-1014; setsid sh -c '\''printf "%s\n" "$$" > /tmp/'"$run_id"'.pid; sleep 12; printf "complete\n" > /tmp/'"$run_id"'.state'\'' > /tmp/'"$run_id"'.log 2>&1 < /dev/null & printf "run_id=%s\n" "$run_id"'
```

随后通过返回的运行 ID 查询，只把记录的 PID 当作提示，并在操作前验证进程启动时间和命令。单独的 PID 不是持久身份。如果进程退出且内核重复使用该编号，之后的清理命令可能会针对无关进程。

两次 fork、`setsid`、`disown` 和关闭描述符都是实现技术。团队常把它们误认为作业协议，因为它们能让终端返回。作业协议回答的是另一组问题：现在谁负责这项工作？如何查询？有哪些终态？退出原因在哪里？如何取消整个进程树？哪个标识符连接请求、日志、效果和审计事件？

如果启动响应不能回答这些问题，就应把它记录为分离启动，而不是已完成操作。

## 在审计契约中定义三个生命周期事件

可审计的远程操作需要分别记录接受、通道完成和工作负载完成。把它们压缩成一个 `success` 布尔值会产生虚假的确定性，并迫使事故调查依赖 shell 历史。

我使用的记录在概念上如下：

```json
{
  "action_id": "act_01J3M8Q4",
  "remote_host": "worker-07",
  "launch": {"state": "accepted", "at": "2026-07-24T10:14:00Z"},
  "ssh_command": {"state": "exited", "status": 0, "at": "2026-07-24T10:14:01Z"},
  "workload": {"id": "job_8931", "state": "running", "result": null},
  "completion_source": "remote-job-manager"
}
```

状态名称没有状态分离本身重要。`accepted` 表示远程管理者已验证请求并接管责任。`exited` 表示 SSH 命令已结束。`running` 表示持续性工作尚未到达终态。只有负责工作负载的组件才能为它写入 `succeeded`、`failed` 或 `cancelled`。

应明确规定状态转换规则。启动可能在创建工作负载 ID 前失败。远程系统接受任务后，SSH 通道可能断开，让调用方处于不确定状态，而不是失败状态。工作负载可以在通道干净退出后失败。取消可能已经请求，但尚未完成。无法表示 `unknown` 的审计模型，迟早会把猜测记录成事实。

幂等性也属于这个契约。如果客户端在提交后丢失通道，它应该用同一个操作 ID 重试，并询问远程管理者是否已经接受。因为第一次响应丢失就启动第二次迁移，比日志不整齐严重得多。

完成证据应包含工作负载 ID、终态、退出原因、开始和结束时间戳，以及观察状态的管理者身份。在风险需要时，加入针对效果的证明，例如已部署版本、完整的备份清单或 schema 版本。除非记录器和存储都属于可信作业协议，否则不要把含有 `done` 的一行日志当成唯一依据。

## 测试失败窗口，而不只是顺利路径

有效的测试矩阵要改变进程的分离方式、描述符的处理方式，以及连接或进程出错的时点。每种受支持的主机类型都要运行，因为登录管理器、shell 和服务管理器会改变进程能否存活。

至少覆盖以下情况：

- 前台命令、shell 后台任务、`nohup` 加后台执行、通过 `setsid` 创建的新会话，以及会自行守护化的程序。
- 无终端和分配了伪终端两种情况。
- 描述符由子进程继承、重定向到文件，以及由子进程关闭。
- 接受前断开、接受后回复前断开，以及 SSH 命令退出后断开。
- 子进程以非零状态退出、收到信号、卡住、生成孙进程，以及一直存活到显式取消。

对于每一种情况，捕获四个时点：客户端启动、启动确认、SSH 通道关闭和远程终态。分别记录 SSH 状态和工作负载结果。在任务运行时检查进程组和会话，然后证明取消操作是否覆盖所有后代进程。

下面这个简洁的测试工具可以在状态 0 到达而没有终态证据时判定失败：

```sh
result=$(ssh testhost '/usr/local/bin/job-submit agent-probe-42')
ssh_rc=$?
printf 'ssh_rc=%s response=%s\n' "$ssh_rc" "$result"
job_id=$(printf '%s\n' "$result" | sed -n 's/^job_id=//p')
test "$ssh_rc" -eq 0 && test -n "$job_id" || exit 1
/usr/local/bin/poll-job "$job_id" || exit 1
```

这个示例假设 `job-submit` 恰好返回一行 `job_id=`，而且 `poll-job` 会验证查询身份、等待终态，并以工作负载结果退出。这些是契约要求，不是 SSH 提供的属性。在真实测试工具中，应拒绝额外输出、设置期限、超时时保留不确定状态，并保存原始响应供调查。

还要测试观察者。启动后停止代理。重启客户端计算机。轮换 SSH 凭据。如果任务按设计应该跨重启存活，就重启远程主机。如果作业 ID 的唯一记录只存在于某个代理的上下文窗口中，这个系统就不具备可审计性。

## 服务管理器通常应该负责长期工作

当工作需要比 SSH 命令存活更久时，应把它交给远程服务或作业管理器，并返回持久标识符。管理器应该负责进程组、收集输出、执行资源和取消行为、持久保存状态，并提供能区分运行中与终态的查询。

在 systemd 主机上，临时服务或模板服务可以提供控制组和 journal 身份。systemd-run 手册区分异步服务启动与等待服务终止，还警告说简单服务可能在 fork 后、目标程序执行前就把启动视为成功。当执行失败必须可见时，我偏好 `Type=exec`，但它仍只证明启动，不证明任务最终成功。

模板单元可以这样建立所有权边界：

```ini
[Unit]
Description=Agent job %i

[Service]
Type=exec
ExecStart=/usr/local/libexec/agent-job %i
StandardOutput=journal
StandardError=journal
KillMode=control-group
TimeoutStopSec=30s
```

提交经过验证的唯一实例 ID，然后查询该单元，直到它到达终态。记录 `ActiveState`、`SubState`、`Result` 和 `ExecMainStatus`，同时记录时间戳和实例 ID。由于服务类型必须与程序行为一致，要确认工作负载 fork 时会发生什么。未经严格验证，不要把任意用户输入变成单元名称或命令参数。

队列、批处理调度器、容器编排器或应用专用作业表也可以提供同样的所有权边界。选择已经负责工作负载资源和恢复的管理者。SSH 应该只负责提交和查询，不应通过一长串 shell 运算符来冒充调度器。

对于短任务，让命令保持前台运行并返回真实状态更简单，通常也更好。分离有成本：额外的状态存储、额外的身份、取消语义、保留和对账。只有工作确实必须比调用存活更久时，才应承担这些成本。

## 审计过程结束于远程终态

操作网关可以准确记录 SSH 调用，却不知道远程后代进程是否存在。Sallyport 会在 Activity journal 中记录 SSH 操作，并在 Sessions journal 中记录代理运行，因此调用记录是通道结果的证据，而不是主机上的进程清单。远程作业 ID 和终态事件仍需通过显式、可审计的操作返回。

这种划分让每条记录保持诚实。网关证明哪个代理运行调用了 SSH、哪把受保护密钥授权了操作、发生了什么调用，以及返回了什么结果。远程管理器证明提交之后发生了什么。用一个操作 ID 连接两边记录，而且代理不能在启动和查询之间悄悄替换它。

不要在界面或日志中把分离启动标为 `completed`。使用 `submitted` 或 `detached`，显示工作负载 ID，并让父操作保持打开或明显待处理，直到可信观察者记录终态。如果观察超时，显示 `unknown` 并要求对账。红色状态可能不方便，但基于错误进程得出的绿色状态很危险。

批准时点也需要同样准确。人工批准使用 SSH 密钥，只是在当时所显示信息的范围内授权一次尝试。点击批准并不等于授权无边界后代进程未来执行的所有操作，也不能证明最终效果。如果提交的任务可以运行数小时或创建更多进程，启动前应显示这一事实，并把批准绑定到操作 ID、主机、命令意图和远程任务类型。单次调用批准记录与工作负载完成记录应进入同一条证据链，但它们描述的是两个决定。

在每个边界保留原始输出。解析前先保存提交响应；协议允许时分别捕获 stderr；还要记录伪终端是否合并了两个流。解析器应拒绝重复作业 ID、控制字符、截断响应和可能让一个响应伪装成另一个响应的额外行。解析字段服务于自动化，原始字节则用于日后检查解析器、shell 引用或远程程序是否出错。两种形式都不能包含秘密。

完成标记还需要原子发布规则。工作进程应先把结果写入受保护存储中的临时文件，在需要持久性时刷新数据，并且只在完整记录准备好后将文件重命名到目标位置。查询端应先验证操作 ID、预期所有者、文件类型和状态，再信任它。更好的办法是让服务管理器或数据库通过经过身份验证的接口公开状态。全局可写的 `/tmp` 标记可以演示时序，却不能为事故定论。

提交和取消都要考虑交付状态不确定的情况。如果远程管理器接受任务后、客户端收到 ID 前 SSH 连接消失，正确的本地状态是 `unknown`。使用原始幂等键重新连接，并要求管理器查找该提交。不要悄悄再次提交。如果取消操作丢失回复，应持续查询，直到管理器报告终态并证明进程组为空。发送信号只是一次尝试，不是工作已停止的证据。

对账必须能在代理进程结束后继续。把未解决的操作 ID 存在临时对话之外，指定负责人，并定期查询，关闭或升级过期记录。截止时间与失败应分别定义：任务可以超过调用方的等待期限，同时仍健康运行并有明确负责人。审计记录应说明调用方停止等待、谁继续观察，以及稍后是否收到完成事件。否则，超时会变成另一种虚假终态。

审核人员需要与运行时相同的词汇。查找 `ssh_command.status` 为零而 `workload.state` 缺失、超过期限仍运行或未知的操作。查找没有对应启动批准的终态作业，以及共享同一幂等键的重复提交。这些查询把生命周期区别变成能发现漏洞的控制措施，而不是工程师看过就忘的一段话。

给操作人员提供一个重新打开远程记录的操作。视图应显示最后观察时间、提供状态的组件，以及状态来自实时查询还是缓存数据。不要为了清空队列就把 `unknown` 改成 `failed`。在远程管理者回复或授权审核员用有记录的证据解决之前，应保留不确定性。中断后可能留下令人不舒服的开放记录，但这才准确。

既要测试创建，也要测试保留。工作负载记录的可查询时间应长于最长预期任务，并覆盖审计或事故复查所需的周期。如果服务管理器会立即丢弃临时单元细节，应在收集前把终态复制到持久操作记录。记录一个下周已经无法由任何系统解析的作业 ID，只建立了关联，没有建立问责。

在代理的真实路径中运行十二秒探针，然后用真正重要的工作负载再测一次。如果远程完成标记出现前 SSH 卡片就变绿，你已经找到了审计缺口。保留这个零，因为它是关于该命令的有效证据。不要再要求它为自己从未观察过的工作作证。
