Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd45c0ad77 | ||
|
|
c81d4f40f3 | ||
|
|
ea2a466d96 | ||
|
|
63b31165b7 | ||
|
|
07b5c2c9c2 | ||
|
|
d71b7903df | ||
|
|
79fd83cd61 | ||
|
|
fedb6b08fb | ||
|
|
83f8f878c7 | ||
|
|
1352a664ab | ||
|
|
57209760cd | ||
|
|
850bb669b7 | ||
|
|
9c20bf1362 | ||
|
|
09fa4f9d73 | ||
|
|
816be7bec5 | ||
|
|
25c25abd3e | ||
|
|
8edb96a0fa | ||
|
|
695a846645 | ||
|
|
d8f01f745d | ||
|
|
c75fa77898 | ||
|
|
9206b883ab | ||
|
|
b199935db8 | ||
|
|
f7307c82b2 | ||
|
|
bf01a13f3a | ||
|
|
8a36f7832b | ||
|
|
f1aeefff20 | ||
|
|
f55dfd975e | ||
|
|
ba7669c3b4 | ||
|
|
bd2784015d | ||
|
|
1aa71f233d | ||
|
|
558dc932fa | ||
|
|
5fd5ae0a33 | ||
|
|
81572b8231 | ||
|
|
8c3d80fe53 | ||
|
|
99bcc03b40 | ||
|
|
c6e7edcf54 | ||
|
|
634f6b164f | ||
|
|
c9ce12d102 | ||
|
|
db50d2fa5a | ||
|
|
63fd23a464 | ||
|
|
2e03ed74f6 | ||
|
|
b86f51c9ab | ||
|
|
09448a5d3d | ||
|
|
18c719b2c6 | ||
|
|
b0a78fa9a8 | ||
|
|
0aca7dbcfc | ||
|
|
3f84d8991e | ||
|
|
5ac8f3ea6f | ||
|
|
bfb1c2fd52 | ||
|
|
cbfe1211c1 | ||
|
|
efd99e620c | ||
|
|
c464560a0f | ||
|
|
f23b694ad3 | ||
|
|
f97dea55ac | ||
|
|
efd08d7771 | ||
|
|
ff6cb2e217 | ||
|
|
d774620fea | ||
|
|
b26ff010fc | ||
|
|
4b49f2ba4b | ||
|
|
b4b8fe8828 | ||
|
|
4e8ab051a0 | ||
|
|
f61e35b218 | ||
|
|
b2f90f24a3 | ||
|
|
6721eb4752 | ||
|
|
a9e4fd1e40 | ||
|
|
621750976a | ||
|
|
5359e88806 | ||
|
|
76636931ba | ||
|
|
180c1fc66e | ||
|
|
c0ff052dab | ||
|
|
94cffb6ba3 | ||
|
|
9ad5628e69 | ||
|
|
6d397a7894 | ||
|
|
165fea6ba9 | ||
|
|
d079e2087c | ||
|
|
e3b36ebdcc | ||
|
|
5e791b11c0 | ||
|
|
50be65b76e | ||
|
|
7bb86e30bf | ||
|
|
ea56b45b7e | ||
|
|
f53682da7a | ||
|
|
71884ac088 | ||
|
|
969c278cb0 | ||
|
|
6fe2a1e036 | ||
|
|
9a60e0c68e | ||
|
|
46f553ca11 | ||
|
|
4f0a0c2184 | ||
|
|
c107b193d9 | ||
|
|
2db7ced7a4 | ||
|
|
6361d9a334 | ||
|
|
2cf54f04ef | ||
|
|
f840ce5c94 | ||
|
|
35e7b55490 | ||
|
|
1f8d1f23b3 | ||
|
|
e93011791e | ||
|
|
4460564a48 | ||
|
|
4d6658136e | ||
|
|
5767e665c2 | ||
|
|
3cf9958cb9 |
@@ -0,0 +1,412 @@
|
|||||||
|
# OpenClaw 在线执行指令:启动执行器、配置 Secret 引用并确认 Gitea 同步
|
||||||
|
|
||||||
|
- 任务 ID:`AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
|
||||||
|
- 执行器:OpenClaw
|
||||||
|
- 任务分支:`task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
|
||||||
|
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||||
|
- 语言:中文
|
||||||
|
- 执行模式:无人值守、证据驱动、默认拒绝
|
||||||
|
|
||||||
|
## 1. 任务目标
|
||||||
|
|
||||||
|
一次性完成以下工作:
|
||||||
|
|
||||||
|
1. 发现并启动环境中已注册的 OpenClaw 在线执行器;
|
||||||
|
2. 领取本任务并持续写入 claim、heartbeat、progress、blocker 和 result;
|
||||||
|
3. 将任务分支同步到最新 Candidate HEAD;
|
||||||
|
4. 确认 Candidate、OpenClaw Task、Codex Task 三个分支已同步到 Gitea,且本地与远端 OID 完全一致;
|
||||||
|
5. 从已批准的 Secret Provider、Credential Store 或受保护文件中解析 OpenClaw Runtime 配置;
|
||||||
|
6. 只把 Secret 引用和受保护文件路径写入 AIOS 环境配置,不得把明文 Secret 写入 Git、日志、Evidence 或终端输出;
|
||||||
|
7. 执行文档—代码检查、候选测试、全量验收和预发布健康检查;
|
||||||
|
8. 对低风险部署或 Runtime 缺陷创建 Patch 并修复;
|
||||||
|
9. 把所有修改和阻塞提交到系统,交由 Codex 统一审视。
|
||||||
|
|
||||||
|
## 2. 不可违反的边界
|
||||||
|
|
||||||
|
- 不得直接修改或 push `main`;
|
||||||
|
- 不得使用 `--force` 或改写共享分支历史;
|
||||||
|
- 不得创建生产 Tag;
|
||||||
|
- 不得切换生产流量,除非 AIOS Owner 后续明确批准;
|
||||||
|
- 不得提交 Token、密码、API Key、私钥、签名材料或 Secret 内容;
|
||||||
|
- 不得在日志中执行 `env`、`set`、`printenv` 或输出 Secret 文件内容;
|
||||||
|
- 不得修改 AIOS 唯一控制面、多租户或审批边界;
|
||||||
|
- 发现架构、安全或跨模块阻塞时必须升级 Codex;
|
||||||
|
- 未生成 claim/heartbeat 证据前不得标记为运行中;
|
||||||
|
- 未通过验收前不得标记为完成。
|
||||||
|
|
||||||
|
## 3. 第一阶段:执行器发现与领取
|
||||||
|
|
||||||
|
### 3.1 发现执行器入口
|
||||||
|
|
||||||
|
使用当前环境中已经注册和批准的 OpenClaw 执行入口。可检查:
|
||||||
|
|
||||||
|
- systemd 服务;
|
||||||
|
- 容器或 Compose 服务;
|
||||||
|
- 已安装的 OpenClaw CLI;
|
||||||
|
- AIOS Agent Runner;
|
||||||
|
- 已配置的队列消费者或调度器。
|
||||||
|
|
||||||
|
不得臆造不存在的命令、服务名或路径。
|
||||||
|
|
||||||
|
如果找不到可执行入口,立即创建 Blocker:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-EXECUTOR-ENTRYPOINT-MISSING",
|
||||||
|
"severity": "high",
|
||||||
|
"description": "环境中未发现已注册的 OpenClaw 执行器入口",
|
||||||
|
"owner": "AIOS 运维负责人",
|
||||||
|
"exit_condition": "提供或启用已批准的 OpenClaw systemd、容器、CLI 或 Agent Runner 入口"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
状态更新为 `blocked`,不得伪报已经启动。
|
||||||
|
|
||||||
|
### 3.2 启动执行器
|
||||||
|
|
||||||
|
发现入口后:
|
||||||
|
|
||||||
|
1. 验证配置来源和运行用户;
|
||||||
|
2. 启动或恢复执行器;
|
||||||
|
3. 验证进程/容器处于运行状态;
|
||||||
|
4. 验证执行器能够读取本任务分支;
|
||||||
|
5. 立即写入 `.agent/claim.json`;
|
||||||
|
6. 更新 `.agent/status.json`:`queued -> claimed -> running`;
|
||||||
|
7. 写入首次 `.agent/heartbeat.json`;
|
||||||
|
8. 将证据提交到任务分支并同步 Gitea。
|
||||||
|
|
||||||
|
`claim.json` 至少包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-claim.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"executor": "openclaw",
|
||||||
|
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"candidate_commit": "<领取时实际 Candidate OID>",
|
||||||
|
"claimed_at": "<UTC ISO-8601>",
|
||||||
|
"executor_instance": "<非敏感实例标识>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Heartbeat 间隔不得超过 300 秒。
|
||||||
|
|
||||||
|
## 4. 第二阶段:同步最新 Candidate
|
||||||
|
|
||||||
|
### 4.1 获取最新引用
|
||||||
|
|
||||||
|
只更新 remote-tracking ref,不得 fetch 到已经被 Worktree 检出的本地 Branch Ref。
|
||||||
|
|
||||||
|
执行原则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fetch remote candidate -> refs/remotes/<remote>/<candidate>
|
||||||
|
```
|
||||||
|
|
||||||
|
不得使用会触发以下错误的流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
refusing to fetch into branch checked out at worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 更新任务分支
|
||||||
|
|
||||||
|
将最新 Candidate 合并到 OpenClaw 任务分支:
|
||||||
|
|
||||||
|
- 使用普通 merge 或团队批准的线性更新方式;
|
||||||
|
- 不得 force reset 已经提交的 Agent Evidence;
|
||||||
|
- 发生冲突时停止自动扩大修改范围;
|
||||||
|
- 文档、任务载荷或架构冲突升级 Codex;
|
||||||
|
- 合并完成后把 `.agent/task.json` 与 `.agent/status.json` 中的 `candidate_commit` 更新为实际 Candidate OID。
|
||||||
|
|
||||||
|
### 4.3 必须记录
|
||||||
|
|
||||||
|
在 `.agent/progress.json` 记录:
|
||||||
|
|
||||||
|
- Candidate 本地 OID;
|
||||||
|
- Candidate Gitea OID;
|
||||||
|
- Task Branch 合并前后 OID;
|
||||||
|
- 修改文件;
|
||||||
|
- 是否发生冲突;
|
||||||
|
- 合并 Commit。
|
||||||
|
|
||||||
|
## 5. 第三阶段:确认 Gitea 同步
|
||||||
|
|
||||||
|
需要验证以下分支:
|
||||||
|
|
||||||
|
```text
|
||||||
|
integration/aios-v2r1-cto-production-candidate-20260720
|
||||||
|
task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720
|
||||||
|
task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720
|
||||||
|
```
|
||||||
|
|
||||||
|
目标仓库:
|
||||||
|
|
||||||
|
```text
|
||||||
|
g.cnnetmkt.top/git/aios/aios
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 同步规则
|
||||||
|
|
||||||
|
- 使用环境中已配置的 Gitea Remote 和凭据引用;
|
||||||
|
- 不在 Evidence 中记录带凭据的 Remote URL;
|
||||||
|
- 禁止 Force Push;
|
||||||
|
- Candidate 和 Task Branch 可以普通 Push;
|
||||||
|
- Push 失败时记录 stderr 的脱敏摘要,不得记录凭据。
|
||||||
|
|
||||||
|
### 5.2 OID 验证
|
||||||
|
|
||||||
|
每个分支必须同时取得:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local_oid
|
||||||
|
gitea_oid
|
||||||
|
match = true/false
|
||||||
|
```
|
||||||
|
|
||||||
|
验收条件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local_oid == gitea_oid
|
||||||
|
```
|
||||||
|
|
||||||
|
建议通过 remote-tracking ref 或 `git ls-remote --heads` 取得远端 OID。输出中不得泄露认证信息。
|
||||||
|
|
||||||
|
如果 Push 或 OID 校验失败,创建 Blocker:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-GITEA-SYNC",
|
||||||
|
"severity": "high",
|
||||||
|
"description": "本地与 Gitea 分支 OID 不一致或无推送权限",
|
||||||
|
"owner": "GitOps/Gitea 运维负责人",
|
||||||
|
"exit_condition": "三个目标分支均完成普通推送且 local_oid 等于 gitea_oid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Gitea OID 一致前,不得继续预发布部署。
|
||||||
|
|
||||||
|
## 6. 第四阶段:配置 Secret 引用
|
||||||
|
|
||||||
|
### 6.1 需要解析的配置
|
||||||
|
|
||||||
|
至少确认:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AIOS_OPENCLAW_URL
|
||||||
|
AIOS_OPENCLAW_CREDENTIAL_REF
|
||||||
|
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE
|
||||||
|
AIOS_OPENCLAW_SIGNING_KEY_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `AIOS_OPENCLAW_URL` 是经过批准的 Runtime Endpoint;
|
||||||
|
- `AIOS_OPENCLAW_CREDENTIAL_REF` 只保存 Secret Provider 引用;
|
||||||
|
- `AIOS_OPENCLAW_SIGNING_MATERIAL_FILE` 指向仓库外的受保护文件;
|
||||||
|
- `AIOS_OPENCLAW_SIGNING_KEY_ID` 是 Key 标识,不是 Key 内容。
|
||||||
|
|
||||||
|
### 6.2 Secret 来源优先级
|
||||||
|
|
||||||
|
按以下顺序查找:
|
||||||
|
|
||||||
|
1. 已批准的运行时 Secret Store;
|
||||||
|
2. 已批准的 Credential Provider;
|
||||||
|
3. systemd Credential;
|
||||||
|
4. 只读 Secret Volume;
|
||||||
|
5. 仓库外受保护文件。
|
||||||
|
|
||||||
|
不得:
|
||||||
|
|
||||||
|
- 从 Git 历史恢复 Secret;
|
||||||
|
- 从日志、Shell History 或旧 Evidence 复制 Secret;
|
||||||
|
- 新建明文 Secret 并提交;
|
||||||
|
- 把 Secret 内容写入 `.env` 模板或任务 JSON。
|
||||||
|
|
||||||
|
### 6.3 配置文件与权限
|
||||||
|
|
||||||
|
生产候选环境文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/aios-v2/aios-v2.env
|
||||||
|
```
|
||||||
|
|
||||||
|
建议权限:
|
||||||
|
|
||||||
|
```text
|
||||||
|
owner: root
|
||||||
|
group: aios
|
||||||
|
mode: 0640 或更严格
|
||||||
|
```
|
||||||
|
|
||||||
|
签名材料文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
位于仓库外
|
||||||
|
owner: aios 或经批准的运行用户
|
||||||
|
mode: 0600 或更严格
|
||||||
|
```
|
||||||
|
|
||||||
|
只验证以下内容:
|
||||||
|
|
||||||
|
- 引用非空;
|
||||||
|
- 文件存在;
|
||||||
|
- 运行用户可读;
|
||||||
|
- 权限不宽松;
|
||||||
|
- 文件不在 Git Worktree;
|
||||||
|
- 不输出文件内容或长度特征。
|
||||||
|
|
||||||
|
### 6.4 缺少 Secret 时的行为
|
||||||
|
|
||||||
|
若无法从已批准来源解析任一必需配置,创建:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-SECRET-REF-MISSING",
|
||||||
|
"severity": "high",
|
||||||
|
"description": "缺少已批准的 OpenClaw Runtime Endpoint、Credential Ref、Signing File 或 Key ID",
|
||||||
|
"owner": "AIOS Secret/运维负责人",
|
||||||
|
"exit_condition": "四项配置均可从批准来源解析,且权限检查通过"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不得要求用户在聊天或 Git 中提供 Secret 明文。
|
||||||
|
|
||||||
|
## 7. 第五阶段:候选验收与预发布
|
||||||
|
|
||||||
|
在 Gitea OID 和 Secret 引用检查都通过后,依次执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/aios_v2_docs_code_check.py \
|
||||||
|
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||||
|
|
||||||
|
python3 -m unittest discover \
|
||||||
|
-s tests/aios_v2_candidate \
|
||||||
|
-p 'test_*.py' \
|
||||||
|
-v
|
||||||
|
|
||||||
|
bash deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
部署后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIOS_RUN_LIVE_HEALTHCHECK=1 \
|
||||||
|
bash deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
|
||||||
|
bash deploy/aios-v2/healthcheck/check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
必须验证:
|
||||||
|
|
||||||
|
- AIOS HTTP Health;
|
||||||
|
- SQLite `quick_check=ok`;
|
||||||
|
- GitOps Controller detached + clean;
|
||||||
|
- OpenClaw execute/status/cancel/timeout/health;
|
||||||
|
- Hermes claim/heartbeat/complete/fail/lease recovery;
|
||||||
|
- 服务重启后 Tenant/Agent/Task/Audit/Idempotency 恢复;
|
||||||
|
- 回滚后健康检查通过;
|
||||||
|
- 日志与 Evidence 无 Secret。
|
||||||
|
|
||||||
|
## 8. Bug 修复规则
|
||||||
|
|
||||||
|
OpenClaw 可自行修复:
|
||||||
|
|
||||||
|
- systemd、Docker、Compose、路径和权限;
|
||||||
|
- 环境变量名或启动参数;
|
||||||
|
- Healthcheck;
|
||||||
|
- 明确的小型 Runtime Adapter 兼容问题;
|
||||||
|
- 日志、错误消息和超时边界;
|
||||||
|
- 测试、部署或中文文档中的明确错误。
|
||||||
|
|
||||||
|
每个修复必须:
|
||||||
|
|
||||||
|
1. 创建 `patch/bugfix/*`;
|
||||||
|
2. 记录复现步骤;
|
||||||
|
3. 添加或更新测试;
|
||||||
|
4. 修改代码;
|
||||||
|
5. 同步 `docs/aios-v2/` 和追踪矩阵;
|
||||||
|
6. 执行文档门禁和相关测试;
|
||||||
|
7. 提交 Git;
|
||||||
|
8. 更新 `.agent/blockers.json` 和 `.agent/progress.json`;
|
||||||
|
9. 提交 Codex 审查。
|
||||||
|
|
||||||
|
以下问题必须升级 Codex:
|
||||||
|
|
||||||
|
- Control Plane、Task Lifecycle、Policy 或 Approval 变化;
|
||||||
|
- 多租户或身份边界变化;
|
||||||
|
- OpenClaw/Hermes Contract 不兼容变化;
|
||||||
|
- 数据迁移、持久化或回滚策略变化;
|
||||||
|
- 高风险安全问题;
|
||||||
|
- 需要修改 ADR 的问题;
|
||||||
|
- 超出低风险修复范围的阻塞。
|
||||||
|
|
||||||
|
## 9. 证据文件
|
||||||
|
|
||||||
|
任务分支必须持续维护:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.agent/claim.json
|
||||||
|
.agent/heartbeat.json
|
||||||
|
.agent/progress.json
|
||||||
|
.agent/blockers.json
|
||||||
|
.agent/result.json
|
||||||
|
.agent/status.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`result.json` 至少包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-result.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"final_status": "complete|failed|blocked",
|
||||||
|
"candidate_commit": "<OID>",
|
||||||
|
"task_commit": "<OID>",
|
||||||
|
"gitea_sync": {
|
||||||
|
"candidate_match": true,
|
||||||
|
"openclaw_task_match": true,
|
||||||
|
"codex_task_match": true
|
||||||
|
},
|
||||||
|
"secret_reference_status": "configured|blocked",
|
||||||
|
"executor_status": "running|blocked",
|
||||||
|
"test_status": "passed|failed|not_run",
|
||||||
|
"deployment_status": "passed|failed|not_run",
|
||||||
|
"rollback_status": "passed|failed|not_run",
|
||||||
|
"documentation_sync_status": "passed|failed",
|
||||||
|
"changed_files": [],
|
||||||
|
"commits": [],
|
||||||
|
"open_blockers": [],
|
||||||
|
"remaining_risks": [],
|
||||||
|
"completed_at": "<UTC ISO-8601>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 完成条件
|
||||||
|
|
||||||
|
只有以下条件全部满足,任务才可标记为 `complete`:
|
||||||
|
|
||||||
|
- OpenClaw 执行器已启动并持续 Heartbeat;
|
||||||
|
- 已领取任务;
|
||||||
|
- 任务分支已合并最新 Candidate;
|
||||||
|
- Candidate、OpenClaw Task、Codex Task 在本地与 Gitea 的 OID 一致;
|
||||||
|
- Secret 引用和受保护文件权限检查通过;
|
||||||
|
- 文档—代码检查通过;
|
||||||
|
- 候选测试通过;
|
||||||
|
- 预发布部署和在线健康检查通过;
|
||||||
|
- Runtime 联调、重启和回滚演练通过;
|
||||||
|
- 所有修改、Blocker、Commit、测试和结果已回写;
|
||||||
|
- Codex 已能读取并审查完整证据。
|
||||||
|
|
||||||
|
如果缺少外部权限、执行入口或已批准 Secret,任务必须标记为 `blocked`,并准确说明 Owner 与解除条件,不得伪报完成。
|
||||||
|
|
||||||
|
## 11. Owner Gate
|
||||||
|
|
||||||
|
完成本指令不等于自动批准生产发布。以下动作仍需 AIOS Owner 明确批准:
|
||||||
|
|
||||||
|
- 合并 `main`;
|
||||||
|
- 创建生产 Tag;
|
||||||
|
- 正式生产部署;
|
||||||
|
- 生产流量切换;
|
||||||
|
- 接受高风险剩余问题。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-blockers.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"updated_at": "2026-07-21T03:23:27Z",
|
||||||
|
"evidence_writer": "windows-chatgpt-owner",
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-OPENCLAW-PLAINTEXT-SECRETS",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "OpenClaw managed config、auth profile store 与 models.json 存在三处明文凭据。",
|
||||||
|
"fix": "使用 OpenClaw 原生 secrets apply 迁移为单一 file Secret Provider;Gateway Token 已轮换,OneAPI 使用单一 auth-profile SecretRef。",
|
||||||
|
"verification": "audit plaintext/unresolved/shadowed/legacy 全部为 0,实际 OneAPI agent 调用通过。",
|
||||||
|
"commit": "bf01a13f3aaf42a386232ed2f5409feb0f79fcf6",
|
||||||
|
"resolved_at": "2026-07-20T10:17:49Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-AIOS-V2-SIGNING-MATERIAL-MISSING",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "Key ID 标称 Ed25519,但代码实际使用 HMAC 且材料文件缺失。",
|
||||||
|
"fix": "签名合同改为真实 Ed25519,并由 Owner 在受保护边界生成运行材料。",
|
||||||
|
"verification": "aios:aios 0600;resolver sign/verify 与篡改拒绝通过。",
|
||||||
|
"commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||||
|
"resolved_at": "2026-07-20T10:17:49Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-CANDIDATE-NOT-MERGED",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "OpenClaw 任务未包含最新 Candidate。",
|
||||||
|
"fix": "保护 Evidence 后以 --no-ff 合并 Candidate ba7669c。",
|
||||||
|
"verification": "Candidate 为任务分支祖先,普通 Push 后 local_oid 等于 gitea_oid。",
|
||||||
|
"commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||||
|
"resolved_at": "2026-07-20T10:17:49Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-OPENCLAW-RUN-PROVENANCE-MISSING",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "旧插件只有进程内 runId/sender Map,缺少 Owner-signed Schema、持久 Store 和限定 Git Writer。",
|
||||||
|
"fix": "部署 aios-tenant-boundary rc2,增加 Ed25519 issuer/store、Run/Sender/OID/期限绑定、一次性 permit 与七路径专用 Writer。",
|
||||||
|
"verification": "Provenance openclaw-v2r1-20260721T031523Z 完成 heartbeat 写入、Commit、普通 Push 和审计;README.md 越权写入被拒绝;Node 测试 9/9。",
|
||||||
|
"commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb",
|
||||||
|
"resolved_at": "2026-07-21T03:23:27Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-CONTROLLER-LEGACY-GITEA-CHECK",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "Controller 硬编码旧 aios-v23 仓库与 API,无法区分本地和 Gitea OID。",
|
||||||
|
"fix": "配置化 V2R1 仓库/分支,使用只读 systemd Credential,部署到 detached clean 工作树。",
|
||||||
|
"verification": "12 项测试通过;gitea/branch_sync/oid_match 为 true,sync_status=fully_synced。",
|
||||||
|
"commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||||
|
"resolved_at": "2026-07-20T10:17:49Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-claim.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"executor": "openclaw",
|
||||||
|
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||||
|
"claimed_at": "2026-07-20T08:24:30Z",
|
||||||
|
"executor_instance": "openclaw-main-cn-network-mkt",
|
||||||
|
"owner_observation_at": "2026-07-21T03:23:27Z",
|
||||||
|
"owner_observation": "五项高风险 Blocker 已解除;Provenance Writer 真实写入、普通 Push、审计和越权拒绝通过。",
|
||||||
|
"evidence_writer": "windows-chatgpt-owner",
|
||||||
|
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||||
|
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||||
|
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.credential-status.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"credential_name": "openclaw-deploy-cli",
|
||||||
|
"credential_type": "gitea-api-token-reference",
|
||||||
|
"endpoints": [
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
"https://g.cnnetmkt.top/git"
|
||||||
|
],
|
||||||
|
"reported_scope": "all",
|
||||||
|
"reported_status": "working",
|
||||||
|
"verification_source": "owner-reported",
|
||||||
|
"security_status": "secretref_resolved",
|
||||||
|
"reason": "令牌值曾在聊天中以明文出现,必须按已暴露凭据处理",
|
||||||
|
"required_actions": [
|
||||||
|
"已完成",
|
||||||
|
"新令牌已保存到受保护文件",
|
||||||
|
"令牌用于HTTPS Git认证"
|
||||||
|
],
|
||||||
|
"rotation_completed_at": "2026-07-20T08:48:00Z",
|
||||||
|
"new_token_created": "openclaw-deploy-task-commit",
|
||||||
|
"new_token_scope": "write:repository,read:repository",
|
||||||
|
"token_file_path": "/etc/openclaw/secrets/openclaw-gitea-deploy-token",
|
||||||
|
"token_file_owner": "root:root",
|
||||||
|
"token_file_mode": "0600",
|
||||||
|
"secret_provider_contract_path": "/etc/openclaw/SECRET_PROVIDER_CONTRACT.json",
|
||||||
|
"last_verified": "2026-07-21T03:23:27Z",
|
||||||
|
"verification_result": "HTTPS Git push successful with new token",
|
||||||
|
"env_file_reference": "AIOS_OPENCLAW_CREDENTIAL_REF=file:///etc/openclaw/secrets/openclaw-gitea-deploy-token",
|
||||||
|
"env_file_path": "/etc/aios-v2/aios-v2.env",
|
||||||
|
"env_file_mode": "0640 root:gitops",
|
||||||
|
"last_updated": "2026-07-21T03:23:27Z",
|
||||||
|
"old_credential_revoked": true,
|
||||||
|
"revoked": true,
|
||||||
|
"new_credential_name": "openclaw-deploy-cli-v2-20260720",
|
||||||
|
"rotated": true,
|
||||||
|
"new_account": "gitops-bot",
|
||||||
|
"scope": "write:repository",
|
||||||
|
"credential_ref": "/data/aios-gitops/runtime/secrets/openclaw-deploy-cli-v2-20260720",
|
||||||
|
"protected_file_owner": "root",
|
||||||
|
"protected_file_group": "gitops",
|
||||||
|
"protected_file_mode": "0640",
|
||||||
|
"verified_at": "2026-07-20T09:02:18Z",
|
||||||
|
"evidence_writer": "windows-chatgpt-owner",
|
||||||
|
"plaintext_findings_before": 3,
|
||||||
|
"plaintext_findings_after": 0,
|
||||||
|
"credentials_rotated": true,
|
||||||
|
"secret_values_recorded": false,
|
||||||
|
"signing_material_configured": true,
|
||||||
|
"signing_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||||
|
"signing_permission_check": "passed",
|
||||||
|
"runtime_sign_verify_test": "passed",
|
||||||
|
"run_provenance_status": "resolved",
|
||||||
|
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||||
|
"provenance_public_key_configured": true,
|
||||||
|
"provenance_secret_values_recorded": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"current_step": "GATE_A_PENDING",
|
||||||
|
"evidence_writer": "aios_evidence_write",
|
||||||
|
"executor": "openclaw",
|
||||||
|
"heartbeat_at": "2026-07-21T03:23:27Z",
|
||||||
|
"heartbeat_source": "owner-signed-provenance-writer",
|
||||||
|
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||||
|
"schema": "aios.v2.agent-heartbeat.v1",
|
||||||
|
"state": "running",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"valid_for_running": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-progress.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"state": "running",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"updated_at": "2026-07-21T03:23:27Z",
|
||||||
|
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||||
|
"task_commit_before_owner_evidence": "558dc932fa200831045e0f2edebbc12d775232bc",
|
||||||
|
"gitea_sync": {
|
||||||
|
"candidate_match": true,
|
||||||
|
"openclaw_task_match_before_owner_evidence": true,
|
||||||
|
"codex_task_match": true
|
||||||
|
},
|
||||||
|
"credential_rotation_status": "passed",
|
||||||
|
"executor_model_path": "passed",
|
||||||
|
"tests": "controller 12/12;provenance Node 9/9;真实签发/越权拒绝/heartbeat commit+push/审计通过;Gate A/B/离线 Acceptance 待执行",
|
||||||
|
"deployment": "aios-tenant-boundary rc2 已部署,runtime doctor 与 Gateway restart 通过",
|
||||||
|
"rollback": "两次验收脚本断言错误均实际触发备份回滚并恢复 rc1;修正后 rc2 部署成功",
|
||||||
|
"current_step": "GATE_A_PENDING",
|
||||||
|
"evidence_writer": "windows-chatgpt-owner",
|
||||||
|
"aios_v2_env_status": {
|
||||||
|
"exists": true,
|
||||||
|
"owner": "root",
|
||||||
|
"group": "gitops",
|
||||||
|
"mode": "0640",
|
||||||
|
"four_fields_nonempty": true,
|
||||||
|
"credential_ref_target_exists": true,
|
||||||
|
"signing_material_target_exists": false
|
||||||
|
},
|
||||||
|
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||||
|
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||||
|
"candidate_updated_at": "2026-07-20T09:39:16Z",
|
||||||
|
"worktree_repair": {
|
||||||
|
"status": "resolved",
|
||||||
|
"branch_unique_checkout": true,
|
||||||
|
"clean_before_merge": true,
|
||||||
|
"stale_lock_count": 0,
|
||||||
|
"prunable_count": 0,
|
||||||
|
"evidence_backup": "protected external backup created before merge",
|
||||||
|
"verified_at": "2026-07-20T09:39:16Z"
|
||||||
|
},
|
||||||
|
"release_recommendation": "STOP_PREPRODUCTION",
|
||||||
|
"gate_a_allowed": true,
|
||||||
|
"resolved_blockers": 5,
|
||||||
|
"open_blockers": 0,
|
||||||
|
"controller_patch_commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||||
|
"signing_contract_commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||||
|
"secret_audit": {
|
||||||
|
"plaintext_before": 3,
|
||||||
|
"plaintext_after": 0,
|
||||||
|
"unresolved_after": 0,
|
||||||
|
"shadowed_after": 0,
|
||||||
|
"legacy_after": 0,
|
||||||
|
"gateway_token_rotated": true,
|
||||||
|
"secret_values_recorded": false
|
||||||
|
},
|
||||||
|
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||||
|
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-result.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"final_status": "PARTIAL",
|
||||||
|
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||||
|
"task_commit_before_owner_evidence": "558dc932fa200831045e0f2edebbc12d775232bc",
|
||||||
|
"gitea_sync": {
|
||||||
|
"candidate_match": true,
|
||||||
|
"openclaw_task_match_before_owner_evidence": true,
|
||||||
|
"codex_task_match": true
|
||||||
|
},
|
||||||
|
"secret_reference_status": "resolved",
|
||||||
|
"executor_status": "running",
|
||||||
|
"test_status": "controller 12/12;provenance 9/9;live write/push/audit/deny passed;Gate A/B/offline pending",
|
||||||
|
"deployment_status": "provenance plugin rc2 active",
|
||||||
|
"rollback_status": "automatic plugin/config rollback exercised and passed",
|
||||||
|
"documentation_sync_status": "updated before Gate A",
|
||||||
|
"changed_files": [
|
||||||
|
".agent/claim.json",
|
||||||
|
".agent/heartbeat.json",
|
||||||
|
".agent/progress.json",
|
||||||
|
".agent/blockers.json",
|
||||||
|
".agent/result.json",
|
||||||
|
".agent/status.json",
|
||||||
|
".agent/credential-status.json"
|
||||||
|
],
|
||||||
|
"open_blockers": [],
|
||||||
|
"remaining_risks": [
|
||||||
|
"缺少 Owner-signed provenance issuer/store/writer 与越权拒绝证据"
|
||||||
|
],
|
||||||
|
"completed_at": "2026-07-21T03:23:27Z",
|
||||||
|
"evidence_writer": "windows-chatgpt-owner",
|
||||||
|
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||||
|
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||||
|
"candidate_updated_at": "2026-07-20T09:39:16Z",
|
||||||
|
"release_recommendation": "STOP_PREPRODUCTION",
|
||||||
|
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||||
|
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb"
|
||||||
|
}
|
||||||
-86
@@ -1,86 +0,0 @@
|
|||||||
---
|
|
||||||
task_id: TASK-AIOS-OPENCLAW-P6-MAIN-FASTFORWARD-20260714T014500+0800
|
|
||||||
executor: OPENCLAW
|
|
||||||
status: PASS
|
|
||||||
owner_approved: true
|
|
||||||
main_before: 6b70a3308031e3ef8e04a89ca02fd3bdcab1ebcc
|
|
||||||
integration_commit: 3cf9958cb94614449e093639cbf902216d73447f
|
|
||||||
integration_parent: 6b70a3308031e3ef8e04a89ca02fd3bdcab1ebcc
|
|
||||||
main_after: 3cf9958cb94614449e093639cbf902216d73447f
|
|
||||||
changed_files: 1
|
|
||||||
accepted_blob: ff31f7ab603d7a7cd07319e6f8422ae464916179
|
|
||||||
push_mode: fast-forward-only
|
|
||||||
force_push_used: false
|
|
||||||
merge_commit_created: false
|
|
||||||
tag_created: false
|
|
||||||
baseline_changed: false
|
|
||||||
deployment_performed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# P6 Main Fast-Forward Report
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| Task ID | `TASK-AIOS-OPENCLAW-P6-MAIN-FASTFORWARD-20260714T014500+0800` |
|
|
||||||
| Executor | OPENCLAW |
|
|
||||||
| Status | **PASS** |
|
|
||||||
| Owner approved | true |
|
|
||||||
|
|
||||||
## Git State
|
|
||||||
|
|
||||||
| Attribute | Before | After |
|
|
||||||
|-----------|--------|-------|
|
|
||||||
| `origin/main` | `6b70a3308031e3ef8e04a89ca02fd3bdcab1ebcc` | `3cf9958cb94614449e093639cbf902216d73447f` |
|
|
||||||
| Integration branch | — | `integration/v2r1-g1-evidence-main-20260714` |
|
|
||||||
| Integration commit | — | `3cf9958cb94614449e093639cbf902216d73447f` |
|
|
||||||
| Integration parent | — | `6b70a3308031e3ef8e04a89ca02fd3bdcab1ebcc` |
|
|
||||||
|
|
||||||
## Pre-push Verification
|
|
||||||
|
|
||||||
| Check | Command | Exit | Result |
|
|
||||||
|-------|---------|------|--------|
|
|
||||||
| Current main unchanged | `git rev-parse origin/main^{commit}` = `6b70a33...` | 0 | ✅ |
|
|
||||||
| Integration exists remotely | `git rev-parse origin/integration/v2r1-g1-evidence-main-20260714^{commit}` = `3cf9958...` | 0 | ✅ |
|
|
||||||
| Integration is direct child of main | `git rev-parse 3cf9958...^` = `6b70a33...` | 0 | ✅ |
|
|
||||||
| Accepted blob matches | `git rev-parse 3cf9958...:.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md` = `ff31f7a...` | 0 | ✅ |
|
|
||||||
| Prefix `3cf9958` matches | `test "${TARGET#3cf9958}" != "$TARGET"` | 0 | ✅ |
|
|
||||||
| Exactly 1 commit in range | `git rev-list --count 6b70a33... 3cf9958...` = `1` | 0 | ✅ |
|
|
||||||
| Main is ancestor of integration | `git merge-base --is-ancestor 6b70a33... 3cf9958...` | 0 | ✅ |
|
|
||||||
| Diff is exactly 1 file | `git diff --name-only 6b70a33... 3cf9958...` → 1 file | 0 | ✅ |
|
|
||||||
| Changed file is accepted evidence | path = `.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md` | 0 | ✅ |
|
|
||||||
|
|
||||||
## Push
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push --porcelain origin 3cf9958...:refs/heads/main
|
|
||||||
```
|
|
||||||
Output:
|
|
||||||
```
|
|
||||||
3cf9958...:refs/heads/main 6b70a33..3cf9958 Done
|
|
||||||
```
|
|
||||||
Exit code: **0**
|
|
||||||
|
|
||||||
## Post-push Verification
|
|
||||||
|
|
||||||
| Check | Command | Exit | Result |
|
|
||||||
|-------|---------|------|--------|
|
|
||||||
| origin/main updated | `git rev-parse origin/main^{commit}` = `3cf9958...` | 0 | ✅ |
|
|
||||||
| origin/main == integration commit | `test "$NEW_MAIN" = "$TARGET"` | 0 | ✅ |
|
|
||||||
| Post-push diff is 1 file | `git diff --name-only 6b70a33... 3cf9958...` → 1 file | 0 | ✅ |
|
|
||||||
| Diff file is accepted evidence | path = `.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md` | 0 | ✅ |
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
| Operation | Performed |
|
|
||||||
|-----------|-----------|
|
|
||||||
| Force push | false |
|
|
||||||
| Merge commit | false |
|
|
||||||
| Tag created | false |
|
|
||||||
| Baseline changed | false |
|
|
||||||
| Deployment | false |
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
**PASS** — `origin/main` fast-forwarded from `6b70a33...` to `3cf9958...` with a single evidence-only commit containing the accepted Gate 1 clean-room final evidence blob `ff31f7a...`. No force push, merge, baseline change, or deployment performed.
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# V2R1 Gate 1 — Clean-Room Final Evidence
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Date | 2026-07-14 |
|
||||||
|
| Source commit | `502ef30642597fb0b70b90ae2e8a0b6af217d80e` |
|
||||||
|
| Source branch | `origin/work/v2r1-integration-quality-2000` |
|
||||||
|
| Result branch | `fix/v2r1-g1-cleanroom-final-evidence-r2-20260714` |
|
||||||
|
| Result commit reference | `HEAD` |
|
||||||
|
| Gate | `G1` |
|
||||||
|
| Variant | `openclaw-cleanroom` |
|
||||||
|
| Status | `PASS` |
|
||||||
|
|
||||||
|
The immutable report uses `HEAD` for post-commit verification. The resolved commit object ID is recorded by the external acceptance result after this file is committed; a Git commit cannot reliably embed its own object ID because changing the file changes the tree and commit identity.
|
||||||
|
|
||||||
|
## Machine-Readable Result
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gate: G1
|
||||||
|
variant: openclaw-cleanroom
|
||||||
|
date: 2026-07-14
|
||||||
|
status: PASS
|
||||||
|
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||||
|
source_branch: origin/work/v2r1-integration-quality-2000
|
||||||
|
result_branch: fix/v2r1-g1-cleanroom-final-evidence-r2-20260714
|
||||||
|
result_commit_ref: HEAD
|
||||||
|
tests_total: 32
|
||||||
|
tests_passed: 32
|
||||||
|
tests_failed: 0
|
||||||
|
cleanup_scan: PASS
|
||||||
|
secret_scan: PASS
|
||||||
|
rollback_status: PASS
|
||||||
|
recovery_verified: true
|
||||||
|
workspace_clean_at_test_completion: true
|
||||||
|
merge_performed: false
|
||||||
|
tag_created: false
|
||||||
|
baseline_promoted: false
|
||||||
|
deployment_performed: false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence Provenance
|
||||||
|
|
||||||
|
The test, cleanup, secret-scan, rollback, and recovery results below are execution evidence supplied by OpenClaw for the Gate 1 run. This correction commit changes only the evidence report and does not claim that the GitOps API reran the suites.
|
||||||
|
|
||||||
|
## HMAC Test-Key Length Verification
|
||||||
|
|
||||||
|
| Test file | Variable | Test-only value | Length |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| `tests/v2r1/test_runtime_wiring.py:24` | `internal_key` | `b"runtime-internal-context-key-is-32bytes"` | 39 bytes |
|
||||||
|
| `tests/v2r1/test_runtime_wiring.py:25` | `ingress_key` | `b"runtime-external-ingress-key-is-32bytes"` | 39 bytes |
|
||||||
|
|
||||||
|
Both values are test fixtures. The supplied evidence reports that no production secret was introduced.
|
||||||
|
|
||||||
|
## Test Results Supplied by OpenClaw
|
||||||
|
|
||||||
|
### V2R1 suite — 29 of 29 passed
|
||||||
|
|
||||||
|
```shell
|
||||||
|
PYTHONPATH=active-runtime/app python3 -m pytest \
|
||||||
|
tests/v2r1/test_hermes_fabric.py \
|
||||||
|
tests/v2r1/test_message_ledger_append.py \
|
||||||
|
tests/v2r1/test_router_isolation.py \
|
||||||
|
tests/v2r1/test_runtime_identity_binding.py \
|
||||||
|
tests/v2r1/test_runtime_wiring.py \
|
||||||
|
tests/v2r1/test_tenant_context.py -v
|
||||||
|
# Reported result: 29 passed, exit code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration suite — 3 of 3 passed
|
||||||
|
|
||||||
|
```shell
|
||||||
|
PYTHONPATH=active-runtime/app python3 -m pytest tests/integration/test_runtime_ingress_flow.py -v
|
||||||
|
# Reported result: 3 passed, exit code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reported total: 32 of 32 passed.**
|
||||||
|
|
||||||
|
## Cleanup, Security, and Recovery Evidence
|
||||||
|
|
||||||
|
| Check | Supplied result |
|
||||||
|
|---|---|
|
||||||
|
| Conflict-marker scan | PASS |
|
||||||
|
| Cleanup scan | PASS |
|
||||||
|
| Secret scan | PASS |
|
||||||
|
| Legitimate hardcoded test keys | Two 39-byte fixtures in `test_runtime_wiring.py` |
|
||||||
|
| Rollback rehearsal | PASS; 13 of 13 core tests reported after rollback to `6077331` |
|
||||||
|
| Recovery verification | PASS |
|
||||||
|
|
||||||
|
## Deployment State
|
||||||
|
|
||||||
|
| Operation | Performed |
|
||||||
|
|---|---|
|
||||||
|
| Merge | No |
|
||||||
|
| Tag creation | No |
|
||||||
|
| Baseline promotion | No |
|
||||||
|
| Staging deployment | No |
|
||||||
|
| Production deployment | No |
|
||||||
|
|
||||||
|
## Post-Commit Verification
|
||||||
|
|
||||||
|
Run from the repository root after the commit is created:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SOURCE=502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||||
|
RESULT_FILE=.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
|
||||||
|
|
||||||
|
git rev-parse HEAD
|
||||||
|
|
||||||
|
test "$(git diff --name-only "$SOURCE" HEAD | wc -l | tr -d ' ')" = "1"
|
||||||
|
test "$(git diff --name-only "$SOURCE" HEAD)" = "$RESULT_FILE"
|
||||||
|
|
||||||
|
git ls-tree -r HEAD -- "$RESULT_FILE"
|
||||||
|
|
||||||
|
python3 - "$RESULT_FILE" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
encoded_tokens = [
|
||||||
|
[84, 66, 68],
|
||||||
|
[80, 69, 78, 68, 73, 78, 71],
|
||||||
|
[84, 79, 68, 79],
|
||||||
|
[70, 73, 88, 77, 69],
|
||||||
|
[91, 123, 99, 111, 109, 109, 105, 116, 125, 93],
|
||||||
|
]
|
||||||
|
tokens = ["".join(chr(value) for value in item) for item in encoded_tokens]
|
||||||
|
angle_token = re.compile(chr(60) + r"[^>\n]+" + chr(62))
|
||||||
|
|
||||||
|
hits = [token for token in tokens if token in text]
|
||||||
|
if angle_token.search(text):
|
||||||
|
hits.append("angle-delimited-token")
|
||||||
|
|
||||||
|
if hits:
|
||||||
|
print("TOKEN_SCAN=FAIL", hits)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print("TOKEN_SCAN=PASS")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Conditions
|
||||||
|
|
||||||
|
Gate 1 is eligible to proceed only when the external acceptance record confirms all of the following against the resolved `HEAD` commit:
|
||||||
|
|
||||||
|
- the commit exists and has source commit `502ef30642597fb0b70b90ae2e8a0b6af217d80e` as its parent;
|
||||||
|
- exactly this one report file changed;
|
||||||
|
- the committed report contains no unresolved legacy token;
|
||||||
|
- the result tree is not identical to rejected commit `71239b4d187e683e05fd22fc9d5df8ae324e3390`;
|
||||||
|
- no merge, tag, baseline promotion, or deployment occurred.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-status.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"executor": "openclaw",
|
||||||
|
"state": "running",
|
||||||
|
"status": "running",
|
||||||
|
"claimed_at": "2026-07-20T08:24:30Z",
|
||||||
|
"updated_at": "2026-07-21T03:23:27Z",
|
||||||
|
"last_heartbeat": "2026-07-21T03:23:27Z",
|
||||||
|
"heartbeat_valid_for_running": true,
|
||||||
|
"current_step": "GATE_A_PENDING",
|
||||||
|
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||||
|
"release_recommendation": "STOP_PREPRODUCTION",
|
||||||
|
"evidence_writer": "windows-chatgpt-owner",
|
||||||
|
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||||
|
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||||
|
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-task.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"display_name": "AIOS V2R1 OpenClaw 预发布部署与小型缺陷修复任务",
|
||||||
|
"language": "zh-CN",
|
||||||
|
"executor": "openclaw",
|
||||||
|
"role": "deployment-operator",
|
||||||
|
"state": "queued",
|
||||||
|
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||||
|
"canonical_task": "tasks/openclaw-production-deploy-task.yaml",
|
||||||
|
"supervisor_task": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"claim_required": true,
|
||||||
|
"heartbeat_interval_seconds": 300,
|
||||||
|
"evidence_directory": ".agent/evidence",
|
||||||
|
"instructions": [
|
||||||
|
"领取前读取候选分支完整中文文档和生产候选报告",
|
||||||
|
"领取后写入 .agent/claim.json 并把状态改为 claimed",
|
||||||
|
"每五分钟或每个主要步骤写入 heartbeat 和 progress",
|
||||||
|
"所有部署修改、小型缺陷、阻塞、测试和结果必须提交到 Git",
|
||||||
|
"架构、控制面、多租户或安全问题必须升级 Codex",
|
||||||
|
"禁止提交明文密钥、直接修改 main 或绕过 GitOps"
|
||||||
|
],
|
||||||
|
"acceptance_commands": [
|
||||||
|
"python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"python3 -m unittest discover -s tests/aios_v2_candidate -p test_*.py -v",
|
||||||
|
"bash deploy/aios-v2/acceptance/run-all.sh",
|
||||||
|
"bash deploy/aios-v2/healthcheck/check.sh"
|
||||||
|
],
|
||||||
|
"created_at": "2026-07-20T00:00:00Z",
|
||||||
|
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||||
|
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||||
|
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||||
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
task_id: TASK-AIOS-OPENCLAW-P6-MAIN-FASTFORWARD-20260714T014500+0800
|
|
||||||
created_at: 2026-07-14T01:45:00+08:00
|
|
||||||
timezone: Asia/Shanghai
|
|
||||||
project: AIOS
|
|
||||||
version: V2R1 Gate 1
|
|
||||||
executor: OPENCLAW
|
|
||||||
status: READY
|
|
||||||
objective: Update origin/main by fast-forward only to the reviewed evidence-only integration commit.
|
|
||||||
repository: /data/aios-gitops/workspace
|
|
||||||
owner_approved: true
|
|
||||||
owner_approval_context: The owner explicitly authorized proceeding to the next step after P6 integration review PASS.
|
|
||||||
expected_main_commit: 6b70a3308031e3ef8e04a89ca02fd3bdcab1ebcc
|
|
||||||
integration_branch: integration/v2r1-g1-evidence-main-20260714
|
|
||||||
integration_commit_expected_prefix: 3cf9958
|
|
||||||
accepted_file: .agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
|
|
||||||
accepted_blob: ff31f7ab603d7a7cd07319e6f8422ae464916179
|
|
||||||
review_result_commit: 86a32e4
|
|
||||||
work_branch: task/openclaw/p6-main-fastforward-20260714-014500
|
|
||||||
scope:
|
|
||||||
- Re-read remote main and integration refs.
|
|
||||||
- Enforce direct-child and exact single-file conditions.
|
|
||||||
- Push integration commit to refs/heads/main without force.
|
|
||||||
- Verify remote main after push.
|
|
||||||
- Record the execution result on the task branch.
|
|
||||||
out_of_scope:
|
|
||||||
- Merge commit
|
|
||||||
- Squash or rebase
|
|
||||||
- Force push or force-with-lease
|
|
||||||
- Tag creation
|
|
||||||
- Baseline promotion
|
|
||||||
- Deployment
|
|
||||||
- Business-code or test changes
|
|
||||||
risk_level: MEDIUM
|
|
||||||
production_authorized: false
|
|
||||||
result_file: .agent/results/openclaw/RESULT-TASK-AIOS-OPENCLAW-P6-MAIN-FASTFORWARD-20260714T014500+0800.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Owner-authorized P6 main fast-forward
|
|
||||||
|
|
||||||
## Mandatory preconditions
|
|
||||||
|
|
||||||
Execute from the authoritative repository, without switching the shared workspace to `main`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO=/data/aios-gitops/workspace
|
|
||||||
EXPECTED_MAIN=6b70a3308031e3ef8e04a89ca02fd3bdcab1ebcc
|
|
||||||
INTEGRATION_BRANCH=integration/v2r1-g1-evidence-main-20260714
|
|
||||||
ACCEPTED_FILE=.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
|
|
||||||
ACCEPTED_BLOB=ff31f7ab603d7a7cd07319e6f8422ae464916179
|
|
||||||
|
|
||||||
cd "$REPO"
|
|
||||||
git fetch --prune origin
|
|
||||||
|
|
||||||
CURRENT_MAIN=$(git rev-parse origin/main^{commit})
|
|
||||||
TARGET=$(git rev-parse "origin/$INTEGRATION_BRANCH"^{commit})
|
|
||||||
TARGET_PARENT=$(git rev-parse "$TARGET^" )
|
|
||||||
TARGET_BLOB=$(git rev-parse "$TARGET:$ACCEPTED_FILE")
|
|
||||||
|
|
||||||
printf 'CURRENT_MAIN=%s\nTARGET=%s\nTARGET_PARENT=%s\nTARGET_BLOB=%s\n' \
|
|
||||||
"$CURRENT_MAIN" "$TARGET" "$TARGET_PARENT" "$TARGET_BLOB"
|
|
||||||
|
|
||||||
test "$CURRENT_MAIN" = "$EXPECTED_MAIN"
|
|
||||||
test "$TARGET_PARENT" = "$EXPECTED_MAIN"
|
|
||||||
test "$TARGET_BLOB" = "$ACCEPTED_BLOB"
|
|
||||||
test "${TARGET#3cf9958}" != "$TARGET"
|
|
||||||
test "$(git rev-list --count "$EXPECTED_MAIN..$TARGET")" = "1"
|
|
||||||
git merge-base --is-ancestor "$EXPECTED_MAIN" "$TARGET"
|
|
||||||
|
|
||||||
mapfile -t CHANGED < <(git diff --name-only "$EXPECTED_MAIN" "$TARGET")
|
|
||||||
test "${#CHANGED[@]}" = "1"
|
|
||||||
test "${CHANGED[0]}" = "$ACCEPTED_FILE"
|
|
||||||
|
|
||||||
# This is a normal non-force push. It must fail if remote main has moved incompatibly.
|
|
||||||
git push --porcelain origin "$TARGET:refs/heads/main"
|
|
||||||
|
|
||||||
git fetch origin main
|
|
||||||
NEW_MAIN=$(git rev-parse origin/main^{commit})
|
|
||||||
test "$NEW_MAIN" = "$TARGET"
|
|
||||||
|
|
||||||
mapfile -t POST_CHANGED < <(git diff --name-only "$EXPECTED_MAIN" "$NEW_MAIN")
|
|
||||||
test "${#POST_CHANGED[@]}" = "1"
|
|
||||||
test "${POST_CHANGED[0]}" = "$ACCEPTED_FILE"
|
|
||||||
|
|
||||||
echo MAIN_FAST_FORWARD=PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
## Abort conditions
|
|
||||||
|
|
||||||
Return `BLOCKED` and do not push if any condition differs:
|
|
||||||
|
|
||||||
- `origin/main` is no longer `6b70a3308031e3ef8e04a89ca02fd3bdcab1ebcc`;
|
|
||||||
- the integration commit is not a direct child of that commit;
|
|
||||||
- the integration range contains more than one commit;
|
|
||||||
- the diff contains anything other than the accepted evidence file;
|
|
||||||
- the file blob differs from `ff31f7ab603d7a7cd07319e6f8422ae464916179`;
|
|
||||||
- the push would require force, merge, squash or rebase.
|
|
||||||
|
|
||||||
## Required result
|
|
||||||
|
|
||||||
Use an isolated worktree for the task branch. Commit and push the result file with:
|
|
||||||
|
|
||||||
```text
|
|
||||||
result(TASK-AIOS-OPENCLAW-P6-MAIN-FASTFORWARD-20260714T014500+0800): record main fast-forward
|
|
||||||
```
|
|
||||||
|
|
||||||
The result must contain:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
task_id: TASK-AIOS-OPENCLAW-P6-MAIN-FASTFORWARD-20260714T014500+0800
|
|
||||||
executor: OPENCLAW
|
|
||||||
status: PASS | BLOCKED | FAILED
|
|
||||||
owner_approved: true
|
|
||||||
main_before:
|
|
||||||
integration_commit:
|
|
||||||
integration_parent:
|
|
||||||
main_after:
|
|
||||||
changed_files:
|
|
||||||
accepted_blob:
|
|
||||||
push_mode: fast-forward-only
|
|
||||||
force_push_used: false
|
|
||||||
merge_commit_created: false
|
|
||||||
tag_created: false
|
|
||||||
baseline_changed: false
|
|
||||||
deployment_performed: false
|
|
||||||
commands:
|
|
||||||
exit_codes:
|
|
||||||
blockers:
|
|
||||||
remaining_risks:
|
|
||||||
recommendation:
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not create a tag, change the baseline, or deploy after the push. Return the complete result commit ID.
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.acceptance.v1",
|
||||||
|
"generated_at": "2026-07-21T03:26:12.120179Z",
|
||||||
|
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"commit": "09fa4f9d73799a2e924153d198d8ebb09ff15cfd",
|
||||||
|
"workspace_dirty": false,
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"warnings": 1,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "文档与代码匹配",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/文档与代码匹配.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python 语法编译",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Python_语法编译.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "候选单元与集成测试",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/候选单元与集成测试.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shell 脚本语法",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Shell_脚本语法.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "文档 Git 冲突标记",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/文档_Git_冲突标记.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "明文密钥基础扫描",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/明文密钥基础扫描.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "systemd 单元验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/systemd_单元验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docker Compose 配置验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Docker_Compose_配置验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "在线健康检查",
|
||||||
|
"status": "警告",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"final_status": "passed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.acceptance.v1",
|
||||||
|
"generated_at": "2026-07-21T03:40:03.455790Z",
|
||||||
|
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"commit": "57209760cd159e564d7de1160090917dbbc7ce0a",
|
||||||
|
"workspace_dirty": false,
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"warnings": 1,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "文档与代码匹配",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/文档与代码匹配.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python 语法编译",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Python_语法编译.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "候选单元与集成测试",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/候选单元与集成测试.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shell 脚本语法",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Shell_脚本语法.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "文档 Git 冲突标记",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/文档_Git_冲突标记.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "明文密钥基础扫描",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/明文密钥基础扫描.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "systemd 单元验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/systemd_单元验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docker Compose 配置验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Docker_Compose_配置验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "在线健康检查",
|
||||||
|
"status": "警告",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"final_status": "passed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.acceptance.v1",
|
||||||
|
"generated_at": "2026-07-21T04:11:34.291792Z",
|
||||||
|
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"commit": "fedb6b08fbb8c825972f700318a81e902e89cda5",
|
||||||
|
"workspace_dirty": false,
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"warnings": 1,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "文档与代码匹配",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/文档与代码匹配.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python 语法编译",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Python_语法编译.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "候选单元与集成测试",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/候选单元与集成测试.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shell 脚本语法",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Shell_脚本语法.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "文档 Git 冲突标记",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/文档_Git_冲突标记.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "明文密钥基础扫描",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/明文密钥基础扫描.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "systemd 单元验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/systemd_单元验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docker Compose 配置验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Docker_Compose_配置验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "在线健康检查",
|
||||||
|
"status": "警告",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"final_status": "passed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.acceptance.v1",
|
||||||
|
"generated_at": "2026-07-21T04:16:01.542048Z",
|
||||||
|
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"commit": "07b5c2c9c28e7840b6261172a7d34aa7de7c48c9",
|
||||||
|
"workspace_dirty": false,
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"warnings": 1,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "文档与代码匹配",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/文档与代码匹配.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python 语法编译",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Python_语法编译.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "候选单元与集成测试",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/候选单元与集成测试.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shell 脚本语法",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Shell_脚本语法.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "文档 Git 冲突标记",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/文档_Git_冲突标记.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "明文密钥基础扫描",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/明文密钥基础扫描.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "systemd 单元验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/systemd_单元验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docker Compose 配置验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Docker_Compose_配置验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "在线健康检查",
|
||||||
|
"status": "警告",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"final_status": "passed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.acceptance.v1",
|
||||||
|
"generated_at": "2026-07-21T04:21:06.025742Z",
|
||||||
|
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"commit": "c81d4f40f3191037cb1a34f169e0a9ed7825bdbf",
|
||||||
|
"workspace_dirty": false,
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"warnings": 1,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": "文档与代码匹配",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/文档与代码匹配.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python 语法编译",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Python_语法编译.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "候选单元与集成测试",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/候选单元与集成测试.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shell 脚本语法",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Shell_脚本语法.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "文档 Git 冲突标记",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/文档_Git_冲突标记.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "明文密钥基础扫描",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/明文密钥基础扫描.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "systemd 单元验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/systemd_单元验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docker Compose 配置验证",
|
||||||
|
"status": "通过",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Docker_Compose_配置验证.log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "在线健康检查",
|
||||||
|
"status": "警告",
|
||||||
|
"exit_code": 0,
|
||||||
|
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"final_status": "passed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
# AIOS V2R1 生产候选报告
|
||||||
|
|
||||||
|
- 候选版本:`V2R1C0-candidate`
|
||||||
|
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||||
|
- 报告语言:中文
|
||||||
|
- 架构负责人:AIOS V2 CTO
|
||||||
|
- 当前状态:代码与文档闭环已完成;精确 Commit 测试、真实 Runtime 联调和预发布验证待执行
|
||||||
|
|
||||||
|
## 1. 执行摘要
|
||||||
|
|
||||||
|
AIOS V2R1 Candidate 已完成以下核心建设:
|
||||||
|
|
||||||
|
- AIOS 唯一控制面;
|
||||||
|
- Tenant、Agent、Task、Policy、Approval 和 Audit 权威模型;
|
||||||
|
- SQLite WAL 候选持久化与重启恢复;
|
||||||
|
- Execution Orchestrator;
|
||||||
|
- OpenClaw 签名命令、Adapter、Transport、Health 和 Audit Callback;
|
||||||
|
- Hermes tenant/cell/profile 分区、Queue、Worker Lease、Heartbeat 和 Result;
|
||||||
|
- Agent Execution Evidence;
|
||||||
|
- Web Console;
|
||||||
|
- GitOps Status;
|
||||||
|
- systemd、Docker、Compose、Healthcheck 和 Rollback;
|
||||||
|
- 用户需求、目标架构、迁移策略、模块契约、追踪矩阵、文档治理和 ADR;
|
||||||
|
- 文档—代码匹配检查器;
|
||||||
|
- 全量验收入口;
|
||||||
|
- OpenClaw 预发布部署任务;
|
||||||
|
- Codex 架构监督和阻塞处理任务。
|
||||||
|
|
||||||
|
本报告不把“代码已提交”误报为“生产已验证”。以下状态仍需由无人值守任务执行并形成证据:
|
||||||
|
|
||||||
|
- 精确 Candidate Commit 的自动测试;
|
||||||
|
- 真实 OpenClaw Runtime 联调;
|
||||||
|
- Hermes 长任务、Worker 崩溃和 Lease Recovery 演练;
|
||||||
|
- systemd/Docker 预发布部署;
|
||||||
|
- 重启恢复与回滚演练;
|
||||||
|
- 本地仓库与 Gitea Candidate OID 一致验证;
|
||||||
|
- 身份代理、安全、性能和容量验证;
|
||||||
|
- Owner 对合并、Tag 和生产部署的批准。
|
||||||
|
|
||||||
|
## 2. 用户需求完成情况
|
||||||
|
|
||||||
|
| 需求 | 代码状态 | 测试状态 | 预发布状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AIOS 唯一控制面 | 已实现 | 已定义 | 待验证 |
|
||||||
|
| 复用 OpenClaw/Hermes/Agent Router | 已实现 | 已定义 | 待真实联调 |
|
||||||
|
| OpenClaw/Hermes 解耦 | 已实现 | 已定义 | 待旁路冻结验证 |
|
||||||
|
| 全面 Web 化 | 候选实现完成 | 冒烟测试待执行 | 待反向代理接入 |
|
||||||
|
| 多租户隔离 | 已实现 | Domain 负向测试已定义 | 待全链路安全验证 |
|
||||||
|
| 统一任务生命周期 | 已实现 | 已定义 | 待精确 Commit 执行 |
|
||||||
|
| 策略与审批 | 已实现 | 部分测试待补强 | 待身份代理验证 |
|
||||||
|
| 幂等与并发安全 | 已实现 | 基础测试已定义 | 待压力和并发验证 |
|
||||||
|
| 执行证据 | 已实现 | 已定义 | 待外部执行器持续回写 |
|
||||||
|
| GitOps 唯一流程 | 已实现并修复 Controller 冲突 | Runtime 部分验证 | 待双目标持续镜像证据 |
|
||||||
|
| 持久化与恢复 | 已实现 | 重启恢复测试已定义 | 待预发布演练 |
|
||||||
|
| 审计与合规 | 已实现候选 | 部分断言待补强 | 待不可变外部固化 |
|
||||||
|
| 密钥安全 | 引用模式已实现 | 基础扫描纳入验收 | 待 Secret Provider 联调 |
|
||||||
|
| 部署与回滚 | 资产完成 | 验收脚本已定义 | 待实际演练 |
|
||||||
|
| 文档与代码同步 | 已实现门禁 | 待精确 Commit 执行 | 待接入持续验收 |
|
||||||
|
|
||||||
|
## 3. 目标架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器 / Web Console / Public API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS 唯一控制面
|
||||||
|
Tenant | Agent | Task | Policy | Approval | Audit
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
OpenClaw Adapter Hermes Boundary
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
OpenClaw Runtime Hermes Workers
|
||||||
|
\ /
|
||||||
|
v v
|
||||||
|
结果回写 + 审计 + 执行证据
|
||||||
|
```
|
||||||
|
|
||||||
|
权威边界:
|
||||||
|
|
||||||
|
- AIOS:租户、身份、Agent、任务、策略、审批、审计和执行证据;
|
||||||
|
- Agent Router:调度与路由;
|
||||||
|
- OpenClaw:签名命令执行、取消、状态、健康和回调;
|
||||||
|
- Hermes:队列、Worker、Lease、Heartbeat 和结果;
|
||||||
|
- Web:只调用 AIOS API;
|
||||||
|
- GitOps:所有代码、文档、部署和修复变更入口。
|
||||||
|
|
||||||
|
## 4. 实现清单
|
||||||
|
|
||||||
|
### 4.1 Control Plane
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_v2/control_plane.py
|
||||||
|
active-runtime/app/aios_v2/persistence.py
|
||||||
|
active-runtime/app/aios_v2/gateway.py
|
||||||
|
active-runtime/app/aios_v2/app.py
|
||||||
|
active-runtime/app/aios_v2/orchestrator.py
|
||||||
|
active-runtime/app/aios_v2/execution_tracking.py
|
||||||
|
active-runtime/app/aios_v2/gitops_status.py
|
||||||
|
active-runtime/app/aios_v2/console.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 OpenClaw
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_openclaw/contract.py
|
||||||
|
active-runtime/app/aios_openclaw/adapter.py
|
||||||
|
active-runtime/app/aios_openclaw/transport.py
|
||||||
|
active-runtime/app/aios_openclaw/health.py
|
||||||
|
active-runtime/app/aios_openclaw/callback.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Hermes
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_hermes/contract.py
|
||||||
|
active-runtime/app/aios_hermes/adapter.py
|
||||||
|
active-runtime/app/aios_hermes/worker.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 测试与治理
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/aios_v2_candidate/test_candidate_flow.py
|
||||||
|
tools/aios_v2_docs_code_check.py
|
||||||
|
deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 部署
|
||||||
|
|
||||||
|
```text
|
||||||
|
deploy/aios-v2/systemd/aios-v2.service
|
||||||
|
deploy/aios-v2/env/aios-v2.env.example
|
||||||
|
deploy/aios-v2/docker/Dockerfile
|
||||||
|
deploy/aios-v2/docker/compose.yaml
|
||||||
|
deploy/aios-v2/healthcheck/check.sh
|
||||||
|
deploy/aios-v2/rollback/rollback.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 文档与任务
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/aios-v2/
|
||||||
|
tasks/openclaw-production-deploy-task.yaml
|
||||||
|
tasks/codex-production-supervisor-task.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 多租户与安全设计
|
||||||
|
|
||||||
|
### 5.1 权威上下文
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
session_id
|
||||||
|
trace_id
|
||||||
|
task_id
|
||||||
|
policy_version
|
||||||
|
idempotency_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 OpenClaw
|
||||||
|
|
||||||
|
- HMAC-SHA256 规范化签名;
|
||||||
|
- 签名绑定 tenant/task/trace/action/payload/idempotency/time;
|
||||||
|
- Runtime Result 身份字段必须匹配;
|
||||||
|
- Secret 只通过受保护文件或引用解析。
|
||||||
|
|
||||||
|
### 5.3 Hermes
|
||||||
|
|
||||||
|
- tenant/cell/profile 分区;
|
||||||
|
- Worker Lease 与 Heartbeat;
|
||||||
|
- revision 乐观并发;
|
||||||
|
- Lease 过期自动回到 QUEUED;
|
||||||
|
- Worker 完成前停止 Heartbeat,避免 revision 竞态。
|
||||||
|
|
||||||
|
### 5.4 Web/API
|
||||||
|
|
||||||
|
- 可信代理注入 `X-AIOS-*`;
|
||||||
|
- 浏览器不选择租户;
|
||||||
|
- CSP、no-store、nosniff、DENY frame、no-referrer;
|
||||||
|
- 应用端口不得直接暴露公网。
|
||||||
|
|
||||||
|
### 5.5 GitOps
|
||||||
|
|
||||||
|
- Controller detached + clean;
|
||||||
|
- 禁止 fetch 到已检出本地分支;
|
||||||
|
- 所有修改进入分支、Commit、测试和统一审查;
|
||||||
|
- 明文 Secret 门禁;
|
||||||
|
- 文档变化与代码变化同步门禁。
|
||||||
|
|
||||||
|
## 6. 持久化策略
|
||||||
|
|
||||||
|
候选版:
|
||||||
|
|
||||||
|
- SQLite WAL;
|
||||||
|
- `synchronous=FULL`;
|
||||||
|
- `BEGIN IMMEDIATE`;
|
||||||
|
- 原子 Snapshot;
|
||||||
|
- schema version;
|
||||||
|
- 状态目录与代码版本分离;
|
||||||
|
- Execution Evidence 租户分区原子 JSON。
|
||||||
|
|
||||||
|
已知限制:
|
||||||
|
|
||||||
|
- 单进程候选优先;
|
||||||
|
- 尚无生产 RLS、多实例 HA、PITR 和容量证据;
|
||||||
|
- 后续必须通过新 ADR 迁移到生产数据层。
|
||||||
|
|
||||||
|
## 7. 测试与验收
|
||||||
|
|
||||||
|
### 7.1 文档—代码匹配
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/aios_v2_docs_code_check.py \
|
||||||
|
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 候选测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover \
|
||||||
|
-s tests/aios_v2_candidate \
|
||||||
|
-p 'test_*.py' \
|
||||||
|
-v
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
|
||||||
|
- OpenClaw 完整执行;
|
||||||
|
- Hermes 完整执行;
|
||||||
|
- 跨租户拒绝;
|
||||||
|
- SQLite 重启恢复;
|
||||||
|
- Execution Evidence。
|
||||||
|
|
||||||
|
### 7.3 全量验收
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
可选在线健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIOS_RUN_LIVE_HEALTHCHECK=1 \
|
||||||
|
bash deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/aios-v2/healthcheck/check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- systemd;
|
||||||
|
- HTTP Health;
|
||||||
|
- 状态目录;
|
||||||
|
- SQLite quick_check;
|
||||||
|
- GitOps detached/clean;
|
||||||
|
- Evidence 文件。
|
||||||
|
|
||||||
|
## 8. 部署方式
|
||||||
|
|
||||||
|
### 8.1 systemd
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/aios-v2/aios-v2.env
|
||||||
|
/opt/aios/current
|
||||||
|
/var/lib/aios-v2
|
||||||
|
```
|
||||||
|
|
||||||
|
运行用户:`aios`。
|
||||||
|
|
||||||
|
安全选项:NoNewPrivileges、ProtectSystem、ProtectHome、PrivateTmp、Capability 限制。
|
||||||
|
|
||||||
|
### 8.2 Docker/Compose
|
||||||
|
|
||||||
|
- 非 root UID/GID;
|
||||||
|
- Read-only root filesystem;
|
||||||
|
- Drop all capabilities;
|
||||||
|
- 独立 State Volume;
|
||||||
|
- HTTP Healthcheck;
|
||||||
|
- Secret 文件只读挂载。
|
||||||
|
|
||||||
|
## 9. 回滚
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/aios-v2/rollback/rollback.sh <previous-release>
|
||||||
|
```
|
||||||
|
|
||||||
|
原则:
|
||||||
|
|
||||||
|
- 回滚前自动备份状态;
|
||||||
|
- 默认只回滚代码;
|
||||||
|
- 状态恢复需显式 `AIOS_V2_RESTORE_STATE=1`;
|
||||||
|
- 回滚后必须执行健康检查;
|
||||||
|
- 目标版本失败时自动恢复 previous release。
|
||||||
|
|
||||||
|
## 10. 无人值守任务
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
|
||||||
|
任务:`tasks/openclaw-production-deploy-task.yaml`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 预发布部署;
|
||||||
|
- 健康检查;
|
||||||
|
- Runtime 联调;
|
||||||
|
- 小型部署与 Runtime Bug 修复;
|
||||||
|
- 证据回写。
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
任务:`tasks/codex-production-supervisor-task.yaml`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 架构监督;
|
||||||
|
- 阻塞分析和跨模块修复;
|
||||||
|
- 文档、测试和安全审查;
|
||||||
|
- 发布建议。
|
||||||
|
|
||||||
|
所有修改和阻塞必须进入 Git 和 Execution Evidence,最终统一审视。
|
||||||
|
|
||||||
|
## 11. 当前已知风险
|
||||||
|
|
||||||
|
| 风险 | 级别 | 当前处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 精确 Candidate Commit 尚未执行全部测试 | 高 | OpenClaw 执行,Codex 监督 |
|
||||||
|
| 真实 OpenClaw Runtime 尚未联调 | 高 | 预发布任务必须完成 |
|
||||||
|
| 候选 SQLite 不支持生产多实例 HA | 高 | Candidate 单实例;后续 ADR 迁移 |
|
||||||
|
| 身份代理 Header 防伪尚未环境验证 | 高 | 预发布安全检查 |
|
||||||
|
| Web Console 为候选只读 MVP | 中 | 生产前补充 E2E 与安全验证 |
|
||||||
|
| 部分策略负向、并发和审计测试待补强 | 中 | Codex 登记 blocker 并补充 |
|
||||||
|
| 本地/Gitea 自动双写状态仍需持续证明 | 中 | 验收记录 OID |
|
||||||
|
| 性能、容量和 SLO 尚无实测 | 中 | 预发布基准测试 |
|
||||||
|
| 外部不可变审计固化尚未实现 | 中 | 生产数据层阶段完成 |
|
||||||
|
|
||||||
|
## 12. 发布 Gate
|
||||||
|
|
||||||
|
### 允许进入预发布
|
||||||
|
|
||||||
|
条件:
|
||||||
|
|
||||||
|
- Candidate 分支可见;
|
||||||
|
- 文档完整;
|
||||||
|
- 任务合同已生成;
|
||||||
|
- 部署资产存在;
|
||||||
|
- 无明文 Secret。
|
||||||
|
|
||||||
|
### 允许进入合并审查
|
||||||
|
|
||||||
|
条件:
|
||||||
|
|
||||||
|
- 全量验收通过;
|
||||||
|
- OpenClaw/Hermes 联调通过;
|
||||||
|
- 重启和回滚通过;
|
||||||
|
- 严重/高风险 blocker 已解决;
|
||||||
|
- Codex 输出正向监督结论;
|
||||||
|
- 本地/Gitea OID 一致。
|
||||||
|
|
||||||
|
### 允许生产发布
|
||||||
|
|
||||||
|
条件:
|
||||||
|
|
||||||
|
- Owner 明确批准;
|
||||||
|
- Merge 与 Tag 通过受控流程;
|
||||||
|
- 生产配置、Secret 和反向代理准备完成;
|
||||||
|
- 发布窗口、观察指标、回滚负责人明确。
|
||||||
|
|
||||||
|
## 13. 当前结论
|
||||||
|
|
||||||
|
**代码和正式中文文档已经达到候选闭环状态,但尚不能宣称生产已验证。**
|
||||||
|
|
||||||
|
下一阶段由 OpenClaw 执行预发布部署和低风险 Bug 修复,由 Codex 监督架构、文档、安全、测试和阻塞。所有过程证据统一进入系统,满足 Gate 后再请求 Owner 批准。
|
||||||
|
|
||||||
|
## 14. 更新规则
|
||||||
|
|
||||||
|
以下变化必须更新本报告:
|
||||||
|
|
||||||
|
- Candidate Commit;
|
||||||
|
- 测试结果;
|
||||||
|
- 部署和回滚结果;
|
||||||
|
- Blocker 和风险;
|
||||||
|
- OpenClaw/Codex 任务状态;
|
||||||
|
- 文档—代码匹配状态;
|
||||||
|
- Gitea OID;
|
||||||
|
- 发布建议和 Owner 决策。
|
||||||
|
|
||||||
|
## 15. Candidate 命令签名算法
|
||||||
|
|
||||||
|
Key ID `openclaw-v2r1-candidate-ed25519` 对应真实 Ed25519 私钥,不得再把同名材料作为 HMAC key 使用。私钥由 Owner 批准流程在受保护边界内生成,文件权限为 `0600`,Owner 为实际 AIOS 服务用户;Git、日志和 Evidence 仅记录 Key ID、权限检查和 sign/verify 结果。
|
||||||
|
|
||||||
|
## 16. 2026-07-20 阻塞清除快照
|
||||||
|
|
||||||
|
五项高风险 Blocker 均已形成运行证据。`BLK-OPENCLAW-RUN-PROVENANCE-MISSING` 通过 Git 中的 `aios-tenant-boundary` rc2 插件解除:Ed25519 Owner 签发记录持久保存,授权绑定 Run、Sender、Task、Claim OID、Candidate OID 与任务分支;专用 `aios_evidence_write` 只允许七个 `.agent` JSON 和固定 Git 操作,通用 Shell/写工具保持拒绝。
|
||||||
|
|
||||||
|
Provenance `openclaw-v2r1-20260721T031523Z` 已完成 `heartbeat.json` 更新、Commit、当前任务分支普通 Push 和追加式审计;`README.md` 越权写入被拒绝。由此允许启动 Gate A、Gate B 和离线验收,但在三项验收及后续在线联调完成前仍保持 `STOP_PREPRODUCTION`。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""AIOS-managed Hermes runtime boundary."""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
HermesAdapter,
|
||||||
|
HermesConflict,
|
||||||
|
HermesNotFound,
|
||||||
|
HermesRuntimeError,
|
||||||
|
InMemoryHermesStore,
|
||||||
|
)
|
||||||
|
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
|
||||||
|
from .worker import HermesWorker, WorkerRunResult
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HermesAdapter",
|
||||||
|
"HermesConflict",
|
||||||
|
"HermesHealth",
|
||||||
|
"HermesJob",
|
||||||
|
"HermesJobState",
|
||||||
|
"HermesNotFound",
|
||||||
|
"HermesResult",
|
||||||
|
"HermesRuntimeError",
|
||||||
|
"HermesWorker",
|
||||||
|
"InMemoryHermesStore",
|
||||||
|
"WorkerRunResult",
|
||||||
|
]
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""Tenant-partitioned Hermes queue and worker lease boundary."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Iterable, Mapping
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
ALLOWED_TRANSITIONS,
|
||||||
|
TERMINAL_STATES,
|
||||||
|
HermesHealth,
|
||||||
|
HermesJob,
|
||||||
|
HermesJobState,
|
||||||
|
HermesResult,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HermesRuntimeError(RuntimeError):
|
||||||
|
"""Base Hermes runtime-boundary failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class HermesNotFound(HermesRuntimeError):
|
||||||
|
"""A tenant-scoped job was not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class HermesConflict(HermesRuntimeError):
|
||||||
|
"""A state, lease, idempotency, or revision conflict occurred."""
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryHermesStore:
|
||||||
|
"""Thread-safe reference store; production backends implement this contract."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.jobs: dict[str, HermesJob] = {}
|
||||||
|
self.results: dict[str, HermesResult] = {}
|
||||||
|
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class HermesAdapter:
|
||||||
|
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: InMemoryHermesStore | None = None,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "hermes-runtime",
|
||||||
|
runtime_version: str = "v1",
|
||||||
|
default_lease_seconds: int = 60,
|
||||||
|
) -> None:
|
||||||
|
if default_lease_seconds < 5:
|
||||||
|
raise ValueError("default_lease_seconds must be >= 5")
|
||||||
|
self.store = store or InMemoryHermesStore()
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.runtime_version = runtime_version
|
||||||
|
self.default_lease_seconds = int(default_lease_seconds)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _digest(job: HermesJob) -> str:
|
||||||
|
raw = json.dumps(
|
||||||
|
{
|
||||||
|
"tenant_id": job.tenant_id,
|
||||||
|
"cell_id": job.cell_id,
|
||||||
|
"profile_id": job.profile_id,
|
||||||
|
"task_id": job.task_id,
|
||||||
|
"trace_id": job.trace_id,
|
||||||
|
"action": job.action,
|
||||||
|
"payload": dict(job.payload),
|
||||||
|
"priority": job.priority,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse(value: str | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format(value: datetime) -> str:
|
||||||
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_tenant(job: HermesJob, tenant_id: str) -> None:
|
||||||
|
if job.tenant_id != tenant_id:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
|
||||||
|
def enqueue(self, job: HermesJob) -> HermesJob:
|
||||||
|
if job.state != HermesJobState.QUEUED or job.revision != 1:
|
||||||
|
raise HermesConflict("new job must be QUEUED at revision 1")
|
||||||
|
digest = self._digest(job)
|
||||||
|
idem_ref = (job.tenant_id, job.idempotency_key)
|
||||||
|
with self.store.lock:
|
||||||
|
existing = self.store.jobs.get(job.job_id)
|
||||||
|
if existing is not None:
|
||||||
|
if self._digest(existing) != digest:
|
||||||
|
raise HermesConflict("job_id reused with different content")
|
||||||
|
return existing
|
||||||
|
prior = self.store.idempotency.get(idem_ref)
|
||||||
|
if prior is not None:
|
||||||
|
prior_id, prior_digest = prior
|
||||||
|
if prior_digest != digest:
|
||||||
|
raise HermesConflict("idempotency key reused with different content")
|
||||||
|
return self.store.jobs[prior_id]
|
||||||
|
self.store.jobs[job.job_id] = job
|
||||||
|
self.store.idempotency[idem_ref] = (job.job_id, digest)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
|
||||||
|
with self.store.lock:
|
||||||
|
job = self.store.jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(job, tenant_id)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def list_jobs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str | None = None,
|
||||||
|
profile_id: str | None = None,
|
||||||
|
states: Iterable[HermesJobState | str] | None = None,
|
||||||
|
) -> list[HermesJob]:
|
||||||
|
state_filter = {HermesJobState(item) for item in states} if states else None
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
jobs = [
|
||||||
|
job
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
if job.tenant_id == tenant_id
|
||||||
|
and (cell_id is None or job.cell_id == cell_id)
|
||||||
|
and (profile_id is None or job.profile_id == profile_id)
|
||||||
|
and (state_filter is None or job.state in state_filter)
|
||||||
|
]
|
||||||
|
return sorted(jobs, key=lambda item: (item.priority, item.created_at, item.job_id))
|
||||||
|
|
||||||
|
def claim(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int | None = None,
|
||||||
|
) -> HermesJob | None:
|
||||||
|
worker_id = str(worker_id or "").strip()
|
||||||
|
if not worker_id:
|
||||||
|
raise ValueError("worker_id is required")
|
||||||
|
duration = int(lease_seconds or self.default_lease_seconds)
|
||||||
|
if duration < 5:
|
||||||
|
raise ValueError("lease_seconds must be >= 5")
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
candidates = [
|
||||||
|
job
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
if job.tenant_id == tenant_id
|
||||||
|
and job.cell_id == cell_id
|
||||||
|
and job.profile_id == profile_id
|
||||||
|
and job.state == HermesJobState.QUEUED
|
||||||
|
]
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
current = sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda item: (item.priority, item.created_at, item.job_id),
|
||||||
|
)[0]
|
||||||
|
claimed = self._transition_locked(
|
||||||
|
current,
|
||||||
|
HermesJobState.CLAIMED,
|
||||||
|
assigned_worker_id=worker_id,
|
||||||
|
lease_expires_at=self._format(
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
return self._transition_locked(current, HermesJobState.RUNNING)
|
||||||
|
|
||||||
|
def heartbeat(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
duration = int(lease_seconds or self.default_lease_seconds)
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
|
||||||
|
raise HermesConflict("job does not accept heartbeat")
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
lease_expires_at=self._format(
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||||
|
),
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.jobs[job_id] = updated
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
output: Mapping[str, object],
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
return self._finish(
|
||||||
|
job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
state=HermesJobState.COMPLETED,
|
||||||
|
output=output,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
error: Mapping[str, object],
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
return self._finish(
|
||||||
|
job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
state=HermesJobState.FAILED,
|
||||||
|
error=error,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self.store.jobs.get(job_id)
|
||||||
|
if current is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(current, tenant_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
if current.state in TERMINAL_STATES:
|
||||||
|
result = self.store.results.get(job_id)
|
||||||
|
if result is None:
|
||||||
|
raise HermesConflict("terminal result missing")
|
||||||
|
return result
|
||||||
|
updated = self._transition_locked(current, HermesJobState.CANCELLED)
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=updated.job_id,
|
||||||
|
tenant_id=updated.tenant_id,
|
||||||
|
task_id=updated.task_id,
|
||||||
|
trace_id=updated.trace_id,
|
||||||
|
state=HermesJobState.CANCELLED,
|
||||||
|
worker_id=updated.assigned_worker_id,
|
||||||
|
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.results[job_id] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
job = self.store.jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(job, tenant_id)
|
||||||
|
result = self.store.results.get(job_id)
|
||||||
|
if result is None:
|
||||||
|
raise HermesConflict("job has no terminal result")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def health(self) -> HermesHealth:
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
active_jobs = sum(
|
||||||
|
1 for job in self.store.jobs.values()
|
||||||
|
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||||
|
)
|
||||||
|
queued_jobs = sum(
|
||||||
|
1 for job in self.store.jobs.values()
|
||||||
|
if job.state == HermesJobState.QUEUED
|
||||||
|
)
|
||||||
|
partitions = {
|
||||||
|
(job.tenant_id, job.cell_id, job.profile_id)
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
}
|
||||||
|
active_workers = sum(
|
||||||
|
1 for heartbeat, _ in self.store.worker_heartbeats.values()
|
||||||
|
if self._parse(heartbeat)
|
||||||
|
and self._parse(heartbeat) >= datetime.now(timezone.utc) - timedelta(seconds=120)
|
||||||
|
)
|
||||||
|
return HermesHealth(
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
status="healthy",
|
||||||
|
version=self.runtime_version,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
active_workers=active_workers,
|
||||||
|
active_jobs=active_jobs,
|
||||||
|
queued_jobs=queued_jobs,
|
||||||
|
tenant_partitions=len(partitions),
|
||||||
|
profile_isolation_enforced=True,
|
||||||
|
details={"store": type(self.store).__name__},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
state: HermesJobState,
|
||||||
|
output: Mapping[str, object] | None = None,
|
||||||
|
error: Mapping[str, object] | None = None,
|
||||||
|
expected_revision: int | None,
|
||||||
|
) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
updated = self._transition_locked(current, state)
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=updated.job_id,
|
||||||
|
tenant_id=updated.tenant_id,
|
||||||
|
task_id=updated.task_id,
|
||||||
|
trace_id=updated.trace_id,
|
||||||
|
state=state,
|
||||||
|
worker_id=worker_id,
|
||||||
|
output=output,
|
||||||
|
error=error,
|
||||||
|
started_at=current.updated_at,
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.results[job_id] = result
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
|
||||||
|
current = self.store.jobs.get(job_id)
|
||||||
|
if current is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(current, tenant_id)
|
||||||
|
if current.assigned_worker_id != worker_id:
|
||||||
|
raise HermesConflict("worker does not own job lease")
|
||||||
|
return current
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_revision(job: HermesJob, expected: int | None) -> None:
|
||||||
|
if expected is not None and job.revision != expected:
|
||||||
|
raise HermesConflict("job revision changed")
|
||||||
|
|
||||||
|
def _transition_locked(
|
||||||
|
self,
|
||||||
|
current: HermesJob,
|
||||||
|
target: HermesJobState,
|
||||||
|
*,
|
||||||
|
assigned_worker_id: str | None = None,
|
||||||
|
lease_expires_at: str | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
if target not in ALLOWED_TRANSITIONS[current.state]:
|
||||||
|
raise HermesConflict(
|
||||||
|
f"invalid transition {current.state.value} -> {target.value}"
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
state=target,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
assigned_worker_id=(
|
||||||
|
assigned_worker_id
|
||||||
|
if assigned_worker_id is not None
|
||||||
|
else current.assigned_worker_id
|
||||||
|
),
|
||||||
|
lease_expires_at=(
|
||||||
|
lease_expires_at
|
||||||
|
if lease_expires_at is not None
|
||||||
|
else (None if target in TERMINAL_STATES else current.lease_expires_at)
|
||||||
|
),
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.jobs[current.job_id] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def _requeue_expired_locked(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for current in list(self.store.jobs.values()):
|
||||||
|
expiry = self._parse(current.lease_expires_at)
|
||||||
|
if (
|
||||||
|
current.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||||
|
and expiry is not None
|
||||||
|
and expiry <= now
|
||||||
|
):
|
||||||
|
self.store.jobs[current.job_id] = replace(
|
||||||
|
current,
|
||||||
|
state=HermesJobState.QUEUED,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
assigned_worker_id=None,
|
||||||
|
lease_expires_at=None,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Tenant-scoped Hermes runtime contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
class HermesJobState(str, Enum):
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
CLAIMED = "CLAIMED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
|
||||||
|
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
|
||||||
|
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
|
||||||
|
HermesJobState.RUNNING: frozenset(
|
||||||
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||||
|
),
|
||||||
|
HermesJobState.COMPLETED: frozenset(),
|
||||||
|
HermesJobState.FAILED: frozenset(),
|
||||||
|
HermesJobState.CANCELLED: frozenset(),
|
||||||
|
}
|
||||||
|
TERMINAL_STATES = frozenset(
|
||||||
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesJob:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
profile_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
idempotency_key: str = ""
|
||||||
|
priority: int = 100
|
||||||
|
state: HermesJobState = HermesJobState.QUEUED
|
||||||
|
revision: int = 1
|
||||||
|
assigned_worker_id: str | None = None
|
||||||
|
lease_expires_at: str | None = None
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
updated_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
|
||||||
|
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||||
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||||
|
if not 0 <= int(self.priority) <= 1000:
|
||||||
|
raise ValueError("priority must be between 0 and 1000")
|
||||||
|
if self.revision < 1:
|
||||||
|
raise ValueError("revision must be >= 1")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
priority: int = 100,
|
||||||
|
) -> "HermesJob":
|
||||||
|
return cls(
|
||||||
|
job_id=f"hermes_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
cell_id=cell_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
task_id=task_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=action,
|
||||||
|
payload=_safe_mapping(payload),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesResult:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
state: HermesJobState
|
||||||
|
worker_id: str | None = None
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||||
|
if self.output is not None:
|
||||||
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||||
|
if self.error is not None:
|
||||||
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesHealth:
|
||||||
|
runtime_id: str
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
heartbeat_at: str
|
||||||
|
active_workers: int = 0
|
||||||
|
active_jobs: int = 0
|
||||||
|
queued_jobs: int = 0
|
||||||
|
tenant_partitions: int = 0
|
||||||
|
profile_isolation_enforced: bool = True
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("runtime_id", "status", "version", "heartbeat_at"):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
|
||||||
|
if int(getattr(self, name)) < 0:
|
||||||
|
raise ValueError(f"{name} must be >= 0")
|
||||||
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Hermes worker loop with lease renewal and bounded recovery."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
from .adapter import HermesAdapter, HermesConflict
|
||||||
|
from .contract import HermesJob, HermesJobState
|
||||||
|
|
||||||
|
|
||||||
|
JobHandler = Callable[[HermesJob], Mapping[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerRunResult:
|
||||||
|
claimed: bool
|
||||||
|
job_id: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HermesWorker:
|
||||||
|
"""Poll one tenant/cell/profile partition and execute jobs safely."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: HermesAdapter,
|
||||||
|
handler: JobHandler,
|
||||||
|
*,
|
||||||
|
worker_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
poll_seconds: float = 1.0,
|
||||||
|
heartbeat_seconds: float = 10.0,
|
||||||
|
) -> None:
|
||||||
|
if poll_seconds <= 0 or heartbeat_seconds <= 0:
|
||||||
|
raise ValueError("poll_seconds and heartbeat_seconds must be positive")
|
||||||
|
for name, value in {
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"cell_id": cell_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
}.items():
|
||||||
|
if not str(value or "").strip():
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
self.adapter = adapter
|
||||||
|
self.handler = handler
|
||||||
|
self.worker_id = worker_id
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.cell_id = cell_id
|
||||||
|
self.profile_id = profile_id
|
||||||
|
self.poll_seconds = float(poll_seconds)
|
||||||
|
self.heartbeat_seconds = float(heartbeat_seconds)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def run_once(self) -> WorkerRunResult:
|
||||||
|
job = self.adapter.claim(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
cell_id=self.cell_id,
|
||||||
|
profile_id=self.profile_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
)
|
||||||
|
if job is None:
|
||||||
|
return WorkerRunResult(claimed=False)
|
||||||
|
|
||||||
|
running = self.adapter.start(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
expected_revision=job.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
heartbeat_thread = threading.Thread(
|
||||||
|
target=self._heartbeat_loop,
|
||||||
|
args=(running.job_id, heartbeat_stop),
|
||||||
|
name=f"hermes-heartbeat-{self.worker_id}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
heartbeat_thread.start()
|
||||||
|
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
execution_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
output = dict(self.handler(running))
|
||||||
|
except Exception as exc: # runtime boundary: normalize below
|
||||||
|
execution_error = exc
|
||||||
|
finally:
|
||||||
|
# Stop the lease heartbeat before reading the final revision. This
|
||||||
|
# prevents a revision increment racing with complete/fail.
|
||||||
|
heartbeat_stop.set()
|
||||||
|
heartbeat_thread.join(timeout=max(self.heartbeat_seconds * 2, 1.0))
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = self.adapter.get(running.job_id, tenant_id=self.tenant_id)
|
||||||
|
if execution_error is None:
|
||||||
|
result = self.adapter.complete(
|
||||||
|
running.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
output=dict(output or {}),
|
||||||
|
expected_revision=current.revision,
|
||||||
|
)
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=result.job_id,
|
||||||
|
state=result.state.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.adapter.fail(
|
||||||
|
running.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
error={
|
||||||
|
"code": "worker_error",
|
||||||
|
"message": str(execution_error),
|
||||||
|
},
|
||||||
|
expected_revision=current.revision,
|
||||||
|
)
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=result.job_id,
|
||||||
|
state=result.state.value,
|
||||||
|
error=str(execution_error),
|
||||||
|
)
|
||||||
|
except HermesConflict as exc:
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=running.job_id,
|
||||||
|
state=HermesJobState.FAILED.value,
|
||||||
|
error=f"reconciliation_conflict: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_forever(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
result = self.run_once()
|
||||||
|
if not result.claimed:
|
||||||
|
self._stop.wait(self.poll_seconds)
|
||||||
|
|
||||||
|
def _heartbeat_loop(self, job_id: str, stop_event: threading.Event) -> None:
|
||||||
|
while not stop_event.wait(self.heartbeat_seconds):
|
||||||
|
try:
|
||||||
|
self.adapter.heartbeat(
|
||||||
|
job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
stop_event.set()
|
||||||
|
return
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""AIOS-managed OpenClaw execution adapter."""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
AdapterError,
|
||||||
|
CommandConflict,
|
||||||
|
CommandNotFound,
|
||||||
|
CommandRejected,
|
||||||
|
InMemoryCallbackSink,
|
||||||
|
OpenClawAdapter,
|
||||||
|
)
|
||||||
|
from .contract import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
sign_command,
|
||||||
|
verify_command_signature,
|
||||||
|
)
|
||||||
|
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AdapterError",
|
||||||
|
"CommandConflict",
|
||||||
|
"CommandNotFound",
|
||||||
|
"CommandRejected",
|
||||||
|
"CommandState",
|
||||||
|
"HTTPRuntimeTransport",
|
||||||
|
"InMemoryCallbackSink",
|
||||||
|
"InProcessRuntimeTransport",
|
||||||
|
"OpenClawAdapter",
|
||||||
|
"OpenClawCommand",
|
||||||
|
"OpenClawResult",
|
||||||
|
"RuntimeHealth",
|
||||||
|
"RuntimeTransport",
|
||||||
|
"sign_command",
|
||||||
|
"verify_command_signature",
|
||||||
|
]
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Signed, tenant-isolated execution adapter for OpenClaw runtimes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Callable, Mapping, Protocol
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
utc_now,
|
||||||
|
verify_command_signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterError(RuntimeError):
|
||||||
|
"""Base adapter failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRejected(AdapterError):
|
||||||
|
"""A command failed signature or contract validation."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandNotFound(AdapterError):
|
||||||
|
"""A tenant-scoped command is not visible."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandConflict(AdapterError):
|
||||||
|
"""A command or idempotency key conflicts with an existing execution."""
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeTransport(Protocol):
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
||||||
|
def cancel(self, command_id: str, *, tenant_id: str) -> bool: ...
|
||||||
|
def health(self) -> RuntimeHealth: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackSink(Protocol):
|
||||||
|
def on_started(self, result: OpenClawResult) -> None: ...
|
||||||
|
def on_completed(self, result: OpenClawResult) -> None: ...
|
||||||
|
def on_failed(self, result: OpenClawResult) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryCallbackSink:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.started: list[OpenClawResult] = []
|
||||||
|
self.completed: list[OpenClawResult] = []
|
||||||
|
self.failed: list[OpenClawResult] = []
|
||||||
|
|
||||||
|
def on_started(self, result: OpenClawResult) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.started.append(result)
|
||||||
|
|
||||||
|
def on_completed(self, result: OpenClawResult) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.completed.append(result)
|
||||||
|
|
||||||
|
def on_failed(self, result: OpenClawResult) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.failed.append(result)
|
||||||
|
|
||||||
|
|
||||||
|
SigningMaterialResolver = Callable[[str, str], bytes | str]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawAdapter:
|
||||||
|
"""Verify, de-duplicate, execute, cancel and reconcile commands."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
transport: RuntimeTransport,
|
||||||
|
signing_material_resolver: SigningMaterialResolver,
|
||||||
|
*,
|
||||||
|
callback_sink: CallbackSink | None = None,
|
||||||
|
default_timeout_seconds: float = 300.0,
|
||||||
|
) -> None:
|
||||||
|
if default_timeout_seconds <= 0:
|
||||||
|
raise ValueError("default_timeout_seconds must be positive")
|
||||||
|
self.transport = transport
|
||||||
|
self.signing_material_resolver = signing_material_resolver
|
||||||
|
self.callback_sink = callback_sink or InMemoryCallbackSink()
|
||||||
|
self.default_timeout_seconds = float(default_timeout_seconds)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._commands: dict[str, OpenClawCommand] = {}
|
||||||
|
self._results: dict[str, OpenClawResult] = {}
|
||||||
|
self._idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
|
||||||
|
def _verify(self, command: OpenClawCommand) -> None:
|
||||||
|
try:
|
||||||
|
key_material = self.signing_material_resolver(command.tenant_id, command.key_id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise CommandRejected("signing material unavailable") from exc
|
||||||
|
if not verify_command_signature(command, key_material):
|
||||||
|
raise CommandRejected("invalid or expired command signature")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_result(command: OpenClawCommand, result: OpenClawResult) -> None:
|
||||||
|
if (
|
||||||
|
result.command_id != command.command_id
|
||||||
|
or result.tenant_id != command.tenant_id
|
||||||
|
or result.task_id != command.task_id
|
||||||
|
or result.trace_id != command.trace_id
|
||||||
|
):
|
||||||
|
raise AdapterError("runtime returned mismatched command identity")
|
||||||
|
if result.state not in {
|
||||||
|
CommandState.COMPLETED,
|
||||||
|
CommandState.FAILED,
|
||||||
|
CommandState.CANCELLED,
|
||||||
|
CommandState.TIMED_OUT,
|
||||||
|
}:
|
||||||
|
raise AdapterError("runtime returned a non-terminal result")
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
command: OpenClawCommand,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
) -> OpenClawResult:
|
||||||
|
timeout = self.default_timeout_seconds if timeout_seconds is None else float(timeout_seconds)
|
||||||
|
if timeout <= 0:
|
||||||
|
raise ValueError("timeout_seconds must be positive")
|
||||||
|
self._verify(command)
|
||||||
|
digest = command.signature
|
||||||
|
idem_ref = (command.tenant_id, command.idempotency_key)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
prior_command = self._commands.get(command.command_id)
|
||||||
|
if prior_command is not None:
|
||||||
|
if prior_command.signature != command.signature:
|
||||||
|
raise CommandConflict("command_id reused with different content")
|
||||||
|
return self._results[command.command_id]
|
||||||
|
prior_idem = self._idempotency.get(idem_ref)
|
||||||
|
if prior_idem is not None:
|
||||||
|
prior_id, prior_digest = prior_idem
|
||||||
|
if prior_digest != digest:
|
||||||
|
raise CommandConflict("idempotency key reused with different content")
|
||||||
|
return self._results[prior_id]
|
||||||
|
running = OpenClawResult(
|
||||||
|
command_id=command.command_id,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
task_id=command.task_id,
|
||||||
|
trace_id=command.trace_id,
|
||||||
|
state=CommandState.RUNNING,
|
||||||
|
started_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._commands[command.command_id] = command
|
||||||
|
self._results[command.command_id] = running
|
||||||
|
self._idempotency[idem_ref] = (command.command_id, digest)
|
||||||
|
|
||||||
|
self.callback_sink.on_started(running)
|
||||||
|
try:
|
||||||
|
result = self.transport.execute(command, timeout)
|
||||||
|
self._validate_result(command, result)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
result = replace(
|
||||||
|
running,
|
||||||
|
state=CommandState.TIMED_OUT,
|
||||||
|
error={"code": "runtime_timeout", "message": str(exc) or "runtime timeout"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
result = replace(
|
||||||
|
running,
|
||||||
|
state=CommandState.FAILED,
|
||||||
|
error={"code": "runtime_error", "message": str(exc)},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.started_at is None:
|
||||||
|
result = replace(result, started_at=running.started_at)
|
||||||
|
if result.finished_at is None:
|
||||||
|
result = replace(result, finished_at=utc_now())
|
||||||
|
with self._lock:
|
||||||
|
self._results[command.command_id] = result
|
||||||
|
if result.state == CommandState.COMPLETED:
|
||||||
|
self.callback_sink.on_completed(result)
|
||||||
|
else:
|
||||||
|
self.callback_sink.on_failed(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def status(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
|
||||||
|
with self._lock:
|
||||||
|
command = self._commands.get(command_id)
|
||||||
|
result = self._results.get(command_id)
|
||||||
|
if command is None or result is None or command.tenant_id != tenant_id:
|
||||||
|
raise CommandNotFound("command not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cancel(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
|
||||||
|
with self._lock:
|
||||||
|
command = self._commands.get(command_id)
|
||||||
|
current = self._results.get(command_id)
|
||||||
|
if command is None or current is None or command.tenant_id != tenant_id:
|
||||||
|
raise CommandNotFound("command not found")
|
||||||
|
if current.state in {
|
||||||
|
CommandState.COMPLETED,
|
||||||
|
CommandState.FAILED,
|
||||||
|
CommandState.CANCELLED,
|
||||||
|
CommandState.TIMED_OUT,
|
||||||
|
CommandState.REJECTED,
|
||||||
|
}:
|
||||||
|
return current
|
||||||
|
if not self.transport.cancel(command_id, tenant_id=tenant_id):
|
||||||
|
raise AdapterError("runtime rejected cancellation")
|
||||||
|
cancelled = replace(
|
||||||
|
current,
|
||||||
|
state=CommandState.CANCELLED,
|
||||||
|
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._results[command_id] = cancelled
|
||||||
|
self.callback_sink.on_failed(cancelled)
|
||||||
|
return cancelled
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealth:
|
||||||
|
return self.transport.health()
|
||||||
|
|
||||||
|
def snapshot(self, *, tenant_id: str) -> Mapping[str, OpenClawResult]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
command_id: result
|
||||||
|
for command_id, result in self._results.items()
|
||||||
|
if self._commands[command_id].tenant_id == tenant_id
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Audit-oriented lifecycle callbacks for OpenClaw execution."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
from .contract import OpenClawResult, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
class EventPublisher(Protocol):
|
||||||
|
def publish(self, event: Mapping[str, Any]) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionEvent:
|
||||||
|
event_type: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
command_id: str
|
||||||
|
state: str
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"event_type": self.event_type,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"trace_id": self.trace_id,
|
||||||
|
"command_id": self.command_id,
|
||||||
|
"state": self.state,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"details": json.loads(
|
||||||
|
json.dumps(dict(self.details), ensure_ascii=False, default=str)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryEventPublisher:
|
||||||
|
"""Thread-safe publisher used by tests and first integration slice."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def publish(self, event: Mapping[str, Any]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.events.append(
|
||||||
|
json.loads(json.dumps(dict(event), ensure_ascii=False, default=str))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditCallbackSink:
|
||||||
|
"""Convert runtime callbacks into tenant-scoped audit events."""
|
||||||
|
|
||||||
|
def __init__(self, publisher: EventPublisher) -> None:
|
||||||
|
self.publisher = publisher
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event(
|
||||||
|
event_type: str,
|
||||||
|
result: OpenClawResult,
|
||||||
|
) -> ExecutionEvent:
|
||||||
|
details: dict[str, Any] = {
|
||||||
|
"runtime_id": result.runtime_id,
|
||||||
|
"attempts": result.attempts,
|
||||||
|
}
|
||||||
|
if result.error:
|
||||||
|
details["error"] = dict(result.error)
|
||||||
|
if result.output:
|
||||||
|
details["output_present"] = True
|
||||||
|
return ExecutionEvent(
|
||||||
|
event_type=event_type,
|
||||||
|
tenant_id=result.tenant_id,
|
||||||
|
task_id=result.task_id,
|
||||||
|
trace_id=result.trace_id,
|
||||||
|
command_id=result.command_id,
|
||||||
|
state=result.state.value,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_started(self, result: OpenClawResult) -> None:
|
||||||
|
self.publisher.publish(self._event("OPENCLAW_STARTED", result).as_dict())
|
||||||
|
|
||||||
|
def on_completed(self, result: OpenClawResult) -> None:
|
||||||
|
self.publisher.publish(self._event("OPENCLAW_COMPLETED", result).as_dict())
|
||||||
|
|
||||||
|
def on_failed(self, result: OpenClawResult) -> None:
|
||||||
|
event_type = {
|
||||||
|
"CANCELLED": "OPENCLAW_CANCELLED",
|
||||||
|
"TIMED_OUT": "OPENCLAW_TIMED_OUT",
|
||||||
|
"REJECTED": "OPENCLAW_REJECTED",
|
||||||
|
}.get(result.state.value, "OPENCLAW_FAILED")
|
||||||
|
self.publisher.publish(self._event(event_type, result).as_dict())
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Versioned AIOS to OpenClaw execution contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
CONTRACT_VERSION = "aios.openclaw.command.v1"
|
||||||
|
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: str) -> datetime:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("timestamp is required")
|
||||||
|
if normalized.endswith("Z"):
|
||||||
|
normalized = normalized[:-1] + "+00:00"
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include a timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
class CommandState(str, Enum):
|
||||||
|
ACCEPTED = "ACCEPTED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
TIMED_OUT = "TIMED_OUT"
|
||||||
|
REJECTED = "REJECTED"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenClawCommand:
|
||||||
|
command_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
idempotency_key: str = ""
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
expires_at: str | None = None
|
||||||
|
contract_version: str = CONTRACT_VERSION
|
||||||
|
key_id: str = "default"
|
||||||
|
signature: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in (
|
||||||
|
"command_id", "tenant_id", "task_id", "trace_id", "action",
|
||||||
|
"idempotency_key", "created_at", "contract_version", "key_id",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||||
|
if self.contract_version != CONTRACT_VERSION:
|
||||||
|
raise ValueError(f"unsupported contract_version: {self.contract_version}")
|
||||||
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||||
|
_parse_timestamp(self.created_at)
|
||||||
|
if self.expires_at is not None:
|
||||||
|
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
||||||
|
_parse_timestamp(self.expires_at)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
expires_at: str | None = None,
|
||||||
|
key_id: str = "default",
|
||||||
|
command_id: str | None = None,
|
||||||
|
) -> "OpenClawCommand":
|
||||||
|
return cls(
|
||||||
|
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=action,
|
||||||
|
payload=_safe_mapping(payload),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
expires_at=expires_at,
|
||||||
|
key_id=key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def unsigned_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"command_id": self.command_id,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"trace_id": self.trace_id,
|
||||||
|
"action": self.action,
|
||||||
|
"payload": _safe_mapping(self.payload),
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
"contract_version": self.contract_version,
|
||||||
|
"key_id": self.key_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenClawResult:
|
||||||
|
command_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
state: CommandState
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
runtime_id: str | None = None
|
||||||
|
attempts: int = 1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
|
||||||
|
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||||
|
object.__setattr__(self, "state", CommandState(self.state))
|
||||||
|
if self.output is not None:
|
||||||
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||||
|
if self.error is not None:
|
||||||
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||||
|
if self.started_at is not None:
|
||||||
|
_parse_timestamp(self.started_at)
|
||||||
|
if self.finished_at is not None:
|
||||||
|
_parse_timestamp(self.finished_at)
|
||||||
|
if self.attempts < 1:
|
||||||
|
raise ValueError("attempts must be >= 1")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeHealth:
|
||||||
|
runtime: str
|
||||||
|
runtime_id: str
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
heartbeat_at: str
|
||||||
|
active_tasks: int = 0
|
||||||
|
workspace_isolated: bool = False
|
||||||
|
credential_ref_configured: bool = False
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("runtime", "runtime_id", "status", "version", "heartbeat_at"):
|
||||||
|
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||||
|
_parse_timestamp(self.heartbeat_at)
|
||||||
|
if self.active_tasks < 0:
|
||||||
|
raise ValueError("active_tasks must be >= 0")
|
||||||
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
command.unsigned_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _ed25519_private_key(key_material: bytes | str) -> Ed25519PrivateKey:
|
||||||
|
material = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||||
|
if material.startswith(b"-----BEGIN"):
|
||||||
|
key = serialization.load_pem_private_key(material, password=None)
|
||||||
|
if not isinstance(key, Ed25519PrivateKey):
|
||||||
|
raise ValueError("signing material is not an Ed25519 private key")
|
||||||
|
return key
|
||||||
|
if len(material) != 32:
|
||||||
|
raise ValueError("Ed25519 raw private signing material must be exactly 32 bytes")
|
||||||
|
return Ed25519PrivateKey.from_private_bytes(material)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_signature(value: str) -> bytes:
|
||||||
|
encoded = value.encode("ascii")
|
||||||
|
return base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
|
||||||
|
key = _ed25519_private_key(key_material)
|
||||||
|
signature = base64.urlsafe_b64encode(key.sign(canonical_command_bytes(command))).decode("ascii").rstrip("=")
|
||||||
|
return replace(command, signature=signature)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_command_signature(
|
||||||
|
command: OpenClawCommand,
|
||||||
|
key_material: bytes | str,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
if not command.signature:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
key = _ed25519_private_key(key_material)
|
||||||
|
key.public_key().verify(_decode_signature(command.signature), canonical_command_bytes(command))
|
||||||
|
except (InvalidSignature, ValueError, TypeError, UnicodeError):
|
||||||
|
return False
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
created = _parse_timestamp(command.created_at)
|
||||||
|
if created.timestamp() - current.timestamp() > clock_skew_seconds:
|
||||||
|
return False
|
||||||
|
if command.expires_at is not None:
|
||||||
|
expires = _parse_timestamp(command.expires_at)
|
||||||
|
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Health policy and self-healing decisions for OpenClaw runtimes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .contract import RuntimeHealth
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(value: str) -> datetime:
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("heartbeat timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthPolicy:
|
||||||
|
max_heartbeat_age_seconds: int = 90
|
||||||
|
require_workspace_isolation: bool = True
|
||||||
|
require_credential_ref: bool = True
|
||||||
|
accepted_statuses: frozenset[str] = field(
|
||||||
|
default_factory=lambda: frozenset({"healthy", "ready"})
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.max_heartbeat_age_seconds < 5:
|
||||||
|
raise ValueError("max_heartbeat_age_seconds must be >= 5")
|
||||||
|
if not self.accepted_statuses:
|
||||||
|
raise ValueError("accepted_statuses must not be empty")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthAssessment:
|
||||||
|
healthy: bool
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
recoverable: bool
|
||||||
|
recommended_action: str
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHealthMonitor:
|
||||||
|
"""Evaluate runtime health and recommend bounded recovery actions."""
|
||||||
|
|
||||||
|
def __init__(self, policy: HealthPolicy | None = None) -> None:
|
||||||
|
self.policy = policy or HealthPolicy()
|
||||||
|
|
||||||
|
def assess(
|
||||||
|
self,
|
||||||
|
health: RuntimeHealth,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> HealthAssessment:
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
heartbeat = _parse(health.heartbeat_at)
|
||||||
|
age = max(0.0, current.timestamp() - heartbeat.timestamp())
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if health.status.lower() not in self.policy.accepted_statuses:
|
||||||
|
reasons.append("runtime_status_unhealthy")
|
||||||
|
if age > self.policy.max_heartbeat_age_seconds:
|
||||||
|
reasons.append("heartbeat_stale")
|
||||||
|
if self.policy.require_workspace_isolation and not health.workspace_isolated:
|
||||||
|
reasons.append("workspace_not_isolated")
|
||||||
|
if self.policy.require_credential_ref and not health.credential_ref_configured:
|
||||||
|
reasons.append("credential_reference_missing")
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
return HealthAssessment(
|
||||||
|
healthy=True,
|
||||||
|
reasons=(),
|
||||||
|
recoverable=True,
|
||||||
|
recommended_action="none",
|
||||||
|
details={"heartbeat_age_seconds": age},
|
||||||
|
)
|
||||||
|
|
||||||
|
non_recoverable = {
|
||||||
|
"workspace_not_isolated",
|
||||||
|
"credential_reference_missing",
|
||||||
|
}.intersection(reasons)
|
||||||
|
if non_recoverable:
|
||||||
|
action = "quarantine_runtime"
|
||||||
|
recoverable = False
|
||||||
|
elif "heartbeat_stale" in reasons:
|
||||||
|
action = "restart_runtime"
|
||||||
|
recoverable = True
|
||||||
|
else:
|
||||||
|
action = "drain_and_restart"
|
||||||
|
recoverable = True
|
||||||
|
|
||||||
|
return HealthAssessment(
|
||||||
|
healthy=False,
|
||||||
|
reasons=tuple(reasons),
|
||||||
|
recoverable=recoverable,
|
||||||
|
recommended_action=action,
|
||||||
|
details={
|
||||||
|
"heartbeat_age_seconds": age,
|
||||||
|
"runtime_id": health.runtime_id,
|
||||||
|
"active_tasks": health.active_tasks,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Runtime transport implementations for the OpenClaw adapter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Callable, Mapping, Protocol
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeTransport(Protocol):
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
||||||
|
def cancel(self, command_id: str, *, tenant_id: str) -> bool: ...
|
||||||
|
def health(self) -> RuntimeHealth: ...
|
||||||
|
|
||||||
|
|
||||||
|
class TransportError(RuntimeError):
|
||||||
|
"""Runtime transport or protocol failure."""
|
||||||
|
|
||||||
|
|
||||||
|
RuntimeHandler = Callable[[OpenClawCommand], Mapping[str, Any] | OpenClawResult]
|
||||||
|
|
||||||
|
|
||||||
|
class InProcessRuntimeTransport:
|
||||||
|
"""Deterministic runtime used by tests and controlled local integrations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
handler: RuntimeHandler,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "openclaw-in-process",
|
||||||
|
version: str = "v1",
|
||||||
|
workspace_isolated: bool = True,
|
||||||
|
credential_ref_configured: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.handler = handler
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.version = version
|
||||||
|
self.workspace_isolated = workspace_isolated
|
||||||
|
self.credential_ref_configured = credential_ref_configured
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._running: set[str] = set()
|
||||||
|
self._cancelled: set[str] = set()
|
||||||
|
self._command_tenants: dict[str, str] = {}
|
||||||
|
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise ValueError("timeout_seconds must be positive")
|
||||||
|
with self._lock:
|
||||||
|
known_tenant = self._command_tenants.get(command.command_id)
|
||||||
|
if known_tenant is not None and known_tenant != command.tenant_id:
|
||||||
|
raise TransportError("command id belongs to another tenant")
|
||||||
|
self._command_tenants[command.command_id] = command.tenant_id
|
||||||
|
if command.command_id in self._cancelled:
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=command.command_id,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
task_id=command.task_id,
|
||||||
|
trace_id=command.trace_id,
|
||||||
|
state=CommandState.CANCELLED,
|
||||||
|
error={"code": "cancelled", "message": "cancelled before execution"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
)
|
||||||
|
self._running.add(command.command_id)
|
||||||
|
try:
|
||||||
|
raw = self.handler(command)
|
||||||
|
if isinstance(raw, OpenClawResult):
|
||||||
|
return raw
|
||||||
|
if not isinstance(raw, Mapping):
|
||||||
|
raise TransportError("runtime handler must return a mapping or OpenClawResult")
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=command.command_id,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
task_id=command.task_id,
|
||||||
|
trace_id=command.trace_id,
|
||||||
|
state=CommandState(str(raw.get("state", "COMPLETED"))),
|
||||||
|
output=raw.get("output"),
|
||||||
|
error=raw.get("error"),
|
||||||
|
started_at=str(raw.get("started_at") or utc_now()),
|
||||||
|
finished_at=str(raw.get("finished_at") or utc_now()),
|
||||||
|
runtime_id=str(raw.get("runtime_id") or self.runtime_id),
|
||||||
|
attempts=int(raw.get("attempts", 1)),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._running.discard(command.command_id)
|
||||||
|
|
||||||
|
def cancel(self, command_id: str, *, tenant_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if self._command_tenants.get(command_id) != tenant_id:
|
||||||
|
return False
|
||||||
|
self._cancelled.add(command_id)
|
||||||
|
return command_id in self._running
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealth:
|
||||||
|
with self._lock:
|
||||||
|
active = len(self._running)
|
||||||
|
return RuntimeHealth(
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
status="healthy",
|
||||||
|
version=self.version,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
active_tasks=active,
|
||||||
|
workspace_isolated=self.workspace_isolated,
|
||||||
|
credential_ref_configured=self.credential_ref_configured,
|
||||||
|
details={"transport": "in_process"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPRuntimeTransport:
|
||||||
|
"""Private HTTP transport for an OpenClaw runtime service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "openclaw-http",
|
||||||
|
credential_ref: str | None = None,
|
||||||
|
max_response_bytes: int = 2_097_152,
|
||||||
|
) -> None:
|
||||||
|
normalized = str(base_url).rstrip("/")
|
||||||
|
if not normalized.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("base_url must use http or https")
|
||||||
|
self.base_url = normalized
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.credential_ref = credential_ref
|
||||||
|
self.max_response_bytes = int(max_response_bytes)
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
timeout_seconds: float,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
body = None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "AIOS-OpenClaw/1"}
|
||||||
|
if payload is not None:
|
||||||
|
body = json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if self.credential_ref:
|
||||||
|
headers["X-AIOS-Credential-Ref"] = self.credential_ref
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base_url}{path}",
|
||||||
|
data=body,
|
||||||
|
method=method,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||||
|
raw = response.read(self.max_response_bytes + 1)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise TransportError(f"runtime returned HTTP {exc.code}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise TransportError(f"runtime unavailable: {exc.reason}") from exc
|
||||||
|
if len(raw) > self.max_response_bytes:
|
||||||
|
raise TransportError("runtime response exceeded size limit")
|
||||||
|
decoded = json.loads(raw.decode("utf-8"))
|
||||||
|
if not isinstance(decoded, Mapping):
|
||||||
|
raise TransportError("runtime response must be a JSON object")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _command_payload(command: OpenClawCommand) -> dict[str, Any]:
|
||||||
|
return {**command.unsigned_dict(), "signature": command.signature}
|
||||||
|
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||||
|
value = self._request(
|
||||||
|
"POST",
|
||||||
|
"/v1/execute",
|
||||||
|
payload=self._command_payload(command),
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=str(value["command_id"]),
|
||||||
|
tenant_id=str(value["tenant_id"]),
|
||||||
|
task_id=str(value["task_id"]),
|
||||||
|
trace_id=str(value["trace_id"]),
|
||||||
|
state=CommandState(str(value["state"])),
|
||||||
|
output=value.get("output"),
|
||||||
|
error=value.get("error"),
|
||||||
|
started_at=value.get("started_at"),
|
||||||
|
finished_at=value.get("finished_at"),
|
||||||
|
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
||||||
|
attempts=int(value.get("attempts", 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(self, command_id: str, *, tenant_id: str) -> bool:
|
||||||
|
value = self._request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel"
|
||||||
|
f"?tenant_id={urllib.parse.quote(tenant_id, safe='')}",
|
||||||
|
payload={},
|
||||||
|
timeout_seconds=10.0,
|
||||||
|
)
|
||||||
|
return bool(value.get("accepted"))
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealth:
|
||||||
|
value = self._request("GET", "/v1/health", payload=None, timeout_seconds=5.0)
|
||||||
|
return RuntimeHealth(
|
||||||
|
runtime=str(value.get("runtime") or "openclaw"),
|
||||||
|
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
||||||
|
status=str(value.get("status") or "unknown"),
|
||||||
|
version=str(value.get("version") or "unknown"),
|
||||||
|
heartbeat_at=str(value.get("heartbeat_at") or utc_now()),
|
||||||
|
active_tasks=int(value.get("active_tasks", 0)),
|
||||||
|
workspace_isolated=bool(value.get("workspace_isolated", False)),
|
||||||
|
credential_ref_configured=bool(value.get("credential_ref_configured", False)),
|
||||||
|
details=value.get("details") if isinstance(value.get("details"), Mapping) else {},
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""AIOS V2 control plane and execution orchestration."""
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
AuditEvent,
|
||||||
|
ControlPlaneError,
|
||||||
|
ControlPlaneService,
|
||||||
|
IdempotencyConflict,
|
||||||
|
InMemoryControlPlaneStore,
|
||||||
|
InvalidTransition,
|
||||||
|
NotFound,
|
||||||
|
PolicyDenied,
|
||||||
|
RequestContext,
|
||||||
|
RevisionConflict,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
TenantIsolationError,
|
||||||
|
TenantRecord,
|
||||||
|
Unauthorized,
|
||||||
|
)
|
||||||
|
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||||
|
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentRecord",
|
||||||
|
"AuditEvent",
|
||||||
|
"ControlPlaneError",
|
||||||
|
"ControlPlaneService",
|
||||||
|
"DispatchRecord",
|
||||||
|
"ExecutionEvidence",
|
||||||
|
"ExecutionOrchestrator",
|
||||||
|
"ExecutionTracker",
|
||||||
|
"IdempotencyConflict",
|
||||||
|
"InMemoryControlPlaneStore",
|
||||||
|
"InvalidTransition",
|
||||||
|
"NotFound",
|
||||||
|
"OrchestrationError",
|
||||||
|
"PolicyDenied",
|
||||||
|
"RequestContext",
|
||||||
|
"RevisionConflict",
|
||||||
|
"TaskRecord",
|
||||||
|
"TaskState",
|
||||||
|
"TenantIsolationError",
|
||||||
|
"TenantRecord",
|
||||||
|
"Unauthorized",
|
||||||
|
]
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""Production-candidate AIOS V2 application entrypoint.
|
||||||
|
|
||||||
|
This process owns the public control-plane API and read-only web console. It
|
||||||
|
loads durable control-plane state, dispatches approved work through runtime
|
||||||
|
adapters, exposes tenant-scoped execution evidence and sanitized GitOps status,
|
||||||
|
and never exposes runtime credentials to the browser.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import urllib.parse
|
||||||
|
from enum import Enum
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_hermes import HermesAdapter, HermesJobState, HermesResult
|
||||||
|
from aios_openclaw import HTTPRuntimeTransport, OpenClawAdapter
|
||||||
|
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
||||||
|
|
||||||
|
from .console import CONSOLE_HTML
|
||||||
|
from .control_plane import AuditEvent, RequestContext
|
||||||
|
from .execution_tracking import ExecutionTracker
|
||||||
|
from .gateway import GatewayApplication, error_response
|
||||||
|
from .gitops_status import collect_gitops_status
|
||||||
|
from .orchestrator import ExecutionOrchestrator
|
||||||
|
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: Any) -> Any:
|
||||||
|
if dataclasses.is_dataclass(value):
|
||||||
|
return {
|
||||||
|
item.name: _json_value(getattr(value, item.name))
|
||||||
|
for item in dataclasses.fields(value)
|
||||||
|
}
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {str(key): _json_value(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return [_json_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class FileSigningMaterialResolver:
|
||||||
|
"""Resolve runtime signing material from a root-owned file reference."""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
|
||||||
|
def __call__(self, tenant_id: str, key_id: str) -> bytes:
|
||||||
|
del tenant_id, key_id
|
||||||
|
metadata = self.path.stat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError("signing material reference is not a regular file")
|
||||||
|
if metadata.st_mode & 0o077:
|
||||||
|
raise RuntimeError("signing material file permissions must be 0600 or stricter")
|
||||||
|
value = self.path.read_bytes()
|
||||||
|
if len(value) < 32:
|
||||||
|
raise RuntimeError("signing material is too short")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneEventPublisher(EventPublisher):
|
||||||
|
"""Append OpenClaw runtime events to the tenant audit stream."""
|
||||||
|
|
||||||
|
def __init__(self, service: DurableControlPlaneService) -> None:
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
def publish(self, event: Mapping[str, Any]) -> None:
|
||||||
|
tenant_id = str(event.get("tenant_id") or "").strip()
|
||||||
|
trace_id = str(event.get("trace_id") or "").strip()
|
||||||
|
command_id = str(event.get("command_id") or "").strip()
|
||||||
|
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
|
||||||
|
if not tenant_id or not trace_id or not command_id:
|
||||||
|
raise ValueError("runtime audit event is missing tenant identity")
|
||||||
|
state_value = str(event.get("state") or "unknown").lower()
|
||||||
|
outcome = "allowed" if state_value == "completed" else state_value
|
||||||
|
audit = AuditEvent(
|
||||||
|
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
principal_id="openclaw-runtime",
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=event_type,
|
||||||
|
resource_type="openclaw_command",
|
||||||
|
resource_id=command_id,
|
||||||
|
outcome=outcome,
|
||||||
|
policy_version="runtime-v1",
|
||||||
|
details=dict(event.get("details") or {}),
|
||||||
|
)
|
||||||
|
with self.service.store.lock:
|
||||||
|
self.service.store.audit_events.append(audit)
|
||||||
|
self.service.repository.save(self.service._snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateApplication:
|
||||||
|
"""Compose durable control plane, runtimes, evidence and console routes."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
gateway: GatewayApplication,
|
||||||
|
orchestrator: ExecutionOrchestrator,
|
||||||
|
tracker: ExecutionTracker,
|
||||||
|
hermes: HermesAdapter,
|
||||||
|
openclaw: OpenClawAdapter | None,
|
||||||
|
) -> None:
|
||||||
|
self.gateway = gateway
|
||||||
|
self.orchestrator = orchestrator
|
||||||
|
self.tracker = tracker
|
||||||
|
self.hermes = hermes
|
||||||
|
self.openclaw = openclaw
|
||||||
|
|
||||||
|
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||||
|
return self.gateway.context_from_headers(headers)
|
||||||
|
|
||||||
|
def handle_json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
raw_path: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: Any,
|
||||||
|
) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
parsed = urllib.parse.urlparse(raw_path)
|
||||||
|
path = parsed.path.rstrip("/") or "/"
|
||||||
|
method = method.upper()
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/gitops":
|
||||||
|
context = self.context(headers)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"tenant_id": context.tenant_id,
|
||||||
|
"gitops": collect_gitops_status().as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/runtime":
|
||||||
|
context = self.context(headers)
|
||||||
|
runtimes: list[dict[str, Any]] = []
|
||||||
|
if self.openclaw is None:
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": "disabled",
|
||||||
|
"details": {"reason": "runtime_not_configured"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
health = self.openclaw.health()
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": health.status,
|
||||||
|
"details": _json_value(health),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": "unavailable",
|
||||||
|
"details": {"message": str(exc)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hermes_health = self.hermes.health()
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "hermes",
|
||||||
|
"status": hermes_health.status,
|
||||||
|
"details": _json_value(hermes_health),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"tenant_id": context.tenant_id,
|
||||||
|
"runtimes": runtimes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/executions":
|
||||||
|
context = self.context(headers)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"executions": _json_value(
|
||||||
|
self.tracker.list(tenant_id=context.tenant_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_match = __import__("re").fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/dispatch",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
if method == "POST" and dispatch_match:
|
||||||
|
context = self.context(headers)
|
||||||
|
task_id = urllib.parse.unquote(dispatch_match.group(1))
|
||||||
|
payload = body if isinstance(body, Mapping) else {}
|
||||||
|
timeout = payload.get("timeout_seconds")
|
||||||
|
record = self.orchestrator.dispatch(
|
||||||
|
context,
|
||||||
|
task_id=task_id,
|
||||||
|
timeout_seconds=float(timeout) if timeout is not None else None,
|
||||||
|
)
|
||||||
|
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||||
|
context = self.context(headers)
|
||||||
|
if not isinstance(body, Mapping):
|
||||||
|
raise ValueError("JSON body must be an object")
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=str(body["job_id"]),
|
||||||
|
tenant_id=str(body["tenant_id"]),
|
||||||
|
task_id=str(body["task_id"]),
|
||||||
|
trace_id=str(body["trace_id"]),
|
||||||
|
state=HermesJobState(str(body["state"])),
|
||||||
|
worker_id=(
|
||||||
|
str(body["worker_id"])
|
||||||
|
if body.get("worker_id") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
output=body.get("output"),
|
||||||
|
error=body.get("error"),
|
||||||
|
started_at=body.get("started_at"),
|
||||||
|
finished_at=body.get("finished_at"),
|
||||||
|
)
|
||||||
|
task = self.orchestrator.reconcile_hermes(context, result)
|
||||||
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
return self.gateway.handle(method, raw_path, headers, body)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "AIOSV2Candidate/1.0"
|
||||||
|
max_body_bytes = 1_048_576
|
||||||
|
|
||||||
|
@property
|
||||||
|
def application(self) -> CandidateApplication:
|
||||||
|
return self.server.application # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def _read_body(self) -> Any:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
if length < 0 or length > self.max_body_bytes:
|
||||||
|
raise ValueError("request body is too large")
|
||||||
|
if length == 0:
|
||||||
|
return {}
|
||||||
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||||
|
if content_type != "application/json":
|
||||||
|
raise ValueError("Content-Type must be application/json")
|
||||||
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
|
||||||
|
def _security_headers(self) -> None:
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
_json_value(payload),
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
self.send_response(int(status_code))
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self._security_headers()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _send_console(self) -> None:
|
||||||
|
encoded = CONSOLE_HTML.encode("utf-8")
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self._security_headers()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _dispatch(self, method: str) -> None:
|
||||||
|
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||||
|
if method == "GET" and path in {"/", "/console"}:
|
||||||
|
self._send_console()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = self._read_body() if method == "POST" else {}
|
||||||
|
status_code, payload = self.application.handle_json(
|
||||||
|
method,
|
||||||
|
self.path,
|
||||||
|
{key: value for key, value in self.headers.items()},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
status_code, payload = error_response(exc)
|
||||||
|
self._send_json(status_code, payload)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("GET")
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("POST")
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
|
||||||
|
self.application = application
|
||||||
|
super().__init__(address, CandidateRequestHandler)
|
||||||
|
|
||||||
|
|
||||||
|
def build_application() -> CandidateApplication:
|
||||||
|
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||||
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
|
||||||
|
service = DurableControlPlaneService(repository)
|
||||||
|
tracker = ExecutionTracker(state_dir / "executions")
|
||||||
|
hermes = HermesAdapter()
|
||||||
|
|
||||||
|
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
||||||
|
material_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||||
|
openclaw_key_id = os.getenv("AIOS_OPENCLAW_SIGNING_KEY_ID", "default").strip() or "default"
|
||||||
|
openclaw = None
|
||||||
|
resolver = None
|
||||||
|
if openclaw_url and material_file:
|
||||||
|
resolver = FileSigningMaterialResolver(material_file)
|
||||||
|
transport = HTTPRuntimeTransport(
|
||||||
|
openclaw_url,
|
||||||
|
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
||||||
|
)
|
||||||
|
openclaw = OpenClawAdapter(
|
||||||
|
transport,
|
||||||
|
resolver,
|
||||||
|
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway = GatewayApplication(service)
|
||||||
|
orchestrator = ExecutionOrchestrator(
|
||||||
|
service,
|
||||||
|
openclaw=openclaw,
|
||||||
|
hermes=hermes,
|
||||||
|
openclaw_signing_material_resolver=resolver,
|
||||||
|
openclaw_key_id=openclaw_key_id,
|
||||||
|
)
|
||||||
|
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("AIOS_V2_PORT", "18920"))
|
||||||
|
server = CandidateHTTPServer((host, port), build_application())
|
||||||
|
print(f"AIOS V2 candidate listening on {host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dependency-free AIOS V2 web console assets.
|
||||||
|
|
||||||
|
Authentication and tenant identity are injected by the trusted reverse proxy.
|
||||||
|
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONSOLE_HTML = r'''<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>AIOS V2 Console</title>
|
||||||
|
<style>
|
||||||
|
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
|
||||||
|
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
|
||||||
|
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
|
||||||
|
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||||
|
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
|
||||||
|
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
|
||||||
|
<main>
|
||||||
|
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
|
||||||
|
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
|
||||||
|
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
|
||||||
|
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
|
||||||
|
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
|
||||||
|
</section>
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
|
||||||
|
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
|
||||||
|
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
|
||||||
|
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
|
||||||
|
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||||
|
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
|
||||||
|
async function refresh(){
|
||||||
|
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
|
||||||
|
try{
|
||||||
|
const [agents,tasks,audit,runtime,executions]=await Promise.all([
|
||||||
|
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
|
||||||
|
]);
|
||||||
|
document.getElementById('agentCount').textContent=agents.agents.length;
|
||||||
|
document.getElementById('taskCount').textContent=tasks.tasks.length;
|
||||||
|
document.getElementById('auditCount').textContent=audit.events.length;
|
||||||
|
document.getElementById('executionCount').textContent=executions.executions.length;
|
||||||
|
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
|
||||||
|
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
|
||||||
|
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
|
||||||
|
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
|
||||||
|
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
|
||||||
|
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
|
||||||
|
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
|
||||||
|
}
|
||||||
|
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
"""Tenant-isolated AIOS V2 control-plane domain service.
|
||||||
|
|
||||||
|
This module deliberately contains no OpenClaw or Hermes implementation details.
|
||||||
|
It owns authoritative tenant, agent, task-lifecycle, idempotency and audit state.
|
||||||
|
Runtime adapters consume approved tasks through a separate boundary.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _new_id(prefix: str) -> str:
|
||||||
|
return f"{prefix}_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_digest(value: Mapping[str, Any]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneError(RuntimeError):
|
||||||
|
"""Base error returned by the control-plane domain."""
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(ControlPlaneError):
|
||||||
|
"""The caller is not authenticated or lacks the required role."""
|
||||||
|
|
||||||
|
|
||||||
|
class TenantIsolationError(ControlPlaneError):
|
||||||
|
"""A request attempted to cross a tenant boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(ControlPlaneError):
|
||||||
|
"""A tenant-scoped resource does not exist or is not visible."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransition(ControlPlaneError):
|
||||||
|
"""A task state transition violates the lifecycle contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyConflict(ControlPlaneError):
|
||||||
|
"""An idempotency key was reused for a different command."""
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionConflict(ControlPlaneError):
|
||||||
|
"""Optimistic task revision does not match current state."""
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyDenied(ControlPlaneError):
|
||||||
|
"""The policy gate denied a control-plane operation."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RequestContext:
|
||||||
|
"""Authenticated request identity propagated across all AIOS layers."""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
principal_id: str
|
||||||
|
trace_id: str
|
||||||
|
roles: frozenset[str] = field(default_factory=frozenset)
|
||||||
|
session_id: str | None = None
|
||||||
|
policy_version: str = "v1"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("tenant_id", "principal_id", "trace_id"):
|
||||||
|
if not str(getattr(self, name, "")).strip():
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
roles: Iterable[str] = (),
|
||||||
|
trace_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
policy_version: str = "v1",
|
||||||
|
) -> "RequestContext":
|
||||||
|
return cls(
|
||||||
|
tenant_id=tenant_id.strip(),
|
||||||
|
principal_id=principal_id.strip(),
|
||||||
|
trace_id=trace_id or _new_id("trace"),
|
||||||
|
roles=frozenset(str(role).strip() for role in roles if str(role).strip()),
|
||||||
|
session_id=session_id,
|
||||||
|
policy_version=policy_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TenantRecord:
|
||||||
|
tenant_id: str
|
||||||
|
display_name: str
|
||||||
|
status: str = "active"
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentRecord:
|
||||||
|
agent_id: str
|
||||||
|
tenant_id: str
|
||||||
|
display_name: str
|
||||||
|
runtime: str
|
||||||
|
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
enabled: bool = True
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskState(str, Enum):
|
||||||
|
CREATED = "CREATED"
|
||||||
|
APPROVED = "APPROVED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_TASK_STATES = frozenset(
|
||||||
|
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
ALLOWED_TASK_TRANSITIONS: Mapping[TaskState, frozenset[TaskState]] = {
|
||||||
|
TaskState.CREATED: frozenset({TaskState.APPROVED, TaskState.CANCELLED}),
|
||||||
|
TaskState.APPROVED: frozenset({TaskState.RUNNING, TaskState.CANCELLED}),
|
||||||
|
TaskState.RUNNING: frozenset(
|
||||||
|
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||||
|
),
|
||||||
|
TaskState.COMPLETED: frozenset(),
|
||||||
|
TaskState.FAILED: frozenset(),
|
||||||
|
TaskState.CANCELLED: frozenset(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskRecord:
|
||||||
|
task_id: str
|
||||||
|
tenant_id: str
|
||||||
|
agent_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
state: TaskState = TaskState.CREATED
|
||||||
|
revision: int = 1
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
request_digest: str = ""
|
||||||
|
result: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
created_by: str = ""
|
||||||
|
approved_by: str | None = None
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuditEvent:
|
||||||
|
event_id: str
|
||||||
|
tenant_id: str
|
||||||
|
principal_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
outcome: str
|
||||||
|
policy_version: str
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PolicyDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
policy_version: str
|
||||||
|
|
||||||
|
|
||||||
|
PolicyEvaluator = Callable[[RequestContext, str, Mapping[str, Any]], PolicyDecision]
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryControlPlaneStore:
|
||||||
|
"""Thread-safe reference store used by tests and the first gateway slice.
|
||||||
|
|
||||||
|
Production backends must preserve the same tenant-scoped interface and add
|
||||||
|
database row-level security, migrations, backup and HA evidence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.tenants: dict[str, TenantRecord] = {}
|
||||||
|
self.agents: dict[str, AgentRecord] = {}
|
||||||
|
self.tasks: dict[str, TaskRecord] = {}
|
||||||
|
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
self.audit_events: list[AuditEvent] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneService:
|
||||||
|
"""Authoritative tenant, agent and task lifecycle service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: InMemoryControlPlaneStore | None = None,
|
||||||
|
*,
|
||||||
|
policy_evaluator: PolicyEvaluator | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.store = store or InMemoryControlPlaneStore()
|
||||||
|
self.policy_evaluator = policy_evaluator or self._default_policy
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_policy(
|
||||||
|
context: RequestContext,
|
||||||
|
action: str,
|
||||||
|
resource: Mapping[str, Any],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
del resource
|
||||||
|
role_requirements = {
|
||||||
|
"tenant.register": {"platform_admin"},
|
||||||
|
"tenant.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"agent.register": {"tenant_admin", "operator", "platform_admin"},
|
||||||
|
"agent.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"task.create": {"tenant_admin", "operator", "platform_admin"},
|
||||||
|
"task.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"task.approve": {"tenant_admin", "approver", "platform_admin"},
|
||||||
|
"task.start": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.complete": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.fail": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.cancel": {"tenant_admin", "operator", "approver", "platform_admin"},
|
||||||
|
"audit.read": {"tenant_admin", "auditor", "platform_admin"},
|
||||||
|
}
|
||||||
|
required = role_requirements.get(action, {"platform_admin"})
|
||||||
|
allowed = bool(context.roles.intersection(required))
|
||||||
|
return PolicyDecision(
|
||||||
|
allowed=allowed,
|
||||||
|
reason="role_allowed" if allowed else "required_role_missing",
|
||||||
|
policy_version=context.policy_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_context(self, context: RequestContext) -> None:
|
||||||
|
if not context.principal_id or not context.tenant_id or not context.trace_id:
|
||||||
|
raise Unauthorized("authenticated tenant context is required")
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
action: str,
|
||||||
|
resource: Mapping[str, Any],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
self._require_context(context)
|
||||||
|
decision = self.policy_evaluator(context, action, resource)
|
||||||
|
if not decision.allowed:
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action=action,
|
||||||
|
resource_type=str(resource.get("resource_type", "policy")),
|
||||||
|
resource_id=str(resource.get("resource_id", "unknown")),
|
||||||
|
outcome="denied",
|
||||||
|
details={"reason": decision.reason},
|
||||||
|
)
|
||||||
|
raise PolicyDenied(f"{action} denied: {decision.reason}")
|
||||||
|
return decision
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_same_tenant(context: RequestContext, tenant_id: str) -> None:
|
||||||
|
if context.tenant_id != tenant_id and "platform_admin" not in context.roles:
|
||||||
|
raise TenantIsolationError("cross-tenant access denied")
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
outcome: str,
|
||||||
|
details: Mapping[str, Any] | None = None,
|
||||||
|
) -> AuditEvent:
|
||||||
|
event = AuditEvent(
|
||||||
|
event_id=_new_id("audit"),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
outcome=outcome,
|
||||||
|
policy_version=context.policy_version,
|
||||||
|
details=copy.deepcopy(dict(details or {})),
|
||||||
|
)
|
||||||
|
self.store.audit_events.append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def register_tenant(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
display_name: str,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> TenantRecord:
|
||||||
|
tenant_id = tenant_id.strip()
|
||||||
|
if not tenant_id or not display_name.strip():
|
||||||
|
raise ValueError("tenant_id and display_name are required")
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"tenant.register",
|
||||||
|
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||||
|
)
|
||||||
|
self._assert_same_tenant(context, tenant_id)
|
||||||
|
with self.store.lock:
|
||||||
|
if tenant_id in self.store.tenants:
|
||||||
|
raise ControlPlaneError(f"tenant already exists: {tenant_id}")
|
||||||
|
tenant = TenantRecord(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
metadata=copy.deepcopy(dict(metadata or {})),
|
||||||
|
)
|
||||||
|
self.store.tenants[tenant_id] = tenant
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="tenant.register",
|
||||||
|
resource_type="tenant",
|
||||||
|
resource_id=tenant_id,
|
||||||
|
outcome="allowed",
|
||||||
|
)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
def get_tenant(self, context: RequestContext, tenant_id: str | None = None) -> TenantRecord:
|
||||||
|
tenant_id = tenant_id or context.tenant_id
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"tenant.read",
|
||||||
|
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||||
|
)
|
||||||
|
self._assert_same_tenant(context, tenant_id)
|
||||||
|
with self.store.lock:
|
||||||
|
tenant = self.store.tenants.get(tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise NotFound("tenant not found")
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
def register_agent(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
display_name: str,
|
||||||
|
runtime: str,
|
||||||
|
capabilities: Iterable[str] = (),
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> AgentRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.register",
|
||||||
|
{"resource_type": "agent", "resource_id": agent_id},
|
||||||
|
)
|
||||||
|
tenant = self.get_tenant(context)
|
||||||
|
if tenant.status != "active":
|
||||||
|
raise ControlPlaneError("tenant is not active")
|
||||||
|
normalized_capabilities = tuple(
|
||||||
|
sorted({str(item).strip() for item in capabilities if str(item).strip()})
|
||||||
|
)
|
||||||
|
if not agent_id.strip() or not display_name.strip() or not runtime.strip():
|
||||||
|
raise ValueError("agent_id, display_name and runtime are required")
|
||||||
|
with self.store.lock:
|
||||||
|
existing = self.store.agents.get(agent_id)
|
||||||
|
if existing is not None and existing.tenant_id != context.tenant_id:
|
||||||
|
raise TenantIsolationError("agent id belongs to another tenant")
|
||||||
|
if existing is not None:
|
||||||
|
raise ControlPlaneError(f"agent already exists: {agent_id}")
|
||||||
|
agent = AgentRecord(
|
||||||
|
agent_id=agent_id.strip(),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
runtime=runtime.strip(),
|
||||||
|
capabilities=normalized_capabilities,
|
||||||
|
metadata=copy.deepcopy(dict(metadata or {})),
|
||||||
|
)
|
||||||
|
self.store.agents[agent.agent_id] = agent
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="agent.register",
|
||||||
|
resource_type="agent",
|
||||||
|
resource_id=agent.agent_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={"runtime": agent.runtime},
|
||||||
|
)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def get_agent(self, context: RequestContext, agent_id: str) -> AgentRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.read",
|
||||||
|
{"resource_type": "agent", "resource_id": agent_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
agent = self.store.agents.get(agent_id)
|
||||||
|
if agent is None:
|
||||||
|
raise NotFound("agent not found")
|
||||||
|
self._assert_same_tenant(context, agent.tenant_id)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def list_agents(self, context: RequestContext) -> list[AgentRecord]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.read",
|
||||||
|
{"resource_type": "agent_collection", "resource_id": context.tenant_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
agent
|
||||||
|
for agent in self.store.agents.values()
|
||||||
|
if agent.tenant_id == context.tenant_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.create",
|
||||||
|
{"resource_type": "task", "resource_id": idempotency_key or "new"},
|
||||||
|
)
|
||||||
|
agent = self.get_agent(context, agent_id)
|
||||||
|
if not agent.enabled:
|
||||||
|
raise ControlPlaneError("agent is disabled")
|
||||||
|
if action not in agent.capabilities and "*" not in agent.capabilities:
|
||||||
|
raise PolicyDenied(f"agent does not advertise capability: {action}")
|
||||||
|
normalized_payload = copy.deepcopy(dict(payload or {}))
|
||||||
|
request_digest = _canonical_digest(
|
||||||
|
{"agent_id": agent_id, "action": action, "payload": normalized_payload}
|
||||||
|
)
|
||||||
|
normalized_key = idempotency_key.strip() if idempotency_key else None
|
||||||
|
with self.store.lock:
|
||||||
|
if normalized_key:
|
||||||
|
idempotency_ref = (context.tenant_id, normalized_key)
|
||||||
|
existing = self.store.idempotency.get(idempotency_ref)
|
||||||
|
if existing is not None:
|
||||||
|
existing_task_id, existing_digest = existing
|
||||||
|
if existing_digest != request_digest:
|
||||||
|
raise IdempotencyConflict(
|
||||||
|
"idempotency key was reused with a different request"
|
||||||
|
)
|
||||||
|
return self.store.tasks[existing_task_id]
|
||||||
|
task = TaskRecord(
|
||||||
|
task_id=_new_id("task"),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
action=action,
|
||||||
|
payload=normalized_payload,
|
||||||
|
idempotency_key=normalized_key,
|
||||||
|
request_digest=request_digest,
|
||||||
|
created_by=context.principal_id,
|
||||||
|
)
|
||||||
|
self.store.tasks[task.task_id] = task
|
||||||
|
if normalized_key:
|
||||||
|
self.store.idempotency[(context.tenant_id, normalized_key)] = (
|
||||||
|
task.task_id,
|
||||||
|
request_digest,
|
||||||
|
)
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="task.create",
|
||||||
|
resource_type="task",
|
||||||
|
resource_id=task.task_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={"agent_id": agent_id, "action": action},
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def get_task(self, context: RequestContext, task_id: str) -> TaskRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.read",
|
||||||
|
{"resource_type": "task", "resource_id": task_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
task = self.store.tasks.get(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise NotFound("task not found")
|
||||||
|
self._assert_same_tenant(context, task.tenant_id)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
states: Iterable[TaskState | str] | None = None,
|
||||||
|
) -> list[TaskRecord]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.read",
|
||||||
|
{"resource_type": "task_collection", "resource_id": context.tenant_id},
|
||||||
|
)
|
||||||
|
state_filter = (
|
||||||
|
{TaskState(item) for item in states}
|
||||||
|
if states is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
task
|
||||||
|
for task in self.store.tasks.values()
|
||||||
|
if task.tenant_id == context.tenant_id
|
||||||
|
and (state_filter is None or task.state in state_filter)
|
||||||
|
]
|
||||||
|
|
||||||
|
def transition_task(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
target_state: TaskState | str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
result: Mapping[str, Any] | None = None,
|
||||||
|
error: Mapping[str, Any] | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
target = TaskState(target_state)
|
||||||
|
action_name = {
|
||||||
|
TaskState.APPROVED: "task.approve",
|
||||||
|
TaskState.RUNNING: "task.start",
|
||||||
|
TaskState.COMPLETED: "task.complete",
|
||||||
|
TaskState.FAILED: "task.fail",
|
||||||
|
TaskState.CANCELLED: "task.cancel",
|
||||||
|
}.get(target)
|
||||||
|
if action_name is None:
|
||||||
|
raise InvalidTransition(f"cannot transition to {target.value}")
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
action_name,
|
||||||
|
{"resource_type": "task", "resource_id": task_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
current = self.store.tasks.get(task_id)
|
||||||
|
if current is None:
|
||||||
|
raise NotFound("task not found")
|
||||||
|
self._assert_same_tenant(context, current.tenant_id)
|
||||||
|
if expected_revision is not None and current.revision != expected_revision:
|
||||||
|
raise RevisionConflict(
|
||||||
|
f"expected revision {expected_revision}, current {current.revision}"
|
||||||
|
)
|
||||||
|
if target not in ALLOWED_TASK_TRANSITIONS[current.state]:
|
||||||
|
raise InvalidTransition(
|
||||||
|
f"invalid transition {current.state.value} -> {target.value}"
|
||||||
|
)
|
||||||
|
if target == TaskState.COMPLETED and result is None:
|
||||||
|
raise ValueError("completed task requires result")
|
||||||
|
if target == TaskState.FAILED and error is None:
|
||||||
|
raise ValueError("failed task requires error")
|
||||||
|
updated = TaskRecord(
|
||||||
|
task_id=current.task_id,
|
||||||
|
tenant_id=current.tenant_id,
|
||||||
|
agent_id=current.agent_id,
|
||||||
|
action=current.action,
|
||||||
|
payload=current.payload,
|
||||||
|
state=target,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
idempotency_key=current.idempotency_key,
|
||||||
|
request_digest=current.request_digest,
|
||||||
|
result=copy.deepcopy(dict(result)) if result is not None else current.result,
|
||||||
|
error=copy.deepcopy(dict(error)) if error is not None else current.error,
|
||||||
|
created_by=current.created_by,
|
||||||
|
approved_by=(
|
||||||
|
context.principal_id
|
||||||
|
if target == TaskState.APPROVED
|
||||||
|
else current.approved_by
|
||||||
|
),
|
||||||
|
created_at=current.created_at,
|
||||||
|
updated_at=_utc_now(),
|
||||||
|
)
|
||||||
|
self.store.tasks[task_id] = updated
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action=action_name,
|
||||||
|
resource_type="task",
|
||||||
|
resource_id=task_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={
|
||||||
|
"from": current.state.value,
|
||||||
|
"to": target.value,
|
||||||
|
"revision": updated.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list_audit_events(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
) -> list[AuditEvent]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"audit.read",
|
||||||
|
{"resource_type": "audit", "resource_id": resource_id or context.tenant_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
event
|
||||||
|
for event in self.store.audit_events
|
||||||
|
if event.tenant_id == context.tenant_id
|
||||||
|
and (resource_id is None or event.resource_id == resource_id)
|
||||||
|
]
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Git-backed agent execution evidence for AIOS V2.
|
||||||
|
|
||||||
|
Evidence is tenant-scoped, append-friendly, and contains no runtime credentials.
|
||||||
|
It provides a deterministic source for claim, heartbeat, progress, result and
|
||||||
|
verification state across Codex, OpenClaw and Hermes executors.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionEvidence:
|
||||||
|
execution_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
executor: str
|
||||||
|
branch: str
|
||||||
|
status: str = "claimed"
|
||||||
|
progress_completed: int = 0
|
||||||
|
progress_total: int = 1
|
||||||
|
current_step: str = "claim"
|
||||||
|
last_commit: str | None = None
|
||||||
|
test_status: str = "not_run"
|
||||||
|
test_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
result_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
claimed_at: str = field(default_factory=utc_now)
|
||||||
|
heartbeat_at: str = field(default_factory=utc_now)
|
||||||
|
completed_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"execution_id",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"executor",
|
||||||
|
"branch",
|
||||||
|
"status",
|
||||||
|
"current_step",
|
||||||
|
"claimed_at",
|
||||||
|
"heartbeat_at",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
if self.progress_completed < 0 or self.progress_total < 1:
|
||||||
|
raise ValueError("invalid progress values")
|
||||||
|
if self.progress_completed > self.progress_total:
|
||||||
|
raise ValueError("progress_completed cannot exceed progress_total")
|
||||||
|
object.__setattr__(self, "test_summary", _safe(self.test_summary))
|
||||||
|
object.__setattr__(self, "result_summary", _safe(self.result_summary))
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionTracker:
|
||||||
|
"""Persist one JSON evidence record per tenant and execution."""
|
||||||
|
|
||||||
|
def __init__(self, root: str | Path) -> None:
|
||||||
|
self.root = Path(root).resolve()
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = _required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ValueError("invalid evidence path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _path(self, tenant_id: str, execution_id: str) -> Path:
|
||||||
|
tenant = self._component(tenant_id)
|
||||||
|
execution = self._component(execution_id)
|
||||||
|
return self.root / tenant / f"{execution}.json"
|
||||||
|
|
||||||
|
def claim(self, evidence: ExecutionEvidence) -> ExecutionEvidence:
|
||||||
|
path = self._path(evidence.tenant_id, evidence.execution_id)
|
||||||
|
with self._lock:
|
||||||
|
if path.exists():
|
||||||
|
current = self._read(path)
|
||||||
|
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
|
||||||
|
raise RuntimeError("execution_id already belongs to another task")
|
||||||
|
return current
|
||||||
|
self._write(path, evidence)
|
||||||
|
return evidence
|
||||||
|
|
||||||
|
def heartbeat(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
execution_id: str,
|
||||||
|
status: str | None = None,
|
||||||
|
current_step: str | None = None,
|
||||||
|
progress_completed: int | None = None,
|
||||||
|
progress_total: int | None = None,
|
||||||
|
last_commit: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(tenant_id, execution_id)
|
||||||
|
with self._lock:
|
||||||
|
current = self._read(path)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
status=status or current.status,
|
||||||
|
current_step=current_step or current.current_step,
|
||||||
|
progress_completed=(
|
||||||
|
progress_completed
|
||||||
|
if progress_completed is not None
|
||||||
|
else current.progress_completed
|
||||||
|
),
|
||||||
|
progress_total=(
|
||||||
|
progress_total if progress_total is not None else current.progress_total
|
||||||
|
),
|
||||||
|
last_commit=last_commit or current.last_commit,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._write(path, updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
execution_id: str,
|
||||||
|
status: str,
|
||||||
|
test_status: str,
|
||||||
|
test_summary: Mapping[str, Any] | None = None,
|
||||||
|
result_summary: Mapping[str, Any] | None = None,
|
||||||
|
last_commit: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(tenant_id, execution_id)
|
||||||
|
with self._lock:
|
||||||
|
current = self._read(path)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
status=_required(status, "status"),
|
||||||
|
current_step="complete",
|
||||||
|
progress_completed=current.progress_total,
|
||||||
|
last_commit=last_commit or current.last_commit,
|
||||||
|
test_status=_required(test_status, "test_status"),
|
||||||
|
test_summary=_safe(test_summary),
|
||||||
|
result_summary=_safe(result_summary),
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
completed_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._write(path, updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def get(self, *, tenant_id: str, execution_id: str) -> ExecutionEvidence:
|
||||||
|
return self._read(self._path(tenant_id, execution_id))
|
||||||
|
|
||||||
|
def list(self, *, tenant_id: str) -> list[ExecutionEvidence]:
|
||||||
|
tenant_dir = self.root / self._component(tenant_id)
|
||||||
|
if not tenant_dir.exists():
|
||||||
|
return []
|
||||||
|
records = [self._read(path) for path in tenant_dir.glob("*.json")]
|
||||||
|
return sorted(records, key=lambda item: (item.heartbeat_at, item.execution_id), reverse=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read(path: Path) -> ExecutionEvidence:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise KeyError("execution evidence not found") from exc
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise RuntimeError("invalid execution evidence file")
|
||||||
|
return ExecutionEvidence(**dict(value))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write(path: Path, evidence: ExecutionEvidence) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""HTTP/JSON gateway for the AIOS V2 control plane.
|
||||||
|
|
||||||
|
The gateway is intentionally runtime-neutral: browsers and external clients
|
||||||
|
call this API, while OpenClaw and Hermes remain behind authenticated adapters.
|
||||||
|
Identity headers are accepted only from a trusted reverse proxy in v1; direct
|
||||||
|
public exposure is prohibited until signed identity middleware is enabled.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
from enum import Enum
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
ControlPlaneError,
|
||||||
|
ControlPlaneService,
|
||||||
|
IdempotencyConflict,
|
||||||
|
InvalidTransition,
|
||||||
|
NotFound,
|
||||||
|
PolicyDenied,
|
||||||
|
RequestContext,
|
||||||
|
RevisionConflict,
|
||||||
|
TaskState,
|
||||||
|
TenantIsolationError,
|
||||||
|
Unauthorized,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_HEADER = "X-AIOS-Tenant-ID"
|
||||||
|
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
|
||||||
|
ROLES_HEADER = "X-AIOS-Roles"
|
||||||
|
TRACE_HEADER = "X-AIOS-Trace-ID"
|
||||||
|
SESSION_HEADER = "X-AIOS-Session-ID"
|
||||||
|
POLICY_HEADER = "X-AIOS-Policy-Version"
|
||||||
|
AUTHENTICATED_HEADER = "X-AIOS-Authenticated"
|
||||||
|
|
||||||
|
_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: Any) -> Any:
|
||||||
|
if dataclasses.is_dataclass(value):
|
||||||
|
return {
|
||||||
|
field.name: _json_value(getattr(value, field.name))
|
||||||
|
for field in dataclasses.fields(value)
|
||||||
|
}
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {str(key): _json_value(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return [_json_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mapping(value: Any, *, label: str = "JSON body") -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_resource_id(value: Any, *, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not _RESOURCE_ID_RE.fullmatch(normalized):
|
||||||
|
raise ValueError(f"invalid {label}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayApplication:
|
||||||
|
"""Pure request router that can be tested without a listening socket."""
|
||||||
|
|
||||||
|
def __init__(self, service: ControlPlaneService | None = None) -> None:
|
||||||
|
self.service = service or ControlPlaneService()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
|
||||||
|
# HTTP field names are case-insensitive. BaseHTTPRequestHandler commonly
|
||||||
|
# presents them as title-cased (for example ``X-Aios-Authenticated``),
|
||||||
|
# so copying the message into a plain dict must not make identity lookup
|
||||||
|
# case-sensitive.
|
||||||
|
normalized_headers = {
|
||||||
|
str(key).strip().lower(): value for key, value in headers.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def header(name: str, default: str = "") -> str:
|
||||||
|
return str(normalized_headers.get(name.lower(), default))
|
||||||
|
|
||||||
|
authenticated = header(AUTHENTICATED_HEADER).strip().lower()
|
||||||
|
if authenticated not in {"1", "true", "yes"}:
|
||||||
|
raise Unauthorized("trusted authentication assertion is required")
|
||||||
|
roles = [
|
||||||
|
role.strip()
|
||||||
|
for role in header(ROLES_HEADER).split(",")
|
||||||
|
if role.strip()
|
||||||
|
]
|
||||||
|
return RequestContext.create(
|
||||||
|
tenant_id=header(TENANT_HEADER),
|
||||||
|
principal_id=header(PRINCIPAL_HEADER),
|
||||||
|
trace_id=header(TRACE_HEADER).strip() or None,
|
||||||
|
session_id=header(SESSION_HEADER).strip() or None,
|
||||||
|
policy_version=header(POLICY_HEADER, "v1").strip() or "v1",
|
||||||
|
roles=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
raw_path: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: Any = None,
|
||||||
|
) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
parsed = urllib.parse.urlparse(raw_path)
|
||||||
|
path = parsed.path.rstrip("/") or "/"
|
||||||
|
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
|
||||||
|
method = method.upper()
|
||||||
|
|
||||||
|
if method == "GET" and path in {"/healthz", "/api/v2/health"}:
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"service": "aios-v2-control-plane",
|
||||||
|
"status": "ok",
|
||||||
|
"store": type(self.service.store).__name__,
|
||||||
|
}
|
||||||
|
|
||||||
|
context = self.context_from_headers(headers)
|
||||||
|
payload = _require_mapping(body or {})
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/tenants":
|
||||||
|
tenant = self.service.register_tenant(
|
||||||
|
context,
|
||||||
|
tenant_id=_require_resource_id(payload.get("tenant_id"), label="tenant_id"),
|
||||||
|
display_name=str(payload.get("display_name", "")),
|
||||||
|
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/tenants/current":
|
||||||
|
tenant = self.service.get_tenant(context)
|
||||||
|
return HTTPStatus.OK, {"tenant": _json_value(tenant)}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/agents":
|
||||||
|
agent = self.service.register_agent(
|
||||||
|
context,
|
||||||
|
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
||||||
|
display_name=str(payload.get("display_name", "")),
|
||||||
|
runtime=str(payload.get("runtime", "")),
|
||||||
|
capabilities=payload.get("capabilities", ()),
|
||||||
|
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/agents":
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"agents": _json_value(self.service.list_agents(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
|
||||||
|
if method == "GET" and agent_match:
|
||||||
|
agent_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(agent_match.group(1)), label="agent_id"
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"agent": _json_value(self.service.get_agent(context, agent_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/tasks":
|
||||||
|
task = self.service.create_task(
|
||||||
|
context,
|
||||||
|
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
||||||
|
action=str(payload.get("action", "")).strip(),
|
||||||
|
payload=_require_mapping(payload.get("payload", {}), label="payload"),
|
||||||
|
idempotency_key=(
|
||||||
|
str(payload["idempotency_key"]).strip()
|
||||||
|
if payload.get("idempotency_key") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/tasks":
|
||||||
|
requested_states = query.get("state")
|
||||||
|
tasks = self.service.list_tasks(context, states=requested_states)
|
||||||
|
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
|
||||||
|
|
||||||
|
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
|
||||||
|
if method == "GET" and task_match:
|
||||||
|
task_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(task_match.group(1)), label="task_id"
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"task": _json_value(self.service.get_task(context, task_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
transition_match = re.fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/(approve|start|complete|fail|cancel)", path
|
||||||
|
)
|
||||||
|
if method == "POST" and transition_match:
|
||||||
|
task_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(transition_match.group(1)), label="task_id"
|
||||||
|
)
|
||||||
|
transition = transition_match.group(2)
|
||||||
|
target = {
|
||||||
|
"approve": TaskState.APPROVED,
|
||||||
|
"start": TaskState.RUNNING,
|
||||||
|
"complete": TaskState.COMPLETED,
|
||||||
|
"fail": TaskState.FAILED,
|
||||||
|
"cancel": TaskState.CANCELLED,
|
||||||
|
}[transition]
|
||||||
|
expected_revision = payload.get("expected_revision")
|
||||||
|
if expected_revision is not None:
|
||||||
|
expected_revision = int(expected_revision)
|
||||||
|
task = self.service.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task_id,
|
||||||
|
target_state=target,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
result=(
|
||||||
|
_require_mapping(payload.get("result"), label="result")
|
||||||
|
if payload.get("result") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
error=(
|
||||||
|
_require_mapping(payload.get("error"), label="error")
|
||||||
|
if payload.get("error") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/audit":
|
||||||
|
resource_id = query.get("resource_id", [None])[0]
|
||||||
|
events = self.service.list_audit_events(
|
||||||
|
context,
|
||||||
|
resource_id=(
|
||||||
|
_require_resource_id(resource_id, label="resource_id")
|
||||||
|
if resource_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"events": _json_value(events)}
|
||||||
|
|
||||||
|
return HTTPStatus.NOT_FOUND, {
|
||||||
|
"error": "not_found",
|
||||||
|
"message": "route not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
|
||||||
|
(Unauthorized, HTTPStatus.UNAUTHORIZED),
|
||||||
|
(PolicyDenied, HTTPStatus.FORBIDDEN),
|
||||||
|
(TenantIsolationError, HTTPStatus.NOT_FOUND),
|
||||||
|
(NotFound, HTTPStatus.NOT_FOUND),
|
||||||
|
(IdempotencyConflict, HTTPStatus.CONFLICT),
|
||||||
|
(RevisionConflict, HTTPStatus.CONFLICT),
|
||||||
|
(InvalidTransition, HTTPStatus.CONFLICT),
|
||||||
|
(ValueError, HTTPStatus.BAD_REQUEST),
|
||||||
|
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
for error_type, status in _ERROR_STATUS:
|
||||||
|
if isinstance(exc, error_type):
|
||||||
|
return status, {
|
||||||
|
"error": error_type.__name__,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {
|
||||||
|
"error": "InternalServerError",
|
||||||
|
"message": "internal server error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "AIOSV2Gateway/1.0"
|
||||||
|
max_body_bytes = 1_048_576
|
||||||
|
|
||||||
|
@property
|
||||||
|
def application(self) -> GatewayApplication:
|
||||||
|
return self.server.application # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def _read_body(self) -> Any:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
if length < 0 or length > self.max_body_bytes:
|
||||||
|
raise ValueError("request body is too large")
|
||||||
|
if length == 0:
|
||||||
|
return {}
|
||||||
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||||
|
if content_type != "application/json":
|
||||||
|
raise ValueError("Content-Type must be application/json")
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
self.send_response(int(status))
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _dispatch(self, method: str) -> None:
|
||||||
|
try:
|
||||||
|
body = self._read_body() if method in {"POST", "PUT", "PATCH"} else {}
|
||||||
|
status, payload = self.application.handle(
|
||||||
|
method,
|
||||||
|
self.path,
|
||||||
|
{key: value for key, value in self.headers.items()},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
except BaseException as exc: # handler boundary; sanitized below
|
||||||
|
status, payload = error_response(exc)
|
||||||
|
self._send_json(status, payload)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("GET")
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("POST")
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
|
# Production deployment routes structured access logs through the
|
||||||
|
# reverse proxy; do not log identity headers or request bodies here.
|
||||||
|
super().log_message(format, *args)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_address: tuple[str, int],
|
||||||
|
application: GatewayApplication,
|
||||||
|
) -> None:
|
||||||
|
self.application = application
|
||||||
|
super().__init__(server_address, GatewayRequestHandler)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
|
||||||
|
server = GatewayHTTPServer((host, port), GatewayApplication())
|
||||||
|
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Sanitized GitOps runtime status for the AIOS Web control plane."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitOpsStatus:
|
||||||
|
available: bool
|
||||||
|
service_status: str
|
||||||
|
workspace_mode: str
|
||||||
|
workspace_branch: str | None
|
||||||
|
workspace_ref: str | None
|
||||||
|
workspace_dirty: bool | None
|
||||||
|
candidate_branch: str
|
||||||
|
release_channel: str
|
||||||
|
release_version: str
|
||||||
|
sync_status: str
|
||||||
|
details: Mapping[str, Any]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(worktree: Path, *args: str) -> tuple[int, str, str]:
|
||||||
|
process = subprocess.run(
|
||||||
|
["git", "-C", str(worktree), *args],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return process.returncode, process.stdout.strip(), process.stderr.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_gitops_status() -> GitOpsStatus:
|
||||||
|
"""Return only non-sensitive Git/workspace metadata.
|
||||||
|
|
||||||
|
Remote URLs, environment values, credentials, filesystem contents and
|
||||||
|
command stderr are deliberately excluded from the Web response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
candidate_branch = os.getenv(
|
||||||
|
"AIOS_CANDIDATE_BRANCH",
|
||||||
|
"integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
)
|
||||||
|
release_channel = os.getenv("AIOS_RELEASE_CHANNEL", "candidate")
|
||||||
|
release_version = os.getenv("AIOS_RELEASE_VERSION", "V2R1C0-candidate")
|
||||||
|
sync_status = os.getenv("AIOS_GITOPS_SYNC_STATUS", "unknown")
|
||||||
|
worktree = Path(
|
||||||
|
os.getenv(
|
||||||
|
"AIOS_GITOPS_CONTROLLER_WORKTREE",
|
||||||
|
"/data/aios-gitops/worktrees/controller",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not worktree.is_dir():
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=False,
|
||||||
|
service_status="unknown",
|
||||||
|
workspace_mode="unavailable",
|
||||||
|
workspace_branch=None,
|
||||||
|
workspace_ref=None,
|
||||||
|
workspace_dirty=None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={"reason": "controller_worktree_unavailable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
inside_code, inside, _ = _run_git(worktree, "rev-parse", "--is-inside-work-tree")
|
||||||
|
if inside_code != 0 or inside != "true":
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=False,
|
||||||
|
service_status="unknown",
|
||||||
|
workspace_mode="invalid",
|
||||||
|
workspace_branch=None,
|
||||||
|
workspace_ref=None,
|
||||||
|
workspace_dirty=None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={"reason": "controller_worktree_invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
branch_code, branch, _ = _run_git(worktree, "branch", "--show-current")
|
||||||
|
ref_code, ref_value, _ = _run_git(worktree, "rev-parse", "HEAD")
|
||||||
|
dirty_code, dirty_value, _ = _run_git(worktree, "status", "--porcelain=v1")
|
||||||
|
|
||||||
|
service_status = "unknown"
|
||||||
|
try:
|
||||||
|
process = subprocess.run(
|
||||||
|
["systemctl", "is-active", "aios-gitops-model-api"],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
service_status = process.stdout.strip() or "inactive"
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
service_status = "unavailable"
|
||||||
|
|
||||||
|
branch_value = branch if branch_code == 0 and branch else None
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=True,
|
||||||
|
service_status=service_status,
|
||||||
|
workspace_mode="branch" if branch_value else "detached",
|
||||||
|
workspace_branch=branch_value,
|
||||||
|
workspace_ref=ref_value if ref_code == 0 else None,
|
||||||
|
workspace_dirty=bool(dirty_value) if dirty_code == 0 else None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={
|
||||||
|
"controller_clean": dirty_code == 0 and not dirty_value,
|
||||||
|
"controller_detached": branch_code == 0 and not branch,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""AIOS V2 execution orchestrator.
|
||||||
|
|
||||||
|
The orchestrator is the only component allowed to translate an approved AIOS
|
||||||
|
task into an OpenClaw command or Hermes job. OpenClaw and Hermes remain pure
|
||||||
|
execution runtimes and never own tenant policy or task lifecycle.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_hermes import HermesAdapter, HermesJob, HermesResult
|
||||||
|
from aios_openclaw import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawAdapter,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
sign_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
ControlPlaneService,
|
||||||
|
RequestContext,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestrationError(RuntimeError):
|
||||||
|
"""AIOS failed to dispatch or reconcile a runtime execution."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DispatchRecord:
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
runtime: str
|
||||||
|
runtime_execution_id: str
|
||||||
|
state: str
|
||||||
|
trace_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionOrchestrator:
|
||||||
|
"""Dispatch approved AIOS tasks to runtime adapters and reconcile results."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
control_plane: ControlPlaneService,
|
||||||
|
*,
|
||||||
|
openclaw: OpenClawAdapter | None = None,
|
||||||
|
hermes: HermesAdapter | None = None,
|
||||||
|
openclaw_signing_material_resolver=None,
|
||||||
|
openclaw_key_id: str = "default",
|
||||||
|
default_cell_id: str = "default",
|
||||||
|
default_profile_id: str = "default",
|
||||||
|
) -> None:
|
||||||
|
self.control_plane = control_plane
|
||||||
|
self.openclaw = openclaw
|
||||||
|
self.hermes = hermes
|
||||||
|
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
|
||||||
|
self.openclaw_key_id = openclaw_key_id
|
||||||
|
self.default_cell_id = default_cell_id
|
||||||
|
self.default_profile_id = default_profile_id
|
||||||
|
self._dispatches: dict[tuple[str, str], DispatchRecord] = {}
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
task = self.control_plane.get_task(context, task_id)
|
||||||
|
if task.state != TaskState.APPROVED:
|
||||||
|
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||||
|
agent = self.control_plane.get_agent(context, task.agent_id)
|
||||||
|
existing = self._dispatches.get((task.tenant_id, task.task_id))
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
running = self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.RUNNING,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime = agent.runtime.strip().lower()
|
||||||
|
try:
|
||||||
|
if runtime == "openclaw":
|
||||||
|
record = self._dispatch_openclaw(
|
||||||
|
context,
|
||||||
|
agent,
|
||||||
|
running,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
elif runtime == "hermes":
|
||||||
|
record = self._dispatch_hermes(context, agent, running)
|
||||||
|
else:
|
||||||
|
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
|
||||||
|
except Exception as exc:
|
||||||
|
current = self.control_plane.get_task(context, task.task_id)
|
||||||
|
if current.state == TaskState.RUNNING:
|
||||||
|
self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.FAILED,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
error={"code": "dispatch_failed", "message": str(exc)},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._dispatches[(task.tenant_id, task.task_id)] = record
|
||||||
|
return record
|
||||||
|
|
||||||
|
def _dispatch_openclaw(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
agent: AgentRecord,
|
||||||
|
task: TaskRecord,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float | None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
if self.openclaw is None or self.openclaw_signing_material_resolver is None:
|
||||||
|
raise OrchestrationError("OpenClaw runtime is not configured")
|
||||||
|
key_material = self.openclaw_signing_material_resolver(
|
||||||
|
task.tenant_id,
|
||||||
|
self.openclaw_key_id,
|
||||||
|
)
|
||||||
|
requested_timeout = (
|
||||||
|
self.openclaw.default_timeout_seconds
|
||||||
|
if timeout_seconds is None
|
||||||
|
else float(timeout_seconds)
|
||||||
|
)
|
||||||
|
effective_timeout = min(requested_timeout, 300.0)
|
||||||
|
expires_at = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=effective_timeout)
|
||||||
|
).isoformat().replace("+00:00", "Z")
|
||||||
|
command = OpenClawCommand.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=task.action,
|
||||||
|
payload=task.payload,
|
||||||
|
idempotency_key=task.idempotency_key or task.task_id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
key_id=self.openclaw_key_id,
|
||||||
|
)
|
||||||
|
command = sign_command(command, key_material)
|
||||||
|
result = self.openclaw.execute(command, timeout_seconds=effective_timeout)
|
||||||
|
self.reconcile_openclaw(context, result)
|
||||||
|
return DispatchRecord(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_execution_id=command.command_id,
|
||||||
|
state=result.state.value,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dispatch_hermes(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
agent: AgentRecord,
|
||||||
|
task: TaskRecord,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
if self.hermes is None:
|
||||||
|
raise OrchestrationError("Hermes runtime is not configured")
|
||||||
|
metadata = dict(agent.metadata)
|
||||||
|
job = HermesJob.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
|
||||||
|
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
|
||||||
|
task_id=task.task_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=task.action,
|
||||||
|
payload=task.payload,
|
||||||
|
idempotency_key=task.idempotency_key or task.task_id,
|
||||||
|
priority=int(metadata.get("priority", 100)),
|
||||||
|
)
|
||||||
|
queued = self.hermes.enqueue(job)
|
||||||
|
return DispatchRecord(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
runtime="hermes",
|
||||||
|
runtime_execution_id=queued.job_id,
|
||||||
|
state=queued.state.value,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reconcile_openclaw(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
result: OpenClawResult,
|
||||||
|
) -> TaskRecord:
|
||||||
|
task = self.control_plane.get_task(context, result.task_id)
|
||||||
|
if result.tenant_id != task.tenant_id:
|
||||||
|
raise OrchestrationError("OpenClaw result tenant mismatch")
|
||||||
|
if task.state != TaskState.RUNNING:
|
||||||
|
return task
|
||||||
|
if result.state == CommandState.COMPLETED:
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.COMPLETED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
result=result.output or {},
|
||||||
|
)
|
||||||
|
if result.state in {
|
||||||
|
CommandState.FAILED,
|
||||||
|
CommandState.TIMED_OUT,
|
||||||
|
CommandState.REJECTED,
|
||||||
|
}:
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.FAILED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
error=result.error or {"code": result.state.value.lower()},
|
||||||
|
)
|
||||||
|
if result.state == CommandState.CANCELLED:
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.CANCELLED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def reconcile_hermes(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
result: HermesResult,
|
||||||
|
) -> TaskRecord:
|
||||||
|
task = self.control_plane.get_task(context, result.task_id)
|
||||||
|
if result.tenant_id != task.tenant_id:
|
||||||
|
raise OrchestrationError("Hermes result tenant mismatch")
|
||||||
|
if task.state != TaskState.RUNNING:
|
||||||
|
return task
|
||||||
|
if result.state.value == "COMPLETED":
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.COMPLETED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
result=result.output or {},
|
||||||
|
)
|
||||||
|
if result.state.value == "FAILED":
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.FAILED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
error=result.error or {"code": "hermes_failed"},
|
||||||
|
)
|
||||||
|
if result.state.value == "CANCELLED":
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.CANCELLED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
|
||||||
|
return {
|
||||||
|
task_id: record
|
||||||
|
for (record_tenant, task_id), record in self._dispatches.items()
|
||||||
|
if record_tenant == tenant_id
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Durable single-process persistence for the AIOS V2 control plane.
|
||||||
|
|
||||||
|
The candidate uses an atomic SQLite snapshot so a gateway restart does not lose
|
||||||
|
tenants, agents, task state, idempotency records, or audit events. The domain
|
||||||
|
service remains storage-agnostic; a later HA backend can replace this module
|
||||||
|
without changing API or runtime-adapter contracts.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
AuditEvent,
|
||||||
|
ControlPlaneService,
|
||||||
|
InMemoryControlPlaneStore,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
TenantRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteSnapshotRepository:
|
||||||
|
"""Store one versioned control-plane snapshot in SQLite."""
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS control_plane_snapshot (
|
||||||
|
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
|
||||||
|
schema_version INTEGER NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def load(self) -> dict[str, Any] | None:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT schema_version, payload_json FROM control_plane_snapshot WHERE singleton_id = 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
schema_version, payload_json = row
|
||||||
|
if int(schema_version) != self.SCHEMA_VERSION:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unsupported control-plane snapshot version: {schema_version}"
|
||||||
|
)
|
||||||
|
decoded = json.loads(str(payload_json))
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
raise RuntimeError("invalid control-plane snapshot")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
def save(self, payload: dict[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO control_plane_snapshot (
|
||||||
|
singleton_id, schema_version, payload_json, updated_at
|
||||||
|
) VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(singleton_id) DO UPDATE SET
|
||||||
|
schema_version = excluded.schema_version,
|
||||||
|
payload_json = excluded.payload_json,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(self.SCHEMA_VERSION, encoded),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class DurableControlPlaneService(ControlPlaneService):
|
||||||
|
"""Control-plane service that persists after every successful mutation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: SQLiteSnapshotRepository,
|
||||||
|
*,
|
||||||
|
policy_evaluator=None,
|
||||||
|
) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
store = InMemoryControlPlaneStore()
|
||||||
|
self._restore(store, repository.load())
|
||||||
|
super().__init__(store, policy_evaluator=policy_evaluator)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore(
|
||||||
|
store: InMemoryControlPlaneStore,
|
||||||
|
snapshot: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
if not snapshot:
|
||||||
|
return
|
||||||
|
for item in snapshot.get("tenants", []):
|
||||||
|
record = TenantRecord(**item)
|
||||||
|
store.tenants[record.tenant_id] = record
|
||||||
|
for item in snapshot.get("agents", []):
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["capabilities"] = tuple(normalized.get("capabilities", ()))
|
||||||
|
record = AgentRecord(**normalized)
|
||||||
|
store.agents[record.agent_id] = record
|
||||||
|
for item in snapshot.get("tasks", []):
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["state"] = TaskState(normalized["state"])
|
||||||
|
record = TaskRecord(**normalized)
|
||||||
|
store.tasks[record.task_id] = record
|
||||||
|
store.audit_events.extend(AuditEvent(**item) for item in snapshot.get("audit", []))
|
||||||
|
for item in snapshot.get("idempotency", []):
|
||||||
|
store.idempotency[(item["tenant_id"], item["key"])] = (
|
||||||
|
item["task_id"],
|
||||||
|
item["digest"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _snapshot(self) -> dict[str, Any]:
|
||||||
|
with self.store.lock:
|
||||||
|
return {
|
||||||
|
"schema_version": self.repository.SCHEMA_VERSION,
|
||||||
|
"tenants": [asdict(item) for item in self.store.tenants.values()],
|
||||||
|
"agents": [asdict(item) for item in self.store.agents.values()],
|
||||||
|
"tasks": [
|
||||||
|
{**asdict(item), "state": item.state.value}
|
||||||
|
for item in self.store.tasks.values()
|
||||||
|
],
|
||||||
|
"audit": [asdict(item) for item in self.store.audit_events],
|
||||||
|
"idempotency": [
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"key": key,
|
||||||
|
"task_id": task_id,
|
||||||
|
"digest": digest,
|
||||||
|
}
|
||||||
|
for (tenant_id, key), (task_id, digest)
|
||||||
|
in self.store.idempotency.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _mutate(self, operation: Callable[[], Any]) -> Any:
|
||||||
|
with self.store.lock:
|
||||||
|
result = operation()
|
||||||
|
self.repository.save(self._snapshot())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def register_tenant(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).register_tenant(*args, **kwargs))
|
||||||
|
|
||||||
|
def register_agent(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).register_agent(*args, **kwargs))
|
||||||
|
|
||||||
|
def create_task(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).create_task(*args, **kwargs))
|
||||||
|
|
||||||
|
def transition_task(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).transition_task(*args, **kwargs))
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="${AIOS_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
|
||||||
|
BASE_REF="${AIOS_DOCS_BASE_REF:-}"
|
||||||
|
RUN_LIVE_HEALTHCHECK="${AIOS_RUN_LIVE_HEALTHCHECK:-0}"
|
||||||
|
RESULT_DIR="${AIOS_ACCEPTANCE_RESULT_DIR:-$REPO_ROOT/.aios-evidence/acceptance}"
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
RUN_DIR="$RESULT_DIR/$STAMP"
|
||||||
|
SUMMARY="$RUN_DIR/summary.json"
|
||||||
|
|
||||||
|
install -d -m 0750 "$RUN_DIR"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
failed=0
|
||||||
|
warnings=0
|
||||||
|
steps=()
|
||||||
|
|
||||||
|
record() {
|
||||||
|
local name="$1" status="$2" code="$3" log="$4"
|
||||||
|
steps+=("$name|$status|$code|$log")
|
||||||
|
case "$status" in
|
||||||
|
通过) pass=$((pass + 1)) ;;
|
||||||
|
失败) failed=$((failed + 1)) ;;
|
||||||
|
警告) warnings=$((warnings + 1)) ;;
|
||||||
|
esac
|
||||||
|
printf '%s:%s(退出码 %s)\n' "$status" "$name" "$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local safe_name
|
||||||
|
safe_name="$(printf '%s' "$name" | tr ' /:' '___')"
|
||||||
|
local log="$RUN_DIR/${safe_name}.log"
|
||||||
|
set +e
|
||||||
|
(cd "$REPO_ROOT" && "$@") >"$log" 2>&1
|
||||||
|
local code=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$code" -eq 0 ]]; then
|
||||||
|
record "$name" "通过" "$code" "$log"
|
||||||
|
else
|
||||||
|
record "$name" "失败" "$code" "$log"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_optional_step() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local safe_name
|
||||||
|
safe_name="$(printf '%s' "$name" | tr ' /:' '___')"
|
||||||
|
local log="$RUN_DIR/${safe_name}.log"
|
||||||
|
set +e
|
||||||
|
(cd "$REPO_ROOT" && "$@") >"$log" 2>&1
|
||||||
|
local code=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$code" -eq 0 ]]; then
|
||||||
|
record "$name" "通过" "$code" "$log"
|
||||||
|
else
|
||||||
|
record "$name" "警告" "$code" "$log"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'AIOS V2R1 Candidate 全量验收开始\n'
|
||||||
|
printf '仓库:%s\n' "$REPO_ROOT"
|
||||||
|
printf '结果目录:%s\n' "$RUN_DIR"
|
||||||
|
|
||||||
|
[[ -d "$REPO_ROOT" ]] || { echo "错误:仓库不存在" >&2; exit 2; }
|
||||||
|
|
||||||
|
DOCS_ARGS=(python3 tools/aios_v2_docs_code_check.py --repo "$REPO_ROOT")
|
||||||
|
if [[ -n "$BASE_REF" ]]; then
|
||||||
|
DOCS_ARGS+=(--base-ref "$BASE_REF")
|
||||||
|
fi
|
||||||
|
run_step "文档与代码匹配" "${DOCS_ARGS[@]}"
|
||||||
|
|
||||||
|
run_step "Python 语法编译" python3 -m compileall -q \
|
||||||
|
active-runtime/app/aios_v2 \
|
||||||
|
active-runtime/app/aios_openclaw \
|
||||||
|
active-runtime/app/aios_hermes
|
||||||
|
|
||||||
|
run_step "候选单元与集成测试" python3 -m unittest discover \
|
||||||
|
-s tests/aios_v2_candidate \
|
||||||
|
-p 'test_*.py' \
|
||||||
|
-v
|
||||||
|
|
||||||
|
run_step "Shell 脚本语法" bash -c '
|
||||||
|
set -Eeuo pipefail
|
||||||
|
while IFS= read -r -d "" path; do
|
||||||
|
bash -n "$path"
|
||||||
|
done < <(find deploy/aios-v2 -type f -name "*.sh" -print0)
|
||||||
|
'
|
||||||
|
|
||||||
|
run_step "文档 Git 冲突标记" bash -c '
|
||||||
|
set -Eeuo pipefail
|
||||||
|
if grep -RInE "^(<<<<<<<|=======|>>>>>>>)" docs/aios-v2; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
|
||||||
|
run_step "明文密钥基础扫描" python3 -c '
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
roots = [Path("active-runtime/app"), Path("deploy/aios-v2"), Path("docs/aios-v2"), Path("tasks")]
|
||||||
|
patterns = [
|
||||||
|
re.compile(r"(?i)(api[_-]?key|password|private[_-]?key|access[_-]?token)\s*[:=]\s*[\"\x27][^\"\x27\n]{12,}[\"\x27]"),
|
||||||
|
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||||
|
]
|
||||||
|
violations = []
|
||||||
|
for root in roots:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if not path.is_file() or path.suffix in {".pyc", ".db", ".sqlite3"}:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
if any(pattern.search(text) for pattern in patterns):
|
||||||
|
violations.append(str(path))
|
||||||
|
if violations:
|
||||||
|
print("疑似明文密钥文件:", *violations, sep="\n", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
'
|
||||||
|
|
||||||
|
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||||
|
run_optional_step "systemd 单元验证" systemd-analyze verify \
|
||||||
|
deploy/aios-v2/systemd/aios-v2.service
|
||||||
|
else
|
||||||
|
record "systemd 单元验证" "警告" "127" "未安装 systemd-analyze"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||||
|
run_optional_step "Docker Compose 配置验证" docker compose \
|
||||||
|
-f deploy/aios-v2/docker/compose.yaml config
|
||||||
|
else
|
||||||
|
record "Docker Compose 配置验证" "警告" "127" "未安装 Docker Compose"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RUN_LIVE_HEALTHCHECK" == "1" ]]; then
|
||||||
|
run_step "在线健康检查" bash deploy/aios-v2/healthcheck/check.sh
|
||||||
|
else
|
||||||
|
record "在线健康检查" "警告" "0" "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)"
|
||||||
|
commit="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || true)"
|
||||||
|
dirty="$(git -C "$REPO_ROOT" status --porcelain=v1 2>/dev/null || true)"
|
||||||
|
|
||||||
|
python3 - "$SUMMARY" "$branch" "$commit" "$dirty" "$pass" "$failed" "$warnings" "${steps[@]}" <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
summary_path = pathlib.Path(sys.argv[1])
|
||||||
|
branch, commit, dirty = sys.argv[2:5]
|
||||||
|
passed, failed, warnings = map(int, sys.argv[5:8])
|
||||||
|
steps = []
|
||||||
|
for item in sys.argv[8:]:
|
||||||
|
name, status, code, log = item.split("|", 3)
|
||||||
|
steps.append({"name": name, "status": status, "exit_code": int(code), "log": log})
|
||||||
|
payload = {
|
||||||
|
"schema": "aios.v2.acceptance.v1",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
"branch": branch or None,
|
||||||
|
"commit": commit or None,
|
||||||
|
"workspace_dirty": bool(dirty),
|
||||||
|
"passed": passed,
|
||||||
|
"failed": failed,
|
||||||
|
"warnings": warnings,
|
||||||
|
"steps": steps,
|
||||||
|
"final_status": "passed" if failed == 0 else "failed",
|
||||||
|
}
|
||||||
|
summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
PY
|
||||||
|
|
||||||
|
printf '验收结果:通过 %s,失败 %s,警告 %s\n' "$pass" "$failed" "$warnings"
|
||||||
|
printf '证据:%s\n' "$SUMMARY"
|
||||||
|
|
||||||
|
if [[ "$failed" -ne 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/opt/aios/app \
|
||||||
|
AIOS_V2_HOST=0.0.0.0 \
|
||||||
|
AIOS_V2_PORT=18920 \
|
||||||
|
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 12001 aios \
|
||||||
|
&& useradd --system --uid 12001 --gid aios --home-dir /nonexistent --shell /usr/sbin/nologin aios \
|
||||||
|
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2
|
||||||
|
|
||||||
|
WORKDIR /opt/aios/app
|
||||||
|
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
|
||||||
|
|
||||||
|
USER aios:aios
|
||||||
|
EXPOSE 18920
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
|
||||||
|
|
||||||
|
CMD ["python3", "-m", "aios_v2.app"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
aios-v2:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: deploy/aios-v2/docker/Dockerfile
|
||||||
|
image: aios-v2:V2R1C0-candidate
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${AIOS_V2_PORT:-18920}:18920"
|
||||||
|
environment:
|
||||||
|
AIOS_V2_HOST: 0.0.0.0
|
||||||
|
AIOS_V2_PORT: 18920
|
||||||
|
AIOS_V2_STATE_DIR: /var/lib/aios-v2
|
||||||
|
AIOS_OPENCLAW_URL: ${AIOS_OPENCLAW_URL:-}
|
||||||
|
AIOS_OPENCLAW_CREDENTIAL_REF: ${AIOS_OPENCLAW_CREDENTIAL_REF:-}
|
||||||
|
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE: ${AIOS_OPENCLAW_SIGNING_MATERIAL_FILE:-}
|
||||||
|
AIOS_RELEASE_CHANNEL: candidate
|
||||||
|
AIOS_RELEASE_VERSION: V2R1C0-candidate
|
||||||
|
volumes:
|
||||||
|
- aios-v2-state:/var/lib/aios-v2
|
||||||
|
# Mount runtime signing material explicitly when OpenClaw is enabled.
|
||||||
|
# The host file must be mode 0600; never store it in this repository.
|
||||||
|
# - /etc/aios-v2/openclaw-signing-material:/run/secrets/openclaw-signing-material:ro
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python3
|
||||||
|
- -c
|
||||||
|
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 4
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
aios-v2-state:
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# AIOS V2 candidate runtime configuration.
|
||||||
|
# Copy to /etc/aios-v2/aios-v2.env and keep ownership root:aios, mode 0640.
|
||||||
|
|
||||||
|
AIOS_V2_HOST=127.0.0.1
|
||||||
|
AIOS_V2_PORT=18920
|
||||||
|
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||||
|
|
||||||
|
# Optional OpenClaw runtime. Leave blank to run with OpenClaw disabled.
|
||||||
|
AIOS_OPENCLAW_URL=
|
||||||
|
AIOS_OPENCLAW_CREDENTIAL_REF=
|
||||||
|
|
||||||
|
# File path only. The referenced file must be mode 0600 or stricter and contain
|
||||||
|
# at least 32 bytes. Never place signing material in this env file or Git.
|
||||||
|
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=
|
||||||
|
|
||||||
|
# Reverse proxy must authenticate users and inject X-AIOS-* identity headers.
|
||||||
|
AIOS_TRUST_PROXY_HEADERS=true
|
||||||
|
|
||||||
|
# Candidate deployment metadata exposed through health tooling.
|
||||||
|
AIOS_RELEASE_CHANNEL=candidate
|
||||||
|
AIOS_RELEASE_VERSION=V2R1C0-candidate
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${AIOS_V2_BASE_URL:-http://127.0.0.1:18920}"
|
||||||
|
STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
|
||||||
|
CONTROLLER_WT="${AIOS_GITOPS_CONTROLLER_WORKTREE:-/data/aios-gitops/worktrees/controller}"
|
||||||
|
SERVICE="${AIOS_V2_SERVICE:-aios-v2}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
echo "PASS: $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v python3 >/dev/null || fail "python3 not installed"
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null && systemctl list-unit-files "$SERVICE.service" >/dev/null 2>&1; then
|
||||||
|
systemctl is-active --quiet "$SERVICE" || fail "$SERVICE is not active"
|
||||||
|
pass "$SERVICE is active"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$BASE_URL" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
base_url = sys.argv[1].rstrip("/")
|
||||||
|
with urllib.request.urlopen(f"{base_url}/api/v2/health", timeout=5) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
if payload.get("status") not in {"ok", "healthy", "ready"}:
|
||||||
|
raise SystemExit(f"unexpected health payload: {payload}")
|
||||||
|
print(f"PASS: HTTP health status={payload.get('status')}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
[[ -d "$STATE_DIR" ]] || fail "state directory missing: $STATE_DIR"
|
||||||
|
[[ -w "$STATE_DIR" ]] || fail "state directory not writable: $STATE_DIR"
|
||||||
|
pass "state directory writable"
|
||||||
|
|
||||||
|
if [[ -e "$STATE_DIR/control-plane.sqlite3" ]]; then
|
||||||
|
python3 - "$STATE_DIR/control-plane.sqlite3" <<'PY'
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
|
||||||
|
try:
|
||||||
|
row = connection.execute("PRAGMA quick_check").fetchone()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
if not row or row[0] != "ok":
|
||||||
|
raise SystemExit(f"SQLite quick_check failed: {row}")
|
||||||
|
print("PASS: SQLite quick_check=ok")
|
||||||
|
PY
|
||||||
|
else
|
||||||
|
echo "WARN: control-plane.sqlite3 not created yet"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "$CONTROLLER_WT" ]] && git -C "$CONTROLLER_WT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
branch="$(git -C "$CONTROLLER_WT" branch --show-current)"
|
||||||
|
dirty="$(git -C "$CONTROLLER_WT" status --porcelain=v1)"
|
||||||
|
[[ -z "$branch" ]] || fail "GitOps controller attached to branch: $branch"
|
||||||
|
[[ -z "$dirty" ]] || fail "GitOps controller workspace is dirty"
|
||||||
|
pass "GitOps controller detached and clean"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$STATE_DIR/executions" <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = pathlib.Path(sys.argv[1])
|
||||||
|
if not root.exists():
|
||||||
|
print("WARN: execution evidence directory not created yet")
|
||||||
|
raise SystemExit(0)
|
||||||
|
for path in root.glob("*/*.json"):
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
for key in ("execution_id", "tenant_id", "task_id", "executor", "status"):
|
||||||
|
if not value.get(key):
|
||||||
|
raise SystemExit(f"invalid execution evidence {path}: missing {key}")
|
||||||
|
print("PASS: execution evidence files valid")
|
||||||
|
PY
|
||||||
|
|
||||||
|
pass "AIOS V2 candidate healthcheck complete"
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SERVICE="${AIOS_V2_SERVICE:-aios-v2}"
|
||||||
|
RELEASE_ROOT="${AIOS_V2_RELEASE_ROOT:-/opt/aios/releases}"
|
||||||
|
CURRENT_LINK="${AIOS_V2_CURRENT_LINK:-/opt/aios/current}"
|
||||||
|
STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
|
||||||
|
BACKUP_ROOT="${AIOS_V2_BACKUP_ROOT:-/var/backups/aios-v2}"
|
||||||
|
TARGET_RELEASE="${1:-}"
|
||||||
|
RESTORE_STATE="${AIOS_V2_RESTORE_STATE:-0}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
|
||||||
|
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
|
||||||
|
|
||||||
|
if [[ "$TARGET_RELEASE" = /* ]]; then
|
||||||
|
TARGET_PATH="$(readlink -f "$TARGET_RELEASE")"
|
||||||
|
else
|
||||||
|
[[ "$TARGET_RELEASE" != *"/"* && "$TARGET_RELEASE" != "." && "$TARGET_RELEASE" != ".." ]] \
|
||||||
|
|| fail "invalid release name"
|
||||||
|
TARGET_PATH="$(readlink -f "$RELEASE_ROOT/$TARGET_RELEASE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -d "$TARGET_PATH/active-runtime/app" ]] || fail "target release is invalid: $TARGET_PATH"
|
||||||
|
[[ -f "$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh" ]] \
|
||||||
|
|| fail "target release has no healthcheck"
|
||||||
|
|
||||||
|
PREVIOUS_PATH=""
|
||||||
|
if [[ -L "$CURRENT_LINK" ]]; then
|
||||||
|
PREVIOUS_PATH="$(readlink -f "$CURRENT_LINK")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
BACKUP_DIR="$BACKUP_ROOT/rollback-$STAMP"
|
||||||
|
install -d -m 0750 "$BACKUP_DIR"
|
||||||
|
|
||||||
|
if [[ -d "$STATE_DIR" ]]; then
|
||||||
|
tar --xattrs --acls -C "$(dirname "$STATE_DIR")" \
|
||||||
|
-czf "$BACKUP_DIR/state-before-rollback.tar.gz" \
|
||||||
|
"$(basename "$STATE_DIR")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$PREVIOUS_PATH" > "$BACKUP_DIR/previous-release.txt"
|
||||||
|
printf '%s\n' "$TARGET_PATH" > "$BACKUP_DIR/target-release.txt"
|
||||||
|
|
||||||
|
systemctl stop "$SERVICE"
|
||||||
|
ln -sfn "$TARGET_PATH" "${CURRENT_LINK}.next"
|
||||||
|
mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start "$SERVICE"
|
||||||
|
|
||||||
|
if AIOS_V2_SERVICE="$SERVICE" \
|
||||||
|
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||||
|
"$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh"; then
|
||||||
|
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Target release failed healthcheck; restoring previous release." >&2
|
||||||
|
systemctl stop "$SERVICE" || true
|
||||||
|
|
||||||
|
if [[ -n "$PREVIOUS_PATH" && -d "$PREVIOUS_PATH" ]]; then
|
||||||
|
ln -sfn "$PREVIOUS_PATH" "${CURRENT_LINK}.previous"
|
||||||
|
mv -Tf "${CURRENT_LINK}.previous" "$CURRENT_LINK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RESTORE_STATE" == "1" && -f "$BACKUP_DIR/state-before-rollback.tar.gz" ]]; then
|
||||||
|
rm -rf "$STATE_DIR"
|
||||||
|
tar --xattrs --acls -C "$(dirname "$STATE_DIR")" \
|
||||||
|
-xzf "$BACKUP_DIR/state-before-rollback.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start "$SERVICE" || true
|
||||||
|
|
||||||
|
if [[ -n "$PREVIOUS_PATH" && -x "$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" ]]; then
|
||||||
|
AIOS_V2_SERVICE="$SERVICE" \
|
||||||
|
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||||
|
"$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS V2 Control Plane Candidate
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios
|
||||||
|
Group=aios
|
||||||
|
WorkingDirectory=/opt/aios/current/active-runtime/app
|
||||||
|
EnvironmentFile=-/etc/aios-v2/aios-v2.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/bin/python3 -m aios_v2.app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStartSec=30
|
||||||
|
TimeoutStopSec=30
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
ReadWritePaths=/var/lib/aios-v2
|
||||||
|
StateDirectory=aios-v2
|
||||||
|
StateDirectoryMode=0750
|
||||||
|
UMask=0027
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# AIOS V2 用户需求规格
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 状态:候选版本需求基线
|
||||||
|
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、审计与部署
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
现有系统已经具备 OpenClaw、Hermes、Agent Router、GitOps、局部 WebUI、审计和多租户候选能力。AIOS V2 的目标不是推倒重写,而是将这些分散能力收敛到统一、可审计、可 Web 化、可多租户隔离的控制面。
|
||||||
|
|
||||||
|
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录和执行证据。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
|
||||||
|
|
||||||
|
## 2. 用户角色
|
||||||
|
|
||||||
|
| 角色 | 主要诉求 |
|
||||||
|
|---|---|
|
||||||
|
| 平台管理员 | 管理租户、运行时、发布、GitOps 状态和全局策略 |
|
||||||
|
| 租户管理员 | 管理本租户 Agent、任务、审批、审计和成员权限 |
|
||||||
|
| 操作员 | 创建任务、发起执行、取消任务、查看运行状态 |
|
||||||
|
| 审批人 | 审批高风险任务,查看策略判定依据 |
|
||||||
|
| 审计员 | 查询本租户完整审计链和执行证据 |
|
||||||
|
| Runtime Worker | 领取已批准任务、心跳、返回执行结果 |
|
||||||
|
| 开发与运维人员 | 通过 GitOps 提交、测试、部署、回滚和修复 Bug |
|
||||||
|
|
||||||
|
## 3. 核心用户需求
|
||||||
|
|
||||||
|
### UR-001 唯一控制面
|
||||||
|
|
||||||
|
所有任务创建、审批、取消、状态变化、策略判定和审计必须由 AIOS Control Plane 统一处理。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Web Console、API、Codex、OpenClaw 和 Hermes 均不得绕过 AIOS 创建或修改任务生命周期。
|
||||||
|
- OpenClaw/Hermes 的公开接口仅限执行、状态、取消、心跳和结果回传。
|
||||||
|
|
||||||
|
### UR-002 复用现有能力
|
||||||
|
|
||||||
|
系统不得重写已经稳定的 OpenClaw 执行能力、Hermes Runtime、Agent Router 或 GitOps 基础设施。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 通过 Adapter/Boundary 接入现有能力。
|
||||||
|
- 替换控制逻辑时保留原执行接口和可迁移数据。
|
||||||
|
|
||||||
|
### UR-003 OpenClaw 与 Hermes 解耦
|
||||||
|
|
||||||
|
OpenClaw 与 Hermes 必须从策略、租户、审批和任务生命周期中逐步解耦。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- OpenClaw 仅接收已签名、已批准、带租户上下文的命令。
|
||||||
|
- Hermes 仅接收已批准的租户分区 Job。
|
||||||
|
- 二者均不得直接写 Control Plane 权威状态。
|
||||||
|
|
||||||
|
### UR-004 全面 Web 化
|
||||||
|
|
||||||
|
所有面向用户和运维的能力必须通过 Web Console 或稳定 API 访问。
|
||||||
|
|
||||||
|
Web Console 至少展示:
|
||||||
|
|
||||||
|
- Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence、GitOps Status。
|
||||||
|
- 所有写操作由 AIOS API 完成,浏览器不得直接调用 OpenClaw/Hermes。
|
||||||
|
|
||||||
|
### UR-005 多租户隔离
|
||||||
|
|
||||||
|
所有模块必须支持租户隔离,且默认拒绝跨租户访问。
|
||||||
|
|
||||||
|
隔离范围:
|
||||||
|
|
||||||
|
- API 与会话
|
||||||
|
- Tenant/Agent/Task 数据
|
||||||
|
- OpenClaw 命令
|
||||||
|
- Hermes Queue、Cell、Profile、Worker Lease
|
||||||
|
- Memory、Cache、Artifact、SecretRef
|
||||||
|
- Audit、Execution Evidence、日志和指标
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 资源记录的 `tenant_id` 不可为空。
|
||||||
|
- 跨租户访问返回拒绝或“资源不存在”,不得泄露存在性。
|
||||||
|
- 必须有跨租户负向测试。
|
||||||
|
|
||||||
|
### UR-006 权威任务生命周期
|
||||||
|
|
||||||
|
任务必须遵循统一状态机:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||||
|
| | |
|
||||||
|
+----------+----------+-> CANCELLED
|
||||||
|
+-> FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 禁止越级变化,例如 `CREATED -> COMPLETED`。
|
||||||
|
- 每次变化包含 revision、principal、trace、时间和审计事件。
|
||||||
|
|
||||||
|
### UR-007 策略与审批
|
||||||
|
|
||||||
|
所有有副作用任务必须经过策略判定;高风险任务必须支持人工审批。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Policy Decision 包含 allow/deny、reason 和 policy version。
|
||||||
|
- 拒绝事件进入审计。
|
||||||
|
- Runtime 不得自行放宽策略。
|
||||||
|
|
||||||
|
### UR-008 幂等与并发安全
|
||||||
|
|
||||||
|
任务和 Runtime 执行必须支持幂等与乐观并发控制。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 相同租户与幂等键、相同内容返回同一任务/执行。
|
||||||
|
- 相同幂等键不同内容返回冲突。
|
||||||
|
- revision 或 branch lease 冲突不得静默覆盖。
|
||||||
|
|
||||||
|
### UR-009 可观测与执行证据
|
||||||
|
|
||||||
|
平台必须能够确认 Codex、OpenClaw、Hermes 等执行器是否领取任务、当前进度、最近提交、测试结果和完成状态。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 存在 claim、heartbeat、progress、result 和 evidence 记录。
|
||||||
|
- Web/API 可按租户查询。
|
||||||
|
- 执行证据不得包含明文密钥。
|
||||||
|
|
||||||
|
### UR-010 GitOps 唯一变更流程
|
||||||
|
|
||||||
|
所有代码、配置、文档和部署资产修改必须进入非 `main` 分支,通过 commit、测试、审查和受控提升。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Controller worktree 必须 detached 且 clean。
|
||||||
|
- 本地 bare repo 与 Gitea 目标分支 OID 一致。
|
||||||
|
- 不允许直接 push `main`、直接创建生产 Tag 或绕过审批部署。
|
||||||
|
|
||||||
|
### UR-011 持久化与恢复
|
||||||
|
|
||||||
|
租户、Agent、Task、Audit、Idempotency 和 Execution Evidence 必须在服务重启后恢复。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 候选版支持 SQLite WAL 原子快照。
|
||||||
|
- 有 restart recovery 测试。
|
||||||
|
- 生产版需进一步提供迁移、备份、恢复、HA 和容量证据。
|
||||||
|
|
||||||
|
### UR-012 审计与合规
|
||||||
|
|
||||||
|
所有控制与执行动作必须生成不可混租户的审计事件。
|
||||||
|
|
||||||
|
至少记录:
|
||||||
|
|
||||||
|
- tenant、principal、trace、action、resource、outcome、policy version、timestamp。
|
||||||
|
- OpenClaw/Hermes Runtime 生命周期。
|
||||||
|
- GitOps、部署和回滚动作。
|
||||||
|
|
||||||
|
### UR-013 密钥安全
|
||||||
|
|
||||||
|
Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key、密码、Token 或私钥。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Git 只存 `secret_ref`、`credential_ref`、`key_id` 或受保护文件路径。
|
||||||
|
- Secret 文件权限为 `0600` 或更严格。
|
||||||
|
- Secret Scanner 默认拒绝可疑提交。
|
||||||
|
|
||||||
|
### UR-014 部署与回滚
|
||||||
|
|
||||||
|
系统必须提供 systemd、Docker/Compose、健康检查和受保护回滚入口。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 服务以非 root 权限运行。
|
||||||
|
- 状态目录独立持久化。
|
||||||
|
- 回滚前自动备份状态。
|
||||||
|
- 默认只回滚代码,状态恢复需要显式授权。
|
||||||
|
|
||||||
|
### UR-015 文档与代码同步
|
||||||
|
|
||||||
|
任何影响用户行为、架构边界、API、数据模型、状态机、安全策略、部署或运维方式的变更,必须在同一分支同步更新 `docs/aios-v2/`。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 架构/功能代码变化但无文档变化时,文档同步门禁失败。
|
||||||
|
- Traceability Matrix 必须指向当前代码和测试。
|
||||||
|
- 新架构决策必须增加或更新 ADR。
|
||||||
|
|
||||||
|
## 4. 非功能需求
|
||||||
|
|
||||||
|
### NFR-001 安全
|
||||||
|
|
||||||
|
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计。
|
||||||
|
|
||||||
|
### NFR-002 可靠性
|
||||||
|
|
||||||
|
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、健康检查和回滚。
|
||||||
|
|
||||||
|
### NFR-003 可维护性
|
||||||
|
|
||||||
|
- 模块边界稳定,Control Plane 与 Runtime 解耦;代码、文档、测试和部署可追踪。
|
||||||
|
|
||||||
|
### NFR-004 可扩展性
|
||||||
|
|
||||||
|
- Store、Transport、Policy、EventPublisher、Runtime Adapter 均通过接口扩展。
|
||||||
|
|
||||||
|
### NFR-005 可运维性
|
||||||
|
|
||||||
|
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告。
|
||||||
|
|
||||||
|
### NFR-006 性能候选指标
|
||||||
|
|
||||||
|
候选版上线前至少验证:
|
||||||
|
|
||||||
|
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间。
|
||||||
|
- 具体 SLO 在预发布基准测试后固化,禁止无证据承诺。
|
||||||
|
|
||||||
|
## 5. 本候选版本范围
|
||||||
|
|
||||||
|
### 已纳入代码
|
||||||
|
|
||||||
|
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、部署和回滚。
|
||||||
|
|
||||||
|
### 尚需环境验证
|
||||||
|
|
||||||
|
- 精确测试执行证据。
|
||||||
|
- OpenClaw 真实 Runtime 联调。
|
||||||
|
- 生产数据库/队列替换或容量验证。
|
||||||
|
- 预发布性能、安全、重启和回滚演练。
|
||||||
|
- Owner 对 merge、Tag 和生产部署的批准。
|
||||||
|
|
||||||
|
## 6. 用户验收场景
|
||||||
|
|
||||||
|
1. 平台管理员创建租户并注册 OpenClaw/Hermes Agent。
|
||||||
|
2. 操作员创建任务,审批人批准。
|
||||||
|
3. AIOS Orchestrator 选择 Runtime,并创建签名命令或租户分区 Job。
|
||||||
|
4. Runtime 执行并返回结果。
|
||||||
|
5. AIOS 更新 Task、Audit 和 Execution Evidence。
|
||||||
|
6. 租户用户在 Web Console 查看完整链路。
|
||||||
|
7. 跨租户访问被拒绝。
|
||||||
|
8. 服务重启后状态恢复。
|
||||||
|
9. 候选部署健康检查通过,回滚演练成功。
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
# AIOS V2 目标架构
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 架构状态:目标架构已实现为候选代码,待预发布验证
|
||||||
|
- 适用范围:Control Plane、Agent Router、OpenClaw、Hermes、Web Console、GitOps、审计、部署与运维
|
||||||
|
|
||||||
|
## 1. 架构目标
|
||||||
|
|
||||||
|
AIOS V2 的核心目标是把分散在 OpenClaw、Hermes、Agent Router、局部 WebUI 和 GitOps 中的控制能力收敛为一个权威控制面,同时保留现有执行能力,避免推倒重写。
|
||||||
|
|
||||||
|
目标状态:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户 / Web Console / Public API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Control Plane
|
||||||
|
Tenant | Identity | Agent | Task | Policy | Approval | Audit
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
OpenClaw Adapter Hermes Boundary
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
OpenClaw Runtime Hermes Workers
|
||||||
|
\ /
|
||||||
|
v v
|
||||||
|
Result Reconciliation + Execution Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
设计结论:
|
||||||
|
|
||||||
|
- AIOS 是唯一控制面。
|
||||||
|
- Agent Router 是调度平面,不是第二控制面。
|
||||||
|
- OpenClaw 是执行适配与 Runtime。
|
||||||
|
- Hermes 是队列、Worker、Profile 与执行 Runtime。
|
||||||
|
- Web Console 只调用 AIOS API。
|
||||||
|
- GitOps 是代码、配置、文档和发布变更的唯一入口。
|
||||||
|
|
||||||
|
## 2. 从现有架构到目标架构
|
||||||
|
|
||||||
|
### 2.1 现有能力
|
||||||
|
|
||||||
|
现有系统已经具备:
|
||||||
|
|
||||||
|
- OpenClaw 执行与 Fleet 能力。
|
||||||
|
- Hermes Worker、Profile、队列和工作区能力。
|
||||||
|
- Agent Router 路由经验和部分调度接口。
|
||||||
|
- GitOps 分支、提交、审批、回滚和审计能力。
|
||||||
|
- Tenant Context、Tenant Router、Memory Scope、SecretRef、Message Ledger 和审计候选实现。
|
||||||
|
- 局部 WebUI、Gateway 和自动任务领取能力。
|
||||||
|
|
||||||
|
### 2.2 现有问题
|
||||||
|
|
||||||
|
- 控制逻辑分散,可能存在多个任务事实源。
|
||||||
|
- OpenClaw/Hermes 内部仍可能保存租户、策略或任务状态。
|
||||||
|
- 候选能力分散在多个分支,主线缺少权威实现。
|
||||||
|
- WebUI 与 API 入口不统一。
|
||||||
|
- 分支名称、代码、测试和部署证据之间缺少映射。
|
||||||
|
- 多租户隔离多为逻辑约束,尚缺全链路生产证据。
|
||||||
|
|
||||||
|
### 2.3 演进原则
|
||||||
|
|
||||||
|
- 先加边界和适配器,再迁移控制权。
|
||||||
|
- 先复用接口,再替换内部实现。
|
||||||
|
- 先在候选分支验证,再受控提升。
|
||||||
|
- 不允许同时存在两套权威任务生命周期。
|
||||||
|
- 不以“大分支存在”代替验收证据。
|
||||||
|
|
||||||
|
## 3. 逻辑分层
|
||||||
|
|
||||||
|
### 3.1 Web 与 API 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 提供 Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence 和 GitOps 页面/API。
|
||||||
|
- 依赖可信身份代理注入 `X-AIOS-*` 身份头。
|
||||||
|
- 不允许浏览器直接访问 OpenClaw 或 Hermes。
|
||||||
|
- 不允许客户端自行选择或覆盖 `tenant_id`。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/app.py`
|
||||||
|
- `active-runtime/app/aios_v2/gateway.py`
|
||||||
|
- `active-runtime/app/aios_v2/console.py`
|
||||||
|
|
||||||
|
### 3.2 AIOS Control Plane
|
||||||
|
|
||||||
|
权威职责:
|
||||||
|
|
||||||
|
- Tenant Registry
|
||||||
|
- Agent Registry
|
||||||
|
- Task Lifecycle
|
||||||
|
- Policy Decision
|
||||||
|
- Approval
|
||||||
|
- Audit Event
|
||||||
|
- Runtime Inventory
|
||||||
|
- Execution Evidence
|
||||||
|
- Deployment/GitOps Status
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- OpenClaw 内部执行细节。
|
||||||
|
- Hermes Worker 内部执行细节。
|
||||||
|
- 直接持有 Runtime 凭据明文。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
- `active-runtime/app/aios_v2/persistence.py`
|
||||||
|
- `active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
- `active-runtime/app/aios_v2/gitops_status.py`
|
||||||
|
|
||||||
|
### 3.3 Execution Orchestrator / Agent Router
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只接受 `APPROVED` 任务。
|
||||||
|
- 根据 Agent Registry 和 Policy Result 选择 Runtime。
|
||||||
|
- 生成 OpenClawCommand 或 HermesJob。
|
||||||
|
- 传播 tenant、task、trace、idempotency 和 policy version。
|
||||||
|
- 将 Runtime Result 回写 AIOS Task。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- 创建租户。
|
||||||
|
- 修改策略。
|
||||||
|
- 绕过审批。
|
||||||
|
- 保存第二套任务状态机。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/orchestrator.py`
|
||||||
|
|
||||||
|
### 3.4 OpenClaw Adapter
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 验证签名命令。
|
||||||
|
- 执行、查询、取消、健康检查和回调。
|
||||||
|
- 支持幂等、超时和租户隔离。
|
||||||
|
- 把 Runtime 生命周期转换为 AIOS 审计事件。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- Tenant、Policy、Approval、Task Lifecycle。
|
||||||
|
- 向浏览器暴露公网控制接口。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_openclaw/contract.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/adapter.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/transport.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/health.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/callback.py`
|
||||||
|
|
||||||
|
### 3.5 Hermes Runtime Boundary
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- Tenant/Cell/Profile 分区队列。
|
||||||
|
- Worker claim、start、heartbeat、complete、fail、cancel。
|
||||||
|
- Lease 超时和自动回队列。
|
||||||
|
- Result 回传和 Runtime Health。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- Policy、Approval、Tenant Registry、权威 Task Lifecycle。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_hermes/contract.py`
|
||||||
|
- `active-runtime/app/aios_hermes/adapter.py`
|
||||||
|
- `active-runtime/app/aios_hermes/worker.py`
|
||||||
|
|
||||||
|
### 3.6 Persistence
|
||||||
|
|
||||||
|
候选实现使用 SQLite WAL 原子快照,持久化:
|
||||||
|
|
||||||
|
- Tenant
|
||||||
|
- Agent
|
||||||
|
- Task
|
||||||
|
- Audit
|
||||||
|
- Idempotency
|
||||||
|
- Execution Evidence 文件
|
||||||
|
|
||||||
|
生产演进:
|
||||||
|
|
||||||
|
- Store 接口保持稳定。
|
||||||
|
- 可替换为 PostgreSQL、对象存储或分布式事件存储。
|
||||||
|
- 生产替换必须提供迁移、备份、恢复、HA、容量和租户隔离证据。
|
||||||
|
|
||||||
|
## 4. 多租户架构
|
||||||
|
|
||||||
|
### 4.1 权威租户上下文
|
||||||
|
|
||||||
|
每次请求和执行至少传播:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
session_id
|
||||||
|
trace_id
|
||||||
|
task_id
|
||||||
|
policy_version
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime 还传播:
|
||||||
|
|
||||||
|
```text
|
||||||
|
command_id / job_id
|
||||||
|
cell_id
|
||||||
|
profile_id
|
||||||
|
idempotency_key
|
||||||
|
worker_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 隔离层级
|
||||||
|
|
||||||
|
| 层级 | 隔离要求 |
|
||||||
|
|---|---|
|
||||||
|
| HTTP/API | 可信身份头、租户不可由浏览器覆盖 |
|
||||||
|
| Control Plane | 所有资源带 `tenant_id`,跨租户默认拒绝 |
|
||||||
|
| OpenClaw | 签名命令绑定 tenant/task/trace |
|
||||||
|
| Hermes | tenant/cell/profile 三层分区 |
|
||||||
|
| Storage | 候选为显式租户字段;生产需 RLS 或物理隔离策略 |
|
||||||
|
| Evidence/Audit | 仅按当前租户查询 |
|
||||||
|
| Cache/Memory | key 必须包含 tenant namespace |
|
||||||
|
| Secret | 只保存引用,不保存明文 |
|
||||||
|
| Logs/Metrics | 必须包含 tenant/task/trace 标签,并限制查询范围 |
|
||||||
|
|
||||||
|
### 4.3 失败行为
|
||||||
|
|
||||||
|
- 跨租户资源访问返回拒绝或“未找到”。
|
||||||
|
- 不泄露其他租户资源存在性。
|
||||||
|
- 缺少 tenant context 时 fail closed。
|
||||||
|
- Runtime 返回的 tenant/task/trace 不匹配时拒绝回写。
|
||||||
|
|
||||||
|
## 5. 任务与执行数据流
|
||||||
|
|
||||||
|
### 5.1 任务创建与审批
|
||||||
|
|
||||||
|
```text
|
||||||
|
User/API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Trusted Identity
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Gateway
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Policy Gate
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task CREATED
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Approval
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 OpenClaw 执行
|
||||||
|
|
||||||
|
```text
|
||||||
|
APPROVED Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Signed OpenClawCommand
|
||||||
|
|
|
||||||
|
v
|
||||||
|
OpenClaw Adapter
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Runtime Result
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task Reconciliation + Audit + Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Hermes 执行
|
||||||
|
|
||||||
|
```text
|
||||||
|
APPROVED Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Tenant/Cell/Profile HermesJob
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Worker Claim + Lease + Heartbeat
|
||||||
|
|
|
||||||
|
v
|
||||||
|
HermesResult
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task Reconciliation + Audit + Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 安全架构
|
||||||
|
|
||||||
|
### 6.1 身份边界
|
||||||
|
|
||||||
|
- Public API 位于可信反向代理之后。
|
||||||
|
- 反向代理完成登录、会话、MFA 和身份头注入。
|
||||||
|
- 候选应用拒绝未认证身份断言。
|
||||||
|
- 生产不得允许客户端直接伪造 `X-AIOS-*`。
|
||||||
|
|
||||||
|
### 6.2 Runtime 命令完整性
|
||||||
|
|
||||||
|
OpenClawCommand 使用规范化序列化和 HMAC-SHA256:
|
||||||
|
|
||||||
|
- 签名覆盖 tenant、task、trace、action、payload、idempotency 和时间。
|
||||||
|
- 支持 key ID、到期时间和时钟偏差。
|
||||||
|
- Runtime 密钥通过受保护文件或外部 Secret Provider 解析。
|
||||||
|
|
||||||
|
### 6.3 Secret 边界
|
||||||
|
|
||||||
|
Git 允许:
|
||||||
|
|
||||||
|
```text
|
||||||
|
secret_ref
|
||||||
|
credential_ref
|
||||||
|
key_id
|
||||||
|
受保护文件路径
|
||||||
|
```
|
||||||
|
|
||||||
|
Git 禁止:
|
||||||
|
|
||||||
|
```text
|
||||||
|
API Key
|
||||||
|
密码
|
||||||
|
Token
|
||||||
|
私钥
|
||||||
|
签名材料
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Web 安全
|
||||||
|
|
||||||
|
候选应用设置:
|
||||||
|
|
||||||
|
- no-store
|
||||||
|
- nosniff
|
||||||
|
- DENY frame
|
||||||
|
- no-referrer
|
||||||
|
- CSP
|
||||||
|
- 请求体大小限制
|
||||||
|
|
||||||
|
生产还需验证:
|
||||||
|
|
||||||
|
- CSRF/CORS
|
||||||
|
- Rate Limit
|
||||||
|
- Session Timeout
|
||||||
|
- CSP Nonce/Hash
|
||||||
|
- WAF 和反向代理访问策略
|
||||||
|
|
||||||
|
## 7. 可靠性架构
|
||||||
|
|
||||||
|
### 7.1 幂等
|
||||||
|
|
||||||
|
- Control Plane:`tenant_id + idempotency_key + request_digest`。
|
||||||
|
- OpenClaw:`tenant_id + idempotency_key + signature`。
|
||||||
|
- Hermes:`tenant_id + idempotency_key + job_digest`。
|
||||||
|
|
||||||
|
### 7.2 并发
|
||||||
|
|
||||||
|
- Task 使用 revision。
|
||||||
|
- Hermes 使用 revision + worker lease。
|
||||||
|
- GitOps 使用 detached controller + branch lease。
|
||||||
|
- 持久化使用 SQLite `BEGIN IMMEDIATE` 和原子快照。
|
||||||
|
|
||||||
|
### 7.3 恢复
|
||||||
|
|
||||||
|
- Controller restart:detached guard。
|
||||||
|
- AIOS restart:SQLite restore。
|
||||||
|
- Worker crash:lease 到期后 Job 回到 QUEUED。
|
||||||
|
- Release failure:健康检查失败自动恢复 previous release。
|
||||||
|
|
||||||
|
## 8. 可观测架构
|
||||||
|
|
||||||
|
统一关联键:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
trace_id
|
||||||
|
task_id
|
||||||
|
agent_id
|
||||||
|
command_id / job_id
|
||||||
|
worker_id
|
||||||
|
commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Web Console 展示:
|
||||||
|
|
||||||
|
- Tasks
|
||||||
|
- Agents
|
||||||
|
- Runtime Health
|
||||||
|
- Audit
|
||||||
|
- Execution Evidence
|
||||||
|
- GitOps Status
|
||||||
|
|
||||||
|
生产还需接入:
|
||||||
|
|
||||||
|
- 结构化日志
|
||||||
|
- Metrics
|
||||||
|
- Distributed Tracing
|
||||||
|
- Alerting
|
||||||
|
- SLO Dashboard
|
||||||
|
|
||||||
|
## 9. 部署架构
|
||||||
|
|
||||||
|
支持两种候选部署:
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
- 非 root `aios` 用户。
|
||||||
|
- `ProtectSystem=strict`、`NoNewPrivileges` 等安全选项。
|
||||||
|
- 状态目录 `/var/lib/aios-v2`。
|
||||||
|
|
||||||
|
### Docker/Compose
|
||||||
|
|
||||||
|
- 非 root UID/GID。
|
||||||
|
- Read-only root filesystem。
|
||||||
|
- Drop all capabilities。
|
||||||
|
- State volume。
|
||||||
|
- HTTP healthcheck。
|
||||||
|
|
||||||
|
## 10. 架构约束
|
||||||
|
|
||||||
|
以下行为视为架构违规:
|
||||||
|
|
||||||
|
1. OpenClaw/Hermes 直接创建或批准 AIOS Task。
|
||||||
|
2. Web Console 直接调用 Runtime。
|
||||||
|
3. 新模块不携带 `tenant_id`。
|
||||||
|
4. 新增第二套任务状态机或租户事实源。
|
||||||
|
5. 在 Git 中提交明文凭据。
|
||||||
|
6. 绕过 GitOps 修改生产代码/配置。
|
||||||
|
7. 架构或功能变化未同步更新 `docs/aios-v2/`。
|
||||||
|
8. 未通过测试和预发布门禁直接提升候选版本。
|
||||||
|
|
||||||
|
## 11. 目标架构完成判定
|
||||||
|
|
||||||
|
目标架构只有在以下证据齐全时才视为完成:
|
||||||
|
|
||||||
|
- 文档与代码映射完整。
|
||||||
|
- Candidate tests 对精确 commit 执行通过。
|
||||||
|
- OpenClaw/Hermes 实际 Runtime 联调通过。
|
||||||
|
- 多租户负向测试通过。
|
||||||
|
- 重启恢复和回滚演练通过。
|
||||||
|
- GitOps 本地/Gitea OID 一致。
|
||||||
|
- Owner 批准 merge、Tag 和生产部署。
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# AIOS V2 迁移与交付策略
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 交付原则:增量迁移、能力复用、控制权收敛、GitOps 唯一变更入口
|
||||||
|
|
||||||
|
## 1. 迁移目标
|
||||||
|
|
||||||
|
AIOS V2 不通过一次性重写替换现有系统,而是在保留 OpenClaw、Hermes、Agent Router、GitOps 和既有运行能力的前提下,将租户、策略、任务、审批、审计和发布治理收敛到 AIOS Control Plane。
|
||||||
|
|
||||||
|
迁移完成的标志不是“新代码存在”,而是:
|
||||||
|
|
||||||
|
- AIOS 成为唯一权威控制面;
|
||||||
|
- OpenClaw/Hermes 不再保存第二套控制状态;
|
||||||
|
- 所有控制功能 Web/API 化;
|
||||||
|
- 所有模块通过租户隔离、测试和预发布门禁;
|
||||||
|
- 本地 Git、Gitea、测试、部署和回滚证据可追踪。
|
||||||
|
|
||||||
|
## 2. 为什么采用增量迁移
|
||||||
|
|
||||||
|
### 2.1 避免能力回退
|
||||||
|
|
||||||
|
OpenClaw、Hermes 和 Agent Router 已经包含大量运行经验、边界处理和执行能力。整体重写会扩大风险面,并导致业务能力、操作流程和历史数据回退。
|
||||||
|
|
||||||
|
### 2.2 保持可回滚
|
||||||
|
|
||||||
|
每个迁移步骤必须:
|
||||||
|
|
||||||
|
- 独立分支;
|
||||||
|
- 小范围提交;
|
||||||
|
- 独立测试;
|
||||||
|
- 可单独回滚;
|
||||||
|
- 不要求立即关闭旧执行路径。
|
||||||
|
|
||||||
|
### 2.3 先收敛控制权,再优化内部实现
|
||||||
|
|
||||||
|
迁移优先级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
唯一入口
|
||||||
|
-> 权威租户/任务/策略
|
||||||
|
-> Runtime Adapter
|
||||||
|
-> 数据层生产化
|
||||||
|
-> Web 与可观测
|
||||||
|
-> 性能与 HA
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 分阶段迁移路线
|
||||||
|
|
||||||
|
### Phase 0:GitOps 与架构事实源
|
||||||
|
|
||||||
|
目标:建立可靠变更链和权威设计基线。
|
||||||
|
|
||||||
|
已完成候选能力:
|
||||||
|
|
||||||
|
- Controller detached guard;
|
||||||
|
- 避免 fetch 到已检出本地分支;
|
||||||
|
- Gitea SSH 镜像;
|
||||||
|
- `docs/aios-v2/` 架构事实源;
|
||||||
|
- 候选分支、部署和回滚资产。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- Controller detached 且 clean;
|
||||||
|
- 本地/Gitea OID 一致;
|
||||||
|
- 架构与需求文档纳入版本。
|
||||||
|
|
||||||
|
### Phase 1:Control Plane 权威化
|
||||||
|
|
||||||
|
目标:将 Tenant、Agent、Task、Policy、Approval 和 Audit 收敛到 AIOS。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `aios_v2/control_plane.py`
|
||||||
|
- `aios_v2/persistence.py`
|
||||||
|
- `aios_v2/gateway.py`
|
||||||
|
- `aios_v2/app.py`
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 任务状态机统一;
|
||||||
|
- 幂等、revision、审计和跨租户拒绝通过测试;
|
||||||
|
- 服务重启后权威状态恢复。
|
||||||
|
|
||||||
|
### Phase 2:OpenClaw 解耦
|
||||||
|
|
||||||
|
目标:OpenClaw 从控制逻辑转为执行适配器。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
|
||||||
|
1. 定义签名命令合同;
|
||||||
|
2. 引入 Adapter;
|
||||||
|
3. 将直接调用迁移到 AIOS Orchestrator;
|
||||||
|
4. Runtime 只返回执行结果和健康状态;
|
||||||
|
5. 关闭或网络隔离旧公共控制入口。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 未批准任务无法生成有效命令;
|
||||||
|
- 命令绑定 tenant/task/trace/idempotency;
|
||||||
|
- 旧控制接口冻结并记录替代路径。
|
||||||
|
|
||||||
|
### Phase 3:Hermes 解耦
|
||||||
|
|
||||||
|
目标:Hermes 只承担租户分区队列和 Worker Runtime。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
|
||||||
|
1. 定义 HermesJob/HermesResult;
|
||||||
|
2. 强制 tenant/cell/profile 分区;
|
||||||
|
3. 引入 claim/lease/heartbeat;
|
||||||
|
4. 将结果统一回写 AIOS;
|
||||||
|
5. 移除 Hermes 内部策略和任务事实源。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- Worker 无权创建 AIOS Task;
|
||||||
|
- 租约过期后 Job 可恢复;
|
||||||
|
- 跨租户 claim/read/result 被拒绝。
|
||||||
|
|
||||||
|
### Phase 4:Web 与运维统一
|
||||||
|
|
||||||
|
目标:所有用户与运维功能通过 AIOS Web/API。
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
- Tenant、Agent、Task、Runtime、Audit、Execution Evidence、GitOps;
|
||||||
|
- systemd/Docker/Compose;
|
||||||
|
- healthcheck、rollback、release report。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 浏览器不直接调用 Runtime;
|
||||||
|
- 生产身份代理和权限模型通过验证;
|
||||||
|
- 部署、重启和回滚演练通过。
|
||||||
|
|
||||||
|
### Phase 5:生产数据层与 HA
|
||||||
|
|
||||||
|
目标:将候选 SQLite/内存运行状态替换或强化为生产级后端。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 数据迁移;
|
||||||
|
- RLS 或物理隔离;
|
||||||
|
- 备份恢复;
|
||||||
|
- HA 与容量;
|
||||||
|
- 多实例并发;
|
||||||
|
- 灾难恢复目标。
|
||||||
|
|
||||||
|
该阶段不得改变上层合同,避免 Runtime 和 Web 再次耦合存储实现。
|
||||||
|
|
||||||
|
## 4. 分支与提交策略
|
||||||
|
|
||||||
|
### 4.1 分支类型
|
||||||
|
|
||||||
|
| 类型 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `task/*` | 可执行任务合同与主体实现 |
|
||||||
|
| `patch/*` | 独立修复或窄范围增强 |
|
||||||
|
| `integration/*` | 多模块候选收敛 |
|
||||||
|
| `result/*` | 自动执行结果与证据 |
|
||||||
|
| `release/*` | 受控发布候选 |
|
||||||
|
|
||||||
|
### 4.2 规则
|
||||||
|
|
||||||
|
- 禁止直接修改 `main`;
|
||||||
|
- 一个提交应可独立审阅和回滚;
|
||||||
|
- 大文件和大范围变更应拆分;
|
||||||
|
- 所有行为变化必须同步文档和测试;
|
||||||
|
- 生产 Tag、部署和回滚需要 Owner 批准。
|
||||||
|
|
||||||
|
## 5. GitOps 交付流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
需求/架构更新
|
||||||
|
|
|
||||||
|
v
|
||||||
|
任务分支 + 文档 + 测试
|
||||||
|
|
|
||||||
|
v
|
||||||
|
代码提交 + Secret Gate
|
||||||
|
|
|
||||||
|
v
|
||||||
|
本地 bare repo 同步
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Gitea 镜像与 OID 校验
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Candidate 集成
|
||||||
|
|
|
||||||
|
v
|
||||||
|
自动测试与预发布
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Owner 审批
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Merge / Tag / Production Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 同步状态
|
||||||
|
|
||||||
|
GitOps 返回必须区分:
|
||||||
|
|
||||||
|
- local sync;
|
||||||
|
- Gitea sync;
|
||||||
|
- branch OID;
|
||||||
|
- workspace mode;
|
||||||
|
- dirty state。
|
||||||
|
|
||||||
|
仅返回 `common_git_dir synced` 不足以证明 Web 远端可见。
|
||||||
|
|
||||||
|
## 6. 发布门禁
|
||||||
|
|
||||||
|
### Gate A:设计完整性
|
||||||
|
|
||||||
|
- 用户需求、目标架构、模块契约、ADR 和追踪矩阵同步;
|
||||||
|
- 无冲突标记、断链或过期路径。
|
||||||
|
|
||||||
|
### Gate B:代码质量
|
||||||
|
|
||||||
|
- 包可导入;
|
||||||
|
- 静态编译通过;
|
||||||
|
- Secret Scanner 通过;
|
||||||
|
- 无已知越权旁路。
|
||||||
|
|
||||||
|
### Gate C:测试
|
||||||
|
|
||||||
|
- 单元、集成、多租户负向、重启恢复、Runtime 边界测试通过;
|
||||||
|
- 记录 commit、命令、exit code、pass/fail 和 artifact digest。
|
||||||
|
|
||||||
|
### Gate D:预发布
|
||||||
|
|
||||||
|
- systemd 或 Compose 启动成功;
|
||||||
|
- 健康检查通过;
|
||||||
|
- OpenClaw/Hermes 真实联调通过;
|
||||||
|
- 备份、重启和回滚演练通过;
|
||||||
|
- 日志中无敏感信息。
|
||||||
|
|
||||||
|
### Gate E:生产批准
|
||||||
|
|
||||||
|
- Owner 批准 merge、Tag 和生产部署;
|
||||||
|
- 发布窗口、回滚负责人和观察指标明确;
|
||||||
|
- 生产变更通过 GitOps 记录。
|
||||||
|
|
||||||
|
## 7. 回滚策略
|
||||||
|
|
||||||
|
### 7.1 代码回滚
|
||||||
|
|
||||||
|
- 使用 `/opt/aios/current` 原子 symlink 切换;
|
||||||
|
- 回滚前备份状态;
|
||||||
|
- 健康检查失败自动恢复 previous release。
|
||||||
|
|
||||||
|
### 7.2 状态回滚
|
||||||
|
|
||||||
|
默认不自动回滚状态,避免覆盖候选运行期间新增的 Task、Audit 和 Evidence。
|
||||||
|
|
||||||
|
状态恢复只有在:
|
||||||
|
|
||||||
|
- 明确确认 schema 不兼容;
|
||||||
|
- 数据备份可验证;
|
||||||
|
- Owner 显式授权;
|
||||||
|
|
||||||
|
时启用。
|
||||||
|
|
||||||
|
### 7.3 Runtime 回滚
|
||||||
|
|
||||||
|
OpenClaw/Hermes Adapter 采用稳定合同。若候选执行层失败,可恢复旧 Runtime 版本,但不得恢复旧控制权旁路。
|
||||||
|
|
||||||
|
## 8. Bug 修复流程
|
||||||
|
|
||||||
|
生产或预发布 Bug:
|
||||||
|
|
||||||
|
1. 创建 `patch/bugfix/*`;
|
||||||
|
2. 添加可复现测试;
|
||||||
|
3. 修复代码;
|
||||||
|
4. 更新相关需求/架构/契约/ADR;
|
||||||
|
5. 运行候选验收;
|
||||||
|
6. 进入新的 release candidate;
|
||||||
|
7. Owner 批准部署。
|
||||||
|
|
||||||
|
禁止在生产目录直接编辑文件后不回写 Git。
|
||||||
|
|
||||||
|
## 9. 文档同步规则
|
||||||
|
|
||||||
|
以下变化必须更新文档:
|
||||||
|
|
||||||
|
- 用户行为或页面;
|
||||||
|
- API 路径、Header、请求/响应;
|
||||||
|
- 数据模型、状态机、策略;
|
||||||
|
- OpenClaw/Hermes/Router 边界;
|
||||||
|
- 多租户与安全模型;
|
||||||
|
- 部署、健康检查、回滚;
|
||||||
|
- 测试或发布门禁。
|
||||||
|
|
||||||
|
对应关系:
|
||||||
|
|
||||||
|
- 需求变化:更新 `01-USER-REQUIREMENTS.md`;
|
||||||
|
- 架构变化:更新 `02-TARGET-ARCHITECTURE.md` 和 ADR;
|
||||||
|
- 迁移/发布变化:更新本文件;
|
||||||
|
- 合同变化:更新 `04-MODULE-CONTRACTS.md`;
|
||||||
|
- 代码/测试路径变化:更新 `05-TRACEABILITY-MATRIX.md`。
|
||||||
|
|
||||||
|
## 10. 当前候选后续步骤
|
||||||
|
|
||||||
|
1. 完成文档同步门禁和 Acceptance Runner;
|
||||||
|
2. 在精确 Candidate commit 执行测试;
|
||||||
|
3. 推送 Gitea 并校验 OID;
|
||||||
|
4. 部署隔离预发布环境;
|
||||||
|
5. 联调真实 OpenClaw/Hermes;
|
||||||
|
6. 执行重启、性能、安全和回滚演练;
|
||||||
|
7. 生成最终 evidence;
|
||||||
|
8. 请求 Owner 的 merge/Tag/production approval。
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
# AIOS V2 模块契约
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 状态:候选实现契约基线
|
||||||
|
- 原则:Control Plane 权威、Runtime 执行、全链路租户隔离、所有状态可审计
|
||||||
|
|
||||||
|
## 1. 通用上下文契约
|
||||||
|
|
||||||
|
所有控制与执行请求必须携带以下上下文:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `tenant_id` | 是 | 权威租户标识 |
|
||||||
|
| `principal_id` | 控制请求是 | 已认证主体 |
|
||||||
|
| `trace_id` | 是 | 全链路追踪 |
|
||||||
|
| `task_id` | 执行阶段是 | AIOS 权威任务 |
|
||||||
|
| `session_id` | Web/交互请求建议 | 用户会话 |
|
||||||
|
| `policy_version` | 控制请求是 | 策略版本 |
|
||||||
|
| `idempotency_key` | 创建/执行是 | 幂等键 |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 缺少必填字段时 fail closed。
|
||||||
|
- 客户端不得覆盖由可信代理或 AIOS 生成的 `tenant_id`。
|
||||||
|
- Runtime 返回的 tenant/task/trace 必须与请求完全匹配。
|
||||||
|
- 所有持久化键、缓存键和队列分区必须包含租户命名空间。
|
||||||
|
|
||||||
|
## 2. Tenant Contract
|
||||||
|
|
||||||
|
### 2.1 TenantRecord
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
display_name
|
||||||
|
status
|
||||||
|
metadata
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `tenant_id` 不可为空且不可变。
|
||||||
|
- `status` 至少支持 `active` 和受控停用状态。
|
||||||
|
- Tenant 只由 AIOS Control Plane 创建和维护。
|
||||||
|
- Runtime 不得创建租户。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
|
||||||
|
## 3. Agent Contract
|
||||||
|
|
||||||
|
### 3.1 AgentRecord
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_id
|
||||||
|
tenant_id
|
||||||
|
display_name
|
||||||
|
runtime
|
||||||
|
capabilities
|
||||||
|
enabled
|
||||||
|
metadata
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Agent 属于唯一租户。
|
||||||
|
- `runtime` 由 Registry 决定,可取 `openclaw`、`hermes` 或后续注册 Runtime。
|
||||||
|
- `capabilities` 是允许调度的动作集合。
|
||||||
|
- `metadata` 可存 `cell_id`、`profile_id`、priority 等非敏感引用。
|
||||||
|
- 不得在 Agent metadata 中保存明文凭据。
|
||||||
|
|
||||||
|
## 4. Task Contract
|
||||||
|
|
||||||
|
### 4.1 TaskRecord
|
||||||
|
|
||||||
|
```text
|
||||||
|
task_id
|
||||||
|
tenant_id
|
||||||
|
agent_id
|
||||||
|
action
|
||||||
|
payload
|
||||||
|
state
|
||||||
|
revision
|
||||||
|
idempotency_key
|
||||||
|
request_digest
|
||||||
|
result
|
||||||
|
error
|
||||||
|
created_by
|
||||||
|
approved_by
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 状态机
|
||||||
|
|
||||||
|
```text
|
||||||
|
CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||||
|
| | |
|
||||||
|
+----------+----------+-> CANCELLED
|
||||||
|
+-> FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
允许转换:
|
||||||
|
|
||||||
|
| 当前 | 允许目标 |
|
||||||
|
|---|---|
|
||||||
|
| CREATED | APPROVED, CANCELLED |
|
||||||
|
| APPROVED | RUNNING, CANCELLED |
|
||||||
|
| RUNNING | COMPLETED, FAILED, CANCELLED |
|
||||||
|
| COMPLETED | 无 |
|
||||||
|
| FAILED | 无 |
|
||||||
|
| CANCELLED | 无 |
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 每次转换必须校验 `revision`。
|
||||||
|
- COMPLETED 必须带 `result`。
|
||||||
|
- FAILED 必须带 `error`。
|
||||||
|
- 每次转换生成 AuditEvent。
|
||||||
|
- Runtime 不得直接写 Task Store,只能通过 Orchestrator reconcile。
|
||||||
|
|
||||||
|
## 5. Policy Contract
|
||||||
|
|
||||||
|
### 5.1 PolicyDecision
|
||||||
|
|
||||||
|
```text
|
||||||
|
allowed
|
||||||
|
reason
|
||||||
|
policy_version
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Policy 默认拒绝。
|
||||||
|
- Runtime 不得覆盖 PolicyDecision。
|
||||||
|
- 拒绝必须审计。
|
||||||
|
- 高风险动作必须经过 Approval。
|
||||||
|
|
||||||
|
## 6. Audit Contract
|
||||||
|
|
||||||
|
### 6.1 AuditEvent
|
||||||
|
|
||||||
|
```text
|
||||||
|
event_id
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
trace_id
|
||||||
|
action
|
||||||
|
resource_type
|
||||||
|
resource_id
|
||||||
|
outcome
|
||||||
|
policy_version
|
||||||
|
details
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 按租户查询。
|
||||||
|
- `details` 不得包含明文凭据。
|
||||||
|
- OpenClaw/Hermes 生命周期事件必须映射到统一 AuditEvent。
|
||||||
|
- 生产版需增加不可变外部固化和完整性验证。
|
||||||
|
|
||||||
|
## 7. OpenClaw Contract
|
||||||
|
|
||||||
|
### 7.1 OpenClawCommand
|
||||||
|
|
||||||
|
```text
|
||||||
|
contract_version
|
||||||
|
command_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
action
|
||||||
|
payload
|
||||||
|
idempotency_key
|
||||||
|
created_at
|
||||||
|
expires_at
|
||||||
|
key_id
|
||||||
|
signature
|
||||||
|
```
|
||||||
|
|
||||||
|
签名覆盖:
|
||||||
|
|
||||||
|
- command_id
|
||||||
|
- tenant_id
|
||||||
|
- task_id
|
||||||
|
- trace_id
|
||||||
|
- action
|
||||||
|
- payload
|
||||||
|
- idempotency_key
|
||||||
|
- created_at / expires_at
|
||||||
|
- contract_version / key_id
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 使用规范化 JSON 和 HMAC-SHA256。
|
||||||
|
- 只接受已批准任务生成的命令。
|
||||||
|
- 过期、未来时间超差、签名错误命令必须拒绝。
|
||||||
|
- `key_id` 只引用运行时材料,不包含材料本身。
|
||||||
|
|
||||||
|
### 7.2 OpenClawResult
|
||||||
|
|
||||||
|
```text
|
||||||
|
command_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
state
|
||||||
|
output
|
||||||
|
error
|
||||||
|
started_at
|
||||||
|
finished_at
|
||||||
|
runtime_id
|
||||||
|
attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ACCEPTED
|
||||||
|
RUNNING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
CANCELLED
|
||||||
|
TIMED_OUT
|
||||||
|
REJECTED
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Adapter 对外只接受终态结果用于 reconcile。
|
||||||
|
- 身份字段不匹配时拒绝回写。
|
||||||
|
- 结果通过 Orchestrator 更新 AIOS Task。
|
||||||
|
|
||||||
|
### 7.3 RuntimeHealth
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime
|
||||||
|
runtime_id
|
||||||
|
status
|
||||||
|
version
|
||||||
|
heartbeat_at
|
||||||
|
active_tasks
|
||||||
|
workspace_isolated
|
||||||
|
credential_ref_configured
|
||||||
|
details
|
||||||
|
```
|
||||||
|
|
||||||
|
健康策略:
|
||||||
|
|
||||||
|
- heartbeat 不得超时。
|
||||||
|
- workspace isolation 与 credential reference 必须符合策略。
|
||||||
|
- 不安全 Runtime 应 quarantine,不得继续接收新任务。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_openclaw/`
|
||||||
|
|
||||||
|
## 8. Hermes Contract
|
||||||
|
|
||||||
|
### 8.1 HermesJob
|
||||||
|
|
||||||
|
```text
|
||||||
|
job_id
|
||||||
|
tenant_id
|
||||||
|
cell_id
|
||||||
|
profile_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
action
|
||||||
|
payload
|
||||||
|
idempotency_key
|
||||||
|
priority
|
||||||
|
state
|
||||||
|
revision
|
||||||
|
assigned_worker_id
|
||||||
|
lease_expires_at
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
状态机:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QUEUED -> CLAIMED -> RUNNING -> COMPLETED
|
||||||
|
| | |
|
||||||
|
+---------+----------+-> CANCELLED
|
||||||
|
+-> FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `tenant_id + cell_id + profile_id` 形成执行分区。
|
||||||
|
- claim 只能领取同分区 Job。
|
||||||
|
- Worker ID 不得跨分区复用。
|
||||||
|
- Lease 到期 Job 回到 QUEUED。
|
||||||
|
- revision 冲突不得覆盖。
|
||||||
|
|
||||||
|
### 8.2 HermesResult
|
||||||
|
|
||||||
|
```text
|
||||||
|
job_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
state
|
||||||
|
worker_id
|
||||||
|
output
|
||||||
|
error
|
||||||
|
started_at
|
||||||
|
finished_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- COMPLETED 必须有 output。
|
||||||
|
- FAILED 必须有 error。
|
||||||
|
- 只允许 AIOS Orchestrator reconcile。
|
||||||
|
|
||||||
|
### 8.3 HermesHealth
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime_id
|
||||||
|
status
|
||||||
|
version
|
||||||
|
heartbeat_at
|
||||||
|
active_workers
|
||||||
|
active_jobs
|
||||||
|
queued_jobs
|
||||||
|
tenant_partitions
|
||||||
|
profile_isolation_enforced
|
||||||
|
details
|
||||||
|
```
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_hermes/`
|
||||||
|
|
||||||
|
## 9. Execution Evidence Contract
|
||||||
|
|
||||||
|
### 9.1 ExecutionEvidence
|
||||||
|
|
||||||
|
```text
|
||||||
|
execution_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
agent_id
|
||||||
|
executor
|
||||||
|
branch
|
||||||
|
status
|
||||||
|
progress_completed
|
||||||
|
progress_total
|
||||||
|
current_step
|
||||||
|
last_commit
|
||||||
|
test_status
|
||||||
|
test_summary
|
||||||
|
result_summary
|
||||||
|
claimed_at
|
||||||
|
heartbeat_at
|
||||||
|
completed_at
|
||||||
|
```
|
||||||
|
|
||||||
|
状态建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
claimed
|
||||||
|
running
|
||||||
|
blocked
|
||||||
|
complete
|
||||||
|
failed
|
||||||
|
cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 按租户存储和查询。
|
||||||
|
- 每个阻塞必须记录原因、责任方和解除条件。
|
||||||
|
- 每次结果必须记录 branch、commit、测试命令和状态。
|
||||||
|
- 不得包含 Secret 或完整敏感 payload。
|
||||||
|
|
||||||
|
## 10. GitOps Status Contract
|
||||||
|
|
||||||
|
```text
|
||||||
|
available
|
||||||
|
service_status
|
||||||
|
workspace_mode
|
||||||
|
workspace_branch
|
||||||
|
workspace_ref
|
||||||
|
workspace_dirty
|
||||||
|
candidate_branch
|
||||||
|
release_channel
|
||||||
|
release_version
|
||||||
|
sync_status
|
||||||
|
details
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Web 响应不得包含 remote URL、凭据或 stderr。
|
||||||
|
- Controller 必须 `detached + clean`。
|
||||||
|
- `sync_status` 必须区分 local 和 Gitea 镜像状态。
|
||||||
|
|
||||||
|
## 11. Web/API Contract
|
||||||
|
|
||||||
|
### 11.1 身份头
|
||||||
|
|
||||||
|
可信代理注入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-AIOS-Authenticated
|
||||||
|
X-AIOS-Tenant-ID
|
||||||
|
X-AIOS-Principal-ID
|
||||||
|
X-AIOS-Roles
|
||||||
|
X-AIOS-Trace-ID
|
||||||
|
X-AIOS-Session-ID
|
||||||
|
X-AIOS-Policy-Version
|
||||||
|
```
|
||||||
|
|
||||||
|
生产约束:
|
||||||
|
|
||||||
|
- 代理必须删除客户端同名头后重新注入。
|
||||||
|
- 应用不得直接暴露公网端口。
|
||||||
|
- HTTP 字段名按协议不区分大小写;应用把 Header 集合转换为普通映射后,必须先统一字段名大小写再解析身份。`X-AIOS-*`、`X-Aios-*` 与全小写形式必须得到相同的 `RequestContext`,但字段值本身不得被改写。
|
||||||
|
- 真实监听器回归测试必须覆盖 `BaseHTTPRequestHandler` 的 title-case 行为,避免纯函数测试使用固定大写键而漏掉在线 401。
|
||||||
|
|
||||||
|
### 11.2 核心 API
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v2/health
|
||||||
|
POST /api/v2/tenants
|
||||||
|
GET /api/v2/tenants/current
|
||||||
|
POST /api/v2/agents
|
||||||
|
GET /api/v2/agents
|
||||||
|
POST /api/v2/tasks
|
||||||
|
GET /api/v2/tasks
|
||||||
|
GET /api/v2/tasks/{id}
|
||||||
|
POST /api/v2/tasks/{id}/approve
|
||||||
|
POST /api/v2/tasks/{id}/start
|
||||||
|
POST /api/v2/tasks/{id}/complete
|
||||||
|
POST /api/v2/tasks/{id}/fail
|
||||||
|
POST /api/v2/tasks/{id}/cancel
|
||||||
|
POST /api/v2/tasks/{id}/dispatch
|
||||||
|
GET /api/v2/audit
|
||||||
|
GET /api/v2/runtime
|
||||||
|
GET /api/v2/executions
|
||||||
|
GET /api/v2/gitops
|
||||||
|
POST /api/v2/hermes/results/reconcile
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Persistence Contract
|
||||||
|
|
||||||
|
候选版:
|
||||||
|
|
||||||
|
- SQLite WAL
|
||||||
|
- `BEGIN IMMEDIATE`
|
||||||
|
- 原子 snapshot
|
||||||
|
- schema version
|
||||||
|
- restart restore
|
||||||
|
|
||||||
|
持久化范围:
|
||||||
|
|
||||||
|
- tenants
|
||||||
|
- agents
|
||||||
|
- tasks
|
||||||
|
- audit
|
||||||
|
- idempotency
|
||||||
|
|
||||||
|
生产替换要求:
|
||||||
|
|
||||||
|
- 不改变 Domain/API Contract。
|
||||||
|
- 提供 migration、backup、restore、HA、RLS/隔离和容量测试。
|
||||||
|
|
||||||
|
## 13. Deployment Contract
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
- 非 root `aios` 用户。
|
||||||
|
- 状态目录 `/var/lib/aios-v2`。
|
||||||
|
- 最小权限和文件系统保护。
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
- 非 root UID/GID。
|
||||||
|
- read-only root filesystem。
|
||||||
|
- drop all capabilities。
|
||||||
|
- 独立状态 volume。
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
- 回滚前备份状态。
|
||||||
|
- 默认只回滚代码。
|
||||||
|
- 状态恢复需显式授权。
|
||||||
|
- 回滚后必须执行 healthcheck。
|
||||||
|
- Release Archive 必须保留 `rollback.sh` 与 `healthcheck/check.sh` 的 executable bit;
|
||||||
|
验收测试必须阻止非执行模式进入候选包。
|
||||||
|
|
||||||
|
## 14. 契约变更规则
|
||||||
|
|
||||||
|
任何以下变化必须在同一提交或同一分支更新本文档:
|
||||||
|
|
||||||
|
- 字段、状态、Header、API、错误语义;
|
||||||
|
- 租户或安全约束;
|
||||||
|
- OpenClaw/Hermes 边界;
|
||||||
|
- Persistence 或 Deployment Contract;
|
||||||
|
- Evidence/Audit 格式。
|
||||||
|
|
||||||
|
不兼容变化必须新增 ADR 和版本号,不得静默修改。
|
||||||
|
|
||||||
|
## 15. OpenClaw Runtime HTTP 合同
|
||||||
|
|
||||||
|
AIOS 只通过 OpenClaw loopback Gateway 的签名 Runtime 路由联调:
|
||||||
|
|
||||||
|
- `GET /v1/health`
|
||||||
|
- `POST /v1/execute`
|
||||||
|
- `GET /v1/commands/<command_id>?tenant_id=<tenant_id>`
|
||||||
|
- `POST /v1/commands/<command_id>/cancel?tenant_id=<tenant_id>`
|
||||||
|
|
||||||
|
`execute` 必须携带 `aios.openclaw.command.v1` Ed25519 签名合同和不超过 300 秒的
|
||||||
|
`expires_at`。Runtime 同时核对仓库外 CredentialRef;签名错误、过期、身份字段篡改、
|
||||||
|
跨租户查询/取消以及幂等冲突均失败关闭。取消接口的 `tenant_id` 是 Transport 合同的
|
||||||
|
组成部分,不允许仅凭 `command_id` 控制另一租户的命令。
|
||||||
|
Candidate 组合层必须把 `AIOS_OPENCLAW_SIGNING_KEY_ID` 原样传入 Orchestrator,
|
||||||
|
不得在已有批准 Key ID 时回落为 `default`。
|
||||||
|
Orchestrator 对调用方请求的更长超时执行 300 秒上限收敛,使签名有效期与 Runtime
|
||||||
|
硬上限保持一致。
|
||||||
|
|
||||||
|
当前 Runtime action allowlist 仅包含无副作用的 `status` 和正式验收专用
|
||||||
|
`acceptance.delay`。其成功只代表 OpenClaw 插件 Runtime 已执行对应合同动作,不代表
|
||||||
|
模型推理或任意 Agent 工具执行。命令身份、幂等键与终态追加持久化;Gateway 重启时
|
||||||
|
未完成的 `RUNNING` 记录收敛为 `FAILED/restart_interrupted`,不得误报为真实超时。
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# AIOS V2 需求—架构—代码—测试—部署追踪矩阵
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 目的:确保每项用户需求都能追踪到架构设计、代码实现、自动测试、部署资产和验收命令
|
||||||
|
|
||||||
|
## 1. 追踪规则
|
||||||
|
|
||||||
|
每条需求至少具备:
|
||||||
|
|
||||||
|
1. 需求编号;
|
||||||
|
2. 架构章节;
|
||||||
|
3. 实现路径;
|
||||||
|
4. 测试路径;
|
||||||
|
5. 部署/运维资产;
|
||||||
|
6. 验收状态。
|
||||||
|
|
||||||
|
状态定义:
|
||||||
|
|
||||||
|
- `Implemented`:代码已进入 Candidate 分支;
|
||||||
|
- `Test-defined`:测试代码已进入 Candidate 分支;
|
||||||
|
- `Test-verified`:精确 commit 的测试已运行并保存证据;
|
||||||
|
- `Pre-production verified`:预发布部署、健康检查和回滚演练已通过;
|
||||||
|
- `Production released`:Owner 批准并完成 merge/tag/deploy。
|
||||||
|
|
||||||
|
## 2. 核心追踪矩阵
|
||||||
|
|
||||||
|
| 需求 | 设计依据 | 代码实现 | 测试 | 部署/运维 | 当前状态 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| UR-001 AIOS 唯一控制面 | `02-TARGET-ARCHITECTURE.md` §3.2/§3.3;ADR-001 | `aios_v2/control_plane.py`、`aios_v2/orchestrator.py`、`aios_v2/app.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/systemd/aios-v2.service` | Implemented / Test-defined |
|
||||||
|
| UR-002 复用现有能力 | `02-TARGET-ARCHITECTURE.md` §2;`03-MIGRATION-AND-DELIVERY.md` §2 | `aios_openclaw/`、`aios_hermes/` | OpenClaw/Hermes flow tests | Candidate branch + adapter boundaries | Implemented / Test-defined |
|
||||||
|
| UR-003 OpenClaw/Hermes 解耦 | `02-TARGET-ARCHITECTURE.md` §3.4/§3.5;ADR-002 | `aios_openclaw/adapter.py`、`aios_hermes/adapter.py` | runtime boundary tests in candidate flow suite | systemd/Docker runtime configuration | Implemented / Test-defined |
|
||||||
|
| UR-004 全面 Web 化 | `02-TARGET-ARCHITECTURE.md` §3.1/§8 | `aios_v2/console.py`、`aios_v2/gateway.py`、`aios_v2/app.py` | API/console smoke test pending | Docker/systemd + reverse proxy requirement | Implemented / Test pending |
|
||||||
|
| UR-005 多租户隔离 | `02-TARGET-ARCHITECTURE.md` §4;ADR-004 | `control_plane.py`、`orchestrator.py`、`aios_openclaw/*`、`aios_hermes/*`、`execution_tracking.py` | `test_cross_tenant_task_read_is_denied` | tenant-scoped state/evidence directories | Implemented / Test-defined |
|
||||||
|
| UR-006 权威任务生命周期 | `04-MODULE-CONTRACTS.md` §4 | `control_plane.py` | OpenClaw/Hermes full-flow tests | persisted state + audit | Implemented / Test-defined |
|
||||||
|
| UR-007 策略与审批 | `04-MODULE-CONTRACTS.md` §5 | `control_plane.py`、`gateway.py` | role/policy negative tests需补充 | reverse proxy identity + audit | Implemented / Partial test |
|
||||||
|
| UR-008 幂等与并发安全 | `02-TARGET-ARCHITECTURE.md` §7 | `control_plane.py`、`aios_openclaw/adapter.py`、`aios_hermes/adapter.py`、`worker.py` | flow + restart tests;并发专项测试需补充 | GitOps detached guard + SQLite WAL | Implemented / Partial test |
|
||||||
|
| UR-009 执行证据 | `04-MODULE-CONTRACTS.md` §9 | `execution_tracking.py` | `test_execution_tracking_persists_claim_heartbeat_and_result` | `/var/lib/aios-v2/executions` | Implemented / Test-defined |
|
||||||
|
| UR-010 GitOps 唯一变更流程 | `03-MIGRATION-AND-DELIVERY.md` §4/§5;ADR-003 | GitOps Runtime、`gitops_status.py` | healthcheck 的 detached/clean 检查 | `deploy/aios-v2/healthcheck/check.sh` | Implemented / Runtime verified partially |
|
||||||
|
| UR-011 持久化与恢复 | `02-TARGET-ARCHITECTURE.md` §3.6 | `persistence.py` | `test_durable_service_recovers_state_after_restart` | state dir + backup/rollback | Implemented / Test-defined |
|
||||||
|
| UR-012 审计与合规 | `04-MODULE-CONTRACTS.md` §6 | `control_plane.py`、`aios_openclaw/callback.py`、`app.py` | flow test + audit assertions需补充 | Console audit view | Implemented / Partial test |
|
||||||
|
| UR-013 密钥安全 | `02-TARGET-ARCHITECTURE.md` §6;ADR-006 | `FileSigningMaterialResolver`、credential refs、Secret Gate | permission/secret-scan test需补充 | env template + external protected file | Implemented / Partial test |
|
||||||
|
| UR-014 部署与回滚 | `03-MIGRATION-AND-DELIVERY.md` §7 | deployment scripts/assets | acceptance runner + healthcheck | `deploy/aios-v2/` | Implemented / Pre-prod pending |
|
||||||
|
| UR-015 文档与代码同步 | `03-MIGRATION-AND-DELIVERY.md` §9;ADR-005 | `tools/aios_v2_docs_code_check.py` | checker self-test / acceptance runner | CI/Gitea Actions 可调用 | Implemented after checker commit |
|
||||||
|
|
||||||
|
## 3. API 追踪
|
||||||
|
|
||||||
|
| API | 需求 | 实现 | 测试状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET /api/v2/health` | UR-004, UR-014 | `gateway.py` | healthcheck 定义完成 |
|
||||||
|
| `POST /api/v2/tenants` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | 需增加 HTTP 集成测试 |
|
||||||
|
| `POST /api/v2/agents` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||||
|
| `POST /api/v2/tasks` | UR-006, UR-008 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||||
|
| `POST /api/v2/tasks/{id}/approve` | UR-006, UR-007 | `gateway.py` | Domain flow 已覆盖 |
|
||||||
|
| `POST /api/v2/tasks/{id}/dispatch` | UR-001, UR-003 | `app.py` / `orchestrator.py` | OpenClaw/Hermes flow 已覆盖 |
|
||||||
|
| `GET /api/v2/runtime` | UR-004, UR-009 | `app.py` | HTTP smoke test待执行 |
|
||||||
|
| `GET /api/v2/executions` | UR-009 | `app.py` / `execution_tracking.py` | Evidence persistence test已定义 |
|
||||||
|
| `GET /api/v2/gitops` | UR-010 | `app.py` / `gitops_status.py` | Runtime/healthcheck验证待执行 |
|
||||||
|
| `POST /api/v2/hermes/results/reconcile` | UR-003, UR-006 | `app.py` / `orchestrator.py` | Hermes flow已覆盖 Domain 路径 |
|
||||||
|
|
||||||
|
## 4. 运行时契约追踪
|
||||||
|
|
||||||
|
| 契约 | 设计 | 代码 | 测试 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| OpenClawCommand | `04-MODULE-CONTRACTS.md` §7.1 | `aios_openclaw/contract.py` | OpenClaw full-flow;签名负向测试需补充 |
|
||||||
|
| OpenClawResult | §7.2 | `aios_openclaw/contract.py` | OpenClaw full-flow |
|
||||||
|
| RuntimeHealth | §7.3 | `aios_openclaw/contract.py`、`health.py` | health policy测试需补充 |
|
||||||
|
| HermesJob | §8.1 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||||
|
| HermesResult | §8.2 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||||
|
| HermesHealth | §8.3 | `aios_hermes/contract.py`、`adapter.py` | healthcheck/worker test待执行 |
|
||||||
|
| ExecutionEvidence | §9 | `execution_tracking.py` | evidence persistence test |
|
||||||
|
| GitOpsStatus | §10 | `gitops_status.py` | healthcheck detached/clean检查 |
|
||||||
|
|
||||||
|
### 4.1 核心控制面模型说明
|
||||||
|
|
||||||
|
`RequestContext` 是每次控制面请求的不可缺失上下文。它承载租户、调用主体、追踪标识和请求来源,所有读取、写入、审批与调度都必须先用该上下文完成租户边界和身份检查。适配器不得自行补造租户,也不得把缺失上下文解释为默认管理权限。上下文校验失败时,控制面必须拒绝请求并写入不含密钥值的审计记录。
|
||||||
|
|
||||||
|
`TaskState` 是权威任务生命周期状态,不是前端展示标签。任务从创建、批准、调度、运行到完成、失败或取消,只能沿合同允许的方向迁移;OpenClaw 与 Hermes 回调只能请求迁移,最终状态由唯一控制面持久化。重复回调依靠幂等键收敛,跨租户回调、旧租约回调和非法逆向迁移必须被拒绝。
|
||||||
|
|
||||||
|
`TenantRecord`、`AgentRecord` 与 `TaskRecord` 共同定义所有权:租户拥有 Agent 和 Task,Agent 只能处理其租户与授权 Cell/Profile 范围内的任务。任何查询若缺少租户过滤条件,都不能作为正式实现。`AuditEvent` 记录主体、动作、对象、结果和追踪标识,但不得保存 Token、密码、私钥或签名材料。
|
||||||
|
|
||||||
|
`OpenClawCommand` 与 `OpenClawResult` 描述短执行路径;前者必须绑定任务、租户、追踪标识、命令期限和签名合同,后者必须回传相同身份域并接受幂等校验。`RuntimeHealth` 只报告可验证运行事实,不能把本地 Bare Repo 更新误报为 Gitea 完全同步。
|
||||||
|
|
||||||
|
`HermesJob`、`HermesResult` 与 `HermesHealth` 描述长任务路径。Job 的领取、心跳和 Lease 续期必须持久化;Result 只有在任务、租户、执行器和租约全部匹配时才能改变权威状态。Worker 崩溃后可重新领取过期 Lease,但不得重复产生业务副作用。
|
||||||
|
|
||||||
|
`ExecutionEvidence` 保存 Claim、Heartbeat、Result 与必要的追踪关系,用于重启恢复和审计闭环。Evidence 的写入必须限定任务分支与固定路径;本轮增加的 Owner-signed Provenance Writer 只允许七个 `.agent` JSON,并以普通 Commit 与 Push 固化,不授予通用 Shell 或任意仓库写权限。
|
||||||
|
|
||||||
|
## 5. 部署追踪
|
||||||
|
|
||||||
|
| 能力 | 文件 | 验收命令 |
|
||||||
|
|---|---|---|
|
||||||
|
| systemd | `deploy/aios-v2/systemd/aios-v2.service` | `systemctl status aios-v2` |
|
||||||
|
| 环境模板 | `deploy/aios-v2/env/aios-v2.env.example` | `systemd-analyze verify` + 文件权限检查 |
|
||||||
|
| Docker | `deploy/aios-v2/docker/Dockerfile` | `docker build` |
|
||||||
|
| Compose | `deploy/aios-v2/docker/compose.yaml` | `docker compose config` / `up` |
|
||||||
|
| 健康检查 | `deploy/aios-v2/healthcheck/check.sh` | 直接执行 |
|
||||||
|
| 回滚 | `deploy/aios-v2/rollback/rollback.sh` | 预发布回滚演练 |
|
||||||
|
| 全量验收 | `deploy/aios-v2/acceptance/run-all.sh` | 直接执行 |
|
||||||
|
|
||||||
|
## 6. 文档覆盖追踪
|
||||||
|
|
||||||
|
| 文档 | 覆盖内容 | 代码/变更触发条件 |
|
||||||
|
|---|---|---|
|
||||||
|
| `README.md` | 事实源索引、状态语言、实现地图 | 新模块/目录/版本 |
|
||||||
|
| `01-USER-REQUIREMENTS.md` | 用户需求、NFR、验收场景 | 用户行为、功能范围、验收变化 |
|
||||||
|
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户 | 架构边界、数据流、运行时变化 |
|
||||||
|
| `03-MIGRATION-AND-DELIVERY.md` | 迁移、GitOps、Gate、回滚 | 发布流程、迁移阶段、门禁变化 |
|
||||||
|
| `04-MODULE-CONTRACTS.md` | 字段、API、状态机、错误语义 | Contract/API/模型变化 |
|
||||||
|
| `05-TRACEABILITY-MATRIX.md` | 需求到代码测试部署映射 | 代码、测试、部署路径变化 |
|
||||||
|
| `adr/*` | 关键不可逆或跨模块决策 | 新架构决策或旧决策替换 |
|
||||||
|
|
||||||
|
## 7. 当前未完成验证项
|
||||||
|
|
||||||
|
以下项目代码或测试定义已存在,但尚不能标记为 `Test-verified` 或 `Pre-production verified`:
|
||||||
|
|
||||||
|
1. 精确 Candidate commit 的测试执行结果;
|
||||||
|
2. HTTP API 与 Web Console 冒烟测试;
|
||||||
|
3. 真实 OpenClaw Runtime 签名、取消、超时和健康联调;
|
||||||
|
4. Hermes 长任务心跳、Worker 崩溃和 Lease 恢复演练;
|
||||||
|
5. 多实例 SQLite/并发容量边界;
|
||||||
|
6. systemd、Docker、Compose、restart 和 rollback 演练;
|
||||||
|
7. local/Gitea Candidate OID 一致证明;
|
||||||
|
8. 生产身份代理、防伪 Header、CSRF/CORS/Rate Limit 验证;
|
||||||
|
9. 文档—代码同步检查器在 CI/Acceptance 中执行通过。
|
||||||
|
|
||||||
|
## 8. 更新要求
|
||||||
|
|
||||||
|
任何代码、API、部署或测试路径变化,必须同步更新本矩阵。验收脚本会检查本文档引用的关键路径存在,并检查架构/功能代码变化是否同时包含 `docs/aios-v2/` 变化。
|
||||||
|
|
||||||
|
### OpenClaw 命令签名材料
|
||||||
|
|
||||||
|
`openclaw-v2r1-candidate-ed25519` 使用 Ed25519 私钥对 canonical command bytes 签名,签名以 URL-safe Base64 编码。运行材料只允许是 32 字节 raw private key 或 Ed25519 PEM;`FileSigningMaterialResolver` 不得对 raw bytes 执行 `strip()`。签名、篡改拒绝、过期拒绝由候选测试与运行时 sign/verify 探针共同验证。
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# AIOS V2 文档治理规范
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 适用范围:`docs/aios-v2/`、架构决策记录、生产候选报告、在线任务合同、部署与验收说明
|
||||||
|
- 状态:已采纳
|
||||||
|
|
||||||
|
## 1. 目的
|
||||||
|
|
||||||
|
本规范用于保证 AIOS V2 的需求、架构、代码、测试、部署和运行事实始终保持一致,避免出现以下问题:
|
||||||
|
|
||||||
|
- 代码已经变化,但架构文档仍描述旧边界;
|
||||||
|
- API、字段或状态机变化后,测试与追踪矩阵未同步;
|
||||||
|
- OpenClaw、Hermes 或 Agent Router 的控制职责发生漂移;
|
||||||
|
- 候选分支存在实现,但版本无法解释为什么这样设计;
|
||||||
|
- 部署、回滚或安全策略发生变化,却没有形成可审计文档;
|
||||||
|
- 归档包只包含摘要或占位符,无法作为正式交付材料。
|
||||||
|
|
||||||
|
## 2. 文档语言要求
|
||||||
|
|
||||||
|
所有面向用户、产品、架构、运维、审计和发布的正式文档统一使用中文。
|
||||||
|
|
||||||
|
以下内容保留原始英文技术标识:
|
||||||
|
|
||||||
|
- 类名、函数名、字段名、环境变量;
|
||||||
|
- API 路径、HTTP 方法和 Header;
|
||||||
|
- Git 分支、Commit、Tag;
|
||||||
|
- OpenClaw、Hermes、Agent Router、GitOps 等专有名称;
|
||||||
|
- systemd、Docker、SQLite、HMAC、HTTP、JSON 等标准术语。
|
||||||
|
|
||||||
|
禁止在同一文档中无必要地切换中英文叙述。
|
||||||
|
|
||||||
|
## 3. 文档完整性要求
|
||||||
|
|
||||||
|
每份正式文档至少包含:
|
||||||
|
|
||||||
|
1. 文档目的;
|
||||||
|
2. 适用范围;
|
||||||
|
3. 当前版本和状态;
|
||||||
|
4. 完整正文;
|
||||||
|
5. 与代码、测试或部署的映射;
|
||||||
|
6. 验收规则;
|
||||||
|
7. 已知限制;
|
||||||
|
8. 后续演进方向;
|
||||||
|
9. 更新触发条件。
|
||||||
|
|
||||||
|
正式归档包禁止使用以下内容代替正文:
|
||||||
|
|
||||||
|
- “后续补充”;
|
||||||
|
- “待完善”;
|
||||||
|
- 只有标题没有说明;
|
||||||
|
- 只包含目录摘要;
|
||||||
|
- 与候选分支代码不一致的旧文档。
|
||||||
|
|
||||||
|
确实尚未验证的项目,应明确标记为“已实现但尚未验证”,并说明验证命令、责任方和进入下一状态的条件。
|
||||||
|
|
||||||
|
## 4. 文档分类与维护责任
|
||||||
|
|
||||||
|
| 文档 | 主要内容 | 触发更新的变化 |
|
||||||
|
|---|---|---|
|
||||||
|
| `README.md` | 文档索引、版本状态、实现地图 | 新增模块、目录、版本或交付物 |
|
||||||
|
| `01-USER-REQUIREMENTS.md` | 用户目标、功能需求、非功能需求、验收场景 | 用户行为、产品范围、验收标准变化 |
|
||||||
|
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户、部署架构 | 控制权、Runtime 边界、数据流或安全模型变化 |
|
||||||
|
| `03-MIGRATION-AND-DELIVERY.md` | 迁移阶段、GitOps、发布门禁、回滚、Bug 修复 | 迁移路线、发布流程、门禁或回滚方式变化 |
|
||||||
|
| `04-MODULE-CONTRACTS.md` | 数据模型、API、状态机、错误语义、Runtime Contract | 字段、接口、状态机、Header、错误行为变化 |
|
||||||
|
| `05-TRACEABILITY-MATRIX.md` | 需求到代码、测试、部署、验收的映射 | 代码、测试、部署路径或状态变化 |
|
||||||
|
| `adr/*` | 跨模块、长期或不可逆架构决策 | 关键决策新增、替换或废弃 |
|
||||||
|
| `AIOS-V2R1-PRODUCTION-CANDIDATE.md` | 候选版本总览、测试、部署、风险和发布结论 | Candidate SHA、测试证据、部署或风险变化 |
|
||||||
|
| `tasks/*` | OpenClaw、Codex 等在线执行任务 | 职责、边界、阻塞、验收或执行流程变化 |
|
||||||
|
|
||||||
|
## 5. 代码变化与文档同步规则
|
||||||
|
|
||||||
|
以下代码或配置路径发生变化时,必须同步更新 `docs/aios-v2/`:
|
||||||
|
|
||||||
|
### 5.1 Control Plane
|
||||||
|
|
||||||
|
触发路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_v2/control_plane.py
|
||||||
|
active-runtime/app/aios_v2/persistence.py
|
||||||
|
active-runtime/app/aios_v2/gateway.py
|
||||||
|
active-runtime/app/aios_v2/app.py
|
||||||
|
active-runtime/app/aios_v2/orchestrator.py
|
||||||
|
active-runtime/app/aios_v2/execution_tracking.py
|
||||||
|
active-runtime/app/aios_v2/gitops_status.py
|
||||||
|
```
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- 用户需求;
|
||||||
|
- 目标架构;
|
||||||
|
- 模块契约;
|
||||||
|
- 追踪矩阵;
|
||||||
|
- 必要时新增或更新 ADR。
|
||||||
|
|
||||||
|
### 5.2 OpenClaw
|
||||||
|
|
||||||
|
触发路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_openclaw/
|
||||||
|
```
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- OpenClawCommand、OpenClawResult、RuntimeHealth;
|
||||||
|
- 签名、幂等、取消、超时和审计边界;
|
||||||
|
- ADR-002;
|
||||||
|
- 真实 Runtime 联调要求。
|
||||||
|
|
||||||
|
### 5.3 Hermes
|
||||||
|
|
||||||
|
触发路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_hermes/
|
||||||
|
```
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- HermesJob、HermesResult、HermesHealth;
|
||||||
|
- tenant/cell/profile 分区;
|
||||||
|
- Worker Lease、Heartbeat、Recovery;
|
||||||
|
- ADR-002、ADR-004。
|
||||||
|
|
||||||
|
### 5.4 部署与运维
|
||||||
|
|
||||||
|
触发路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
deploy/aios-v2/
|
||||||
|
```
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- 迁移与交付策略;
|
||||||
|
- 部署契约;
|
||||||
|
- 健康检查;
|
||||||
|
- 回滚;
|
||||||
|
- 候选报告。
|
||||||
|
|
||||||
|
### 5.5 API、Web 与用户行为
|
||||||
|
|
||||||
|
任何 API 路径、Header、请求/响应、页面或用户操作变化,必须同步:
|
||||||
|
|
||||||
|
- `01-USER-REQUIREMENTS.md`;
|
||||||
|
- `02-TARGET-ARCHITECTURE.md`;
|
||||||
|
- `04-MODULE-CONTRACTS.md`;
|
||||||
|
- `05-TRACEABILITY-MATRIX.md`。
|
||||||
|
|
||||||
|
## 6. ADR 治理
|
||||||
|
|
||||||
|
必须新增或更新 ADR 的场景:
|
||||||
|
|
||||||
|
- AIOS、OpenClaw、Hermes、Agent Router 的控制边界变化;
|
||||||
|
- 多租户隔离模型变化;
|
||||||
|
- 持久化、消息、审计、密钥或部署出现跨模块变化;
|
||||||
|
- 不兼容 API、字段或状态机变化;
|
||||||
|
- GitOps、文档门禁或发布策略变化;
|
||||||
|
- 替换核心基础设施或引入新的权威事实源。
|
||||||
|
|
||||||
|
ADR 状态:
|
||||||
|
|
||||||
|
- `已提出`:尚未正式采纳;
|
||||||
|
- `已采纳`:当前有效;
|
||||||
|
- `已替代`:被新 ADR 替代;
|
||||||
|
- `已废弃`:保留历史但禁止新实现采用。
|
||||||
|
|
||||||
|
任何 ADR 替代必须说明:
|
||||||
|
|
||||||
|
- 被替代的 ADR;
|
||||||
|
- 迁移影响;
|
||||||
|
- 兼容性和回滚方案;
|
||||||
|
- 受影响代码、测试和文档。
|
||||||
|
|
||||||
|
## 7. 文档—代码匹配门禁
|
||||||
|
|
||||||
|
验收必须执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 tools/aios_v2_docs_code_check.py
|
||||||
|
```
|
||||||
|
|
||||||
|
门禁至少检查:
|
||||||
|
|
||||||
|
1. 必需文档存在且非空;
|
||||||
|
2. 文档语言以中文为主;
|
||||||
|
3. 文档引用的关键代码、测试和部署路径存在;
|
||||||
|
4. 文档列出的核心 API 能在代码中找到;
|
||||||
|
5. 文档列出的核心模型和字段能在代码中找到;
|
||||||
|
6. Traceability Matrix 覆盖所有 `UR-*` 需求;
|
||||||
|
7. ADR 索引引用的文件存在;
|
||||||
|
8. 架构或功能代码变化时,当前交付范围包含 `docs/aios-v2/` 变化;
|
||||||
|
9. 文档中不存在未解决 Git 冲突标记;
|
||||||
|
10. 正式文档中不存在摘要占位符或空章节。
|
||||||
|
|
||||||
|
门禁失败时:
|
||||||
|
|
||||||
|
- Candidate 不得标记为 `Test-verified`;
|
||||||
|
- OpenClaw 不得继续生产部署;
|
||||||
|
- Codex 必须登记 blocker 并给出修复建议;
|
||||||
|
- 修复必须进入 Git 分支并统一审视。
|
||||||
|
|
||||||
|
## 8. 在线任务文档要求
|
||||||
|
|
||||||
|
OpenClaw 和 Codex 任务必须使用中文说明,并保留可执行字段名。
|
||||||
|
|
||||||
|
每个任务至少包含:
|
||||||
|
|
||||||
|
- 任务目标;
|
||||||
|
- 责任范围;
|
||||||
|
- 禁止事项;
|
||||||
|
- 输入和输出;
|
||||||
|
- 分支;
|
||||||
|
- 执行状态;
|
||||||
|
- 验收命令;
|
||||||
|
- claim、heartbeat、progress、blocker、commit、test_status、result 证据要求;
|
||||||
|
- 阻塞升级路径。
|
||||||
|
|
||||||
|
## 9. 归档包要求
|
||||||
|
|
||||||
|
正式归档包必须包含完整正文:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/aios-v2/
|
||||||
|
tasks/openclaw-production-deploy-task.yaml
|
||||||
|
tasks/codex-production-supervisor-task.yaml
|
||||||
|
deploy/aios-v2/
|
||||||
|
AIOS-V2R1-PRODUCTION-CANDIDATE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
归档包必须同时生成清单:
|
||||||
|
|
||||||
|
- 文件路径;
|
||||||
|
- 文件大小;
|
||||||
|
- SHA-256;
|
||||||
|
- Candidate branch;
|
||||||
|
- Candidate commit;
|
||||||
|
- 生成时间。
|
||||||
|
|
||||||
|
禁止只打包摘要副本。
|
||||||
|
|
||||||
|
## 10. 审查职责
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
|
||||||
|
- 部署和运行修复时同步相关部署、运维和已知问题文档;
|
||||||
|
- 不得修改架构边界;
|
||||||
|
- 发现架构影响时升级给 Codex。
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
- 监督需求、架构、契约、追踪和 ADR 一致性;
|
||||||
|
- 解决阻塞;
|
||||||
|
- 拒绝缺少文档或证据的补丁;
|
||||||
|
- 输出统一发布建议。
|
||||||
|
|
||||||
|
### AIOS Owner
|
||||||
|
|
||||||
|
- 审批合并、Tag 和生产部署;
|
||||||
|
- 对重大架构决策和风险接受做最终确认。
|
||||||
|
|
||||||
|
## 11. 验收状态
|
||||||
|
|
||||||
|
文档完成状态只有在以下条件全部满足时才标记为“完整”:
|
||||||
|
|
||||||
|
- 所有必需文档和 ADR 已提交;
|
||||||
|
- 文档—代码匹配检查通过;
|
||||||
|
- 归档包包含仓库完整正文;
|
||||||
|
- 任务合同为中文完整版本;
|
||||||
|
- Candidate commit、清单和 SHA-256 已记录;
|
||||||
|
- Codex 监督结论不存在未解决阻塞。
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# AIOS V2R1 监督状态与阻塞处置清单
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 快照时间:`2026-07-20 17:03:53 CST`
|
||||||
|
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||||
|
- 候选 OID:`634f6b164f5b20372ab9ec8144bbf76df97de335`
|
||||||
|
- 当前决策:`STOP_PREPRODUCTION`
|
||||||
|
- 当前状态:`BLOCKED`
|
||||||
|
|
||||||
|
## 1. 执行摘要
|
||||||
|
|
||||||
|
AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和 GitOps 证据机制已经形成。OpenClaw 与 Codex 已经领取任务并向 Gitea 提交监督证据,三个关键分支的本地 OID 与 Gitea OID 已一致。
|
||||||
|
|
||||||
|
当前仍不允许进入预发布,原因不是主体功能缺失,而是安全、签名材料、Candidate 合并、执行来源证明和 Controller 健康检查五项高风险门禁未通过。
|
||||||
|
|
||||||
|
当前禁止:
|
||||||
|
|
||||||
|
- 进入 Gate A–D;
|
||||||
|
- 执行在线 Runtime 联调;
|
||||||
|
- 执行重启恢复和回滚演练;
|
||||||
|
- 合并 `main`;
|
||||||
|
- 创建 Tag;
|
||||||
|
- 正式部署或切换流量。
|
||||||
|
|
||||||
|
## 2. 权威仓库与执行状态
|
||||||
|
|
||||||
|
| 项目 | 当前值 |
|
||||||
|
|---|---|
|
||||||
|
| 权威 Bare Repo | `/data/aios-gitops/repo.git` |
|
||||||
|
| OpenClaw Worktree | 独立且 clean |
|
||||||
|
| Codex Worktree | 独立且 clean |
|
||||||
|
| Gitea Remote | `https://g.cnnetmkt.top/git/aios/aios.git` |
|
||||||
|
| Candidate OID | `634f6b164f5b20372ab9ec8144bbf76df97de335` |
|
||||||
|
| OpenClaw Task OID | `bd2784015d40c328d158cd52ebd8683b6e56b3ad` |
|
||||||
|
| Codex Task OID | `e28245f411dd85ff9384ed595dacc53aab8a91ba` |
|
||||||
|
| 三分支 Gitea 同步 | `local_oid == gitea_oid` |
|
||||||
|
| OpenClaw 状态 | `blocked` |
|
||||||
|
| OpenClaw Heartbeat | 有记录,但 `valid_for_running=false` |
|
||||||
|
| Codex 状态 | `blocked` |
|
||||||
|
| Codex Heartbeat | 新鲜 |
|
||||||
|
| Codex 发布建议 | `STOP_PREPRODUCTION` |
|
||||||
|
|
||||||
|
## 3. 已完成的安全与运行修复
|
||||||
|
|
||||||
|
### 3.1 Gitea 凭据轮换
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
|
||||||
|
- 旧 `owner/openclaw-deploy-cli`、`scope=all` 凭据 live count 为 `0`;
|
||||||
|
- 新凭据:`gitops-bot/openclaw-deploy-cli-v2-20260720`;
|
||||||
|
- 新凭据作用域:`write:repository`;
|
||||||
|
- 凭据文件权限:`root:gitops 0640`;
|
||||||
|
- 令牌值未进入 Git、日志、Evidence 或报告。
|
||||||
|
|
||||||
|
### 3.2 Gitea 与执行服务
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
|
||||||
|
- 验证 Gitea SSH 指纹;
|
||||||
|
- 备份并修复 `known_hosts`;
|
||||||
|
- 恢复 `aios-agent-command-watcher`;
|
||||||
|
- 恢复 `aios-autonomous-controller`;
|
||||||
|
- 两项服务均为 `active`;
|
||||||
|
- OpenClaw/One API 真实模型调用返回 HTTP 200;
|
||||||
|
- OpenClaw/Codex claim、heartbeat、progress、blockers、result、status 已提交并同步 Gitea。
|
||||||
|
|
||||||
|
## 4. 当前开放阻塞
|
||||||
|
|
||||||
|
### 4.1 BLK-OPENCLAW-PLAINTEXT-SECRETS
|
||||||
|
|
||||||
|
- 严重级别:高
|
||||||
|
- 状态:开放
|
||||||
|
- 现象:OpenClaw 受管配置仍发现 3 处 `PLAINTEXT_FOUND`。
|
||||||
|
- 风险:明文 Secret 可能进入配置、日志、命令参数或执行证据。
|
||||||
|
- 解除条件:
|
||||||
|
1. 三处明文内容全部迁移为 SecretRef、systemd Credential、只读 Secret Volume 或仓库外受保护文件;
|
||||||
|
2. 旧值完成撤销和轮换;
|
||||||
|
3. Secret Scanner 复验通过;
|
||||||
|
4. Git 历史、日志和 Evidence 不包含明文。
|
||||||
|
|
||||||
|
### 4.2 BLK-AIOS-V2-SIGNING-MATERIAL-MISSING
|
||||||
|
|
||||||
|
- 严重级别:高
|
||||||
|
- 状态:开放
|
||||||
|
- 现象:`/etc/aios-v2/aios-v2.env` 已存在且权限合规,但 `/etc/aios-v2/openclaw-signing-material.bin` 不存在。
|
||||||
|
- 风险:无法验证 OpenClaw 签名命令、Key ID 与真实 Runtime 身份链。
|
||||||
|
- 外部受信输入:批准的签名材料。
|
||||||
|
- 解除条件:
|
||||||
|
1. 签名材料由批准的 Secret/Key 交付链安装到仓库外;
|
||||||
|
2. 权限为 `0600` 或更严格;
|
||||||
|
3. 运行用户可读;
|
||||||
|
4. Key ID 与材料匹配;
|
||||||
|
5. execute/status 路径完成真实签名验证;
|
||||||
|
6. 不输出材料内容、Hash 或长度特征。
|
||||||
|
|
||||||
|
### 4.3 BLK-CANDIDATE-NOT-MERGED
|
||||||
|
|
||||||
|
- 严重级别:高
|
||||||
|
- 状态:开放
|
||||||
|
- 现象:OpenClaw Task 尚未包含当前 Candidate,`merge-base --is-ancestor` 未通过。
|
||||||
|
- 风险:后续测试和部署不能证明当前 Candidate 可用。
|
||||||
|
- 解除条件:
|
||||||
|
1. 保护已有 `.agent/` Evidence;
|
||||||
|
2. 普通 Merge 当前 Candidate 到 OpenClaw Task;
|
||||||
|
3. 解决冲突并由 Codex 审查;
|
||||||
|
4. 更新所有 Evidence 中的 `candidate_commit`;
|
||||||
|
5. 本地与 Gitea OID 再次一致。
|
||||||
|
|
||||||
|
### 4.4 BLK-OPENCLAW-RUN-PROVENANCE-MISSING
|
||||||
|
|
||||||
|
- 严重级别:高
|
||||||
|
- 状态:已解决
|
||||||
|
- 根因:旧插件只有进程内 `runId -> sender` Map,没有 Owner-signed Schema、持久 Store 和限定 Git Writer。
|
||||||
|
- 修复:部署 `aios-tenant-boundary` rc2,增加 Ed25519 issuer/store、Run/Sender/OID/期限绑定、一次性 permit 与专用 `aios_evidence_write`。
|
||||||
|
- 验证:Provenance `openclaw-v2r1-20260721T031523Z` 完成 Heartbeat 写入、Commit、普通 Push 和审计;`README.md` 越权请求被拒绝;Node 测试 9/9。
|
||||||
|
|
||||||
|
### 4.5 BLK-CONTROLLER-LEGACY-GITEA-CHECK
|
||||||
|
|
||||||
|
- 严重级别:高
|
||||||
|
- 状态:已解决
|
||||||
|
- 现象:Controller 健康检查仍硬编码旧 `aios-v23`,并返回 `gitea=false`、`branch_sync=false`。
|
||||||
|
- 风险:健康状态与当前 AIOS V2R1 仓库不一致,可能错误阻止或错误放行发布。
|
||||||
|
- 解除条件:
|
||||||
|
1. 移除旧仓库硬编码;
|
||||||
|
2. 从配置读取权威仓库、Remote 和目标分支;
|
||||||
|
3. 健康检查分别报告 `local_sync`、`gitea_sync` 和 `oid_match`;
|
||||||
|
4. 不再把本地 Bare Repo 更新当作完全同步;
|
||||||
|
5. 增加回归测试;
|
||||||
|
6. 更新中文文档和追踪矩阵。
|
||||||
|
|
||||||
|
## 5. 模型审查策略修正
|
||||||
|
|
||||||
|
`gpt-5.3-codex-spark` 是子 Agent 审查的优先推荐模型,不是强制依赖。
|
||||||
|
|
||||||
|
执行规则:
|
||||||
|
|
||||||
|
1. Spark 可用时优先使用;
|
||||||
|
2. Spark 不可用时,使用模型注册表中已批准、已启用且优先级最高的合适模型;
|
||||||
|
3. 必须记录实际 `model_id`、模型名称和审查结果;
|
||||||
|
4. 禁止冒充使用不存在的模型;
|
||||||
|
5. 只有完全没有可用审查模型时,模型能力才构成阻塞。
|
||||||
|
|
||||||
|
当前五个 Spark 角色的独立结论一致为“禁止生产放行”,该结论作为已有审查证据保留;后续复验可使用任何满足上述规则的批准模型。
|
||||||
|
|
||||||
|
## 6. Gate 状态
|
||||||
|
|
||||||
|
| Gate | 当前状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| Gate A:文档与设计 | 允许启动 | 五项高风险 Blocker 已解除,等待正式执行 |
|
||||||
|
| Gate B:代码与测试 | 等待 Gate A | 等待正式执行候选测试 |
|
||||||
|
| Gate C:预发布 | 禁止 | 等待 Gate A、Gate B、离线全量验收和在线联调 |
|
||||||
|
| Gate D:发布审查 | `STOP_PREPRODUCTION` | Codex 明确停止预发布 |
|
||||||
|
| Gate E:生产发布 | 禁止 | 未满足任何生产 Gate |
|
||||||
|
|
||||||
|
## 7. 阻塞解除顺序
|
||||||
|
|
||||||
|
必须按以下顺序处理,避免重复测试和错误放行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 清除 3 处明文 Secret
|
||||||
|
↓
|
||||||
|
2. 安装批准的签名材料
|
||||||
|
↓
|
||||||
|
3. 签发最小范围 Run Provenance
|
||||||
|
↓
|
||||||
|
4. 修复 Controller 旧仓库健康检查
|
||||||
|
↓
|
||||||
|
5. Merge 当前 Candidate 到 OpenClaw Task
|
||||||
|
↓
|
||||||
|
6. 校验 local_oid == gitea_oid
|
||||||
|
↓
|
||||||
|
7. 执行 Gate A:文档—代码检查
|
||||||
|
↓
|
||||||
|
8. 执行 Gate B:单元与集成测试
|
||||||
|
↓
|
||||||
|
9. 执行离线全量验收
|
||||||
|
↓
|
||||||
|
10. 执行在线 Runtime 联调
|
||||||
|
↓
|
||||||
|
11. 执行重启恢复与回滚
|
||||||
|
↓
|
||||||
|
12. Codex 重新审查并给出发布建议
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 外部受信输入
|
||||||
|
|
||||||
|
五项高风险 Blocker 当前均不再需要新的外部受信输入。签名材料继续留在受保护边界,Provenance 仍须按 Run 由 Owner 限时签发,不得转化为长期通用权限。
|
||||||
|
|
||||||
|
## 9. 监督结论
|
||||||
|
|
||||||
|
当前状态保持:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PARTIAL
|
||||||
|
STOP_PREPRODUCTION
|
||||||
|
```
|
||||||
|
|
||||||
|
三分支已同步 Gitea,五项高风险 Blocker 已解除,允许执行 Gate A、Gate B 和离线全量验收;在三项正式结果与后续在线验收形成证据前,不进入预发布。
|
||||||
|
|
||||||
|
任何后续状态更新必须同步:
|
||||||
|
|
||||||
|
- OpenClaw `.agent/status.json`、`blockers.json`、`progress.json`、`result.json`;
|
||||||
|
- Codex 对应监督 Evidence;
|
||||||
|
- 本文档;
|
||||||
|
- `05-TRACEABILITY-MATRIX.md`;
|
||||||
|
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md`;
|
||||||
|
- 如涉及架构或发布语义,更新 ADR。
|
||||||
|
|
||||||
|
## 10. 2026-07-20 至 2026-07-21 Owner 修复实况
|
||||||
|
|
||||||
|
- Worktree:OpenClaw 与 Codex 独立、目标分支唯一检出、无 lock、无 prunable;现有 Evidence 已先备份。
|
||||||
|
- Candidate:`ba7669c3b49f98cf3500af6ea966ba0db472ff3b` 已普通 Push 到 Gitea,并以 `--no-ff` 合入 OpenClaw 任务分支。
|
||||||
|
- Secrets:OpenClaw 原生 audit 从 3 项明文降为 `plaintext=0`、`unresolved=0`、`shadowed=0`、`legacy=0`;Gateway Token 已轮换,OneAPI 实际 agent 调用通过。
|
||||||
|
- Signing:命令合同已从名实不符的 HMAC 改为真实 Ed25519;`openclaw-v2r1-candidate-ed25519` 材料由 Owner 在受保护边界生成,运行用户权限检查、sign/verify 和篡改拒绝通过。
|
||||||
|
- Controller:修复分支 `patch/bugfix/controller-gitea-health-v2r1-20260720` 的 OID `6188da26453505dacde9d6d298191437164c8d27` 已部署;12 项测试通过,active worktree detached + clean,`gitea=true`、`branch_sync=true`、`oid_match=true`、`sync_status=fully_synced`。
|
||||||
|
- Provenance:插件 rc2 已声明原生工具合同并通过 runtime doctor;Owner-signed Store、一次性 permit、七路径 Writer、普通 Push、审计与越权拒绝均通过。首次部署检查脚本曾两次因读取错误 JSON 层级触发自动回滚,旧插件与配置均成功恢复,修正断言后部署成功。
|
||||||
|
|
||||||
|
因此当前结论仍为 `STOP_PREPRODUCTION`,但 Gate A 已允许启动;后续结论必须基于 Gate A、Gate B、离线 Acceptance、在线 Runtime、重启恢复和正式回滚演练的最新证据。
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# AIOS V2 架构与交付文档索引
|
||||||
|
|
||||||
|
- 版本线:`V2R1C0-candidate`
|
||||||
|
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||||
|
- 架构负责人:AIOS V2 CTO
|
||||||
|
- 当前状态:候选代码闭环已完成,运行时、预发布和生产验证尚待执行
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本目录是 AIOS V2 用户需求、目标架构、模块边界、迁移策略、发布门禁和架构决策的唯一事实源。
|
||||||
|
|
||||||
|
所有后续架构、功能、接口、数据模型、安全策略、部署方式和验收流程变化,都必须在同一交付流程中同步更新本目录。
|
||||||
|
|
||||||
|
AIOS V2 遵循以下不可协商原则:
|
||||||
|
|
||||||
|
1. 不推倒重写现有平台。
|
||||||
|
2. 优先复用 OpenClaw、Hermes 和 Agent Router 能力。
|
||||||
|
3. OpenClaw 与 Hermes 从控制面职责中逐步解耦。
|
||||||
|
4. AIOS 是唯一控制面。
|
||||||
|
5. 所有面向用户和运维的能力必须通过 Web 或 API 访问。
|
||||||
|
6. 所有模块必须强制多租户隔离。
|
||||||
|
7. 所有修改必须通过 Git 分支、提交、测试、审查和受控提升完成。
|
||||||
|
8. 代码变化必须同步更新需求、架构、契约、追踪矩阵或 ADR。
|
||||||
|
|
||||||
|
## 2. 文档地图
|
||||||
|
|
||||||
|
| 文档 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| [01-USER-REQUIREMENTS.md](01-USER-REQUIREMENTS.md) | 产品目标、用户角色、功能需求、非功能需求和验收标准 |
|
||||||
|
| [02-TARGET-ARCHITECTURE.md](02-TARGET-ARCHITECTURE.md) | 目标逻辑架构、控制边界、数据流、安全模型和多租户模型 |
|
||||||
|
| [03-MIGRATION-AND-DELIVERY.md](03-MIGRATION-AND-DELIVERY.md) | 增量迁移、能力复用、GitOps 流程、发布门禁和回滚策略 |
|
||||||
|
| [04-MODULE-CONTRACTS.md](04-MODULE-CONTRACTS.md) | 租户上下文、任务生命周期、OpenClaw、Hermes、审计和执行证据契约 |
|
||||||
|
| [05-TRACEABILITY-MATRIX.md](05-TRACEABILITY-MATRIX.md) | 需求、设计、代码、测试、部署和验收之间的追踪关系 |
|
||||||
|
| [06-DOCUMENT-GOVERNANCE.md](06-DOCUMENT-GOVERNANCE.md) | 文档语言、完整性、同步门禁、版本与审查要求 |
|
||||||
|
| [adr/README.md](adr/README.md) | 架构决策记录索引与治理规则 |
|
||||||
|
|
||||||
|
## 3. 当前实现映射
|
||||||
|
|
||||||
|
| 架构能力 | 候选实现 |
|
||||||
|
|---|---|
|
||||||
|
| Tenant、Agent、Task、Policy 和 Audit 控制面 | `active-runtime/app/aios_v2/control_plane.py` |
|
||||||
|
| 控制面持久化 | `active-runtime/app/aios_v2/persistence.py` |
|
||||||
|
| Web/API Gateway | `active-runtime/app/aios_v2/gateway.py` |
|
||||||
|
| 统一应用入口 | `active-runtime/app/aios_v2/app.py` |
|
||||||
|
| Runtime 调度与结果回写 | `active-runtime/app/aios_v2/orchestrator.py` |
|
||||||
|
| Agent 执行证据 | `active-runtime/app/aios_v2/execution_tracking.py` |
|
||||||
|
| GitOps 状态可视化 | `active-runtime/app/aios_v2/gitops_status.py` |
|
||||||
|
| Web Console | `active-runtime/app/aios_v2/console.py` |
|
||||||
|
| OpenClaw 执行边界 | `active-runtime/app/aios_openclaw/` |
|
||||||
|
| Hermes Runtime 边界 | `active-runtime/app/aios_hermes/` |
|
||||||
|
| 候选测试 | `tests/aios_v2_candidate/` |
|
||||||
|
| 部署、健康检查与回滚 | `deploy/aios-v2/` |
|
||||||
|
| OpenClaw 生产部署任务 | `tasks/openclaw-production-deploy-task.yaml` |
|
||||||
|
| Codex 生产监督任务 | `tasks/codex-production-supervisor-task.yaml` |
|
||||||
|
|
||||||
|
## 4. 状态术语
|
||||||
|
|
||||||
|
项目必须严格区分“代码存在”和“可生产发布”:
|
||||||
|
|
||||||
|
- **已设计**:文档中存在明确边界和验收标准。
|
||||||
|
- **已实现**:代码已经提交到候选分支。
|
||||||
|
- **已定义测试**:Git 中存在自动化测试代码。
|
||||||
|
- **已验证测试**:已对精确 commit 执行测试并保存证据。
|
||||||
|
- **已验证预发布**:已在隔离环境完成部署、健康检查、重启和回滚演练。
|
||||||
|
- **已生产发布**:已取得 Owner 批准并完成合并、Tag 和生产部署。
|
||||||
|
|
||||||
|
分支、文件、测试脚本或部署脚本存在,并不自动代表能力已通过生产验收。
|
||||||
|
|
||||||
|
## 5. 设计摘要
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器 / Web Console / Public API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS 唯一控制面
|
||||||
|
Tenant | Agent | Task | Policy | Audit
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
OpenClaw Adapter Hermes Boundary
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
OpenClaw Runtime Hermes Workers
|
||||||
|
\
|
||||||
|
v
|
||||||
|
结果回写 + 审计 + 执行证据
|
||||||
|
```
|
||||||
|
|
||||||
|
控制面拥有权威身份、策略、审批、任务生命周期和审计状态。OpenClaw 与 Hermes 只接收带租户上下文、已经批准的执行工作,并返回运行结果。
|
||||||
|
|
||||||
|
## 6. 发布规则
|
||||||
|
|
||||||
|
候选版本在满足以下条件前不得提升:
|
||||||
|
|
||||||
|
- 需求到代码的追踪关系完整;
|
||||||
|
- 文档—代码匹配检查通过;
|
||||||
|
- 多租户负向测试通过;
|
||||||
|
- GitOps Controller worktree 保持 detached 且 clean;
|
||||||
|
- 本地 Git 与 Gitea 目标分支 OID 一致;
|
||||||
|
- 持久化重启恢复测试通过;
|
||||||
|
- 预发布健康检查和回滚演练通过;
|
||||||
|
- Git 中不存在明文密钥;
|
||||||
|
- OpenClaw 与 Hermes 真实 Runtime 联调通过;
|
||||||
|
- Owner 对合并、Tag 和生产部署做出明确批准。
|
||||||
|
|
||||||
|
## 7. 文档完整性要求
|
||||||
|
|
||||||
|
每份正式文档必须包含:
|
||||||
|
|
||||||
|
- 文档目的与适用范围;
|
||||||
|
- 当前版本和状态;
|
||||||
|
- 关键设计或需求正文;
|
||||||
|
- 代码、测试或部署映射;
|
||||||
|
- 验收与更新规则;
|
||||||
|
- 已知限制和后续演进方向。
|
||||||
|
|
||||||
|
禁止在正式归档包中以摘要、占位符或“后续补充”代替正文。
|
||||||
|
|
||||||
|
## 8. 中文输出要求
|
||||||
|
|
||||||
|
所有面向用户、产品、架构、运维和审计的正式文档统一使用中文。以下技术标识保留原始英文,避免与代码不一致:
|
||||||
|
|
||||||
|
- 类名、函数名、字段名;
|
||||||
|
- API 路径和 HTTP Header;
|
||||||
|
- Git 分支、Commit、Tag;
|
||||||
|
- OpenClaw、Hermes、Agent Router、GitOps 等专有名称;
|
||||||
|
- systemd、Docker、SQLite、HMAC、HTTP 等标准技术术语。
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# ADR-001:AIOS 作为唯一控制面
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:Control Plane、Agent Router、OpenClaw、Hermes、Web Console、GitOps、审计
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
现有系统已经包含多个执行、调度和治理能力。如果 OpenClaw、Hermes、Agent Router 或 Web 层分别保存自己的租户、策略、审批和任务状态,就会形成多个权威事实源,带来以下问题:
|
||||||
|
|
||||||
|
- 同一任务在不同模块中状态不一致;
|
||||||
|
- 审计链不完整;
|
||||||
|
- 多租户隔离难以统一验证;
|
||||||
|
- Web、Runtime 和运维入口可能绕过策略;
|
||||||
|
- Bug 修复和发布无法判断应该修改哪个事实源。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
AIOS Control Plane 是唯一权威控制面,负责:
|
||||||
|
|
||||||
|
- Tenant Registry;
|
||||||
|
- Identity 与角色上下文;
|
||||||
|
- Agent Registry;
|
||||||
|
- Task Lifecycle;
|
||||||
|
- Policy Decision;
|
||||||
|
- Approval;
|
||||||
|
- Audit Event;
|
||||||
|
- Runtime Inventory;
|
||||||
|
- Execution Evidence;
|
||||||
|
- GitOps 与发布状态可视化。
|
||||||
|
|
||||||
|
OpenClaw、Hermes 和 Agent Router 只能执行、路由或调度已经由 AIOS 批准的工作,不得拥有第二套租户、策略、审批或任务生命周期。
|
||||||
|
|
||||||
|
## 3. 目标边界
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web / API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Control Plane
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
|
|
||||||
|
+--> OpenClaw Adapter --> OpenClaw Runtime
|
||||||
|
|
|
||||||
|
+--> Hermes Boundary --> Hermes Workers
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:保持多个控制面
|
||||||
|
|
||||||
|
拒绝原因:状态会持续分裂,无法形成统一审计和租户隔离。
|
||||||
|
|
||||||
|
### 方案 B:由 OpenClaw 或 Hermes 成为主控制面
|
||||||
|
|
||||||
|
拒绝原因:二者主要价值是执行与 Runtime,不适合作为统一产品、租户、审批和发布控制面。
|
||||||
|
|
||||||
|
### 方案 C:推倒重写全部能力
|
||||||
|
|
||||||
|
拒绝原因:会丢失现有执行能力、运行经验和迁移可回滚性。
|
||||||
|
|
||||||
|
## 5. 正面影响
|
||||||
|
|
||||||
|
- 任务、策略和租户状态唯一;
|
||||||
|
- Web/API、OpenClaw、Hermes 和 GitOps 使用同一权威模型;
|
||||||
|
- 审计、执行证据和发布判断可以统一;
|
||||||
|
- 新 Runtime 可以通过稳定 Adapter 接入;
|
||||||
|
- 多租户和权限模型可集中验证。
|
||||||
|
|
||||||
|
## 6. 负面影响与风险
|
||||||
|
|
||||||
|
- 所有旧执行入口需要迁移到 Orchestrator;
|
||||||
|
- Runtime 需要稳定 Contract;
|
||||||
|
- Control Plane 可靠性要求提高;
|
||||||
|
- 在迁移完成前可能短期存在旧旁路,需要明确冻结和监控。
|
||||||
|
|
||||||
|
## 7. 迁移与回滚
|
||||||
|
|
||||||
|
迁移顺序:
|
||||||
|
|
||||||
|
1. 建立 AIOS Control Plane 权威模型;
|
||||||
|
2. 引入 Orchestrator;
|
||||||
|
3. OpenClaw/Hermes 通过 Adapter 接入;
|
||||||
|
4. 将 Web 和自动任务切换到 AIOS API;
|
||||||
|
5. 关闭旧控制旁路。
|
||||||
|
|
||||||
|
回滚只允许回滚 Runtime 或应用版本,不允许恢复第二套权威控制状态。
|
||||||
|
|
||||||
|
## 8. 合规检查
|
||||||
|
|
||||||
|
任何新功能必须回答:
|
||||||
|
|
||||||
|
- 权威状态是否存放在 AIOS?
|
||||||
|
- Runtime 是否只接收已批准工作?
|
||||||
|
- Web 是否只调用 AIOS API?
|
||||||
|
- 是否新增了第二套状态机或租户事实源?
|
||||||
|
|
||||||
|
若最后一项为“是”,变更必须拒绝或新增替代 ADR。
|
||||||
|
|
||||||
|
## 9. 实现与测试映射
|
||||||
|
|
||||||
|
- 实现:`active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
- 编排:`active-runtime/app/aios_v2/orchestrator.py`
|
||||||
|
- 应用入口:`active-runtime/app/aios_v2/app.py`
|
||||||
|
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
|
- 追踪:`docs/aios-v2/05-TRACEABILITY-MATRIX.md`
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# ADR-002:OpenClaw 与 Hermes 从控制面职责中解耦
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:OpenClaw、Hermes、Execution Orchestrator、Agent Router、Audit、Web Console
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
OpenClaw 和 Hermes 已经具备成熟的执行能力,但历史实现可能同时承担部分租户、策略、任务状态或运维控制职责。如果继续扩展这种模式,AIOS 将无法成为唯一控制面,也无法统一 Web、审计和多租户隔离。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
OpenClaw 与 Hermes 通过稳定的 Adapter/Boundary 接入 AIOS:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AIOS Control Plane
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator
|
||||||
|
|
|
||||||
|
+--> OpenClaw Adapter --> OpenClaw Runtime
|
||||||
|
|
|
||||||
|
+--> Hermes Boundary --> Hermes Workers
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenClaw 只负责:
|
||||||
|
|
||||||
|
- 验证签名命令;
|
||||||
|
- 执行;
|
||||||
|
- 查询状态;
|
||||||
|
- 取消;
|
||||||
|
- 健康检查;
|
||||||
|
- 返回结果和生命周期事件。
|
||||||
|
|
||||||
|
Hermes 只负责:
|
||||||
|
|
||||||
|
- tenant/cell/profile 分区队列;
|
||||||
|
- Worker claim;
|
||||||
|
- Lease 与 Heartbeat;
|
||||||
|
- 执行;
|
||||||
|
- 完成、失败、取消和结果回传。
|
||||||
|
|
||||||
|
二者均不得:
|
||||||
|
|
||||||
|
- 创建或批准 AIOS Task;
|
||||||
|
- 修改租户或策略;
|
||||||
|
- 维护第二套任务生命周期;
|
||||||
|
- 绕过 AIOS Audit;
|
||||||
|
- 向浏览器提供直接控制入口。
|
||||||
|
|
||||||
|
## 3. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:让 OpenClaw 继续拥有控制职责
|
||||||
|
|
||||||
|
拒绝原因:会产生第二控制面,并使 Web、审批和审计持续分裂。
|
||||||
|
|
||||||
|
### 方案 B:让 Hermes 统一控制和执行
|
||||||
|
|
||||||
|
拒绝原因:Hermes 的核心价值是 Worker Runtime 与队列,不应承担产品、租户和策略治理。
|
||||||
|
|
||||||
|
### 方案 C:直接替换 OpenClaw/Hermes
|
||||||
|
|
||||||
|
拒绝原因:不符合能力复用原则,会扩大迁移风险和开发量。
|
||||||
|
|
||||||
|
## 4. 正面影响
|
||||||
|
|
||||||
|
- AIOS 保持唯一权威状态;
|
||||||
|
- OpenClaw/Hermes 可以独立升级;
|
||||||
|
- 新 Runtime 可使用相同接入模式;
|
||||||
|
- Web 和审计不再依赖 Runtime 内部实现;
|
||||||
|
- 可逐步关闭旧旁路,而不需要一次性重写执行层。
|
||||||
|
|
||||||
|
## 5. 负面影响与风险
|
||||||
|
|
||||||
|
- 需要维护稳定的命令、结果和健康 Contract;
|
||||||
|
- 旧调用路径可能在迁移期并存;
|
||||||
|
- Runtime 联调和错误语义需要统一;
|
||||||
|
- Adapter 本身成为重要可靠性边界。
|
||||||
|
|
||||||
|
## 6. 迁移步骤
|
||||||
|
|
||||||
|
1. 定义 OpenClawCommand/OpenClawResult/RuntimeHealth;
|
||||||
|
2. 定义 HermesJob/HermesResult/HermesHealth;
|
||||||
|
3. 引入 AIOS Orchestrator;
|
||||||
|
4. 将新任务先通过 Adapter 执行;
|
||||||
|
5. 迁移 Web、Codex 和自动任务入口;
|
||||||
|
6. 冻结旧 Runtime 控制接口;
|
||||||
|
7. 通过网络策略或代码门禁关闭旁路。
|
||||||
|
|
||||||
|
## 7. 回滚原则
|
||||||
|
|
||||||
|
允许回滚 Runtime 或 Adapter 版本,但不允许回滚到 Runtime 重新拥有 Tenant、Policy、Approval 或 Task Lifecycle 的模式。
|
||||||
|
|
||||||
|
## 8. 合规检查
|
||||||
|
|
||||||
|
- OpenClaw 输入是否为 AIOS 生成的签名命令?
|
||||||
|
- Hermes Job 是否包含 tenant/cell/profile?
|
||||||
|
- Runtime 是否只返回结果,不直接写 AIOS Store?
|
||||||
|
- Web 是否只调用 AIOS API?
|
||||||
|
- Runtime Bug 修复是否同步更新 Contract、测试和文档?
|
||||||
|
|
||||||
|
## 9. 实现与测试映射
|
||||||
|
|
||||||
|
- OpenClaw:`active-runtime/app/aios_openclaw/`
|
||||||
|
- Hermes:`active-runtime/app/aios_hermes/`
|
||||||
|
- Orchestrator:`active-runtime/app/aios_v2/orchestrator.py`
|
||||||
|
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
|
- 模块契约:`docs/aios-v2/04-MODULE-CONTRACTS.md`
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# ADR-003:所有变更必须通过 GitOps
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:代码、配置、文档、测试、部署、回滚、生产修复
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
直接在生产目录、长期 worktree、主分支或运行容器中修改代码,会造成以下问题:
|
||||||
|
|
||||||
|
- 变更不可追踪;
|
||||||
|
- 无法稳定回滚;
|
||||||
|
- 本地仓库与 Gitea 不一致;
|
||||||
|
- Worktree 和 Branch Ref 冲突;
|
||||||
|
- 紧急修复无法统一审视;
|
||||||
|
- 文档、测试和部署资产无法同步。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
所有代码、配置、文档、测试、部署和回滚资产必须经过以下流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
非 main 分支
|
||||||
|
-> Commit
|
||||||
|
-> 本地仓库同步
|
||||||
|
-> Gitea 镜像与 OID 校验
|
||||||
|
-> 自动测试
|
||||||
|
-> Codex 监督审查
|
||||||
|
-> 候选集成
|
||||||
|
-> Owner 批准
|
||||||
|
-> Merge / Tag / Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
GitOps Controller worktree 必须长期保持 detached 且 clean。相同分支写入必须串行,任务工作区必须可回收。
|
||||||
|
|
||||||
|
## 3. 必需证据
|
||||||
|
|
||||||
|
每个任务必须记录:
|
||||||
|
|
||||||
|
- 任务 ID;
|
||||||
|
- 执行器;
|
||||||
|
- 分支;
|
||||||
|
- Commit;
|
||||||
|
- 修改文件;
|
||||||
|
- claim;
|
||||||
|
- heartbeat;
|
||||||
|
- progress;
|
||||||
|
- blocker;
|
||||||
|
- 测试命令、exit code 和结果;
|
||||||
|
- 本地/Gitea 同步状态;
|
||||||
|
- 回滚说明;
|
||||||
|
- 最终结果和监督结论。
|
||||||
|
|
||||||
|
## 4. 禁止事项
|
||||||
|
|
||||||
|
- 直接 push `main`;
|
||||||
|
- 未审批创建生产 Tag;
|
||||||
|
- 未记录任务直接修改生产;
|
||||||
|
- 在生产目录手工修复后不回写 Git;
|
||||||
|
- fetch 到已被 worktree 检出的本地 branch ref;
|
||||||
|
- 以新分支名反复规避 Worktree 生命周期缺陷;
|
||||||
|
- 在 Git 中提交明文凭据;
|
||||||
|
- 缺少文档和测试时标记为完成。
|
||||||
|
|
||||||
|
## 5. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:允许紧急生产手工修改
|
||||||
|
|
||||||
|
拒绝原因:即使紧急修复也必须形成 Patch、Commit 和证据,否则无法统一审视和回滚。
|
||||||
|
|
||||||
|
### 方案 B:只同步本地 Bare Repo
|
||||||
|
|
||||||
|
拒绝原因:Web Gitea 无法看到真实任务进度,团队无法审查。
|
||||||
|
|
||||||
|
### 方案 C:Controller 长期检出任务分支
|
||||||
|
|
||||||
|
拒绝原因:会触发 `refusing to fetch into branch checked out at worktree`,并破坏并发任务。
|
||||||
|
|
||||||
|
## 6. 正面影响
|
||||||
|
|
||||||
|
- 所有变更可审计;
|
||||||
|
- 本地和 Web 远端一致;
|
||||||
|
- 紧急修复也可回滚;
|
||||||
|
- OpenClaw/Codex 进度可统一查询;
|
||||||
|
- 文档与代码可通过门禁同步。
|
||||||
|
|
||||||
|
## 7. 负面影响与风险
|
||||||
|
|
||||||
|
- 生产修复增加提交与证据步骤;
|
||||||
|
- GitOps Runtime 成为关键基础设施;
|
||||||
|
- 需要处理分支锁、任务恢复和镜像失败;
|
||||||
|
- 自动执行器必须遵循统一任务格式。
|
||||||
|
|
||||||
|
## 8. 运行规则
|
||||||
|
|
||||||
|
- Controller:detached + clean;
|
||||||
|
- Fetch:更新 remote-tracking ref,不写已检出本地 branch;
|
||||||
|
- Write:独立任务工作区或受控写入;
|
||||||
|
- Lock:同一分支串行;
|
||||||
|
- Mirror:本地成功和 Gitea 成功分别记录;
|
||||||
|
- Failure:状态标记为 degraded/blocked,不得伪报 synced。
|
||||||
|
|
||||||
|
## 9. 紧急 Bug 修复流程
|
||||||
|
|
||||||
|
1. 创建 `patch/bugfix/*`;
|
||||||
|
2. 登记 blocker 和影响;
|
||||||
|
3. 添加复现测试;
|
||||||
|
4. 修复代码;
|
||||||
|
5. 同步文档;
|
||||||
|
6. 执行验收;
|
||||||
|
7. Codex 审核;
|
||||||
|
8. Owner 批准发布;
|
||||||
|
9. 生产观察并记录结果。
|
||||||
|
|
||||||
|
## 10. 实现与验收映射
|
||||||
|
|
||||||
|
- GitOps 状态:`active-runtime/app/aios_v2/gitops_status.py`
|
||||||
|
- 健康检查:`deploy/aios-v2/healthcheck/check.sh`
|
||||||
|
- 回滚:`deploy/aios-v2/rollback/rollback.sh`
|
||||||
|
- 任务:`tasks/openclaw-production-deploy-task.yaml`、`tasks/codex-production-supervisor-task.yaml`
|
||||||
|
- 文档门禁:`tools/aios_v2_docs_code_check.py`
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# ADR-004:多租户隔离采用默认拒绝模型
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:Web/API、Control Plane、OpenClaw、Hermes、Persistence、Audit、Execution Evidence、Logs、Metrics、Secret
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
AIOS V2 同时承载多个租户的用户请求、控制面状态、OpenClaw 命令、Hermes 队列、执行证据、审计和 GitOps 状态。任何一层缺失租户边界,都可能造成数据泄露、跨租户任务执行或错误回写。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
多租户隔离采用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
显式租户上下文
|
||||||
|
+
|
||||||
|
默认拒绝
|
||||||
|
+
|
||||||
|
全链路传播
|
||||||
|
+
|
||||||
|
负向测试
|
||||||
|
```
|
||||||
|
|
||||||
|
所有资源、命令、Job、证据和审计记录都必须显式绑定 `tenant_id`。缺少上下文、上下文不匹配或无法确认所有权时,系统必须 fail closed。
|
||||||
|
|
||||||
|
## 3. 权威上下文
|
||||||
|
|
||||||
|
控制请求至少包含:
|
||||||
|
|
||||||
|
- `tenant_id`;
|
||||||
|
- `principal_id`;
|
||||||
|
- `trace_id`;
|
||||||
|
- `policy_version`;
|
||||||
|
- 可选 `session_id`。
|
||||||
|
|
||||||
|
执行请求增加:
|
||||||
|
|
||||||
|
- `task_id`;
|
||||||
|
- `command_id` 或 `job_id`;
|
||||||
|
- `idempotency_key`。
|
||||||
|
|
||||||
|
Hermes 额外包含:
|
||||||
|
|
||||||
|
- `cell_id`;
|
||||||
|
- `profile_id`;
|
||||||
|
- `worker_id`。
|
||||||
|
|
||||||
|
## 4. 各层执行规则
|
||||||
|
|
||||||
|
### Web/API
|
||||||
|
|
||||||
|
- 可信代理必须删除客户端同名 `X-AIOS-*` Header 后重新注入;
|
||||||
|
- 浏览器不得自行选择或覆盖租户;
|
||||||
|
- 未认证身份断言必须拒绝。
|
||||||
|
|
||||||
|
### Control Plane
|
||||||
|
|
||||||
|
- Tenant、Agent、Task、Audit 全部带不可为空的 `tenant_id`;
|
||||||
|
- 跨租户读取返回拒绝或“未找到”;
|
||||||
|
- 不泄露其他租户资源存在性。
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
|
||||||
|
- 签名覆盖 tenant/task/trace/action/payload/idempotency/time;
|
||||||
|
- Result 身份字段必须与命令完全一致;
|
||||||
|
- 跨租户 status/cancel 返回“未找到”。
|
||||||
|
|
||||||
|
### Hermes
|
||||||
|
|
||||||
|
- Job 按 tenant/cell/profile 分区;
|
||||||
|
- Worker 只能 claim 自己的分区;
|
||||||
|
- Worker ID 不得在未授权分区之间迁移;
|
||||||
|
- Result 必须匹配 tenant/task/trace。
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
- 候选版使用显式租户字段;
|
||||||
|
- 生产数据库必须提供 RLS 或物理隔离策略;
|
||||||
|
- 备份、恢复和迁移必须保持租户边界。
|
||||||
|
|
||||||
|
### Audit 与 Evidence
|
||||||
|
|
||||||
|
- 仅按当前租户查询;
|
||||||
|
- 不记录明文 Secret;
|
||||||
|
- 统一包含 tenant/task/trace。
|
||||||
|
|
||||||
|
### Cache、Memory、Artifact、Logs、Metrics
|
||||||
|
|
||||||
|
- Key 或标签必须包含租户命名空间;
|
||||||
|
- 查询和导出必须受租户权限限制;
|
||||||
|
- 不允许使用无租户前缀的全局缓存保存业务数据。
|
||||||
|
|
||||||
|
## 5. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:仅在 API 层检查租户
|
||||||
|
|
||||||
|
拒绝原因:后台任务、Runtime、缓存和结果回写仍可能跨租户。
|
||||||
|
|
||||||
|
### 方案 B:仅依赖数据库隔离
|
||||||
|
|
||||||
|
拒绝原因:命令、队列、审计和文件系统仍需要显式边界。
|
||||||
|
|
||||||
|
### 方案 C:发生冲突时自动推断租户
|
||||||
|
|
||||||
|
拒绝原因:推断可能产生跨租户副作用,必须默认拒绝。
|
||||||
|
|
||||||
|
## 6. 正面影响
|
||||||
|
|
||||||
|
- 安全模型一致;
|
||||||
|
- Runtime 无法绕过租户边界;
|
||||||
|
- 负向测试可自动化;
|
||||||
|
- 审计和问题定位更明确。
|
||||||
|
|
||||||
|
## 7. 负面影响与风险
|
||||||
|
|
||||||
|
- 所有接口和模型都需要传播租户上下文;
|
||||||
|
- 日志、指标和缓存设计更复杂;
|
||||||
|
- 候选 SQLite 只有逻辑隔离,生产仍需更强存储证据;
|
||||||
|
- 身份代理配置错误可能破坏入口隔离。
|
||||||
|
|
||||||
|
## 8. 测试要求
|
||||||
|
|
||||||
|
至少覆盖:
|
||||||
|
|
||||||
|
- Tenant B 读取 Tenant A Task;
|
||||||
|
- Tenant B 查询或取消 Tenant A OpenClawCommand;
|
||||||
|
- Worker 跨 tenant/cell/profile claim;
|
||||||
|
- Runtime 返回错误 tenant/task/trace;
|
||||||
|
- Evidence 和 Audit 跨租户查询;
|
||||||
|
- 缺少 Tenant Context 的请求;
|
||||||
|
- 客户端伪造身份 Header。
|
||||||
|
|
||||||
|
## 9. 合规检查
|
||||||
|
|
||||||
|
任何新模块没有以下内容时不得进入 Candidate:
|
||||||
|
|
||||||
|
- 显式 `tenant_id`;
|
||||||
|
- 所有权检查;
|
||||||
|
- 跨租户负向测试;
|
||||||
|
- 审计映射;
|
||||||
|
- 文档与追踪矩阵更新。
|
||||||
|
|
||||||
|
## 10. 实现与测试映射
|
||||||
|
|
||||||
|
- Control Plane:`active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
- OpenClaw:`active-runtime/app/aios_openclaw/`
|
||||||
|
- Hermes:`active-runtime/app/aios_hermes/`
|
||||||
|
- Evidence:`active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# ADR-005:文档与代码同步门禁
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:需求、架构、代码、测试、部署、在线任务、发布和归档
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
架构、功能和部署变化如果不同步文档,会导致版本无法审计,后续 Agent 无法判断真实设计,发布人员也无法确认代码是否符合用户需求。历史上还出现过文档目录不存在、文档含冲突标记、分支名与代码内容不一致等问题。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
所有影响以下内容的变更必须在同一交付流程中同步 `docs/aios-v2/`:
|
||||||
|
|
||||||
|
- 用户行为和功能范围;
|
||||||
|
- API、Header、请求和响应;
|
||||||
|
- 数据模型、字段和状态机;
|
||||||
|
- Control Plane、Agent Router、OpenClaw、Hermes 的边界;
|
||||||
|
- 多租户和安全策略;
|
||||||
|
- 持久化、审计和执行证据;
|
||||||
|
- 部署、健康检查和回滚;
|
||||||
|
- 测试和发布门禁。
|
||||||
|
|
||||||
|
正式文档统一使用中文,技术标识保留代码中的原始英文名称。
|
||||||
|
|
||||||
|
## 3. 门禁要求
|
||||||
|
|
||||||
|
验收必须执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 tools/aios_v2_docs_code_check.py
|
||||||
|
```
|
||||||
|
|
||||||
|
检查至少包括:
|
||||||
|
|
||||||
|
1. 必需文档和 ADR 存在且非空;
|
||||||
|
2. 文档以中文叙述为主;
|
||||||
|
3. 文档引用的代码、测试和部署路径存在;
|
||||||
|
4. 核心 API 能在代码中找到;
|
||||||
|
5. 核心模型和字段能在代码中找到;
|
||||||
|
6. Traceability Matrix 覆盖所有 `UR-*`;
|
||||||
|
7. ADR 索引引用的文件存在;
|
||||||
|
8. 文档中不存在 Git 冲突标记;
|
||||||
|
9. 不存在摘要占位符或空章节;
|
||||||
|
10. 架构/功能代码变化时,同一交付范围包含文档变化。
|
||||||
|
|
||||||
|
## 4. 状态影响
|
||||||
|
|
||||||
|
门禁失败时:
|
||||||
|
|
||||||
|
- Candidate 不得标记为 `Test-verified`;
|
||||||
|
- OpenClaw 不得继续生产部署;
|
||||||
|
- Codex 必须登记 blocker;
|
||||||
|
- 修复必须进入 Git 分支;
|
||||||
|
- Owner 不得批准 Tag 或生产部署。
|
||||||
|
|
||||||
|
## 5. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:依赖人工记忆更新文档
|
||||||
|
|
||||||
|
拒绝原因:长期必然产生文档漂移。
|
||||||
|
|
||||||
|
### 方案 B:发布前集中补文档
|
||||||
|
|
||||||
|
拒绝原因:无法确定每次代码变化对应的真实设计,也会增加错误归因。
|
||||||
|
|
||||||
|
### 方案 C:只检查文件存在
|
||||||
|
|
||||||
|
拒绝原因:文件存在不代表内容完整、路径有效或与代码一致。
|
||||||
|
|
||||||
|
## 6. 正面影响
|
||||||
|
|
||||||
|
- 架构和代码长期一致;
|
||||||
|
- Agent 有明确执行上下文;
|
||||||
|
- Bug 修复可以追踪需求和决策;
|
||||||
|
- 归档包可以作为正式交付材料;
|
||||||
|
- 发布风险和未验证项更透明。
|
||||||
|
|
||||||
|
## 7. 负面影响与风险
|
||||||
|
|
||||||
|
- 小功能也需要更新对应文档;
|
||||||
|
- 检查器需要随代码结构维护;
|
||||||
|
- 自动检查不能替代架构审查;
|
||||||
|
- 文档完整性会增加提交内容,但这是可审计交付的必要成本。
|
||||||
|
|
||||||
|
## 8. 文档更新映射
|
||||||
|
|
||||||
|
- 用户需求变化:`01-USER-REQUIREMENTS.md`;
|
||||||
|
- 架构边界变化:`02-TARGET-ARCHITECTURE.md` 和 ADR;
|
||||||
|
- 迁移/发布变化:`03-MIGRATION-AND-DELIVERY.md`;
|
||||||
|
- Contract 变化:`04-MODULE-CONTRACTS.md`;
|
||||||
|
- 路径和状态变化:`05-TRACEABILITY-MATRIX.md`;
|
||||||
|
- 文档规则变化:`06-DOCUMENT-GOVERNANCE.md`。
|
||||||
|
|
||||||
|
## 9. 在线任务要求
|
||||||
|
|
||||||
|
OpenClaw 和 Codex 的任何修改或阻塞必须回写:
|
||||||
|
|
||||||
|
- claim;
|
||||||
|
- heartbeat;
|
||||||
|
- progress;
|
||||||
|
- blocker;
|
||||||
|
- branch;
|
||||||
|
- commit;
|
||||||
|
- test_status;
|
||||||
|
- result;
|
||||||
|
- 文档同步状态。
|
||||||
|
|
||||||
|
OpenClaw 发现架构影响时必须停止扩大修复范围,并升级给 Codex。
|
||||||
|
|
||||||
|
## 10. 实现与验收映射
|
||||||
|
|
||||||
|
- 检查器:`tools/aios_v2_docs_code_check.py`
|
||||||
|
- 验收入口:`deploy/aios-v2/acceptance/run-all.sh`
|
||||||
|
- 治理规范:`docs/aios-v2/06-DOCUMENT-GOVERNANCE.md`
|
||||||
|
- 追踪矩阵:`docs/aios-v2/05-TRACEABILITY-MATRIX.md`
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# ADR-006:候选持久化采用可替换 SQLite Snapshot
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:Control Plane、Persistence、Deployment、Backup、Recovery、HA 演进
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
AIOS V2 候选版本需要在服务重启后恢复 Tenant、Agent、Task、Audit 和 Idempotency 状态,同时又必须尽快进入预发布部署与 Bug 修复阶段。
|
||||||
|
|
||||||
|
候选阶段如果立即引入完整分布式数据库、消息系统和 HA,会显著扩大开发范围,推迟控制面、Runtime Boundary 和多租户模型的验证。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
候选版本使用可替换的 SQLite WAL 原子 Snapshot:
|
||||||
|
|
||||||
|
- `SQLiteSnapshotRepository` 保存版本化 Control Plane Snapshot;
|
||||||
|
- `DurableControlPlaneService` 在成功变更后持久化;
|
||||||
|
- 使用 WAL、`synchronous=FULL` 和 `BEGIN IMMEDIATE`;
|
||||||
|
- 状态目录独立于代码版本;
|
||||||
|
- 上层 Domain、API、Orchestrator 和 Runtime Contract 不依赖 SQLite 实现。
|
||||||
|
|
||||||
|
执行证据使用租户分区 JSON 文件,由 `ExecutionTracker` 原子写入。
|
||||||
|
|
||||||
|
## 3. 持久化范围
|
||||||
|
|
||||||
|
候选 Snapshot 包含:
|
||||||
|
|
||||||
|
- Tenant;
|
||||||
|
- Agent;
|
||||||
|
- Task;
|
||||||
|
- Audit;
|
||||||
|
- Idempotency。
|
||||||
|
|
||||||
|
独立 Evidence 目录包含:
|
||||||
|
|
||||||
|
- claim;
|
||||||
|
- heartbeat;
|
||||||
|
- progress;
|
||||||
|
- blocker;
|
||||||
|
- commit;
|
||||||
|
- test_status;
|
||||||
|
- result。
|
||||||
|
|
||||||
|
## 4. 选择理由
|
||||||
|
|
||||||
|
- 无额外外部依赖;
|
||||||
|
- 易于快速部署和回滚;
|
||||||
|
- 支持事务、WAL 和完整性检查;
|
||||||
|
- 可以验证上层控制面和 Runtime Contract;
|
||||||
|
- 便于在预发布阶段复现和修复 Bug;
|
||||||
|
- 可通过 Repository 边界替换为生产数据库。
|
||||||
|
|
||||||
|
## 5. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:候选阶段直接使用 PostgreSQL 集群
|
||||||
|
|
||||||
|
暂不采用原因:会增加部署、迁移、HA、Secret、网络和容量范围,延迟核心架构验证。
|
||||||
|
|
||||||
|
### 方案 B:继续使用纯内存状态
|
||||||
|
|
||||||
|
拒绝原因:服务重启会丢失权威状态,无法进入预发布。
|
||||||
|
|
||||||
|
### 方案 C:每个模块各自使用 SQLite
|
||||||
|
|
||||||
|
拒绝原因:会形成多个事实源和跨模块事务问题。
|
||||||
|
|
||||||
|
### 方案 D:将完整状态写入 Git
|
||||||
|
|
||||||
|
拒绝原因:运行状态不应成为 Git 内容,可能暴露敏感业务数据并造成提交风暴。
|
||||||
|
|
||||||
|
## 6. 正面影响
|
||||||
|
|
||||||
|
- 候选版具备重启恢复能力;
|
||||||
|
- 状态与代码版本解耦;
|
||||||
|
- 回滚默认只切换代码,不覆盖新状态;
|
||||||
|
- 可快速进入预发布测试;
|
||||||
|
- 生产后端替换不改变上层 Contract。
|
||||||
|
|
||||||
|
## 7. 负面影响与风险
|
||||||
|
|
||||||
|
- SQLite 主要适用于单进程候选部署;
|
||||||
|
- 尚未提供多实例写入和分布式锁;
|
||||||
|
- 租户隔离是逻辑隔离,不是 RLS 或物理隔离;
|
||||||
|
- 大规模任务、审计和并发容量尚未验证;
|
||||||
|
- Evidence 文件数量增长需要归档策略;
|
||||||
|
- Snapshot 粒度可能导致状态增长后的写放大。
|
||||||
|
|
||||||
|
## 8. 生产迁移要求
|
||||||
|
|
||||||
|
生产数据层替换必须满足:
|
||||||
|
|
||||||
|
1. 保持 Control Plane Domain/API Contract 不变;
|
||||||
|
2. 提供 schema migration;
|
||||||
|
3. 提供租户 RLS 或物理隔离;
|
||||||
|
4. 提供备份、恢复、PITR 和灾难恢复;
|
||||||
|
5. 提供 HA、多实例并发和故障转移;
|
||||||
|
6. 提供容量、延迟和锁等待测试;
|
||||||
|
7. 保持 Audit 与 Evidence 可追踪;
|
||||||
|
8. 提供从 SQLite Candidate 到生产后端的数据迁移工具;
|
||||||
|
9. 通过新的 ADR 说明生产持久化决策。
|
||||||
|
|
||||||
|
## 9. 回滚策略
|
||||||
|
|
||||||
|
- 回滚前备份 `/var/lib/aios-v2`;
|
||||||
|
- 默认只回滚代码;
|
||||||
|
- 状态恢复必须由 Owner 显式授权;
|
||||||
|
- 恢复前必须验证 Snapshot schema 兼容性;
|
||||||
|
- 回滚后执行 SQLite `PRAGMA quick_check` 和 AIOS Healthcheck。
|
||||||
|
|
||||||
|
## 10. 验收要求
|
||||||
|
|
||||||
|
候选版至少通过:
|
||||||
|
|
||||||
|
- 创建 Tenant、Agent、Task;
|
||||||
|
- 服务重启;
|
||||||
|
- 读取恢复后的 Task;
|
||||||
|
- Idempotency 仍有效;
|
||||||
|
- Audit 仍存在;
|
||||||
|
- SQLite `quick_check=ok`;
|
||||||
|
- 状态目录权限正确;
|
||||||
|
- 回滚演练不丢失新增状态。
|
||||||
|
|
||||||
|
## 11. 实现与测试映射
|
||||||
|
|
||||||
|
- Repository:`active-runtime/app/aios_v2/persistence.py`
|
||||||
|
- 应用入口:`active-runtime/app/aios_v2/app.py`
|
||||||
|
- Evidence:`active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
|
- 健康检查:`deploy/aios-v2/healthcheck/check.sh`
|
||||||
|
- 回滚:`deploy/aios-v2/rollback/rollback.sh`
|
||||||
|
|
||||||
|
## 12. 更新触发条件
|
||||||
|
|
||||||
|
以下变化必须更新或替代本 ADR:
|
||||||
|
|
||||||
|
- 引入生产数据库;
|
||||||
|
- 支持多实例写入;
|
||||||
|
- 修改 Snapshot 格式;
|
||||||
|
- 改变 Evidence 存储;
|
||||||
|
- 增加 RLS、HA、分片或事件溯源;
|
||||||
|
- 修改状态回滚策略。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# AIOS V2 架构决策记录索引
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 适用范围:影响多个模块、长期演进、兼容性、安全、数据、部署或交付流程的关键架构决策
|
||||||
|
|
||||||
|
## 1. 架构决策记录列表
|
||||||
|
|
||||||
|
| 编号 | 决策 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| [ADR-001](ADR-001-single-control-plane.md) | AIOS 作为唯一控制面 | 已采纳 |
|
||||||
|
| [ADR-002](ADR-002-openclaw-hermes-decoupling.md) | OpenClaw 与 Hermes 从控制面职责中解耦 | 已采纳 |
|
||||||
|
| [ADR-003](ADR-003-gitops-only-change-flow.md) | 所有变更必须通过 GitOps | 已采纳 |
|
||||||
|
| [ADR-004](ADR-004-multi-tenant-isolation.md) | 多租户隔离采用默认拒绝模型 | 已采纳 |
|
||||||
|
| [ADR-005](ADR-005-document-code-sync-gate.md) | 文档与代码同步门禁 | 已采纳 |
|
||||||
|
| [ADR-006](ADR-006-candidate-persistence-strategy.md) | 候选持久化采用可替换 SQLite Snapshot | 已采纳 |
|
||||||
|
|
||||||
|
## 2. 状态定义
|
||||||
|
|
||||||
|
- **已提出**:决策已提出,但尚未被正式采纳。
|
||||||
|
- **已采纳**:当前有效,所有新实现必须遵守。
|
||||||
|
- **已替代**:已被新的 ADR 替代,保留用于历史追踪。
|
||||||
|
- **已废弃**:保留历史,但禁止新实现继续采用。
|
||||||
|
|
||||||
|
## 3. 必须新增或更新 ADR 的场景
|
||||||
|
|
||||||
|
以下变化必须新增或更新 ADR:
|
||||||
|
|
||||||
|
1. AIOS、OpenClaw、Hermes、Agent Router 的控制边界变化;
|
||||||
|
2. 多租户隔离模型变化;
|
||||||
|
3. API、数据模型或状态机出现不兼容变化;
|
||||||
|
4. 持久化、消息、密钥、审计或部署方案发生跨模块变化;
|
||||||
|
5. GitOps、发布、回滚或文档门禁变化;
|
||||||
|
6. 替换 OpenClaw、Hermes、Agent Router 或核心基础设施;
|
||||||
|
7. 引入新的权威事实源、任务生命周期或租户状态;
|
||||||
|
8. 生产安全模型、身份代理或 Secret 管理方式变化。
|
||||||
|
|
||||||
|
## 4. ADR 内容要求
|
||||||
|
|
||||||
|
每份 ADR 必须包含:
|
||||||
|
|
||||||
|
- 背景;
|
||||||
|
- 决策;
|
||||||
|
- 备选方案;
|
||||||
|
- 采用理由;
|
||||||
|
- 正面影响;
|
||||||
|
- 负面影响和风险;
|
||||||
|
- 迁移与回滚;
|
||||||
|
- 合规检查;
|
||||||
|
- 受影响的代码、测试和文档。
|
||||||
|
|
||||||
|
## 5. ADR 变更联动
|
||||||
|
|
||||||
|
ADR 新增、替代或废弃时,必须同步检查并更新:
|
||||||
|
|
||||||
|
- `02-TARGET-ARCHITECTURE.md`;
|
||||||
|
- `03-MIGRATION-AND-DELIVERY.md`;
|
||||||
|
- `04-MODULE-CONTRACTS.md`;
|
||||||
|
- `05-TRACEABILITY-MATRIX.md`;
|
||||||
|
- 相关代码、测试和部署资产;
|
||||||
|
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md` 中的风险与发布结论。
|
||||||
|
|
||||||
|
## 6. 审查规则
|
||||||
|
|
||||||
|
- OpenClaw 不得自行采纳改变控制面边界的 ADR;发现需求后必须升级给 Codex。
|
||||||
|
- Codex 负责审查 ADR 与代码、测试和文档是否一致。
|
||||||
|
- 涉及合并、Tag、生产部署或重大风险接受的 ADR,必须由 AIOS Owner 最终批准。
|
||||||
|
- ADR 未完成审查前,相关变更不得标记为 `Test-verified` 或 `Production released`。
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# TASK-AIOS-CONTROL-PLANE-GATEWAY-V1
|
||||||
|
|
||||||
|
## Owner
|
||||||
|
Codex
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
建立 AIOS V2 唯一控制面 Gateway v1。
|
||||||
|
|
||||||
|
## Architecture Boundary
|
||||||
|
|
||||||
|
AIOS Control Plane:
|
||||||
|
- Tenant Registry
|
||||||
|
- Agent Registry
|
||||||
|
- Task Lifecycle
|
||||||
|
- Policy Gate
|
||||||
|
- Approval
|
||||||
|
- Audit Event
|
||||||
|
|
||||||
|
不允许:
|
||||||
|
- OpenClaw 直接管理任务生命周期
|
||||||
|
- Hermes 直接暴露控制 API
|
||||||
|
- Web UI 绕过 AIOS API
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. Control Plane API skeleton
|
||||||
|
2. Tenant context middleware
|
||||||
|
3. Task state machine
|
||||||
|
4. Agent registration contract
|
||||||
|
5. Audit event interface
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- 所有 API 携带 tenant_id
|
||||||
|
- 未授权跨租户访问拒绝
|
||||||
|
- Task create/update/cancel 可审计
|
||||||
|
- GitOps commit 可追踪
|
||||||
|
- pytest + integration test 通过
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
复用:
|
||||||
|
- Agent Router
|
||||||
|
- OpenClaw adapter
|
||||||
|
- Hermes runtime
|
||||||
|
|
||||||
|
不重写执行层。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"type": "branch",
|
||||||
|
"branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"base": "task/codex/AIOS-V2-CONTROL-PLANE-GATEWAY-V1-20260720",
|
||||||
|
"created_by": "chatgpt-gitops-bot",
|
||||||
|
"created_at": "2026-07-20T01:47:01Z",
|
||||||
|
"direct_main_push": false,
|
||||||
|
"owner_approval_required": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"type": "branch",
|
||||||
|
"branch": "task/codex/AIOS-V2-CONTROL-PLANE-GATEWAY-V1-20260720",
|
||||||
|
"base": "main",
|
||||||
|
"created_by": "chatgpt-gitops-bot",
|
||||||
|
"created_at": "2026-07-19T17:06:04Z",
|
||||||
|
"direct_main_push": false,
|
||||||
|
"owner_approval_required": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"type": "branch",
|
||||||
|
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"base": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"created_by": "chatgpt-gitops-bot",
|
||||||
|
"created_at": "2026-07-20T03:49:09Z",
|
||||||
|
"direct_main_push": false,
|
||||||
|
"owner_approval_required": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# AIOS OpenClaw Run Provenance Writer
|
||||||
|
|
||||||
|
本插件为 AIOS V2R1 提供 Owner 签名的运行授权、持久 Provenance Store 与专用 Evidence Writer。它不授予通用 Shell 或任意文件写权限。
|
||||||
|
|
||||||
|
## AIOS Runtime HTTP 合同
|
||||||
|
|
||||||
|
插件在 OpenClaw loopback Gateway 内注册 `/v1/health`、`/v1/execute` 和
|
||||||
|
`/v1/commands/<id>`。执行请求必须同时通过本机 CredentialRef 比对与 Ed25519
|
||||||
|
签名验证;热路径只读取公钥,不读取 Candidate 私密签名材料。
|
||||||
|
|
||||||
|
Runtime 仅允许无副作用的 `status` 与在线验收专用 `acceptance.delay`。它不把
|
||||||
|
返回成功冒充为模型推理或任意 Agent 执行。命令身份、幂等键和终态以追加式
|
||||||
|
JSONL 持久化;Gateway 重启时遗留的 `RUNNING` 命令会收敛为
|
||||||
|
`FAILED/restart_interrupted`,避免把重启误报成真实超时。
|
||||||
|
|
||||||
|
取消只对当前进程内仍在执行的命令生效,超时由签名命令的 `expires_at` 与
|
||||||
|
30 秒硬上限共同约束。未知 action、错误签名、过期命令、跨 tenant 状态查询、
|
||||||
|
冲突的 `command_id` 或幂等键均失败关闭。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 签名合同:`aios.run-provenance/v1`,Ed25519,授权最长 15 分钟。
|
||||||
|
- 授权绑定:`run_id`、`sender_id`、任务、执行器实例、当前 Claim OID、Candidate OID 与任务分支。
|
||||||
|
- 路径:仅允许七个 `.agent/*.json` Evidence 文件。
|
||||||
|
- 操作:仅允许 `create/update`,随后执行固定 argv 的 `git add`、`git commit` 和当前任务分支普通 push。
|
||||||
|
- 通用 `exec/shell/bash/write/edit/apply_patch/file_write` 始终拒绝。
|
||||||
|
- `toolCallId` permit 只能消费一次;参数摘要变化、过期、签名篡改、Run/Sender 不匹配均拒绝。
|
||||||
|
- Writer 拒绝 symlink、路径逃逸、脏 Worktree、错误分支、错误 HEAD、Candidate 非祖先及暂存范围扩大。
|
||||||
|
- Gateway 以服务用户调用 Git 时,仅对配置中的精确 `writerRepoRoot` 注入命令级 `safe.directory`,不写全局 Git 配置。
|
||||||
|
- 禁止 reset、rebase、amend、Tag 和 force push。提交后 push 失败时保留可审计的 local-ahead 状态,不伪造回滚。
|
||||||
|
|
||||||
|
## 组件
|
||||||
|
|
||||||
|
- `issuer.mjs`:在受保护边界读取现有 Ed25519 材料,生成公开验签材料并原子写入一次性签发记录。
|
||||||
|
- `provenance.js`:严格 Schema、canonical JSON、Ed25519 sign/verify 和安全 Store 读取。
|
||||||
|
- `index.js`:OpenClaw hook、Owner/Run/Sender 验证与一次性 permit。
|
||||||
|
- `writer.js`:限定路径原子写入、固定 Git 操作和追加式审计。
|
||||||
|
|
||||||
|
## 部署与回滚
|
||||||
|
|
||||||
|
部署必须先备份运行插件和 OpenClaw 配置,再安装 Git 中已测试的完整目录,配置绝对路径、Key ID、任务分支和审计路径,执行 `openclaw plugins doctor` 后才重启 Gateway。回滚只恢复备份插件与配置并重启,不修改任务分支历史或业务状态。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test test/*.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
验收还必须包含受保护签发、Heartbeat 实际 commit/push、审计链验证,以及 `README.md` 等未授权路径的拒绝测试。
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../index.js";
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "../policy.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "../provenance.js";
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "../writer.js";
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { canonicalize, loadStoredRecord, readPublicKey, validatePayload, verifyRecord } from "./provenance.js";
|
||||||
|
import { classifyRun, decideToolCall, runKey, validateWriterParams } from "./policy.js";
|
||||||
|
import { executeEvidenceWrite } from "./writer.js";
|
||||||
|
import { createRuntimeApi } from "./runtime.js";
|
||||||
|
|
||||||
|
const activeRuns = new Map();
|
||||||
|
const permits = new Map();
|
||||||
|
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
||||||
|
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
function pluginConfig(event, api) {
|
||||||
|
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune(now = Date.now()) {
|
||||||
|
for (const [key, value] of activeRuns) if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
||||||
|
for (const [key, value] of permits) if (now - value.recordedAt > value.ttlMs) permits.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallId(event) {
|
||||||
|
return typeof event?.toolCallId === "string" && event.toolCallId.trim() ? event.toolCallId.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventParams(event) {
|
||||||
|
return event?.params ?? event?.parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAbsoluteConfig(config, key) {
|
||||||
|
const value = config[key];
|
||||||
|
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${key}_missing`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorizeEvidenceCall({ event, trust, config, now = Date.now() }) {
|
||||||
|
const callId = toolCallId(event);
|
||||||
|
if (!callId) return { ok: false, code: "tool_call_id_missing" };
|
||||||
|
if (!trust?.trusted) return { ok: false, code: trust?.reason || "run_provenance_missing" };
|
||||||
|
const params = eventParams(event);
|
||||||
|
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "tool_params_missing" };
|
||||||
|
let record;
|
||||||
|
try { record = loadStoredRecord(requireAbsoluteConfig(config, "provenanceStoreDir"), params.provenance_id); }
|
||||||
|
catch (error) {
|
||||||
|
const code = error instanceof Error && ["unsafe_provenance_file", "invalid_provenance_location", "provenance_record_too_large"].includes(error.message)
|
||||||
|
? error.message : "provenance_store_rejected";
|
||||||
|
return { ok: false, code };
|
||||||
|
}
|
||||||
|
const validation = validatePayload(record.payload, now);
|
||||||
|
if (!validation.ok) return validation;
|
||||||
|
let publicKey;
|
||||||
|
try { publicKey = readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")); }
|
||||||
|
catch { return { ok: false, code: "public_key_rejected" }; }
|
||||||
|
const signature = verifyRecord(record, publicKey, config.provenanceKeyId);
|
||||||
|
if (!signature.ok) return signature;
|
||||||
|
const payload = record.payload;
|
||||||
|
const runId = runKey(event, {});
|
||||||
|
if (!runId || payload.run_id !== runId) return { ok: false, code: "run_id_mismatch" };
|
||||||
|
if (payload.sender_id !== trust.senderId) return { ok: false, code: "sender_id_mismatch" };
|
||||||
|
if (config.writerTaskBranch && payload.task_branch !== config.writerTaskBranch) return { ok: false, code: "task_branch_mismatch" };
|
||||||
|
if (config.writerTaskId && payload.task_id !== config.writerTaskId) return { ok: false, code: "task_id_mismatch" };
|
||||||
|
if (config.provenanceIssuer && payload.issued_by !== config.provenanceIssuer) return { ok: false, code: "issuer_mismatch" };
|
||||||
|
if (config.executor && payload.executor !== config.executor) return { ok: false, code: "executor_mismatch" };
|
||||||
|
if (config.executorInstance && payload.executor_instance !== config.executorInstance) return { ok: false, code: "executor_instance_mismatch" };
|
||||||
|
const paramsValidation = validateWriterParams(params, payload);
|
||||||
|
if (!paramsValidation.ok) return paramsValidation;
|
||||||
|
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||||
|
if (permits.has(callId)) return { ok: false, code: "permit_replay" };
|
||||||
|
const ttlMs = Number.isSafeInteger(config.permitTtlMs) && config.permitTtlMs > 0
|
||||||
|
? Math.min(config.permitTtlMs, DEFAULT_PERMIT_TTL_MS)
|
||||||
|
: DEFAULT_PERMIT_TTL_MS;
|
||||||
|
permits.set(callId, { payload, paramsDigest: digest, recordedAt: now, ttlMs });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePermit(toolCallIdValue, params, now = Date.now()) {
|
||||||
|
const permit = permits.get(toolCallIdValue);
|
||||||
|
permits.delete(toolCallIdValue);
|
||||||
|
if (!permit) throw new Error("permit_missing");
|
||||||
|
if (now - permit.recordedAt > permit.ttlMs) throw new Error("permit_expired");
|
||||||
|
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(permit.paramsDigest))) throw new Error("permit_params_mismatch");
|
||||||
|
return permit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResult(details) {
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerWriterTool(api) {
|
||||||
|
api.registerTool((toolContext) => ({
|
||||||
|
name: "aios_evidence_write",
|
||||||
|
label: "AIOS Evidence Writer",
|
||||||
|
description: "Write one Owner-authorized JSON evidence file and normally push the current task branch.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["provenance_id", "path", "operation", "document"],
|
||||||
|
properties: {
|
||||||
|
provenance_id: { type: "string" },
|
||||||
|
path: { type: "string" },
|
||||||
|
operation: { type: "string", enum: ["create", "update"] },
|
||||||
|
document: { type: "object" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
executionMode: "sequential",
|
||||||
|
async execute(callId, params) {
|
||||||
|
const permit = consumePermit(callId, params);
|
||||||
|
const config = toolContext?.getRuntimeConfig?.()?.plugins?.entries?.["aios-tenant-boundary"]?.config
|
||||||
|
?? api.pluginConfig ?? {};
|
||||||
|
const result = await executeEvidenceWrite({
|
||||||
|
repoRoot: requireAbsoluteConfig(config, "writerRepoRoot"),
|
||||||
|
evidencePath: params.path,
|
||||||
|
operation: params.operation,
|
||||||
|
document: params.document,
|
||||||
|
payload: permit.payload,
|
||||||
|
pushRemote: config.writerPushRemote ?? "gitea-http",
|
||||||
|
askPassPath: config.writerAskPassPath ?? "",
|
||||||
|
auditLogPath: requireAbsoluteConfig(config, "writerAuditLogPath"),
|
||||||
|
gitBinary: config.gitBinary ?? "/usr/bin/git",
|
||||||
|
});
|
||||||
|
return toolResult(result);
|
||||||
|
},
|
||||||
|
}), { name: "aios_evidence_write" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "aios-tenant-boundary",
|
||||||
|
name: "AIOS Tenant Boundary",
|
||||||
|
description: "Fail-closed Owner provenance and dedicated evidence-writer boundary.",
|
||||||
|
register(api) {
|
||||||
|
const runtimeApi = createRuntimeApi(api);
|
||||||
|
api.registerHttpRoute({ path: "/v1/health", auth: "plugin", match: "exact", handler: runtimeApi.handleHealth });
|
||||||
|
api.registerHttpRoute({ path: "/v1/execute", auth: "plugin", match: "exact", handler: runtimeApi.handleExecute });
|
||||||
|
api.registerHttpRoute({ path: "/v1/commands/", auth: "plugin", match: "prefix", handler: runtimeApi.commandRequest });
|
||||||
|
registerWriterTool(api);
|
||||||
|
api.on("before_agent_run", async (event, ctx) => {
|
||||||
|
prune();
|
||||||
|
const config = pluginConfig(event, api);
|
||||||
|
const trust = classifyRun(event, ctx, config);
|
||||||
|
const key = runKey(event, ctx);
|
||||||
|
if (key) activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
||||||
|
if (config.blockUnknownAgentRuns === true && !trust.trusted && trust.reason !== "job_id_present") {
|
||||||
|
return { outcome: "block", reason: trust.reason, message: "Authenticated Owner provenance is required." };
|
||||||
|
}
|
||||||
|
return { outcome: "pass" };
|
||||||
|
}, { priority: 100 });
|
||||||
|
|
||||||
|
api.on("before_tool_call", async (event, ctx) => {
|
||||||
|
prune();
|
||||||
|
const config = pluginConfig(event, api);
|
||||||
|
const trust = activeRuns.get(runKey(event, ctx));
|
||||||
|
const decision = decideToolCall(event.toolName, trust, config);
|
||||||
|
if (decision.action === "block") return { block: true, blockReason: `AIOS V2R1 policy denied: ${decision.code}` };
|
||||||
|
if (decision.action === "verify_provenance") {
|
||||||
|
const authorized = authorizeEvidenceCall({ event, trust, config });
|
||||||
|
if (!authorized.ok) return { block: true, blockReason: `AIOS V2R1 provenance denied: ${authorized.code}` };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (decision.action === "approval") {
|
||||||
|
return { requireApproval: {
|
||||||
|
title: `Approve ${String(event.toolName)}`,
|
||||||
|
description: "Authenticated Owner requested a non-evidence side effect.",
|
||||||
|
severity: "warning", timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
|
||||||
|
allowedDecisions: ["allow-once", "deny"], pluginId: "aios-tenant-boundary",
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, { priority: 100 });
|
||||||
|
|
||||||
|
api.on("agent_end", async (event, ctx) => {
|
||||||
|
const key = runKey(event, ctx);
|
||||||
|
if (key) activeRuns.delete(key);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const __state = { activeRuns, permits };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { canonicalize, privateKeyFromMaterial, signRecord, validatePayload } from "./provenance.js";
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const result = {};
|
||||||
|
for (let index = 0; index < argv.length; index += 2) {
|
||||||
|
const key = argv[index];
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!key?.startsWith("--") || value === undefined) throw new Error("invalid_arguments");
|
||||||
|
result[key.slice(2)] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWrite(filePath, contents, mode) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
|
||||||
|
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
|
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
|
||||||
|
fs.renameSync(temporary, filePath);
|
||||||
|
fs.chmodSync(filePath, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireProtectedPrivateKey(privateKeyPath) {
|
||||||
|
const stat = fs.lstatSync(privateKeyPath);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_private_key");
|
||||||
|
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("unsafe_private_key_mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId, now = Date.now() }) {
|
||||||
|
for (const value of [payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId]) {
|
||||||
|
if (typeof value !== "string" || !value) throw new Error("missing_argument");
|
||||||
|
}
|
||||||
|
const payload = JSON.parse(fs.readFileSync(payloadPath, "utf8"));
|
||||||
|
const validated = validatePayload(payload, now);
|
||||||
|
if (!validated.ok) throw new Error(validated.code);
|
||||||
|
requireProtectedPrivateKey(privateKeyPath);
|
||||||
|
const recordPath = path.join(storeDir, `${payload.provenance_id}.json`);
|
||||||
|
if (fs.existsSync(recordPath)) throw new Error("provenance_id_already_issued");
|
||||||
|
const privateMaterial = fs.readFileSync(privateKeyPath);
|
||||||
|
const privateKey = privateKeyFromMaterial(privateMaterial);
|
||||||
|
const publicPem = crypto.createPublicKey(privateKey).export({ format: "pem", type: "spki" });
|
||||||
|
const record = signRecord(payload, privateMaterial, keyId);
|
||||||
|
atomicWrite(publicKeyPath, publicPem, 0o644);
|
||||||
|
atomicWrite(recordPath, `${canonicalize(record)}\n`, 0o640);
|
||||||
|
return payload.provenance_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
if (isMain) {
|
||||||
|
try {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const provenanceId = issueFromFiles({
|
||||||
|
payloadPath: args.payload,
|
||||||
|
privateKeyPath: args["private-key"],
|
||||||
|
publicKeyPath: args["public-key"],
|
||||||
|
storeDir: args["store-dir"],
|
||||||
|
keyId: args["key-id"],
|
||||||
|
});
|
||||||
|
process.stdout.write(`PROVENANCE_ISSUED=${provenanceId}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`PROVENANCE_ISSUE_FAILED=${error instanceof Error ? error.message : "unknown"}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"id": "aios-tenant-boundary",
|
||||||
|
"name": "AIOS Tenant Boundary",
|
||||||
|
"description": "Enforces Owner-signed run provenance, a dedicated evidence writer, and the signed AIOS runtime contract.",
|
||||||
|
"version": "2.1.0-rc3",
|
||||||
|
"entry": "dist/index.js",
|
||||||
|
"contracts": {
|
||||||
|
"tools": ["aios_evidence_write"]
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"requireExplicitTrustedSenders": { "type": "boolean", "default": true },
|
||||||
|
"requireSenderIsOwner": { "type": "boolean", "default": true },
|
||||||
|
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
|
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||||
|
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true },
|
||||||
|
"blockUnknownAgentRuns": { "type": "boolean", "default": true },
|
||||||
|
"approvalTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
|
||||||
|
"permitTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||||
|
"provenanceStoreDir": { "type": "string" },
|
||||||
|
"provenancePublicKeyPath": { "type": "string" },
|
||||||
|
"provenanceKeyId": { "type": "string" },
|
||||||
|
"provenanceIssuer": { "type": "string" },
|
||||||
|
"executor": { "type": "string" },
|
||||||
|
"executorInstance": { "type": "string" },
|
||||||
|
"writerRepoRoot": { "type": "string" },
|
||||||
|
"writerTaskId": { "type": "string" },
|
||||||
|
"writerTaskBranch": { "type": "string" },
|
||||||
|
"writerPushRemote": { "type": "string" },
|
||||||
|
"writerAskPassPath": { "type": "string" },
|
||||||
|
"writerAuditLogPath": { "type": "string" },
|
||||||
|
"gitBinary": { "type": "string" },
|
||||||
|
"runtimeStatePath": { "type": "string" },
|
||||||
|
"runtimeSigningPublicKeyPath": { "type": "string" },
|
||||||
|
"runtimeSigningKeyId": { "type": "string" },
|
||||||
|
"runtimeCredentialRef": { "type": "string" },
|
||||||
|
"runtimeMaxActive": { "type": "integer", "minimum": 1, "maximum": 16 },
|
||||||
|
"runtimeMaxDelayMs": { "type": "integer", "minimum": 100, "maximum": 30000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@aios/openclaw-tenant-boundary",
|
||||||
|
"version": "2.1.0-rc.3",
|
||||||
|
"type": "module",
|
||||||
|
"description": "AIOS V2R1 Tenant Cell provenance and side-effect policy boundary for OpenClaw.",
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"policy.js",
|
||||||
|
"provenance.js",
|
||||||
|
"writer.js",
|
||||||
|
"runtime.js",
|
||||||
|
"issuer.mjs",
|
||||||
|
"dist",
|
||||||
|
"openclaw.plugin.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
|
},
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": [
|
||||||
|
"./dist/index.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ALLOWED_OPERATIONS, ALLOWED_PATHS } from "./provenance.js";
|
||||||
|
|
||||||
|
const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
||||||
|
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
|
||||||
|
"memory_search", "sessions_list", "sessions_history", "session_status",
|
||||||
|
]);
|
||||||
|
const GENERIC_MUTATION_TOOLS = new Set([
|
||||||
|
"exec", "shell", "bash", "write", "edit", "apply_patch", "file_write",
|
||||||
|
"computer", "process", "terminal",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function strings(value) {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSenderId(ctx = {}) {
|
||||||
|
const direct = typeof ctx.senderId === "string" ? ctx.senderId.trim() : "";
|
||||||
|
const nested = typeof ctx.channelContext?.sender?.id === "string" ? ctx.channelContext.sender.id.trim() : "";
|
||||||
|
return direct || nested || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyRun(event = {}, ctx = {}, config = {}) {
|
||||||
|
const senderId = resolveSenderId({ ...ctx, senderId: event.senderId ?? ctx.senderId });
|
||||||
|
const trusted = new Set(strings(config.trustedSenderIds));
|
||||||
|
const blocked = new Set(strings(config.blockedSenderIds));
|
||||||
|
if (typeof ctx.jobId === "string" && ctx.jobId) return { trusted: false, origin: "automation", reason: "job_id_present" };
|
||||||
|
if (!senderId) return { trusted: false, origin: "system_or_unknown", reason: "sender_id_missing" };
|
||||||
|
if (blocked.has(senderId)) return { trusted: false, origin: "blocked_sender", reason: "sender_blocked", senderId };
|
||||||
|
if (config.requireExplicitTrustedSenders !== false && !trusted.has(senderId)) {
|
||||||
|
return { trusted: false, origin: "untrusted_sender", reason: "sender_not_allowlisted", senderId };
|
||||||
|
}
|
||||||
|
if (config.requireSenderIsOwner !== false && event.senderIsOwner !== true) {
|
||||||
|
return { trusted: false, origin: "non_owner", reason: "sender_not_owner", senderId };
|
||||||
|
}
|
||||||
|
return { trusted: true, origin: "authenticated_owner", reason: "owner_verified", senderId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runKey(event = {}, ctx = {}) {
|
||||||
|
const key = event.runId || ctx.runId;
|
||||||
|
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideToolCall(toolName, runTrust, config = {}) {
|
||||||
|
const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
|
||||||
|
if (!name) return { action: "block", code: "tool_name_missing" };
|
||||||
|
if (GENERIC_MUTATION_TOOLS.has(name)) return { action: "block", code: "dedicated_writer_required" };
|
||||||
|
const configured = strings(config.readOnlyTools).map((item) => item.toLowerCase());
|
||||||
|
const readOnly = new Set(configured.length ? configured : DEFAULT_READ_ONLY_TOOLS);
|
||||||
|
if (readOnly.has(name)) return { action: "allow", code: "read_only" };
|
||||||
|
if (!runTrust?.trusted) return { action: "block", code: runTrust?.reason || "run_provenance_missing" };
|
||||||
|
if (name === "aios_evidence_write") return { action: "verify_provenance", code: "dedicated_writer" };
|
||||||
|
if (config.requireApprovalForHumanSideEffects === false) return { action: "allow", code: "trusted_side_effect" };
|
||||||
|
return { action: "approval", code: "human_side_effect" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWriterParams(params = {}, payload) {
|
||||||
|
const keys = Object.keys(params).sort();
|
||||||
|
const expected = ["document", "operation", "path", "provenance_id"];
|
||||||
|
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) return { ok: false, code: "tool_params_schema_mismatch" };
|
||||||
|
if (params.provenance_id !== payload.provenance_id) return { ok: false, code: "provenance_id_mismatch" };
|
||||||
|
if (!ALLOWED_PATHS.includes(params.path)) return { ok: false, code: "path_not_allowed" };
|
||||||
|
if (params.operation !== "create" && params.operation !== "update") return { ok: false, code: "operation_not_allowed" };
|
||||||
|
if (!payload.allowed_paths.includes(params.path)) return { ok: false, code: "path_not_authorized" };
|
||||||
|
for (const operation of [params.operation, "git add", "git commit", "normal push current task branch"]) {
|
||||||
|
if (!payload.allowed_operations.includes(operation)) return { ok: false, code: "operation_not_authorized" };
|
||||||
|
}
|
||||||
|
let serialized;
|
||||||
|
try { serialized = JSON.stringify(params.document); } catch { return { ok: false, code: "document_not_json" }; }
|
||||||
|
if (serialized === undefined || Buffer.byteLength(serialized) > 256 * 1024) return { ok: false, code: "document_invalid_or_too_large" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export const ALLOWED_PATHS = Object.freeze([
|
||||||
|
".agent/claim.json",
|
||||||
|
".agent/heartbeat.json",
|
||||||
|
".agent/progress.json",
|
||||||
|
".agent/status.json",
|
||||||
|
".agent/blockers.json",
|
||||||
|
".agent/result.json",
|
||||||
|
".agent/credential-status.json",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ALLOWED_OPERATIONS = Object.freeze([
|
||||||
|
"create",
|
||||||
|
"update",
|
||||||
|
"git add",
|
||||||
|
"git commit",
|
||||||
|
"normal push current task branch",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PAYLOAD_FIELDS = Object.freeze([
|
||||||
|
"schema_version", "provenance_id", "run_id", "sender_id", "task_id",
|
||||||
|
"executor", "executor_instance", "claim_oid", "candidate_oid", "task_branch",
|
||||||
|
"allowed_paths", "allowed_operations", "issued_by", "issued_at", "expires_at",
|
||||||
|
]);
|
||||||
|
const OID_RE = /^[0-9a-f]{40}$/;
|
||||||
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||||
|
const MAX_LIFETIME_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function normalized(value) {
|
||||||
|
if (value === null || typeof value !== "object") return value;
|
||||||
|
if (Array.isArray(value)) return value.map(normalized);
|
||||||
|
const result = {};
|
||||||
|
for (const key of Object.keys(value).sort()) {
|
||||||
|
if (value[key] !== undefined) result[key] = normalized(value[key]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalize(value) {
|
||||||
|
return JSON.stringify(normalized(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactStringSet(actual, expected) {
|
||||||
|
if (!Array.isArray(actual) || actual.some((v) => typeof v !== "string")) return false;
|
||||||
|
if (actual.length !== expected.length || new Set(actual).size !== expected.length) return false;
|
||||||
|
const set = new Set(actual);
|
||||||
|
return expected.every((value) => set.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(code) {
|
||||||
|
return { ok: false, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePayload(payload, now = Date.now()) {
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return failure("payload_not_object");
|
||||||
|
const keys = Object.keys(payload).sort();
|
||||||
|
if (keys.length !== PAYLOAD_FIELDS.length || PAYLOAD_FIELDS.some((key) => !keys.includes(key))) {
|
||||||
|
return failure("payload_schema_mismatch");
|
||||||
|
}
|
||||||
|
if (payload.schema_version !== "aios.run-provenance/v1") return failure("schema_version_mismatch");
|
||||||
|
for (const key of ["run_id", "sender_id", "task_id", "executor", "executor_instance", "task_branch", "issued_by"]) {
|
||||||
|
if (typeof payload[key] !== "string" || !payload[key].trim()) return failure(`invalid_${key}`);
|
||||||
|
}
|
||||||
|
if (!ID_RE.test(payload.provenance_id)) return failure("invalid_provenance_id");
|
||||||
|
if (!OID_RE.test(payload.claim_oid) || !OID_RE.test(payload.candidate_oid)) return failure("invalid_oid");
|
||||||
|
if (!exactStringSet(payload.allowed_paths, ALLOWED_PATHS)) return failure("allowed_paths_mismatch");
|
||||||
|
if (!exactStringSet(payload.allowed_operations, ALLOWED_OPERATIONS)) return failure("allowed_operations_mismatch");
|
||||||
|
if (!Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)) return failure("invalid_time");
|
||||||
|
if (payload.expires_at <= payload.issued_at || payload.expires_at - payload.issued_at > MAX_LIFETIME_MS) {
|
||||||
|
return failure("invalid_lifetime");
|
||||||
|
}
|
||||||
|
if (now < payload.issued_at) return failure("not_yet_valid");
|
||||||
|
if (now >= payload.expires_at) return failure("expired");
|
||||||
|
return { ok: true, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function privateKeyFromMaterial(material) {
|
||||||
|
if (!Buffer.isBuffer(material)) material = Buffer.from(material);
|
||||||
|
if (material.length === 32) {
|
||||||
|
const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||||
|
return crypto.createPrivateKey({ key: Buffer.concat([pkcs8Prefix, material]), format: "der", type: "pkcs8" });
|
||||||
|
}
|
||||||
|
if (material.subarray(0, 5).toString() === "-----") return crypto.createPrivateKey(material);
|
||||||
|
return crypto.createPrivateKey({ key: material, format: "der", type: "pkcs8" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signRecord(payload, privateMaterial, keyId) {
|
||||||
|
const signature = crypto.sign(null, Buffer.from(canonicalize(payload)), privateKeyFromMaterial(privateMaterial));
|
||||||
|
return {
|
||||||
|
payload,
|
||||||
|
signature: { algorithm: "Ed25519", key_id: keyId, value: signature.toString("base64url") },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyRecord(record, publicKeyPem, expectedKeyId) {
|
||||||
|
if (!record || typeof record !== "object" || Array.isArray(record)) return failure("record_not_object");
|
||||||
|
const signature = record.signature;
|
||||||
|
if (!signature || signature.algorithm !== "Ed25519" || signature.key_id !== expectedKeyId || typeof signature.value !== "string") {
|
||||||
|
return failure("signature_contract_mismatch");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ok = crypto.verify(
|
||||||
|
null,
|
||||||
|
Buffer.from(canonicalize(record.payload)),
|
||||||
|
crypto.createPublicKey(publicKeyPem),
|
||||||
|
Buffer.from(signature.value, "base64url"),
|
||||||
|
);
|
||||||
|
return ok ? { ok: true } : failure("signature_invalid");
|
||||||
|
} catch {
|
||||||
|
return failure("signature_invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRegularFile(filePath) {
|
||||||
|
const stat = fs.lstatSync(filePath);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_provenance_file");
|
||||||
|
if (process.platform !== "win32" && (stat.mode & 0o022) !== 0) throw new Error("unsafe_provenance_file");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStoredRecord(storeDir, provenanceId) {
|
||||||
|
if (!path.isAbsolute(storeDir) || !ID_RE.test(provenanceId)) throw new Error("invalid_provenance_location");
|
||||||
|
const storeReal = fs.realpathSync(storeDir);
|
||||||
|
const target = path.join(storeReal, `${provenanceId}.json`);
|
||||||
|
safeRegularFile(target);
|
||||||
|
const targetReal = fs.realpathSync(target);
|
||||||
|
if (path.dirname(targetReal) !== storeReal || path.basename(targetReal) !== `${provenanceId}.json`) {
|
||||||
|
throw new Error("unsafe_provenance_file");
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(target, "utf8");
|
||||||
|
if (Buffer.byteLength(raw) > 128 * 1024) throw new Error("provenance_record_too_large");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPublicKey(publicKeyPath) {
|
||||||
|
if (!path.isAbsolute(publicKeyPath)) throw new Error("invalid_public_key_path");
|
||||||
|
safeRegularFile(publicKeyPath);
|
||||||
|
return fs.readFileSync(publicKeyPath, "utf8");
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const CONTRACT_VERSION = "aios.openclaw.command.v1";
|
||||||
|
const MAX_BODY_BYTES = 64 * 1024;
|
||||||
|
const CLOCK_SKEW_MS = 300_000;
|
||||||
|
const COMMAND_FIELDS = [
|
||||||
|
"command_id", "tenant_id", "task_id", "trace_id", "action", "payload",
|
||||||
|
"idempotency_key", "created_at", "expires_at", "contract_version", "key_id",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isObject(value) {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sorted(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(sorted);
|
||||||
|
if (!isObject(value)) return value;
|
||||||
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonical(value) {
|
||||||
|
return JSON.stringify(sorted(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, body) {
|
||||||
|
const data = Buffer.from(JSON.stringify(body));
|
||||||
|
res.writeHead(status, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Length": String(data.length),
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
});
|
||||||
|
res.end(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(req) {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY_BYTES) throw new Error("body_too_large");
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
let value;
|
||||||
|
try { value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); }
|
||||||
|
catch { throw new Error("invalid_json"); }
|
||||||
|
if (!isObject(value)) throw new Error("invalid_json_object");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAbsolute(config, name) {
|
||||||
|
const value = config[name];
|
||||||
|
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${name}_missing`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRecord(statePath, record) {
|
||||||
|
const parent = path.dirname(statePath);
|
||||||
|
fs.mkdirSync(parent, { recursive: true, mode: 0o750 });
|
||||||
|
const fd = fs.openSync(statePath, "a", 0o640);
|
||||||
|
try {
|
||||||
|
fs.writeSync(fd, `${JSON.stringify(record)}\n`, null, "utf8");
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
fs.chmodSync(statePath, 0o640);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommand(value, expectedKeyId) {
|
||||||
|
const allowed = new Set([...COMMAND_FIELDS, "signature"]);
|
||||||
|
if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("unknown_command_field");
|
||||||
|
for (const name of COMMAND_FIELDS.filter((name) => name !== "expires_at" && name !== "payload")) {
|
||||||
|
if (typeof value[name] !== "string" || !value[name].trim()) throw new Error(`invalid_${name}`);
|
||||||
|
}
|
||||||
|
if (!isObject(value.payload)) throw new Error("invalid_payload");
|
||||||
|
if (typeof value.expires_at !== "string" || !value.expires_at) throw new Error("expires_at_required");
|
||||||
|
if (typeof value.signature !== "string" || !value.signature) throw new Error("signature_missing");
|
||||||
|
if (value.contract_version !== CONTRACT_VERSION) throw new Error("contract_version_rejected");
|
||||||
|
if (value.key_id !== expectedKeyId) throw new Error("key_id_rejected");
|
||||||
|
const created = Date.parse(value.created_at);
|
||||||
|
const expires = Date.parse(value.expires_at);
|
||||||
|
if (!Number.isFinite(created) || !Number.isFinite(expires)) throw new Error("timestamp_invalid");
|
||||||
|
if (created - Date.now() > CLOCK_SKEW_MS) throw new Error("created_at_in_future");
|
||||||
|
if (expires <= created || expires - created > 300_000) throw new Error("expiry_window_rejected");
|
||||||
|
if (Date.now() - expires > CLOCK_SKEW_MS) throw new Error("command_expired");
|
||||||
|
const unsigned = Object.fromEntries(COMMAND_FIELDS.map((name) => [name, value[name]]));
|
||||||
|
return { unsigned, expires };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySignature(command, publicKey, expectedKeyId) {
|
||||||
|
const { unsigned, expires } = validateCommand(command, expectedKeyId);
|
||||||
|
let signature;
|
||||||
|
try { signature = Buffer.from(command.signature, "base64url"); }
|
||||||
|
catch { throw new Error("signature_invalid"); }
|
||||||
|
if (!crypto.verify(null, Buffer.from(canonical(unsigned)), publicKey, signature)) throw new Error("signature_invalid");
|
||||||
|
return { digest: crypto.createHash("sha256").update(canonical(unsigned)).digest("hex"), expires };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultFor(command, state, { output = null, error = null, startedAt, finishedAt = nowIso() } = {}) {
|
||||||
|
return {
|
||||||
|
command_id: command.command_id, tenant_id: command.tenant_id,
|
||||||
|
task_id: command.task_id, trace_id: command.trace_id, state, output, error,
|
||||||
|
started_at: startedAt ?? nowIso(), finished_at: finishedAt,
|
||||||
|
runtime_id: "openclaw-aios-runtime-v1", attempts: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState(statePath) {
|
||||||
|
const commands = new Map();
|
||||||
|
const idempotency = new Map();
|
||||||
|
if (!fs.existsSync(statePath)) return { commands, idempotency };
|
||||||
|
for (const line of fs.readFileSync(statePath, "utf8").split("\n").filter(Boolean)) {
|
||||||
|
let record;
|
||||||
|
try { record = JSON.parse(line); } catch { throw new Error("runtime_state_corrupt"); }
|
||||||
|
if (!isObject(record) || typeof record.command_id !== "string") throw new Error("runtime_state_corrupt");
|
||||||
|
commands.set(record.command_id, record);
|
||||||
|
if (record.tenant_id && record.idempotency_key) idempotency.set(`${record.tenant_id}\0${record.idempotency_key}`, record.command_id);
|
||||||
|
}
|
||||||
|
return { commands, idempotency };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntimeApi(api) {
|
||||||
|
const config = api.pluginConfig ?? {};
|
||||||
|
const statePath = requireAbsolute(config, "runtimeStatePath");
|
||||||
|
const publicKeyPath = requireAbsolute(config, "runtimeSigningPublicKeyPath");
|
||||||
|
const expectedKeyId = String(config.runtimeSigningKeyId || "");
|
||||||
|
const credentialRef = String(config.runtimeCredentialRef || "");
|
||||||
|
if (!expectedKeyId) throw new Error("config_runtimeSigningKeyId_missing");
|
||||||
|
if (!credentialRef) throw new Error("config_runtimeCredentialRef_missing");
|
||||||
|
const publicKey = crypto.createPublicKey(fs.readFileSync(publicKeyPath));
|
||||||
|
const maxActive = Math.max(1, Math.min(Number(config.runtimeMaxActive || 4), 16));
|
||||||
|
const maxDelayMs = Math.max(100, Math.min(Number(config.runtimeMaxDelayMs || 30_000), 30_000));
|
||||||
|
const state = loadState(statePath);
|
||||||
|
const active = new Map();
|
||||||
|
|
||||||
|
for (const record of state.commands.values()) {
|
||||||
|
if (record.result?.state === "RUNNING") {
|
||||||
|
const reconciled = { ...record, result: { ...record.result, state: "FAILED", finished_at: nowIso(), error: { code: "restart_interrupted", message: "execution outcome is unknown after runtime restart" } } };
|
||||||
|
appendRecord(statePath, reconciled);
|
||||||
|
state.commands.set(record.command_id, reconciled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCredential(req) {
|
||||||
|
const supplied = String(req.headers["x-aios-credential-ref"] || "");
|
||||||
|
const left = Buffer.from(supplied);
|
||||||
|
const right = Buffer.from(credentialRef);
|
||||||
|
if (left.length !== right.length || !crypto.timingSafeEqual(left, right)) throw new Error("credential_ref_rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(command, digest, result) {
|
||||||
|
const record = {
|
||||||
|
command_id: command.command_id, tenant_id: command.tenant_id,
|
||||||
|
task_id: command.task_id, trace_id: command.trace_id,
|
||||||
|
idempotency_key: command.idempotency_key, digest, result, recorded_at: nowIso(),
|
||||||
|
};
|
||||||
|
appendRecord(statePath, record);
|
||||||
|
state.commands.set(command.command_id, record);
|
||||||
|
state.idempotency.set(`${command.tenant_id}\0${command.idempotency_key}`, command.command_id);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute(command, digest, expires) {
|
||||||
|
const startedAt = nowIso();
|
||||||
|
persist(command, digest, resultFor(command, "RUNNING", { startedAt, finishedAt: null }));
|
||||||
|
if (command.action === "status") {
|
||||||
|
const completed = resultFor(command, "COMPLETED", { startedAt, output: { status: "ok", runtime: "openclaw-plugin", side_effects: false } });
|
||||||
|
persist(command, digest, completed);
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
if (command.action !== "acceptance.delay") throw new Error("unsupported_action");
|
||||||
|
const delayMs = Number(command.payload.delay_ms);
|
||||||
|
if (!Number.isInteger(delayMs) || delayMs < 0 || delayMs > maxDelayMs) {
|
||||||
|
const failed = resultFor(command, "FAILED", { startedAt, error: { code: "invalid_delay", message: "delay_ms is outside the allowed range" } });
|
||||||
|
persist(command, digest, failed);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
active.set(command.command_id, controller);
|
||||||
|
let timedOut = false;
|
||||||
|
const deadlineMs = Math.max(0, Math.min(maxDelayMs, expires - Date.now()));
|
||||||
|
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, deadlineMs);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(resolve, delayMs);
|
||||||
|
controller.signal.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("aborted")); }, { once: true });
|
||||||
|
});
|
||||||
|
const completed = resultFor(command, "COMPLETED", { startedAt, output: { delayed_ms: delayMs, side_effects: false } });
|
||||||
|
persist(command, digest, completed);
|
||||||
|
return completed;
|
||||||
|
} catch {
|
||||||
|
const terminal = resultFor(command, timedOut ? "TIMED_OUT" : "CANCELLED", {
|
||||||
|
startedAt,
|
||||||
|
error: timedOut ? { code: "runtime_timeout", message: "runtime deadline exceeded" } : { code: "cancelled", message: "cancelled by AIOS" },
|
||||||
|
});
|
||||||
|
persist(command, digest, terminal);
|
||||||
|
return terminal;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
active.delete(command.command_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExecute(req, res) {
|
||||||
|
if (req.method !== "POST") return sendJson(res, 405, { error: "method_not_allowed" });
|
||||||
|
try {
|
||||||
|
requireCredential(req);
|
||||||
|
const command = await readJson(req);
|
||||||
|
const { digest, expires } = verifySignature(command, publicKey, expectedKeyId);
|
||||||
|
const prior = state.commands.get(command.command_id);
|
||||||
|
if (prior) {
|
||||||
|
if (prior.digest !== digest) return sendJson(res, 409, { error: "command_id_conflict" });
|
||||||
|
return sendJson(res, 200, prior.result);
|
||||||
|
}
|
||||||
|
const idemId = state.idempotency.get(`${command.tenant_id}\0${command.idempotency_key}`);
|
||||||
|
if (idemId) {
|
||||||
|
const idem = state.commands.get(idemId);
|
||||||
|
if (!idem || idem.digest !== digest) return sendJson(res, 409, { error: "idempotency_conflict" });
|
||||||
|
return sendJson(res, 200, idem.result);
|
||||||
|
}
|
||||||
|
if (!new Set(["status", "acceptance.delay"]).has(command.action)) return sendJson(res, 422, { error: "unsupported_action" });
|
||||||
|
if (active.size >= maxActive) return sendJson(res, 429, { error: "runtime_capacity_exceeded" });
|
||||||
|
return sendJson(res, 200, await execute(command, digest, expires));
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof Error ? error.message : "request_rejected";
|
||||||
|
const status = code === "body_too_large" ? 413 : code.startsWith("config_") || code === "runtime_state_corrupt" ? 503 : 401;
|
||||||
|
return sendJson(res, status, { error: code });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandRequest(req, res) {
|
||||||
|
try {
|
||||||
|
requireCredential(req);
|
||||||
|
const url = new URL(req.url, "http://localhost");
|
||||||
|
const match = url.pathname.match(/^\/v1\/commands\/([^/]+)(?:\/(status|cancel))?$/);
|
||||||
|
if (!match) return false;
|
||||||
|
const commandId = decodeURIComponent(match[1]);
|
||||||
|
const operation = match[2] || "status";
|
||||||
|
const record = state.commands.get(commandId);
|
||||||
|
if (!record) return sendJson(res, 404, { error: "command_not_found" });
|
||||||
|
const tenantId = String(url.searchParams.get("tenant_id") || "");
|
||||||
|
if (!tenantId || tenantId !== record.tenant_id) return sendJson(res, 404, { error: "command_not_found" });
|
||||||
|
if (operation === "status" && req.method === "GET") {
|
||||||
|
return sendJson(res, 200, record.result);
|
||||||
|
}
|
||||||
|
if (operation === "cancel" && req.method === "POST") {
|
||||||
|
const controller = active.get(commandId);
|
||||||
|
if (controller) controller.abort();
|
||||||
|
return sendJson(res, 200, { accepted: Boolean(controller), command_id: commandId });
|
||||||
|
}
|
||||||
|
return sendJson(res, 405, { error: "method_not_allowed" });
|
||||||
|
} catch (error) {
|
||||||
|
return sendJson(res, 401, { error: error instanceof Error ? error.message : "request_rejected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleHealth(req, res) {
|
||||||
|
if (req.method !== "GET") return sendJson(res, 405, { error: "method_not_allowed" });
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
runtime: "openclaw", runtime_id: "openclaw-aios-runtime-v1", status: "healthy",
|
||||||
|
version: "2.1.0-rc.3", heartbeat_at: nowIso(), active_tasks: active.size,
|
||||||
|
workspace_isolated: true, credential_ref_configured: true,
|
||||||
|
details: { transport: "openclaw_plugin_http", allowed_actions: ["status", "acceptance.delay"] },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleExecute,
|
||||||
|
commandRequest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __test = { canonical, validateCommand, verifySignature, loadState };
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
import plugin, { authorizeEvidenceCall, consumePermit, __state } from "../index.js";
|
||||||
|
import { decideToolCall, validateWriterParams } from "../policy.js";
|
||||||
|
import {
|
||||||
|
ALLOWED_OPERATIONS, ALLOWED_PATHS, canonicalize, signRecord, validatePayload, verifyRecord,
|
||||||
|
} from "../provenance.js";
|
||||||
|
import { buildPushArgs, executeEvidenceWrite } from "../writer.js";
|
||||||
|
import { issueFromFiles } from "../issuer.mjs";
|
||||||
|
|
||||||
|
function fixture(now = Date.now()) {
|
||||||
|
return {
|
||||||
|
schema_version: "aios.run-provenance/v1",
|
||||||
|
provenance_id: "prov-test-1",
|
||||||
|
run_id: "run-test-1",
|
||||||
|
sender_id: "owner-1",
|
||||||
|
task_id: "task/openclaw/test",
|
||||||
|
executor: "openclaw",
|
||||||
|
executor_instance: "gateway-test",
|
||||||
|
claim_oid: "a".repeat(40),
|
||||||
|
candidate_oid: "b".repeat(40),
|
||||||
|
task_branch: "task/openclaw/test",
|
||||||
|
allowed_paths: [...ALLOWED_PATHS],
|
||||||
|
allowed_operations: [...ALLOWED_OPERATIONS],
|
||||||
|
issued_by: "AIOS_OWNER",
|
||||||
|
issued_at: now - 1000,
|
||||||
|
expires_at: now + 60_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFixture() {
|
||||||
|
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||||
|
return {
|
||||||
|
privateDer: privateKey.export({ format: "der", type: "pkcs8" }),
|
||||||
|
publicPem: publicKey.export({ format: "pem", type: "spki" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Ed25519 record verifies and tampering is rejected", () => {
|
||||||
|
const payload = fixture();
|
||||||
|
const keys = keyFixture();
|
||||||
|
const record = signRecord(payload, keys.privateDer, "key-1");
|
||||||
|
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, true);
|
||||||
|
record.payload.task_id = "tampered";
|
||||||
|
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("payload rejects expiry and set drift", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const expired = fixture(now);
|
||||||
|
expired.issued_at = now - 2000;
|
||||||
|
expired.expires_at = now - 1000;
|
||||||
|
assert.equal(validatePayload(expired, now).code, "expired");
|
||||||
|
const drift = fixture(now);
|
||||||
|
drift.allowed_paths = drift.allowed_paths.slice(1);
|
||||||
|
assert.equal(validatePayload(drift, now).code, "allowed_paths_mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("issuer accepts protected raw 32-byte material and refuses duplicate ids", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-issuer-"));
|
||||||
|
const keys = keyFixture();
|
||||||
|
const payload = fixture();
|
||||||
|
const payloadPath = path.join(root, "payload.json");
|
||||||
|
const privateKeyPath = path.join(root, "private.bin");
|
||||||
|
const publicKeyPath = path.join(root, "public.pem");
|
||||||
|
const storeDir = path.join(root, "store");
|
||||||
|
fs.writeFileSync(payloadPath, canonicalize(payload));
|
||||||
|
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
|
||||||
|
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
|
||||||
|
assert.equal(issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), payload.provenance_id);
|
||||||
|
const record = JSON.parse(fs.readFileSync(path.join(storeDir, `${payload.provenance_id}.json`), "utf8"));
|
||||||
|
assert.equal(verifyRecord(record, fs.readFileSync(publicKeyPath, "utf8"), "key-1").ok, true);
|
||||||
|
assert.throws(() => issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), /already_issued/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generic mutation tools stay blocked", () => {
|
||||||
|
for (const name of ["exec", "shell", "bash", "write", "edit", "apply_patch", "file_write"]) {
|
||||||
|
assert.deepEqual(decideToolCall(name, { trusted: true }, {}), { action: "block", code: "dedicated_writer_required" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plugin registers the native writer factory and required hook names", () => {
|
||||||
|
const hooks = [];
|
||||||
|
const routes = [];
|
||||||
|
let factory;
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-runtime-register-"));
|
||||||
|
const keys = keyFixture();
|
||||||
|
const publicKeyPath = path.join(root, "public.pem");
|
||||||
|
fs.writeFileSync(publicKeyPath, keys.publicPem);
|
||||||
|
const api = {
|
||||||
|
pluginConfig: {
|
||||||
|
runtimeStatePath: path.join(root, "state.jsonl"),
|
||||||
|
runtimeSigningPublicKeyPath: publicKeyPath,
|
||||||
|
runtimeSigningKeyId: "key-1",
|
||||||
|
runtimeCredentialRef: "secretref:test",
|
||||||
|
},
|
||||||
|
registerTool(value, options) { factory = value; assert.equal(options.name, "aios_evidence_write"); },
|
||||||
|
registerHttpRoute(value) { routes.push(value); },
|
||||||
|
on(name) { hooks.push(name); },
|
||||||
|
};
|
||||||
|
plugin.register(api);
|
||||||
|
const tool = factory({});
|
||||||
|
assert.equal(tool.name, "aios_evidence_write");
|
||||||
|
assert.equal(tool.label, "AIOS Evidence Writer");
|
||||||
|
assert.equal(tool.executionMode, "sequential");
|
||||||
|
assert.deepEqual(routes.map((route) => route.path), ["/v1/health", "/v1/execute", "/v1/commands/"]);
|
||||||
|
assert.deepEqual(hooks.sort(), ["agent_end", "before_agent_run", "before_tool_call"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manifest explicitly declares the dedicated tool contract", () => {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"));
|
||||||
|
assert.deepEqual(manifest.contracts.tools, ["aios_evidence_write"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writer params reject unauthorized paths and operations", () => {
|
||||||
|
const payload = fixture();
|
||||||
|
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: "README.md", operation: "update", document: {} }, payload).code, "path_not_allowed");
|
||||||
|
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "exec", document: {} }, payload).code, "operation_not_allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authorization binds signature, run, sender, params, and one-use permit", () => {
|
||||||
|
__state.permits.clear();
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-prov-"));
|
||||||
|
const store = path.join(root, "store");
|
||||||
|
fs.mkdirSync(store);
|
||||||
|
const keys = keyFixture();
|
||||||
|
const payload = fixture();
|
||||||
|
const record = signRecord(payload, keys.privateDer, "key-1");
|
||||||
|
fs.writeFileSync(path.join(store, `${payload.provenance_id}.json`), canonicalize(record), { mode: 0o600 });
|
||||||
|
const publicKeyPath = path.join(root, "public.pem");
|
||||||
|
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o600 });
|
||||||
|
const params = { provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "update", document: { state: "ok" } };
|
||||||
|
const event = { toolName: "aios_evidence_write", toolCallId: "call-1", runId: payload.run_id, params };
|
||||||
|
const config = {
|
||||||
|
provenanceStoreDir: store, provenancePublicKeyPath: publicKeyPath, provenanceKeyId: "key-1",
|
||||||
|
writerTaskBranch: payload.task_branch, writerTaskId: payload.task_id, provenanceIssuer: payload.issued_by,
|
||||||
|
executor: payload.executor, executorInstance: payload.executor_instance,
|
||||||
|
};
|
||||||
|
const authorized = authorizeEvidenceCall({ event, trust: { trusted: true, senderId: payload.sender_id }, config });
|
||||||
|
assert.equal(authorized.ok, true, authorized.code);
|
||||||
|
assert.equal(consumePermit("call-1", params).payload.run_id, payload.run_id);
|
||||||
|
assert.throws(() => consumePermit("call-1", params), /permit_missing/);
|
||||||
|
const wrongRun = { ...event, toolCallId: "call-2", runId: "wrong" };
|
||||||
|
assert.equal(authorizeEvidenceCall({ event: wrongRun, trust: { trusted: true, senderId: payload.sender_id }, config }).code, "run_id_mismatch");
|
||||||
|
const wrongSender = { trusted: true, senderId: "wrong" };
|
||||||
|
assert.equal(authorizeEvidenceCall({ event: { ...event, toolCallId: "call-3" }, trust: wrongSender, config }).code, "sender_id_mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
function git(cwd, args) {
|
||||||
|
const result = spawnSync("git", args, { cwd, encoding: "utf8", shell: false });
|
||||||
|
if (result.status !== 0) throw new Error(result.stderr || "git failed");
|
||||||
|
return (result.stdout || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("writer commits only one evidence path and explicitly pushes current branch", async (t) => {
|
||||||
|
if (spawnSync("git", ["--version"], { shell: false }).status !== 0) return t.skip("git unavailable");
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-writer-"));
|
||||||
|
const repo = path.join(root, "repo");
|
||||||
|
const bare = path.join(root, "remote.git");
|
||||||
|
fs.mkdirSync(repo);
|
||||||
|
git(root, ["init", "--bare", bare]);
|
||||||
|
git(repo, ["init", "-b", "task/openclaw/test"]);
|
||||||
|
git(repo, ["config", "user.name", "AIOS Test"]);
|
||||||
|
git(repo, ["config", "user.email", "aios-test@example.invalid"]);
|
||||||
|
fs.mkdirSync(path.join(repo, ".agent"));
|
||||||
|
fs.writeFileSync(path.join(repo, ".agent", "status.json"), "{\"state\":\"before\"}\n");
|
||||||
|
git(repo, ["add", ".agent/status.json"]);
|
||||||
|
git(repo, ["commit", "-m", "base"]);
|
||||||
|
const claim = git(repo, ["rev-parse", "HEAD"]);
|
||||||
|
git(repo, ["remote", "add", "origin", bare]);
|
||||||
|
git(repo, ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
|
||||||
|
const payload = fixture();
|
||||||
|
payload.claim_oid = claim;
|
||||||
|
payload.candidate_oid = claim;
|
||||||
|
const result = await executeEvidenceWrite({
|
||||||
|
repoRoot: repo, evidencePath: ".agent/status.json", operation: "update",
|
||||||
|
document: { state: "after" }, payload, pushRemote: "origin",
|
||||||
|
auditLogPath: path.join(root, "audit", "writer.jsonl"), gitBinary: "git",
|
||||||
|
});
|
||||||
|
assert.equal(result.outcome, "pushed");
|
||||||
|
assert.equal(git(root, ["--git-dir", bare, "rev-parse", "refs/heads/task/openclaw/test"]), result.commit_oid);
|
||||||
|
assert.deepEqual(buildPushArgs("origin", payload.task_branch), ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import { createRuntimeApi, __test } from "../runtime.js";
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-runtime-"));
|
||||||
|
const keys = crypto.generateKeyPairSync("ed25519");
|
||||||
|
const publicKeyPath = path.join(root, "public.pem");
|
||||||
|
fs.writeFileSync(publicKeyPath, keys.publicKey.export({ format: "pem", type: "spki" }));
|
||||||
|
const api = createRuntimeApi({ pluginConfig: {
|
||||||
|
runtimeStatePath: path.join(root, "state.jsonl"),
|
||||||
|
runtimeSigningPublicKeyPath: publicKeyPath,
|
||||||
|
runtimeSigningKeyId: "runtime-key-1",
|
||||||
|
runtimeCredentialRef: "secretref:runtime-test",
|
||||||
|
runtimeMaxDelayMs: 3000,
|
||||||
|
} });
|
||||||
|
return { root, keys, api };
|
||||||
|
}
|
||||||
|
|
||||||
|
function signed(keys, overrides = {}) {
|
||||||
|
const command = {
|
||||||
|
command_id: `cmd-${crypto.randomUUID()}`,
|
||||||
|
tenant_id: "tenant-a",
|
||||||
|
task_id: "task-a",
|
||||||
|
trace_id: "trace-a",
|
||||||
|
action: "status",
|
||||||
|
payload: { side_effects: false },
|
||||||
|
idempotency_key: `idem-${crypto.randomUUID()}`,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
contract_version: "aios.openclaw.command.v1",
|
||||||
|
key_id: "runtime-key-1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
command.signature = crypto.sign(null, Buffer.from(__test.canonical(command)), keys.privateKey).toString("base64url");
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(method, url, body = null) {
|
||||||
|
const req = Readable.from(body === null ? [] : [Buffer.from(JSON.stringify(body))]);
|
||||||
|
req.method = method;
|
||||||
|
req.url = url;
|
||||||
|
req.headers = { "x-aios-credential-ref": "secretref:runtime-test" };
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
function response() {
|
||||||
|
let resolve;
|
||||||
|
const done = new Promise((value) => { resolve = value; });
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
headers: {},
|
||||||
|
body: null,
|
||||||
|
done,
|
||||||
|
writeHead(status, headers) { this.status = status; this.headers = headers; },
|
||||||
|
end(data) { this.body = JSON.parse(Buffer.from(data).toString("utf8")); resolve(this); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(handler, req) {
|
||||||
|
const res = response();
|
||||||
|
await handler(req, res);
|
||||||
|
return res.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("signed status executes, persists, and replays idempotently", async () => {
|
||||||
|
const { keys, api, root } = fixture();
|
||||||
|
const command = signed(keys);
|
||||||
|
const first = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
assert.equal(first.body.state, "COMPLETED");
|
||||||
|
assert.equal(first.body.output.runtime, "openclaw-plugin");
|
||||||
|
const second = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||||
|
assert.deepEqual(second.body, first.body);
|
||||||
|
assert.ok(fs.statSync(path.join(root, "state.jsonl")).size > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tamper, expiry, and conflicting identity are rejected", async () => {
|
||||||
|
const { keys, api } = fixture();
|
||||||
|
const tampered = signed(keys);
|
||||||
|
tampered.tenant_id = "tenant-b";
|
||||||
|
assert.equal((await call(api.handleExecute, request("POST", "/v1/execute", tampered))).status, 401);
|
||||||
|
const expired = signed(keys, {
|
||||||
|
created_at: new Date(Date.now() - 700_000).toISOString(),
|
||||||
|
expires_at: new Date(Date.now() - 600_000).toISOString(),
|
||||||
|
});
|
||||||
|
assert.equal((await call(api.handleExecute, request("POST", "/v1/execute", expired))).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status is tenant-scoped and unknown actions fail closed", async () => {
|
||||||
|
const { keys, api } = fixture();
|
||||||
|
const rejected = signed(keys, { action: "arbitrary.exec" });
|
||||||
|
const denied = await call(api.handleExecute, request("POST", "/v1/execute", rejected));
|
||||||
|
assert.equal(denied.status, 422);
|
||||||
|
assert.equal(denied.body.error, "unsupported_action");
|
||||||
|
const command = signed(keys);
|
||||||
|
await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||||
|
const wrong = await call(api.commandRequest, request("GET", `/v1/commands/${command.command_id}?tenant_id=tenant-b`));
|
||||||
|
assert.equal(wrong.status, 404);
|
||||||
|
const correct = await call(api.commandRequest, request("GET", `/v1/commands/${command.command_id}?tenant_id=tenant-a`));
|
||||||
|
assert.equal(correct.body.command_id, command.command_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("active delay supports deterministic cancellation", async () => {
|
||||||
|
const { keys, api } = fixture();
|
||||||
|
const command = signed(keys, { action: "acceptance.delay", payload: { delay_ms: 2000 } });
|
||||||
|
const executeResponse = response();
|
||||||
|
const execution = api.handleExecute(request("POST", "/v1/execute", command), executeResponse);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
const cancelled = await call(api.commandRequest, request("POST", `/v1/commands/${command.command_id}/cancel?tenant_id=tenant-a`));
|
||||||
|
assert.equal(cancelled.body.accepted, true);
|
||||||
|
await execution;
|
||||||
|
const terminal = await executeResponse.done;
|
||||||
|
assert.equal(terminal.body.state, "CANCELLED");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signed deadline produces TIMED_OUT", async () => {
|
||||||
|
const { keys, api } = fixture();
|
||||||
|
const command = signed(keys, {
|
||||||
|
action: "acceptance.delay",
|
||||||
|
payload: { delay_ms: 1000 },
|
||||||
|
expires_at: new Date(Date.now() + 40).toISOString(),
|
||||||
|
});
|
||||||
|
const result = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||||
|
assert.equal(result.body.state, "TIMED_OUT");
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { ALLOWED_PATHS, canonicalize } from "./provenance.js";
|
||||||
|
|
||||||
|
function safeFsync(fd) {
|
||||||
|
try { fs.fsyncSync(fd); }
|
||||||
|
catch (error) {
|
||||||
|
if (process.platform !== "win32" || error?.code !== "EPERM") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(args, { cwd, gitBinary = "/usr/bin/git", env = {} }) {
|
||||||
|
const result = spawnSync(gitBinary, ["-c", `safe.directory=${cwd}`, ...args], {
|
||||||
|
cwd,
|
||||||
|
shell: false,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) throw new Error(`git_${args[0]}_failed`);
|
||||||
|
return result.stdout || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanBranch(branch) {
|
||||||
|
const value = typeof branch === "string" ? branch.replace(/^refs\/heads\//, "") : "";
|
||||||
|
if (!value || value.startsWith("-") || value.includes("..") || value.endsWith(".") || value.includes("@{")) {
|
||||||
|
throw new Error("invalid_branch");
|
||||||
|
}
|
||||||
|
for (const forbidden of ["~", "^", ":", "?", "*", "[", "\\", " ", "\t", "\n"]) {
|
||||||
|
if (value.includes(forbidden)) throw new Error("invalid_branch");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPushArgs(remote, branch) {
|
||||||
|
const clean = cleanBranch(branch);
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(remote)) throw new Error("invalid_remote");
|
||||||
|
return ["push", remote, `refs/heads/${clean}:refs/heads/${clean}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSafeTarget(repoRoot, evidencePath) {
|
||||||
|
if (!ALLOWED_PATHS.includes(evidencePath)) throw new Error("path_not_allowed");
|
||||||
|
const root = fs.realpathSync(repoRoot);
|
||||||
|
const target = path.resolve(root, evidencePath);
|
||||||
|
if (!target.startsWith(`${root}${path.sep}`)) throw new Error("path_escape");
|
||||||
|
let cursor = root;
|
||||||
|
for (const segment of path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean)) {
|
||||||
|
cursor = path.join(cursor, segment);
|
||||||
|
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error("symlink_path");
|
||||||
|
}
|
||||||
|
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) throw new Error("symlink_path");
|
||||||
|
return { root, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWrite(target, document, previousMode) {
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o770 });
|
||||||
|
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
|
||||||
|
const mode = previousMode ?? 0o660;
|
||||||
|
fs.writeFileSync(temporary, `${canonicalize(document)}\n`, { flag: "wx", mode });
|
||||||
|
const handle = fs.openSync(temporary, "r");
|
||||||
|
try { safeFsync(handle); } finally { fs.closeSync(handle); }
|
||||||
|
fs.renameSync(temporary, target);
|
||||||
|
fs.chmodSync(target, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendAudit(auditLogPath, entry) {
|
||||||
|
if (!path.isAbsolute(auditLogPath)) throw new Error("invalid_audit_log_path");
|
||||||
|
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true, mode: 0o750 });
|
||||||
|
const fd = fs.openSync(auditLogPath, "a", 0o640);
|
||||||
|
try {
|
||||||
|
fs.writeSync(fd, `${canonicalize(entry)}\n`);
|
||||||
|
safeFsync(fd);
|
||||||
|
} finally { fs.closeSync(fd); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitEnvironment(askPassPath) {
|
||||||
|
if (!askPassPath) return {};
|
||||||
|
const stat = fs.lstatSync(askPassPath);
|
||||||
|
if (!path.isAbsolute(askPassPath) || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) {
|
||||||
|
throw new Error("unsafe_askpass");
|
||||||
|
}
|
||||||
|
return { GIT_ASKPASS: askPassPath, GIT_ASKPASS_REQUIRE: "force", DISPLAY: "aios" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreBeforeCommit(target, existed, backup, mode, root, relative, gitOptions) {
|
||||||
|
try { runGit(["restore", "--staged", "--", relative], gitOptions); } catch { /* best effort */ }
|
||||||
|
if (existed) {
|
||||||
|
fs.writeFileSync(target, backup, { mode });
|
||||||
|
} else if (fs.existsSync(target)) {
|
||||||
|
fs.unlinkSync(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeEvidenceWrite({
|
||||||
|
repoRoot, evidencePath, operation, document, payload, pushRemote = "gitea-http",
|
||||||
|
askPassPath = "", auditLogPath, gitBinary = "/usr/bin/git",
|
||||||
|
}) {
|
||||||
|
const { root, target } = ensureSafeTarget(repoRoot, evidencePath);
|
||||||
|
const relative = path.relative(root, target).replace(/\\/g, "/");
|
||||||
|
const gitOptions = { cwd: root, gitBinary, env: gitEnvironment(askPassPath) };
|
||||||
|
const branch = cleanBranch(payload.task_branch);
|
||||||
|
if (runGit(["symbolic-ref", "--short", "HEAD"], gitOptions).trim() !== branch) throw new Error("branch_mismatch");
|
||||||
|
if (runGit(["rev-parse", "HEAD"], gitOptions).trim() !== payload.claim_oid) throw new Error("head_mismatch");
|
||||||
|
runGit(["merge-base", "--is-ancestor", payload.candidate_oid, payload.claim_oid], gitOptions);
|
||||||
|
if (runGit(["status", "--porcelain", "--untracked-files=all"], gitOptions).trim()) throw new Error("worktree_not_clean");
|
||||||
|
|
||||||
|
const existed = fs.existsSync(target);
|
||||||
|
if (operation === "create" && existed) throw new Error("path_exists_for_create");
|
||||||
|
if (operation === "update" && !existed) throw new Error("path_missing_for_update");
|
||||||
|
const backup = existed ? fs.readFileSync(target) : null;
|
||||||
|
const priorMode = existed ? fs.statSync(target).mode & 0o777 : 0o660;
|
||||||
|
let committed = false;
|
||||||
|
let commitOid = null;
|
||||||
|
try {
|
||||||
|
atomicWrite(target, document, priorMode);
|
||||||
|
runGit(["add", "--", relative], gitOptions);
|
||||||
|
const staged = runGit(["diff", "--cached", "--name-only", "-z"], gitOptions).split("\0").filter(Boolean);
|
||||||
|
if (staged.length !== 1 || staged[0] !== relative) throw new Error("staged_scope_mismatch");
|
||||||
|
runGit(["commit", "-m", `chore(aios): provenance ${payload.provenance_id} ${path.basename(relative)}`], gitOptions);
|
||||||
|
committed = true;
|
||||||
|
commitOid = runGit(["rev-parse", "HEAD"], gitOptions).trim();
|
||||||
|
runGit(buildPushArgs(pushRemote, branch), gitOptions);
|
||||||
|
appendAudit(auditLogPath, {
|
||||||
|
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
|
||||||
|
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
|
||||||
|
branch, commit_oid: commitOid, outcome: "pushed", recorded_at: Date.now(),
|
||||||
|
});
|
||||||
|
return { path: relative, operation, branch, commit_oid: commitOid, outcome: "pushed" };
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof Error ? error.message : "writer_failed";
|
||||||
|
if (!committed) restoreBeforeCommit(target, existed, backup, priorMode, root, relative, gitOptions);
|
||||||
|
try {
|
||||||
|
appendAudit(auditLogPath, {
|
||||||
|
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
|
||||||
|
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
|
||||||
|
branch, commit_oid: commitOid, outcome: committed ? "committed_push_blocked" : "rejected",
|
||||||
|
error_code: code, recorded_at: Date.now(),
|
||||||
|
});
|
||||||
|
} catch { /* the original failure remains authoritative */ }
|
||||||
|
throw new Error(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
apiVersion: aios.v2/v1
|
||||||
|
kind: AgentTask
|
||||||
|
metadata:
|
||||||
|
name: codex-production-supervisor-task
|
||||||
|
displayName: AIOS V2R1 Codex 架构监督、阻塞处理与发布审查任务
|
||||||
|
owner: AIOS-Control-Plane
|
||||||
|
language: zh-CN
|
||||||
|
spec:
|
||||||
|
executor: codex
|
||||||
|
role: architecture-supervisor
|
||||||
|
candidateBranch: integration/aios-v2r1-cto-production-candidate-20260720
|
||||||
|
taskBranch: task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720
|
||||||
|
initialState: queued
|
||||||
|
objective: >-
|
||||||
|
监督 AIOS V2R1 Candidate 的预发布部署和缺陷修复,持续检查唯一控制面、
|
||||||
|
多租户、安全、文档、测试和回滚边界;分析并解决 OpenClaw 无法独立处理的阻塞,
|
||||||
|
最终输出统一的发布就绪结论。
|
||||||
|
inputs:
|
||||||
|
- docs/aios-v2/
|
||||||
|
- docs/aios-v2/adr/
|
||||||
|
- AIOS-V2R1-PRODUCTION-CANDIDATE.md
|
||||||
|
- tasks/openclaw-production-deploy-task.yaml
|
||||||
|
- deploy/aios-v2/
|
||||||
|
- active-runtime/app/
|
||||||
|
- tests/aios_v2_candidate/
|
||||||
|
- tools/aios_v2_docs_code_check.py
|
||||||
|
responsibilities:
|
||||||
|
- 领取任务并写入 claim 证据
|
||||||
|
- 定期写入 heartbeat、progress 和监督结论
|
||||||
|
- 审阅 OpenClaw 的部署、健康检查、联调和回滚证据
|
||||||
|
- 分析失败测试、运行时异常和阻塞根因
|
||||||
|
- 审查所有 patch 的架构边界、租户隔离和 Secret 安全
|
||||||
|
- 解决需要跨模块修改的阻塞问题
|
||||||
|
- 对控制面、数据模型、状态机或 Runtime Contract 变化要求新增或更新 ADR
|
||||||
|
- 执行文档与代码匹配检查
|
||||||
|
- 验证需求、架构、契约、代码、测试、部署和验收追踪完整
|
||||||
|
- 拒绝缺少复现、测试、文档或回滚说明的修改
|
||||||
|
- 将所有阻塞、审查结论、修复 Commit 和剩余风险提交到系统
|
||||||
|
- 输出是否允许进入合并、Tag 和生产部署 Gate 的最终建议
|
||||||
|
prohibited:
|
||||||
|
- 绕过 GitOps 直接应用生产修改
|
||||||
|
- 直接 push main 或创建生产 Tag
|
||||||
|
- 在没有证据时批准发布
|
||||||
|
- 为快速通过测试而削弱租户隔离、安全门禁或文档门禁
|
||||||
|
- 忽略 OpenClaw 提交的 blocker
|
||||||
|
- 未经 AIOS Owner 批准接受重大剩余风险
|
||||||
|
requiredChecks:
|
||||||
|
architecture:
|
||||||
|
- AIOS 仍是唯一控制面
|
||||||
|
- Agent Router 只承担调度
|
||||||
|
- OpenClaw 与 Hermes 不拥有权威 Task Lifecycle
|
||||||
|
- Web 不直接访问 Runtime
|
||||||
|
tenancy:
|
||||||
|
- 所有新资源和接口包含 tenant_id
|
||||||
|
- 跨租户负向测试存在且通过
|
||||||
|
- Runtime Result tenant/task/trace 校验存在
|
||||||
|
security:
|
||||||
|
- Git 中无明文 Secret
|
||||||
|
- Secret 仅通过引用或受保护文件解析
|
||||||
|
- 身份代理防伪边界明确
|
||||||
|
quality:
|
||||||
|
- 文档—代码匹配检查通过
|
||||||
|
- 测试命令、exit code 和 pass/fail 有证据
|
||||||
|
- 重启、健康检查和回滚演练有证据
|
||||||
|
- 本地与 Gitea Candidate OID 一致
|
||||||
|
documentation:
|
||||||
|
- 用户需求已同步
|
||||||
|
- 目标架构已同步
|
||||||
|
- 模块契约已同步
|
||||||
|
- 追踪矩阵已同步
|
||||||
|
- ADR 已同步
|
||||||
|
- 候选报告已同步
|
||||||
|
blockerWorkflow:
|
||||||
|
states: [new, analyzing, fix_proposed, fix_in_progress, verification, resolved, accepted_risk]
|
||||||
|
requiredFields:
|
||||||
|
- blocker_id
|
||||||
|
- severity
|
||||||
|
- affected_requirement
|
||||||
|
- affected_module
|
||||||
|
- reproduction
|
||||||
|
- root_cause
|
||||||
|
- proposed_fix
|
||||||
|
- owner
|
||||||
|
- patch_branch
|
||||||
|
- verification
|
||||||
|
- documentation_updates
|
||||||
|
- exit_condition
|
||||||
|
evidenceRequired:
|
||||||
|
claim:
|
||||||
|
fields: [task_id, executor, branch, candidate_commit, claimed_at]
|
||||||
|
heartbeat:
|
||||||
|
intervalSeconds: 300
|
||||||
|
fields: [status, current_step, reviewed_commits, open_blockers, heartbeat_at]
|
||||||
|
review:
|
||||||
|
fields:
|
||||||
|
- patch_branch
|
||||||
|
- patch_commit
|
||||||
|
- architecture_result
|
||||||
|
- tenant_result
|
||||||
|
- security_result
|
||||||
|
- documentation_result
|
||||||
|
- test_result
|
||||||
|
- decision
|
||||||
|
result:
|
||||||
|
fields:
|
||||||
|
- final_status
|
||||||
|
- candidate_commit
|
||||||
|
- reviewed_commits
|
||||||
|
- resolved_blockers
|
||||||
|
- open_blockers
|
||||||
|
- accepted_risks
|
||||||
|
- documentation_sync_status
|
||||||
|
- test_evidence_status
|
||||||
|
- preproduction_status
|
||||||
|
- rollback_status
|
||||||
|
- local_gitea_oid_status
|
||||||
|
- final_recommendation
|
||||||
|
acceptanceCommands:
|
||||||
|
- python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||||
|
- python3 -m unittest discover -s tests/aios_v2_candidate -p 'test_*.py' -v
|
||||||
|
- bash deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
completionCriteria:
|
||||||
|
- OpenClaw 部署与修复结果已完整审阅
|
||||||
|
- 所有严重和高风险 blocker 已解决或由 Owner 明确接受
|
||||||
|
- 文档、代码、测试和部署一致
|
||||||
|
- Candidate 精确 Commit 的测试和预发布证据完整
|
||||||
|
- 输出明确的进入下一 Gate、继续修复或停止发布建议
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
apiVersion: aios.v2/v1
|
||||||
|
kind: AgentTask
|
||||||
|
metadata:
|
||||||
|
name: openclaw-production-deploy-task
|
||||||
|
displayName: AIOS V2R1 OpenClaw 预发布部署与小型缺陷修复任务
|
||||||
|
owner: AIOS-Control-Plane
|
||||||
|
language: zh-CN
|
||||||
|
spec:
|
||||||
|
executor: openclaw
|
||||||
|
role: deployment-operator
|
||||||
|
candidateBranch: integration/aios-v2r1-cto-production-candidate-20260720
|
||||||
|
taskBranch: task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720
|
||||||
|
initialState: queued
|
||||||
|
objective: >-
|
||||||
|
在隔离预发布环境部署 AIOS V2R1 Candidate,执行健康检查、运行时联调、
|
||||||
|
重启与回滚演练;对部署、配置、脚本和 Runtime 适配中的低风险缺陷进行小范围修复。
|
||||||
|
inputs:
|
||||||
|
- docs/aios-v2/
|
||||||
|
- AIOS-V2R1-PRODUCTION-CANDIDATE.md
|
||||||
|
- deploy/aios-v2/
|
||||||
|
- active-runtime/app/aios_v2/
|
||||||
|
- active-runtime/app/aios_openclaw/
|
||||||
|
- active-runtime/app/aios_hermes/
|
||||||
|
- tests/aios_v2_candidate/
|
||||||
|
responsibilities:
|
||||||
|
- 领取任务并写入 claim 证据
|
||||||
|
- 每个主要步骤写入 heartbeat 和 progress
|
||||||
|
- 安装或验证 systemd 部署
|
||||||
|
- 构建或验证 Docker/Compose 部署
|
||||||
|
- 配置非敏感环境变量和 Secret 引用
|
||||||
|
- 执行文档与代码匹配检查
|
||||||
|
- 执行候选测试和健康检查
|
||||||
|
- 验证 SQLite 状态目录和重启恢复
|
||||||
|
- 联调真实 OpenClaw Runtime 的 execute、status、cancel、timeout 和 health
|
||||||
|
- 验证 Hermes Worker claim、heartbeat、complete、fail 和 lease recovery
|
||||||
|
- 执行代码回滚和健康检查演练
|
||||||
|
- 对低风险部署或 Runtime 缺陷创建 patch 分支并修复
|
||||||
|
- 所有修改同步更新 docs/aios-v2/ 和追踪矩阵
|
||||||
|
- 所有阻塞提交到 Execution Evidence,交由 Codex 监督任务统一审视
|
||||||
|
prohibited:
|
||||||
|
- 修改 AIOS 唯一控制面原则
|
||||||
|
- 修改 Tenant、Policy、Approval 或 Task Lifecycle 权威边界
|
||||||
|
- 绕过 GitOps 直接修改生产
|
||||||
|
- 直接 push main 或创建生产 Tag
|
||||||
|
- 提交 Token、密码、私钥、API Key 或签名材料
|
||||||
|
- 在没有复现测试和文档更新时扩大修复范围
|
||||||
|
- 对架构问题自行做不可逆决策
|
||||||
|
lowRiskFixScope:
|
||||||
|
- systemd 单元和文件权限
|
||||||
|
- Dockerfile、Compose 和健康检查
|
||||||
|
- 环境变量、路径和启动参数
|
||||||
|
- 日志、错误消息和超时边界
|
||||||
|
- Runtime Adapter 的小型兼容性缺陷
|
||||||
|
- 测试、部署或文档中的明确错误
|
||||||
|
escalation:
|
||||||
|
architectureChange: Codex 监督任务
|
||||||
|
controlPlaneChange: Codex 监督任务
|
||||||
|
tenantIsolationIssue: Codex 监督任务和 AIOS Owner
|
||||||
|
secretOrSecurityIssue: 立即停止并升级 AIOS Owner
|
||||||
|
productionApproval: AIOS Owner
|
||||||
|
evidenceRequired:
|
||||||
|
claim:
|
||||||
|
fields: [task_id, executor, branch, candidate_commit, claimed_at]
|
||||||
|
heartbeat:
|
||||||
|
intervalSeconds: 300
|
||||||
|
fields: [status, current_step, progress, last_commit, heartbeat_at]
|
||||||
|
blocker:
|
||||||
|
fields: [blocker_id, severity, description, reproduction, owner, exit_condition]
|
||||||
|
result:
|
||||||
|
fields:
|
||||||
|
- final_status
|
||||||
|
- branch
|
||||||
|
- commit
|
||||||
|
- changed_files
|
||||||
|
- test_commands
|
||||||
|
- test_exit_codes
|
||||||
|
- passed
|
||||||
|
- failed
|
||||||
|
- healthcheck
|
||||||
|
- deployment_status
|
||||||
|
- rollback_status
|
||||||
|
- documentation_sync_status
|
||||||
|
- remaining_risks
|
||||||
|
acceptanceCommands:
|
||||||
|
- python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||||
|
- python3 -m unittest discover -s tests/aios_v2_candidate -p 'test_*.py' -v
|
||||||
|
- bash deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
- bash deploy/aios-v2/healthcheck/check.sh
|
||||||
|
completionCriteria:
|
||||||
|
- 文档—代码检查通过
|
||||||
|
- 候选自动测试通过
|
||||||
|
- systemd 或 Docker 预发布部署成功
|
||||||
|
- OpenClaw 与 Hermes 真实联调完成
|
||||||
|
- 重启恢复和回滚演练完成
|
||||||
|
- 所有缺陷、阻塞、提交和测试证据已回写
|
||||||
|
- Codex 输出可进入下一 Gate 的监督结论
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
APP_ROOT = REPO_ROOT / "active-runtime" / "app"
|
||||||
|
if str(APP_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(APP_ROOT))
|
||||||
|
|
||||||
|
from aios_hermes import HermesAdapter, HermesJobState, HermesWorker
|
||||||
|
from aios_openclaw import (
|
||||||
|
CommandState,
|
||||||
|
InProcessRuntimeTransport,
|
||||||
|
OpenClawAdapter,
|
||||||
|
)
|
||||||
|
from aios_openclaw.contract import OpenClawCommand, sign_command, verify_command_signature
|
||||||
|
from aios_v2 import (
|
||||||
|
ControlPlaneService,
|
||||||
|
ExecutionEvidence,
|
||||||
|
ExecutionOrchestrator,
|
||||||
|
ExecutionTracker,
|
||||||
|
RequestContext,
|
||||||
|
TaskState,
|
||||||
|
TenantIsolationError,
|
||||||
|
)
|
||||||
|
from aios_v2.persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||||
|
from aios_v2.gateway import GatewayApplication
|
||||||
|
from aios_v2.app import build_application
|
||||||
|
|
||||||
|
|
||||||
|
SIGNING_MATERIAL = bytes(range(32))
|
||||||
|
|
||||||
|
|
||||||
|
def context(
|
||||||
|
tenant_id: str,
|
||||||
|
principal: str,
|
||||||
|
*roles: str,
|
||||||
|
) -> RequestContext:
|
||||||
|
return RequestContext.create(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
principal_id=principal,
|
||||||
|
roles=roles,
|
||||||
|
trace_id=f"trace-{tenant_id}-{principal}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap(service: ControlPlaneService, tenant_id: str = "tenant-a"):
|
||||||
|
platform = context(tenant_id, "platform", "platform_admin")
|
||||||
|
service.register_tenant(
|
||||||
|
platform,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
display_name=tenant_id,
|
||||||
|
)
|
||||||
|
operator = context(
|
||||||
|
tenant_id,
|
||||||
|
"operator",
|
||||||
|
"tenant_admin",
|
||||||
|
"operator",
|
||||||
|
"approver",
|
||||||
|
"auditor",
|
||||||
|
"runtime",
|
||||||
|
)
|
||||||
|
return platform, operator
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateFlowTests(unittest.TestCase):
|
||||||
|
def test_rollback_entrypoints_are_executable_in_release_archive(self) -> None:
|
||||||
|
for relative in (
|
||||||
|
"deploy/aios-v2/rollback/rollback.sh",
|
||||||
|
"deploy/aios-v2/healthcheck/check.sh",
|
||||||
|
):
|
||||||
|
mode = (REPO_ROOT / relative).stat().st_mode
|
||||||
|
self.assertTrue(mode & stat.S_IXUSR, relative)
|
||||||
|
|
||||||
|
def test_build_application_propagates_openclaw_signing_key_id(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
material_path = Path(temp_dir) / "signing-material.bin"
|
||||||
|
material_path.write_bytes(SIGNING_MATERIAL)
|
||||||
|
values = {
|
||||||
|
"AIOS_V2_STATE_DIR": temp_dir,
|
||||||
|
"AIOS_OPENCLAW_URL": "http://127.0.0.1:18789",
|
||||||
|
"AIOS_OPENCLAW_SIGNING_MATERIAL_FILE": str(material_path),
|
||||||
|
"AIOS_OPENCLAW_SIGNING_KEY_ID": "openclaw-v2r1-candidate-ed25519",
|
||||||
|
"AIOS_OPENCLAW_CREDENTIAL_REF": "secretref:test",
|
||||||
|
}
|
||||||
|
previous = {key: os.environ.get(key) for key in values}
|
||||||
|
try:
|
||||||
|
os.environ.update(values)
|
||||||
|
application = build_application()
|
||||||
|
finally:
|
||||||
|
for key, value in previous.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
else:
|
||||||
|
os.environ[key] = value
|
||||||
|
self.assertEqual(
|
||||||
|
application.orchestrator.openclaw_key_id,
|
||||||
|
"openclaw-v2r1-candidate-ed25519",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_http_identity_headers_are_case_insensitive(self) -> None:
|
||||||
|
request_context = GatewayApplication.context_from_headers(
|
||||||
|
{
|
||||||
|
"X-Aios-Authenticated": "true",
|
||||||
|
"x-aios-tenant-id": "tenant-a",
|
||||||
|
"X-AIOS-PRINCIPAL-ID": "operator-a",
|
||||||
|
"X-Aios-Roles": "operator,auditor",
|
||||||
|
"x-aios-trace-id": "trace-http-header-case",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(request_context.tenant_id, "tenant-a")
|
||||||
|
self.assertEqual(request_context.principal_id, "operator-a")
|
||||||
|
self.assertEqual(request_context.trace_id, "trace-http-header-case")
|
||||||
|
self.assertEqual(request_context.roles, frozenset({"operator", "auditor"}))
|
||||||
|
|
||||||
|
def test_ed25519_command_signature_accepts_valid_and_rejects_tampering(self) -> None:
|
||||||
|
command = OpenClawCommand(
|
||||||
|
command_id="cmd-signature-test",
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
task_id="task-signature-test",
|
||||||
|
trace_id="trace-signature-test",
|
||||||
|
action="echo",
|
||||||
|
payload={"message": "hello"},
|
||||||
|
idempotency_key="signature-test-1",
|
||||||
|
key_id="openclaw-v2r1-candidate-ed25519",
|
||||||
|
)
|
||||||
|
signed = sign_command(command, SIGNING_MATERIAL)
|
||||||
|
self.assertTrue(verify_command_signature(signed, SIGNING_MATERIAL))
|
||||||
|
self.assertFalse(
|
||||||
|
verify_command_signature(replace(signed, payload={"message": "tampered"}), SIGNING_MATERIAL)
|
||||||
|
)
|
||||||
|
self.assertFalse(verify_command_signature(signed, bytes(reversed(SIGNING_MATERIAL))))
|
||||||
|
|
||||||
|
def test_openclaw_full_flow_reconciles_completed_task(self) -> None:
|
||||||
|
service = ControlPlaneService()
|
||||||
|
_, operator = bootstrap(service)
|
||||||
|
agent = service.register_agent(
|
||||||
|
operator,
|
||||||
|
agent_id="openclaw-agent",
|
||||||
|
display_name="OpenClaw",
|
||||||
|
runtime="openclaw",
|
||||||
|
capabilities=("echo",),
|
||||||
|
)
|
||||||
|
task = service.create_task(
|
||||||
|
operator,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
action="echo",
|
||||||
|
payload={"message": "hello"},
|
||||||
|
idempotency_key="openclaw-flow-1",
|
||||||
|
)
|
||||||
|
approved = service.transition_task(
|
||||||
|
operator,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.APPROVED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
captured_commands = []
|
||||||
|
|
||||||
|
def execute(command):
|
||||||
|
captured_commands.append(command)
|
||||||
|
return {
|
||||||
|
"state": CommandState.COMPLETED.value,
|
||||||
|
"output": {"echo": command.payload["message"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
transport = InProcessRuntimeTransport(execute)
|
||||||
|
resolver = lambda tenant_id, key_id: SIGNING_MATERIAL
|
||||||
|
adapter = OpenClawAdapter(transport, resolver)
|
||||||
|
orchestrator = ExecutionOrchestrator(
|
||||||
|
service,
|
||||||
|
openclaw=adapter,
|
||||||
|
openclaw_signing_material_resolver=resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatch = orchestrator.dispatch(
|
||||||
|
operator, task_id=approved.task_id, timeout_seconds=600
|
||||||
|
)
|
||||||
|
completed = service.get_task(operator, task.task_id)
|
||||||
|
|
||||||
|
self.assertEqual(dispatch.runtime, "openclaw")
|
||||||
|
self.assertEqual(dispatch.state, CommandState.COMPLETED.value)
|
||||||
|
self.assertEqual(completed.state, TaskState.COMPLETED)
|
||||||
|
self.assertEqual(completed.result, {"echo": "hello"})
|
||||||
|
created = datetime.fromisoformat(captured_commands[0].created_at.replace("Z", "+00:00"))
|
||||||
|
expires = datetime.fromisoformat(captured_commands[0].expires_at.replace("Z", "+00:00"))
|
||||||
|
self.assertLessEqual((expires - created).total_seconds(), 300)
|
||||||
|
|
||||||
|
def test_hermes_full_flow_reconciles_worker_result(self) -> None:
|
||||||
|
service = ControlPlaneService()
|
||||||
|
_, operator = bootstrap(service)
|
||||||
|
agent = service.register_agent(
|
||||||
|
operator,
|
||||||
|
agent_id="hermes-agent",
|
||||||
|
display_name="Hermes",
|
||||||
|
runtime="hermes",
|
||||||
|
capabilities=("transform",),
|
||||||
|
metadata={"cell_id": "cell-a", "profile_id": "profile-a"},
|
||||||
|
)
|
||||||
|
task = service.create_task(
|
||||||
|
operator,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
action="transform",
|
||||||
|
payload={"value": 7},
|
||||||
|
idempotency_key="hermes-flow-1",
|
||||||
|
)
|
||||||
|
approved = service.transition_task(
|
||||||
|
operator,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.APPROVED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
hermes = HermesAdapter(default_lease_seconds=10)
|
||||||
|
orchestrator = ExecutionOrchestrator(service, hermes=hermes)
|
||||||
|
dispatch = orchestrator.dispatch(operator, task_id=approved.task_id)
|
||||||
|
self.assertEqual(dispatch.runtime, "hermes")
|
||||||
|
self.assertEqual(dispatch.state, HermesJobState.QUEUED.value)
|
||||||
|
|
||||||
|
worker = HermesWorker(
|
||||||
|
hermes,
|
||||||
|
lambda job: {"transformed": int(job.payload["value"]) * 2},
|
||||||
|
worker_id="worker-a",
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
cell_id="cell-a",
|
||||||
|
profile_id="profile-a",
|
||||||
|
heartbeat_seconds=0.05,
|
||||||
|
)
|
||||||
|
run = worker.run_once()
|
||||||
|
self.assertTrue(run.claimed)
|
||||||
|
self.assertEqual(run.state, HermesJobState.COMPLETED.value)
|
||||||
|
|
||||||
|
result = hermes.result(dispatch.runtime_execution_id, tenant_id="tenant-a")
|
||||||
|
completed = orchestrator.reconcile_hermes(operator, result)
|
||||||
|
self.assertEqual(completed.state, TaskState.COMPLETED)
|
||||||
|
self.assertEqual(completed.result, {"transformed": 14})
|
||||||
|
|
||||||
|
def test_cross_tenant_task_read_is_denied(self) -> None:
|
||||||
|
service = ControlPlaneService()
|
||||||
|
_, tenant_a = bootstrap(service, "tenant-a")
|
||||||
|
platform_b = context("tenant-b", "platform", "platform_admin")
|
||||||
|
service.register_tenant(
|
||||||
|
platform_b,
|
||||||
|
tenant_id="tenant-b",
|
||||||
|
display_name="Tenant B",
|
||||||
|
)
|
||||||
|
tenant_b = context("tenant-b", "auditor", "auditor")
|
||||||
|
|
||||||
|
agent = service.register_agent(
|
||||||
|
tenant_a,
|
||||||
|
agent_id="agent-a",
|
||||||
|
display_name="Agent A",
|
||||||
|
runtime="hermes",
|
||||||
|
capabilities=("work",),
|
||||||
|
)
|
||||||
|
task = service.create_task(
|
||||||
|
tenant_a,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
action="work",
|
||||||
|
payload={},
|
||||||
|
idempotency_key="tenant-isolation-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(TenantIsolationError):
|
||||||
|
service.get_task(tenant_b, task.task_id)
|
||||||
|
|
||||||
|
def test_durable_service_recovers_state_after_restart(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
path = Path(temporary) / "state.sqlite3"
|
||||||
|
service = DurableControlPlaneService(SQLiteSnapshotRepository(path))
|
||||||
|
_, operator = bootstrap(service)
|
||||||
|
agent = service.register_agent(
|
||||||
|
operator,
|
||||||
|
agent_id="persistent-agent",
|
||||||
|
display_name="Persistent",
|
||||||
|
runtime="hermes",
|
||||||
|
capabilities=("persist",),
|
||||||
|
)
|
||||||
|
task = service.create_task(
|
||||||
|
operator,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
action="persist",
|
||||||
|
payload={"ok": True},
|
||||||
|
idempotency_key="persist-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
recovered = DurableControlPlaneService(SQLiteSnapshotRepository(path))
|
||||||
|
restored_task = recovered.get_task(operator, task.task_id)
|
||||||
|
self.assertEqual(restored_task.payload, {"ok": True})
|
||||||
|
self.assertEqual(restored_task.state, TaskState.CREATED)
|
||||||
|
|
||||||
|
def test_execution_tracking_persists_claim_heartbeat_and_result(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
tracker = ExecutionTracker(temporary)
|
||||||
|
claimed = tracker.claim(
|
||||||
|
ExecutionEvidence(
|
||||||
|
execution_id="exec-1",
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
task_id="task-1",
|
||||||
|
agent_id="agent-1",
|
||||||
|
executor="codex",
|
||||||
|
branch="task/codex/example",
|
||||||
|
progress_total=3,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(claimed.status, "claimed")
|
||||||
|
tracker.heartbeat(
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
execution_id="exec-1",
|
||||||
|
status="running",
|
||||||
|
current_step="tests",
|
||||||
|
progress_completed=2,
|
||||||
|
last_commit="abc123",
|
||||||
|
)
|
||||||
|
tracker.complete(
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
execution_id="exec-1",
|
||||||
|
status="complete",
|
||||||
|
test_status="passed",
|
||||||
|
test_summary={"passed": 5, "failed": 0},
|
||||||
|
)
|
||||||
|
restored = ExecutionTracker(temporary).get(
|
||||||
|
tenant_id="tenant-a",
|
||||||
|
execution_id="exec-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(restored.status, "complete")
|
||||||
|
self.assertEqual(restored.progress_completed, 3)
|
||||||
|
self.assertEqual(restored.test_summary["passed"], 5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AIOS V2 文档—代码匹配门禁。
|
||||||
|
|
||||||
|
仅使用 Python 标准库,可由本地、Gitea Actions、Codex 监督任务或发布验收脚本调用。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CheckResult:
|
||||||
|
level: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_DOCUMENTS = (
|
||||||
|
"docs/aios-v2/README.md",
|
||||||
|
"docs/aios-v2/01-USER-REQUIREMENTS.md",
|
||||||
|
"docs/aios-v2/02-TARGET-ARCHITECTURE.md",
|
||||||
|
"docs/aios-v2/03-MIGRATION-AND-DELIVERY.md",
|
||||||
|
"docs/aios-v2/04-MODULE-CONTRACTS.md",
|
||||||
|
"docs/aios-v2/05-TRACEABILITY-MATRIX.md",
|
||||||
|
"docs/aios-v2/06-DOCUMENT-GOVERNANCE.md",
|
||||||
|
"docs/aios-v2/adr/README.md",
|
||||||
|
"docs/aios-v2/adr/ADR-001-single-control-plane.md",
|
||||||
|
"docs/aios-v2/adr/ADR-002-openclaw-hermes-decoupling.md",
|
||||||
|
"docs/aios-v2/adr/ADR-003-gitops-only-change-flow.md",
|
||||||
|
"docs/aios-v2/adr/ADR-004-multi-tenant-isolation.md",
|
||||||
|
"docs/aios-v2/adr/ADR-005-document-code-sync-gate.md",
|
||||||
|
"docs/aios-v2/adr/ADR-006-candidate-persistence-strategy.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUIRED_CODE_PATHS = (
|
||||||
|
"active-runtime/app/aios_v2/control_plane.py",
|
||||||
|
"active-runtime/app/aios_v2/persistence.py",
|
||||||
|
"active-runtime/app/aios_v2/gateway.py",
|
||||||
|
"active-runtime/app/aios_v2/app.py",
|
||||||
|
"active-runtime/app/aios_v2/orchestrator.py",
|
||||||
|
"active-runtime/app/aios_v2/execution_tracking.py",
|
||||||
|
"active-runtime/app/aios_v2/gitops_status.py",
|
||||||
|
"active-runtime/app/aios_v2/console.py",
|
||||||
|
"active-runtime/app/aios_openclaw/contract.py",
|
||||||
|
"active-runtime/app/aios_openclaw/adapter.py",
|
||||||
|
"active-runtime/app/aios_openclaw/transport.py",
|
||||||
|
"active-runtime/app/aios_openclaw/health.py",
|
||||||
|
"active-runtime/app/aios_openclaw/callback.py",
|
||||||
|
"active-runtime/app/aios_hermes/contract.py",
|
||||||
|
"active-runtime/app/aios_hermes/adapter.py",
|
||||||
|
"active-runtime/app/aios_hermes/worker.py",
|
||||||
|
"tests/aios_v2_candidate/test_candidate_flow.py",
|
||||||
|
"deploy/aios-v2/systemd/aios-v2.service",
|
||||||
|
"deploy/aios-v2/docker/Dockerfile",
|
||||||
|
"deploy/aios-v2/docker/compose.yaml",
|
||||||
|
"deploy/aios-v2/healthcheck/check.sh",
|
||||||
|
"deploy/aios-v2/rollback/rollback.sh",
|
||||||
|
"tasks/openclaw-production-deploy-task.yaml",
|
||||||
|
"tasks/codex-production-supervisor-task.yaml",
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUIRED_APIS = (
|
||||||
|
"/api/v2/health",
|
||||||
|
"/api/v2/tenants",
|
||||||
|
"/api/v2/agents",
|
||||||
|
"/api/v2/tasks",
|
||||||
|
"/api/v2/audit",
|
||||||
|
"/api/v2/runtime",
|
||||||
|
"/api/v2/executions",
|
||||||
|
"/api/v2/gitops",
|
||||||
|
"/api/v2/hermes/results/reconcile",
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUIRED_MODELS = (
|
||||||
|
"RequestContext",
|
||||||
|
"TenantRecord",
|
||||||
|
"AgentRecord",
|
||||||
|
"TaskRecord",
|
||||||
|
"TaskState",
|
||||||
|
"AuditEvent",
|
||||||
|
"OpenClawCommand",
|
||||||
|
"OpenClawResult",
|
||||||
|
"RuntimeHealth",
|
||||||
|
"HermesJob",
|
||||||
|
"HermesResult",
|
||||||
|
"HermesHealth",
|
||||||
|
"ExecutionEvidence",
|
||||||
|
)
|
||||||
|
|
||||||
|
CODE_CHANGE_PREFIXES = (
|
||||||
|
"active-runtime/app/aios_v2/",
|
||||||
|
"active-runtime/app/aios_openclaw/",
|
||||||
|
"active-runtime/app/aios_hermes/",
|
||||||
|
"deploy/aios-v2/",
|
||||||
|
"tests/aios_v2_candidate/",
|
||||||
|
"tasks/",
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFLICT_MARKERS = ("<<<<<<<", "=======", ">>>>>>>")
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(repo: Path, *args: str) -> tuple[int, str]:
|
||||||
|
process = subprocess.run(
|
||||||
|
["git", "-C", str(repo), *args],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
return process.returncode, process.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def chinese_ratio(text: str) -> float:
|
||||||
|
chinese = len(re.findall(r"[\u3400-\u9fff]", text))
|
||||||
|
latin = len(re.findall(r"[A-Za-z]", text))
|
||||||
|
denominator = chinese + latin
|
||||||
|
return chinese / denominator if denominator else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def all_text(paths: Iterable[Path]) -> str:
|
||||||
|
return "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def check_required_files(repo: Path, results: list[CheckResult]) -> list[Path]:
|
||||||
|
document_paths: list[Path] = []
|
||||||
|
for relative in REQUIRED_DOCUMENTS:
|
||||||
|
path = repo / relative
|
||||||
|
if not path.is_file():
|
||||||
|
results.append(CheckResult("错误", f"缺少必需文档:{relative}"))
|
||||||
|
continue
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size < 500:
|
||||||
|
results.append(CheckResult("错误", f"文档正文过短:{relative}({size} 字节)"))
|
||||||
|
else:
|
||||||
|
document_paths.append(path)
|
||||||
|
for relative in REQUIRED_CODE_PATHS:
|
||||||
|
if not (repo / relative).is_file():
|
||||||
|
results.append(CheckResult("错误", f"文档引用的实现路径不存在:{relative}"))
|
||||||
|
return document_paths
|
||||||
|
|
||||||
|
|
||||||
|
def check_document_quality(paths: list[Path], results: list[CheckResult]) -> None:
|
||||||
|
for path in paths:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
relative = path.as_posix()
|
||||||
|
if any(marker in text for marker in CONFLICT_MARKERS):
|
||||||
|
results.append(CheckResult("错误", f"文档存在未解决 Git 冲突标记:{relative}"))
|
||||||
|
ratio = chinese_ratio(text)
|
||||||
|
if ratio < 0.18:
|
||||||
|
results.append(
|
||||||
|
CheckResult(
|
||||||
|
"错误",
|
||||||
|
f"文档中文正文比例过低:{relative}({ratio:.1%})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
headings = re.findall(r"^#{1,6}\s+.+$", text, flags=re.MULTILINE)
|
||||||
|
if len(headings) < 3:
|
||||||
|
results.append(CheckResult("错误", f"文档章节不完整:{relative}"))
|
||||||
|
if re.search(r"(?mi)^\s*(TODO|TBD)\s*$", text):
|
||||||
|
results.append(CheckResult("错误", f"文档包含未完成占位符:{relative}"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_requirements(repo: Path, results: list[CheckResult]) -> None:
|
||||||
|
requirements = (repo / "docs/aios-v2/01-USER-REQUIREMENTS.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
matrix = (repo / "docs/aios-v2/05-TRACEABILITY-MATRIX.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
for number in range(1, 16):
|
||||||
|
requirement = f"UR-{number:03d}"
|
||||||
|
if requirement not in requirements:
|
||||||
|
results.append(CheckResult("错误", f"需求文档缺少:{requirement}"))
|
||||||
|
if requirement not in matrix:
|
||||||
|
results.append(CheckResult("错误", f"追踪矩阵缺少:{requirement}"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_api_and_models(repo: Path, docs_text: str, results: list[CheckResult]) -> None:
|
||||||
|
code_paths = [repo / path for path in REQUIRED_CODE_PATHS if path.endswith(".py")]
|
||||||
|
code_text = all_text(path for path in code_paths if path.is_file())
|
||||||
|
for api in REQUIRED_APIS:
|
||||||
|
if api not in docs_text:
|
||||||
|
results.append(CheckResult("错误", f"正式文档未覆盖核心 API:{api}"))
|
||||||
|
if api not in code_text:
|
||||||
|
results.append(CheckResult("错误", f"代码中未找到核心 API:{api}"))
|
||||||
|
for model in REQUIRED_MODELS:
|
||||||
|
if model not in docs_text:
|
||||||
|
results.append(CheckResult("错误", f"正式文档未覆盖核心模型:{model}"))
|
||||||
|
if model not in code_text:
|
||||||
|
results.append(CheckResult("错误", f"代码中未找到核心模型:{model}"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_adr_links(repo: Path, results: list[CheckResult]) -> None:
|
||||||
|
index = (repo / "docs/aios-v2/adr/README.md").read_text(encoding="utf-8")
|
||||||
|
linked = re.findall(r"\((ADR-[^)]+\.md)\)", index)
|
||||||
|
if len(set(linked)) < 6:
|
||||||
|
results.append(CheckResult("错误", "ADR 索引未覆盖六项必需决策"))
|
||||||
|
for filename in linked:
|
||||||
|
if not (repo / "docs/aios-v2/adr" / filename).is_file():
|
||||||
|
results.append(CheckResult("错误", f"ADR 索引引用不存在:{filename}"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_changed_docs(
|
||||||
|
repo: Path,
|
||||||
|
base_ref: str | None,
|
||||||
|
results: list[CheckResult],
|
||||||
|
) -> None:
|
||||||
|
if not base_ref:
|
||||||
|
results.append(CheckResult("警告", "未提供基准分支,跳过变更级文档同步检查"))
|
||||||
|
return
|
||||||
|
code, output = run_git(repo, "diff", "--name-only", f"{base_ref}...HEAD")
|
||||||
|
if code != 0:
|
||||||
|
results.append(CheckResult("警告", f"无法读取与 {base_ref} 的变更,跳过变更级检查"))
|
||||||
|
return
|
||||||
|
changed = [line.strip() for line in output.splitlines() if line.strip()]
|
||||||
|
code_changed = any(path.startswith(CODE_CHANGE_PREFIXES) for path in changed)
|
||||||
|
docs_changed = any(path.startswith("docs/aios-v2/") for path in changed)
|
||||||
|
if code_changed and not docs_changed:
|
||||||
|
results.append(
|
||||||
|
CheckResult(
|
||||||
|
"错误",
|
||||||
|
"检测到架构、功能、测试、任务或部署变化,但本交付范围没有 docs/aios-v2/ 变化",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="AIOS V2 文档—代码匹配检查")
|
||||||
|
parser.add_argument("--repo", default=".", help="仓库根目录")
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-ref",
|
||||||
|
default=None,
|
||||||
|
help="用于检查代码变化是否同步文档的基准引用",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
repo = Path(args.repo).resolve()
|
||||||
|
if not (repo / ".git").exists() and run_git(repo, "rev-parse", "--is-inside-work-tree")[0] != 0:
|
||||||
|
print(f"错误:不是 Git 仓库:{repo}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
results: list[CheckResult] = []
|
||||||
|
document_paths = check_required_files(repo, results)
|
||||||
|
check_document_quality(document_paths, results)
|
||||||
|
check_requirements(repo, results)
|
||||||
|
docs_text = all_text(document_paths)
|
||||||
|
check_api_and_models(repo, docs_text, results)
|
||||||
|
check_adr_links(repo, results)
|
||||||
|
check_changed_docs(repo, args.base_ref, results)
|
||||||
|
|
||||||
|
errors = [item for item in results if item.level == "错误"]
|
||||||
|
warnings = [item for item in results if item.level == "警告"]
|
||||||
|
for item in results:
|
||||||
|
stream = sys.stderr if item.level == "错误" else sys.stdout
|
||||||
|
print(f"{item.level}:{item.message}", file=stream)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"文档—代码匹配检查完成:错误 {len(errors)},警告 {len(warnings)},正式文档 {len(document_paths)}。"
|
||||||
|
)
|
||||||
|
return 1 if errors else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user