# 强制退出测试能证明智能体网关什么？

保存 AI 智能体凭据的网关，必须在进程于最糟糕的时刻消失时仍然安全。顺利路径测试可以证明批准出现了、请求成功了，之后也有审计记录。但它无法证明请求中断时凭据有没有泄露，也无法证明审计轨迹有没有把从未完成的工作说成已完成。

强制退出测试会揭开这些说法之间的空隙：「请求已开始」「凭据已附加」「远程系统已收到」「记录已持久化」。这些是彼此独立的事实。把它们当成一个事件，团队就可能交付一个看起来受控的操作网关，直到一次崩溃把普通重试变成重复部署，或变成无法追踪的出站调用。

对于 macOS 网关，要区分正常退出和进程死亡。Apple 将正常应用终止描述为一种生命周期路径，在这条路径上，应用可以保存状态并运行终止处理。强制终止则可能根本不给它这些时间。Apple 还指出，用户强制退出应用时可能产生 SIGKILL。测试计划必须假定清理处理器、延迟写入和尽力而为的遥测都不会运行。

有用的问题不是「应用能重启吗？」而是：在每个中断点上，你能否准确说明哪些副作用可能已经发生，哪些不可能发生，以及哪些证据能够保留下来？

## 强制退出必须穿透清理流程

只有在测试真正剥夺网关整理现场的机会时，强制退出测试才有效。如果测试调用了礼貌的关闭函数，等待队列排空，刷新日志，然后退出，那么你测试的是有序终止。这当然有价值，但它并不对应安全工程师最担心的故障情形。

使用两种终止模式，并准确标注它们：

- 正常终止请求测试取消处理、有序关闭文件，以及界面如何报告中断的会话。
- 强制杀死测试的是：在没有任何应用清理工作运行时，内存、文件缓冲区、打开的套接字和进行中的工作处于什么状态。

在 macOS 上，测试工具可以用 `kill -TERM` 测试第一种情况，用 `kill -KILL` 测试第二种情况。`SIGTERM` 会给进程处理终止的机会，`SIGKILL` 不会。不要在结果表中把两种情况都叫作「强制退出」，因为它们回答的是不同问题。

```sh
# 找到你为测试启动的网关进程。
pgrep -fl Sallyport

# 正常终止。
kill -TERM 48192

# 突然终止。
kill -KILL 48192

# 确认进程已经消失。
ps -p 48192
# PID TTY           TIME CMD
# 48192 ttys003    0:00.42 gateway-test
# SIGKILL 之后，ps 不应打印任何进程行。
```

进程 ID 不是测试标识符。每次测试运行都要分配运行 ID、操作 ID 和远程请求 ID，并把这些 ID 同时写入夹具记录和网关记录。没有共享标识符，你最终只能比较时间戳，猜测某个远程 POST 是否属于被你杀死的那次运行。

不要对生产端点进行测试，即使你认为调用没有危害。这项工作的目的，是有意制造结果不明确的情况。搭建一个可以在接收正文前、读取请求头后、记录正文后，或释放响应前暂停的接收器。真实的第三方 API 不会稳定地提供这些边界，还可能加入你没有要求的重试或缓存。

## 为请求设置明确的中断点

如果实现没有定义「在凭据注入期间」具体指什么，就无法在这个时刻杀死进程。在操作流水线周围加入可观察的暂停点。这些是测试控制，不是策略语言，也不是生产授权机制。

对于 HTTP 操作，可以采用这样的顺序：

1. 网关接受经过授权的操作描述，并分配操作 ID。
2. 如果设计要求在网络工作之前记录意图，就写入意图记录。
3. 在受保护进程内解析凭据。
4. 构造出站请求并注入凭据。
5. 打开连接并写入请求。
6. 接收足够的响应，以判断远程结果。
7. 写入完成记录或未知结果记录，然后把结果返回给智能体。

在每个编号操作之后的边界设置暂停点。暂停必须能从独立进程观察到。测试专用的本地套接字、命名管道或由测试工具控制的文件描述符都可以。单纯调用 sleep 不够可靠，因为时间漂移会把边界测试变成竞态。

一个简洁的夹具协议可以如下：

```json
{
  "action_id": "fq-2026-07-22-017",
  "hold_after": "request_headers_built",
  "release": false
}
```

网关在创建请求头后、尝试连接前暂停。测试工具确认状态后杀死进程，再询问接收器是否看到连接。预期结果是零个入站请求和零个携带凭据的请求头。如果接收器在此时看到了请求，说明检查点设置得太晚，或者某个未被记录的后台任务越过了边界。

