# Runtime Agent 通用接入规范

本文定义任意新 runtime 接入 ClawManager Agent Control Plane 的通用方案。后续新增 OpenClaw、Hermes 以外的 runtime 时，应优先遵守本文，再补充该 runtime 自己的镜像构建细节。

## 接入目标

每个可托管 runtime 镜像内都应包含一个常驻 agent。Agent 不需要暴露端口，只需要向 ClawManager 发起出站 HTTP 请求，并完成：

- 注册和 session token 续期。
- 心跳和在线状态上报。
- 运行时状态、CPU、内存、磁盘、网络、健康信息上报。
- skill inventory 同步和 skill 包上传。
- 平台命令轮询、执行、完成结果回传。
- 使用 ClawManager 注入的 AI Gateway 环境变量访问 LLM。

Agent 应把 runtime 自身名称写入 `host_info.runtime_type`、`summary.runtime_type`、`system_info.runtime_type`。当前 v1 协议里仍保留 `openclaw_status`、`openclaw_pid`、`openclaw_version` 这几个历史字段名，新 runtime 在协议升级前需要复用这些字段承载自己的主进程状态、PID 和版本。

## 平台侧新增 runtime type 检查清单

新增 runtime 不只是做镜像。ClawManager 平台侧至少需要完成下面几项：

| 模块 | 必做事项 |
| --- | --- |
| 数据库 | 在 `instances.type` 枚举迁移中加入新的 `runtime_type` |
| 后端 runtime 支持 | 在 runtime 类型校验、镜像解析、默认端口、持久化目录、代理路径逻辑中加入新类型 |
| 后端托管能力 | 在 `supportsManagedRuntimeIntegration` 加入新类型，否则不会注入 Agent 和 AI Gateway 环境变量，注册也会被拒绝 |
| Agent 注册 | 确认注册 allowlist、错误映射、测试覆盖都包含新类型 |
| Runtime Image Cards | 在系统镜像设置的支持类型、默认镜像、默认启用项中加入新类型 |
| 创建实例页 | 在前端 `INSTANCE_TYPES`、类型文案、图标、默认 env 模板中加入新类型 |
| 图标 | 提供 runtime 官方图标或明确授权图标，放入 `frontend/public` 或现有资产路径 |
| 测试 | 覆盖 env 注入、agent 注册、runtime 状态上报、创建实例表单、系统镜像卡片 |

如果新 runtime 采用 Webtop/KasmVNC 基础镜像，默认约定通常是：

- 桌面端口：`3001`
- 持久化目录：`/config`
- 代理路径：由 ClawManager 写入 `SUBFOLDER=/api/v1/instances/{instance_id}/proxy/`

如果新 runtime 不基于 Webtop，必须在平台侧明确它的服务端口、健康检查、代理路径、持久化目录和用户数据目录，不能让镜像和平台各自猜测。

## 镜像和 Agent 运行要求

Runtime 镜像必须满足：

- Agent 随容器启动自动运行，例如 systemd、s6 overlay、supervisord 或入口脚本。
- Agent 不占用 runtime 的业务端口或桌面端口。
- Agent 通过环境变量读取所有 ClawManager 配置，不写死地址、实例 ID、token、路径。
- Agent 的本地状态必须写入持久化目录，例如 `${CLAWMANAGER_AGENT_PERSISTENT_DIR}/<runtime>-agent/`。
- Agent 日志中不能打印 bootstrap token、session token、AI Gateway API key。
- Agent 可以在 ClawManager 暂时不可达时退避重试，不应退出导致容器不可用。

建议本地状态目录：

```text
${CLAWMANAGER_AGENT_PERSISTENT_DIR}/<runtime>-agent/
  session.json
  state.json
  logs/
  cache/
```

## ClawManager 注入的环境变量

### Agent 控制面

| 变量 | 说明 |
| --- | --- |
| `CLAWMANAGER_AGENT_ENABLED` | 为 `true` 时启动 agent |
| `CLAWMANAGER_AGENT_BASE_URL` | ClawManager API 根地址，不带 `/api/v1/agent` 后缀 |
| `CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN` | 首次注册使用的一次性 bootstrap token |
| `CLAWMANAGER_AGENT_INSTANCE_ID` | 当前实例 ID |
| `CLAWMANAGER_AGENT_PROTOCOL_VERSION` | 当前为 `v1` |
| `CLAWMANAGER_AGENT_PERSISTENT_DIR` | 当前实例持久化目录 |
| `CLAWMANAGER_AGENT_DISK_LIMIT_BYTES` | 实例磁盘配额字节数 |

