# 控制平面 ↔ Host Agent 协议

> **当前状态（2026-07-27）**：独立 Host Agent 已实现注册、注册口令、bearer token、
> 本地 `0600` 凭据文件、主机指纹和心跳；控制平面只保存 token SHA-256 摘要。
> 心跳迟到 15 秒会把 Host 降级为 `DEGRADED`，丢失 30 秒会标记为 `OFFLINE`；
> 安全预检会先刷新该状态，避免失联 Host 被误判为可用。
> Step 租约、事件、完成与产物端点仍是下一阶段契约。`make demo` 暂时继续使用
> 控制平面内置模拟执行器验证状态、恢复与证据链。

Agent 是**拉模式**。控制平面永远不主动连接测试主机。

理由：客户测试主机在内网/NAT 后面，很多实验室根本不给外部反连；
拉模式还顺带解决了 Agent 重启后的重新接入问题。

## 端点

| 状态 | 方法 | 路径 | 用途 |
|---|---|---|---|
| 已实现 | POST | `/api/v1/agent/register` | 首次注册 / token 轮换；上报主机指纹与工具能力 |
| 已实现 | POST | `/api/v1/agent/{agent_id}/heartbeat` | bearer 心跳，上报健康和设备探针；返回待办指令 |
| 已实现 | GET | `/api/v1/agent/hosts` | 查看 Host 与 Agent 在线投影，不返回 token |
| 待实现 | POST | `/api/v1/agent/{agent_id}/lease` | 领取下一个步骤（长轮询，最多挂 20s） |
| 待实现 | POST | `/api/v1/agent/{agent_id}/steps/{step_id}/events` | 批量上报执行事件与日志片段 |
| 待实现 | POST | `/api/v1/agent/{agent_id}/steps/{step_id}/complete` | 上报步骤终态 + UnifiedResult + 产物清单 |
| 待实现 | POST | `/api/v1/agent/{agent_id}/artifacts` | 上传产物（日志 / 结果文件 / 画面） |

带外控制器走独立端点（它必须能在主机死后独立上报）：

| 方法 | 路径 | 用途 |
|---|---|---|
| POST | `/api/v1/oob/{controller_id}/heartbeat` | 带外心跳 + 电源状态 + 温度 + 画面指纹 |
| POST | `/api/v1/oob/{controller_id}/observation` | 主动上报观测（蓝屏画面、掉电、按钮触发） |

## 心跳返回的指令

心跳响应已经带 `commands` 字段；当前固定为空数组。命令队列接入后，Agent 收到后立即执行：

```json
{
  "ok": true,
  "server_time": "2026-07-27T03:14:15Z",
  "commands": [
    {"kind": "CANCEL_STEP", "step_id": "..."},
    {"kind": "SOFT_RESTART", "reason": "recovery_L1"},
    {"kind": "EMERGENCY_STOP", "run_id": "..."},
    {"kind": "REFRESH_ASSETS"}
  ]
}
```

紧急停止走心跳而不是新连接——心跳是唯一能保证到达的通道。
最坏情况延迟 = 心跳间隔（5s）。真正需要毫秒级的停止靠带外物理急停，不靠软件。

## 租约（lease）语义

```
Agent  ──lease──▶  控制平面
                    ├─ 有活：分配步骤，step → DISPATCHED，写租约（TTL 60s）
                    └─ 没活：长轮询挂起最多 20s，返回 204
Agent  ──ack───▶   step → RUNNING
（租约到期未 ack 或未 complete）→ step 回 PENDING，重新派发
```

租约 TTL 必须**大于**步骤超时？不——恰恰相反：租约由 Agent 在执行期间通过心跳续期。
Agent 死了 → 心跳停 → 租约到期 → 步骤回收。这是 Agent 崩溃能被发现的机制之一
（另一条是心跳超时判定，见 state-machine.md §5）。

## 认证

首次注册带 `X-FlashOps-Enrollment-Token`。开发态未配置注册口令时允许本机演示；
生产态未配置时注册接口返回 503。注册成功下发 bearer token，之后每个请求带
`Authorization: Bearer <token>`。`agent_id` 与 `test_host_id` 绑定，重新注册会轮换 token。

原始 token 仅存于 Agent 本机 `0600` state 文件；控制平面数据库只保存 SHA-256 摘要。
Agent 客户端保留可选 gateway Basic Auth 能力，供未来重新启用边界认证或部署在其他网关后使用；
当前 `flashops.imagebrewing.com` 预览环境未启用这层认证。

**下一波必须升级**：方案 §6.2 要求"每个 Agent 使用唯一证书或密钥"。
bearer token 在客户内网够用，但要进客户安全评审得上 mTLS。
升级点在 `api/deps.py::require_agent()` 一处。

## 幂等（待实现）

Step 租约接入时，所有 Agent → 控制平面的 Step 写请求必须带
`Idempotency-Key`（Agent 生成的 UUID）。控制平面需要记录并拒绝重复副作用；
`run_events` 已有唯一约束 `(run_id, idempotency_key)`，但当前注册 / 心跳链路尚未实现
通用的请求幂等存储。

这一条在真实实验室里不是可选项：主机重启、网线松动、Wi-Fi 掉线是常态，
没有幂等就会出现"一次故障记了三条"的时间线，取证时说不清。