检查点要足够窄。「网络 I/O 之前」范围太大，因为 DNS 解析、建立连接、TLS 协商和正文传输可能运行在不同任务中。你不必在每个库函数内部设置检查点，但必须有足够清晰的结构，说明远程服务器是否可能已经收到凭据或会改变状态的正文。

SSH 也遵循同样的思路，只是证据不同。在 `sp-ssh` 收到带凭据的连接请求之前、辅助工具启动但尚未认证时、认证完成但命令尚未开始时，以及远程命令返回但本地完成状态尚未提交时，都设置暂停点。在一次性测试主机上记录远程命令的开始和退出。不要根据 TCP 连接已打开就推断命令已经执行。

## 注入前杀死进程，远程端不应留下痕迹

最早的故障情形有最清楚的预期结果：如果网关在附加凭据或启动传输之前死亡，任何外部服务都不应看到该操作的任何内容。

这听起来很明显，但实现经常把准备和分发混在一起。客户端库可能在另一个任务获取或格式化请求头时已经开始连接。重试封装器可能在代码写入预期日志记录前就分配了出站请求。指标回调也可能写入包含 URL 查询参数的操作描述，而普通的脱敏路径根本不会处理这个参数。

测试四个分发前中断点：

- 授权之后、查找任何凭据之前。
- 查找凭据之后、构造请求之前。
- 构造请求之后、建立套接字连接之前。
- 连接建立开始之后、任何携带凭据的字节离开进程之前。

每个中断点都有不同的断言。在查找凭据之前，检查不会泄露值或可能在之后被替换的占位符的诊断信息和日志。查找凭据之后，检查相同输出，并确认秘密从未越过进程边界。建立连接之前，接收器不应有连接记录。连接建立期间，接收器可能看到失败的握手或连接尝试，但不应收到 Authorization 请求头、basic-auth 字段、自定义凭据请求头或 SSH 认证尝试。

这一区别很重要，因为连接尝试不等于经过认证的操作。如果远程边缘记录了连接尝试，不要报告「没有发生任何操作」。应准确报告：没有发送凭据，也没有收到应用请求。安全记录如果抹平了操作员在事后调查中需要的证据，就会失去价值。

Sallyport 的设计值得直接测试这个边界：智能体永远不应持有明文凭据，由应用执行带凭据的 HTTP 或 SSH 操作，再把结果返回给智能体。测试并不是因为智能体没有秘密就结束了。只有确认被杀死的网关也无法把半准备状态的操作变成携带秘密的出站请求，测试才算完成。

## 注入是本地事件，不是分发证明

凭据注入是团队最容易犯下严重记账错误的地方。代码创建请求头，或把身份交给 SSH 库时，他们记录「已使用凭据」。这个事件只能说明网关准备进行认证，不能证明任何对等方收到了凭据。

至少要区分以下四种本地状态：

| 状态 | 可以如实声明 | 不能声明 |
|---|---|---|
| 已解析凭据 | 受保护进程为此次操作读取了凭据 | 对等方已经收到凭据 |
| 已组装请求 | 进程在内存中创建了带凭据的请求 | 已经打开连接 |
| 已开始传输写入 | 进程尝试发送字节 | 远程应用已经处理这些字节 |
| 已观察到远程结果 | 进程收到了来自远程端的证据 | 完成记录已经持久化 |

在凭据解析后杀死一次进程，再在请求对象存在但传输写入尚未开始时杀死一次。两种情况下，智能体都不应收到秘密，远程夹具也不应记录携带凭据的请求。操作日志应显示中断的准备状态，或不应有持久记录，具体取决于你的持久化边界所在位置。不要为了让日志看起来整洁而伪造已完成的操作。

然后在传输库第一次可能写入数据的时刻杀死进程。这是最棘手的测试。虽然本地进程可能已经调用了写函数，但内核、代理、TLS 层或远程服务可能没有收到完整请求。除非接收器有积极证据证明请求已经或尚未收到，否则预期结果应为 `outcome=unknown`。

不要为了让测试更容易而记录完整请求头。那会创建第二条秘密路径。应让夹具根据收到的凭据值计算不可逆的测试标记，只保存该标记。网关可以保存凭据引用或内部凭据标识符。测试期间比较标识符和标记，不要打印凭据本身。

一个有用的夹具记录可以是：

```json
{
  "action_id": "fq-2026-07-22-017",
  "connection_seen": true,
  "headers_complete": true,
  "credential_marker": "sha256:7b8c...",
  "body_complete": false,
  "response_sent": false
}
```