Agent 启动时如果 `CLAWMANAGER_AGENT_ENABLED` 不是 `true`，应进入空闲状态或直接不启动控制面逻辑。

### AI Gateway

支持托管 runtime 的实例会被注入 OpenAI-compatible 网关变量：

| 变量 | 说明 |
| --- | --- |
| `CLAWMANAGER_LLM_BASE_URL` | ClawManager AI Gateway OpenAI-compatible base URL |
| `CLAWMANAGER_LLM_API_KEY` | 当前实例专属 Gateway API key |
| `CLAWMANAGER_LLM_MODEL` | 平台注入的模型目录 JSON，首项通常包含 `auto` |
| `CLAWMANAGER_LLM_PROVIDER` | 当前为 `openai-compatible` |
| `CLAWMANAGER_INSTANCE_TOKEN` | 当前实例 token，和 Gateway API key 同源 |
| `OPENAI_BASE_URL` | OpenAI SDK 兼容别名 |
| `OPENAI_API_BASE` | OpenAI SDK 兼容别名 |
| `OPENAI_API_KEY` | OpenAI SDK 兼容别名 |
| `OPENAI_MODEL` | 默认模型，通常为 `auto` |

Runtime 内的应用和 agent 如果需要调用模型，优先使用这些变量，不要让用户在镜像内手工写入 provider key。

### LLM Session 归因

托管 runtime 的每次 LLM 请求应携带稳定 session 标识，供平台按会话统计 token：

| 项 | 要求 |
| --- | --- |
| Header | `x-openclaw-session-key: {sessionKey}` |
| 示例 | `main` 会归一化为 `agent:openclaw:main` |
| 托管实例 Gateway Token 默认 | 未显式传 key 时，ClawManager 默认使用 `main` |
| 备选 | 请求体 `session_id` 或 OpenAI `user` 字段 |
| 禁止 | 长期依赖 Gateway 自动生成的 `sess_{traceID}`（用户 JWT 等非托管调用仍会 fallback） |

Agent state report 可选上报 LLM 配置指纹，便于平台检测配置漂移：

```json
{
  "runtime": {
    "llm_config_status": "gateway",
    "llm_provider_base_url": "http://clawmanager-gateway.../api/v1/gateway/llm",
    "llm_config_fingerprint": "sha256..."
  }
}
```

### Egress 代理与实例归因

托管 runtime 实例会注入 egress 代理环境变量，并将实例 ID 写入 `CLAWMANAGER_EGRESS_INSTANCE_ID`。当 egress 拦截直连 LLM 提供商域名时，平台会把该事件记为 `egress.llm.blocked` 审计。

| 变量 / Header | 说明 |
| --- | --- |
| `HTTP_PROXY` / `HTTPS_PROXY` | 指向 ClawManager egress proxy |
| `CLAWMANAGER_EGRESS_INSTANCE_ID` | 当前实例 ID，供代理客户端上报 |
| `X-ClawManager-Instance-Id` | egress 请求应携带的实例 ID header（与 `X-ClawManager-Egress-Instance-Id` 等价） |

建议：任何从实例内主动发起的 egress CONNECT/HTTP 代理请求（包括自定义脚本、sidecar、调试工具）在可行时读取 `CLAWMANAGER_EGRESS_INSTANCE_ID` 并设置 `X-ClawManager-Instance-Id`，以便平台将 bypass 尝试关联到具体实例。

可选网络加固：在 ClawManager 后端设置 `CLAWMANAGER_INSTANCE_NETWORK_LOCK=true` 后，新建的 **Pro（独立 Pod）** OpenClaw/Hermes 实例会自动创建 egress NetworkPolicy。**Lite（gateway 池）** 实例共享 runtime Pod，不适用按实例 NetworkPolicy。参考 `deployments/k8s/single-node/instance-egress-networkpolicy.yaml`。

## Agent 生命周期

推荐主循环：

1. 读取环境变量，确认 agent 已启用。
2. 生成稳定 `agent_id`，建议格式为 `<runtime_type>-<instance_id>-main`。
3. 从持久化目录读取 session token；没有 token 时用 bootstrap token 注册。
4. 发送一次完整 state report。
5. 按注册响应里的 `heartbeat_interval_seconds` 发送 heartbeat。
6. 按注册响应里的 `command_poll_interval_seconds` 拉取命令；heartbeat 返回 `has_pending_command=true` 时立即拉取。
7. 按 5 到 10 秒间隔采样并发送 state report。
8. 定期同步 skill inventory，或在 skill 变化后立即同步。
9. 接口返回 401、session 过期或本地 token 丢失时，重新注册。