使用测试专用的凭据，并确保它的值不会出现在夹具之外。即便如此，也不要让它进入截图、shell 历史记录和普通日志。一次性秘密仍然是秘密形态的对象，而测试习惯很容易进入生产代码。

## 网络 I/O 会产生诚实的未知状态

一旦会改变状态的请求能够离开机器，本地确定性就会在远程系统回应之前结束。这种情况必须影响重试行为、界面措辞和审计语义。

假设一个 POST 请求要求部署服务启动一次发布。网关写出了完整请求，服务持久化了部署任务，但响应到达网关之前，网关进程被杀死。重启后，可能有三种现实：

1. 服务从未收到请求。
2. 服务收到了请求，但拒绝了它。
3. 服务接受了请求，并创建了部署。

本地网关可能不知道是哪一种。情况三中，把记录标为 `failed` 是错误的；情况一和二中，把记录标为 `succeeded` 也是错误的。应将其标为 `unknown`，并保留操作 ID、远程请求 ID、目标、方法，以及本地观察停止的时刻。

这也是盲目重试会造成损害的地方。团队喜欢自动重试，因为瞬时网络故障很常见，顺利演示也看起来很流畅。对于 GET，如果不会造成服务器端记账或速率限制问题，重试通常可以接受。对于 POST、PATCH、远程命令或能写入数据的 API 调用，重试需要幂等机制或后续对账。

远程系统提供幂等功能时，应使用它。提供针对具体操作的幂等令牌，不要在整个智能体会话中重复使用同一个令牌。如果远程 API 不支持幂等，就建立远程查询路径，在重试前回答该操作 ID 是否已被接受。如果两者都不存在，就让人来决定。这不如自动恢复优雅，但比重复执行安全得多。

使用接收器测试以下精确顺序：

```text
网关写入意图记录
网关发送带有操作 ID fq-2026-07-22-017 的 POST
接收器保存操作 ID 和正文
接收器延迟 HTTP 响应
测试工具使用 SIGKILL 杀死网关
网关重启
智能体请求重试
网关在再次发送 POST 前查询接收器中的操作 ID
```

结果应显示一项已保存的远程操作，以及一条结果中断或未知的本地记录，之后该记录与远程状态完成对账。如果第二个 POST 在对账检查之前出现，测试就找到了重试漏洞。如果日志覆盖了不确定性，只留下干净的成功记录，就找到了审计漏洞。

不要把 TCP 关闭事件当作远程应用没有执行操作的证明。套接字关闭时，对等端可能早已把请求交给应用代码。远程端持久化的操作记录才是重要证据。

## 审计提交必须有明确的持久化承诺

审计日志包含哈希链，并不代表它自动可信。哈希链可以揭示现存序列中的篡改或删除，却无法找回进程死亡前从未到达持久存储的事件。

用一句话写下承诺。例如：「在网关开始会改变状态的外部操作之前，它会持久记录操作意图；观察到结果后，它会在把结果返回给智能体之前持久记录该结果。」然后测试「开始」和「持久」这两个动词，不要把带时间戳的内存对象当成记录。

POSIX 将 `fsync()` 定义为把文件数据传输到关联存储的请求，并说明它要么在该操作完成后返回，要么报告错误。标准也提醒，存储保证取决于实现和配置。这不是跳过调用的理由，而是说明你必须准确界定软件能承诺什么，并测试它所能控制的恢复行为。

对于加密哈希链式日志，至少测试以下中断点：

- 写入候选条目后、持久化屏障之前杀死进程。
- 持久化屏障之后、内存索引更新之前杀死进程。
- 完成条目持久化之后、智能体收到响应之前杀死进程。
- 在压缩、轮换或任何日志投影过程中杀死进程。

每次重启后，运行离线验证命令，同时检查原始审计序列和面向用户的投影。Sallyport 提供 `sp audit verify`，无需保险库密钥即可验证其加密哈希链式审计日志。这对这组测试很有用，但验证应只是多个断言之一，不能成为整个测试。

预期结果需要有细微区分。如果在持久化边界之前杀死进程后记录消失，而外部操作尚未开始，这可以接受。如果网关已经开始发送会改变状态的请求，而设计承诺了预写意图，那么记录消失就不可接受。出现未知结果的记录通常是正确的。在观察到远程响应前就声称完成，即使测试通常碰巧通过，也是不正确的。

检查恢复时，要把智能体运行历史与操作调用历史分开。一个会话可能突然结束，而其中多个操作的结果各不相同。一条写着「已终止」的运行级记录，不能替代那些已经到达远程系统的请求和仍停留在内存中的请求各自的调用记录。