注册响应里的 session token 当前为滚动续期。每次成功 heartbeat 都会延长有效期。Agent 仍应能处理 401 并自动重新注册。

## API 约定

以下 `{base}` 都表示 `CLAWMANAGER_AGENT_BASE_URL`，认证头均为：

```http
Authorization: Bearer <token>
Content-Type: application/json
```

### 注册

```http
POST {base}/api/v1/agent/register
Authorization: Bearer {CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN}
```

```json
{
  "instance_id": 123,
  "agent_id": "myruntime-123-main",
  "agent_version": "0.1.0",
  "protocol_version": "v1",
  "capabilities": [
    "runtime.status",
    "runtime.health",
    "metrics.report",
    "skills.inventory",
    "skills.upload",
    "commands.poll",
    "llm.gateway"
  ],
  "host_info": {
    "runtime_type": "myruntime",
    "runtime_name": "My Runtime",
    "image": "registry.example.com/myruntime:latest",
    "desktop_base": "webtop",
    "persistent_dir": "/config",
    "port": 3001,
    "arch": "amd64"
  }
}
```

响应：

```json
{
  "data": {
    "session_token": "agt_sess_xxx",
    "session_expires_at": "2026-04-28T10:00:00Z",
    "heartbeat_interval_seconds": 15,
    "command_poll_interval_seconds": 5,
    "server_time": "2026-04-27T10:00:00Z"
  }
}
```

Agent 必须缓存 `session_token`，后续接口都使用它认证。

### 心跳

```http
POST {base}/api/v1/agent/heartbeat
Authorization: Bearer {session_token}
```

```json
{
  "agent_id": "myruntime-123-main",
  "timestamp": "2026-04-27T10:00:15Z",
  "openclaw_status": "running",
  "summary": {
    "runtime_type": "myruntime",
    "runtime_status": "running",
    "runtime_pid": 245,
    "runtime_version": "0.4.0",
    "openclaw_pid": 245,
    "skill_count": 8,
    "disk_used_bytes": 2147483648,
    "disk_limit_bytes": 10737418240
  }
}
```

兼容要求：

- `openclaw_status` 当前仍是 v1 状态槽位。新 runtime 填自己的主进程状态。
- `summary.openclaw_pid` 是当前后端识别 PID 的兼容别名；建议同时上报 `runtime_pid` 和 `openclaw_pid`，后续协议升级后再收敛。
- 状态建议使用 `starting`、`running`、`stopped`、`error`、`unknown`。
- 心跳按服务端响应间隔执行，默认约 15 秒。
- ClawManager 45 秒内收到心跳显示 online，45 到 120 秒显示 stale，超过 120 秒显示 offline。

### 完整状态和监测数据上报

```http
POST {base}/api/v1/agent/state/report
Authorization: Bearer {session_token}
```

```json
{
  "agent_id": "myruntime-123-main",
  "reported_at": "2026-04-27T10:00:20Z",
  "runtime": {
    "openclaw_status": "running",
    "openclaw_pid": 245,
    "openclaw_version": "myruntime-0.4.0",
    "current_config_revision_id": null
  },
  "system_info": {
    "runtime_type": "myruntime",
    "runtime_name": "My Runtime",
    "sampled_at": "2026-04-27T10:00:20Z",
    "cpu": {
      "cores": 2,
      "load": {
        "1m": 0.64,
        "5m": 0.52,
        "15m": 0.40
      }
    },
    "memory": {
      "mem_total_bytes": 4294967296,
      "mem_available_bytes": 2147483648
    },
    "disk": {
      "mount_path": "/config",
      "root_total_bytes": 10737418240,
      "root_free_bytes": 8589934592
    },
    "network": {
      "interfaces": [
        {
          "name": "eth0",
          "status": "up",
          "addresses": ["10.42.0.12"],
          "rx_bytes": 123456789,
          "tx_bytes": 98765432
        }
      ]
    }
  },
  "health": {
    "runtime_process": "ok",
    "desktop": "ok",
    "agent": "ok",
    "metrics_collector": "ok",
    "metrics_sample_interval_seconds": 5
  }
}
```

后端会原样保存 `system_info` 和 `health`。前端实例详情页当前按以下字段绘制指标：

| 路径 | 类型 | 单位 | 说明 |
| --- | --- | --- | --- |
| `system_info.cpu.cores` | number | 核数 | 容器可用 CPU 核数 |
| `system_info.cpu.load.1m` | number | load average | 1 分钟 load |
| `system_info.cpu.load.5m` | number | load average | 5 分钟 load |
| `system_info.cpu.load.15m` | number | load average | 15 分钟 load |
| `system_info.memory.mem_total_bytes` | number | bytes | 内存上限或总内存 |
| `system_info.memory.mem_available_bytes` | number | bytes | 可用内存 |
| `system_info.disk.root_total_bytes` | number | bytes | 持久化目录所在文件系统总容量 |
| `system_info.disk.root_free_bytes` | number | bytes | 持久化目录所在文件系统剩余容量 |
| `system_info.network.interfaces[].rx_bytes` | number | bytes | 网卡累计接收字节数 |
| `system_info.network.interfaces[].tx_bytes` | number | bytes | 网卡累计发送字节数 |

CPU 百分比由前端按 `load.1m / cores * 100` 计算。内存百分比由 `(mem_total_bytes - mem_available_bytes) / mem_total_bytes * 100` 计算。磁盘百分比由 `(root_total_bytes - root_free_bytes) / root_total_bytes * 100` 计算。网络速率由相邻两次 `rx_bytes`、`tx_bytes` counter 差值计算，所以 agent 必须上报单调递增的累计 counter，不要把瞬时速率填进这两个字段。

推荐采样来源：

- CPU load：`/proc/loadavg`。
- CPU cores：优先 cgroup CPU quota，例如 cgroup v2 `/sys/fs/cgroup/cpu.max`；没有 quota 时使用 `/proc/cpuinfo` 或语言运行时 CPU 数。
- 内存：优先 cgroup memory limit/current，例如 cgroup v2 `/sys/fs/cgroup/memory.max`、`/sys/fs/cgroup/memory.current`；没有限制时使用 `/proc/meminfo` 的 `MemTotal` 和 `MemAvailable`。
- 磁盘：对 `CLAWMANAGER_AGENT_PERSISTENT_DIR` 调用 `statvfs`。
- 网络：读取 `/proc/net/dev`，默认排除 `lo`。

上报频率：

- 启动成功后立即发送一次。
- 正常运行每 5 秒发送一次；资源敏感时可放宽到 10 秒。
- 采样间隔不要短于 2 秒。
- runtime 状态变化、skill inventory 变化、命令完成后立即补发一次。

### 命令轮询和执行

拉取命令：

```http
GET {base}/api/v1/agent/commands/next
Authorization: Bearer {session_token}
```

如果没有命令，响应的 `data` 可能为空。拿到命令后，agent 必须先标记开始，再执行：

```http
POST {base}/api/v1/agent/commands/{id}/start
Authorization: Bearer {session_token}
```

```json
{
  "agent_id": "myruntime-123-main",
  "started_at": "2026-04-27T10:01:00Z"
}
```

完成后必须 finish，失败也要 finish：

```http
POST {base}/api/v1/agent/commands/{id}/finish
Authorization: Bearer {session_token}
```

```json
{
  "agent_id": "myruntime-123-main",
  "status": "succeeded",
  "finished_at": "2026-04-27T10:01:05Z",
  "result": {
    "message": "system info collected"
  },
  "error_message": ""
}
```

当前通用命令类型：

| 命令 | Agent 行为 |
| --- | --- |
| `collect_system_info` | 立即采样，发送 state report，并在 finish result 中带上同一份摘要 |
| `health_check` | 检查主进程、桌面入口、agent、metrics collector，并发送 state report |
| `sync_skill_inventory` | 扫描 skill 目录并上报完整 inventory |
| `refresh_skill_inventory` | **已废弃**。请使用 `sync_skill_inventory` |
| `collect_skill_package` | 打包指定 skill 并上传 |
| `install_skill` | 下载并安装平台指定 skill version |
| `update_skill` | 更新已安装 skill |
| `uninstall_skill` / `remove_skill` | 移除指定 skill |
| `disable_skill` | 禁用指定 skill |
| `quarantine_skill` | 隔离指定 skill |
| `handle_skill_risk` | 按平台风控 payload 处理 skill |
| `apply_config_revision` | 获取并应用配置 revision |
| `reload_config` | 重新加载 runtime 配置 |

`start_openclaw`、`stop_openclaw`、`restart_openclaw` 是历史命名命令。非 OpenClaw runtime 不应误执行，除非平台侧明确把它们映射为该 runtime 的启动、停止、重启语义。

Agent 遇到未知命令时，应 finish 为 `failed`，`error_message` 写明 `unsupported command type: <type>`。

### Skill inventory

上报：

```http
POST {base}/api/v1/agent/skills/inventory
Authorization: Bearer {session_token}
```