## 批准状态不能脱离其对象继续存在

重启测试的不只是持久性，也包括授权状态。如果网关为某个会话授权了特定智能体进程，那么杀死网关不能把这项授权变成可供新进程重复使用的许可，即使新进程发出了类似请求。

最简单的安全规则是：会话批准属于一个已观察到的进程身份，并随该运行一起失效。重启后，新的智能体进程需要新的会话授权。即使原进程仍然存活而网关重启，只要网关无法重新建立精确的身份绑定，或产品没有明确记录这种行为，也需要新的决定。不要从命令名称、工作目录或友好的进程标签等松散缓存记录中恢复批准。

用两个在命令行上看起来相似、但签名权限或可执行文件来源不同的智能体测试重启边界。批准第一个智能体。在一次操作期间停止网关并重启，然后让第二个智能体发出相同操作。网关必须显示新的批准决定，而不能继承第一个智能体的会话。

逐次调用批准有不同的测试方法。批准卡片显示时杀死网关，然后重启并重复操作。旧的界面事件不能为新的调用授权。在用户批准之后、凭据解析之前杀死进程，然后确认新进程不能复用旧批准。这些测试能发现一个常见错误：持久化了没有绑定到操作 ID、具体会话和过期条件的「approved」布尔值。

Sallyport 的固定控制让这项测试有清晰形态。它的保险库入口在锁定时拒绝操作，会话授权绑定到新的智能体进程，选定凭据还可以要求每次使用都批准。强制退出测试应证明，即使界面、保险库状态和操作流水线在不同时间重启，这些边界仍然成立。

## 运行测试套件前先建立结果矩阵

仅仅写「在阶段 X 杀死进程」的测试，留下了太多解释空间。创建一个矩阵，用行表示中断点，用列表示可观察结果。在编写测试代码之前先审查预期结果。如果团队无法就应当发生什么达成一致，说明实现还没有定义故障契约。

使用以下列：

| 中断点 | 远程端可能看到连接吗？ | 远程端可能看到凭据吗？ | 允许的本地操作状态 | 恢复后智能体结果 | 必需证据 |
|---|---:|---:|---|---|---|
| 查找凭据之前 | 否 | 否 | 未开始或已中断 | 新批准或重试路径 | 仅网关跟踪 |
| 凭据解析之后 | 否 | 否 | 准备中断 | 新批准或重试路径 | 仅网关跟踪 |
| 请求写入期间 | 是 | 可能 | 未知 | 重试前先对账 | 接收器记录和本地记录 |
| 远程接受之后 | 是 | 是 | 在观察或对账前为未知 | 重试前先对账 | 远程持久记录 |
| 持久完成之后 | 是 | 是 | 已完成 | 返回缓存或完成对账的结果 | 已验证的审计记录 |

「可能」和「未知」不是弱点，而是对分布式工作的诚实描述。危险的词是「失败」，尤其是在网关没有证据证明远程服务没有执行操作时。

重复运行每一行，但不要让重复代替控制。随机杀死一百次，仍可能错过日志追加和持久化屏障之间的边界。一次在暂停点上受控的杀死，就能确定这条边界的行为。之后再重复测试，用来发现竞态、调度错误和检查点意外移动的问题。

每次运行都从两端收集产物：测试工具事件时间线、远程夹具的持久操作列表、进程退出信息、网关恢复诊断、审计验证输出，以及智能体可见的输出。把运行 ID 放进文件名。测试失败时，在重新运行前保存证据。第二次运行经常会破坏唯一有用的线索。

## 把差异当作设计工作，而不是测试噪声

当强制退出测试发现网关日志和远程夹具之间不一致时，不要急着修改断言直到它通过。这种不一致通常就是测试发现的问题。

如果夹具报告请求已接受，而网关没有记录意图，就把持久意图边界提前，或让操作在越过该边界前停止。如果网关报告完成，而夹具没有操作记录，就查明网关是否把本地写入误认为远程结果。如果重启后的智能体可以不经新批准就执行操作，就修复身份绑定，而不是增加更长的超时时间。

最好的结果不是仪表板上满是绿色测试，而是一份操作员在压力下也能使用的故障契约：这项操作确定没有离开机器；这项操作可能已经到达远程系统，需要对账；这项操作已经完成，且记录通过了验证。只有这些类别，才能让崩溃后的决策经得起审查。

每当你修改凭据处理、传输库、日志记录、重试行为、进程监管或批准处理时，都要运行这套测试。网关只有在没有机会解释自己时仍能以可预测的方式行动，才真正值得信任。