```json
{
  "agent_id": "myruntime-123-main",
  "reported_at": "2026-04-27T10:02:00Z",
  "mode": "full",
  "trigger": "startup",
  "skills": [
    {
      "skill_id": "filesystem-name-or-manifest-id",
      "skill_version": "1.0.0",
      "identifier": "vendor.skill-name",
      "install_path": "/config/myruntime/skills/vendor.skill-name",
      "content_md5": "0123456789abcdef0123456789abcdef",
      "source": "runtime",
      "type": "agent-skill",
      "size_bytes": 12345,
      "file_count": 12,
      "collected_at": "2026-04-27T10:02:00Z",
      "metadata": {
        "runtime_type": "myruntime"
      }
    }
  ]
}
```

要求：

- `identifier` 和 `content_md5` 必须稳定。
- `mode=full` 表示这次 inventory 是全量结果，平台会用它对齐实例 skill 状态。
- skill 内容变化后要重新计算 `content_md5` 并上报。
- 如果支持上传 skill 包，使用 `POST {base}/api/v1/agent/skills/upload`，multipart 表单中带 `file`、`agent_id`、`skill_id`、`skill_version`、`identifier`、`content_md5`、`source`。
- `content_md5` 必须按目录内容指纹计算，不是 zip 文件 MD5。完整算法见 [Skill Content MD5 Calculation Spec](skill-content-md5-spec.md)。

### 配置和安装包下载

应用配置 revision：

```http
GET {base}/api/v1/agent/config/revisions/{id}
Authorization: Bearer {session_token}
```

下载 skill version：

```http
GET {base}/api/v1/agent/skills/versions/{external_version_id}/download
Authorization: Bearer {session_token}
```

下载内容只能应用到当前实例，不应泄露到其他实例或日志。

## 错误处理和重试

Agent 应按下面规则处理错误：

| 情况 | 处理 |
| --- | --- |
| HTTP 401 | 删除本地 session token，使用 bootstrap token 重新注册 |
| HTTP 403 | 停止重试当前认证路径，记录简短错误，等待配置修复 |
| HTTP 404 | 对命令或资源类请求标记失败，错误写入 finish |
| HTTP 429 / 5xx / 网络错误 | 指数退避重试，保留本地状态 |
| JSON 校验失败 | 修正 payload；命令执行场景下 finish 为 failed |
| 采样部分失败 | 上报可用字段，并在 `health.metrics_collector` 写 `degraded` 或 `error` |

推荐退避：1s、2s、5s、10s、30s，最大 60s，并加入少量 jitter。

## 安全要求

- 不记录 token、API key、完整 Authorization header。
- session token 只写入当前实例持久化目录，文件权限建议 `0600`。
- 不接受来自用户输入的任意 URL 覆盖 `CLAWMANAGER_AGENT_BASE_URL`。
- 上传 skill 包前需要限制目录边界，不能打包持久化目录以外的文件。
- 解压平台下发的 skill 包时必须防止 zip slip，例如拒绝 `../` 和绝对路径。
- 命令执行要做超时控制，超过 `timeout_seconds` 应主动终止并 finish 为 failed。

## 验收标准

新增 runtime agent 接入完成后，至少满足：

1. 创建实例后容器环境中存在 Agent 和 AI Gateway 变量。
2. Agent 能注册成功，实例详情页显示 agent online。
3. 心跳持续发送，45 秒内不会变 stale。
4. `GET /api/v1/instances/{instance_id}/runtime` 能看到 `runtime.system_info` 和 `runtime.health`。
5. CPU、Memory、Disk、Network 指标在实例详情页 10 秒内开始刷新。
6. `collect_system_info` 命令能成功完成，并更新 state report。
7. `health_check` 命令能成功完成，并体现 runtime、agent、metrics collector 状态。
8. Skill inventory 能上报全量结果，skill 变化后能重新同步。
9. session token 过期或 401 后能自动重新注册。
10. 日志中没有 bootstrap token、session token、AI Gateway API key。

## 最小实现伪代码

```text
load env
if CLAWMANAGER_AGENT_ENABLED != "true":
  sleep forever

agent_id = "<runtime_type>-" + CLAWMANAGER_AGENT_INSTANCE_ID + "-main"
session = load session from persistent dir
if session missing:
  session = register_with_bootstrap_token()
  save session

report_state(sample_system_info(), health_check())
sync_skill_inventory(trigger="startup")

loop:
  every heartbeat_interval:
    resp = heartbeat(summary)
    if resp.has_pending_command:
      poll_and_execute_commands()

  every command_poll_interval:
    poll_and_execute_commands()

  every 5 seconds:
    report_state(sample_system_info(), health_snapshot)

  on 401:
    delete session
    session = register_with_bootstrap_token()
    save session
```
