Compare commits

..
Author SHA1 Message Date
Codex 0be3ba09e4 task(agent): 固化在线验收与回滚审查证据 2026-07-21 12:33:03 +08:00
Codex b158041b86 test(aios-v2): 固化回滚就绪重试验收证据 2026-07-21 12:25:00 +08:00
Codex 2a2b702eb8 merge(deploy): 修复回滚启动就绪竞态 2026-07-21 12:24:39 +08:00
Codex 341556c780 fix(deploy): 等待回滚目标服务就绪 2026-07-21 12:24:39 +08:00
Codex bd45c0ad77 test(aios-v2): 固化回滚执行模式验收证据 2026-07-21 12:21:16 +08:00
Codex c81d4f40f3 merge(deploy): 修复回滚脚本执行模式 2026-07-21 12:20:52 +08:00
Codex ea2a466d96 fix(deploy): 保留回滚健康脚本执行模式 2026-07-21 12:20:52 +08:00
Codex 63b31165b7 test(aios-v2): 固化 Key ID 修复验收证据 2026-07-21 12:16:10 +08:00
Codex 07b5c2c9c2 merge(aios-v2): 修复 OpenClaw Key ID 传播 2026-07-21 12:15:48 +08:00
Codex d71b7903df fix(aios-v2): 传播 OpenClaw 签名 Key ID 2026-07-21 12:15:48 +08:00
Codex 79fd83cd61 test(aios-v2): 固化 Runtime HTTP 离线验收证据 2026-07-21 12:11:42 +08:00
Codex fedb6b08fb merge(openclaw): 接入签名 Runtime HTTP 合同 2026-07-21 12:05:52 +08:00
Codex 83f8f878c7 feat(openclaw): 增加签名 Runtime HTTP 合同 2026-07-21 12:05:29 +08:00
Codex 1352a664ab test(aios-v2): 固化 HTTP 修复后验收证据 2026-07-21 11:40:16 +08:00
Codex 57209760cd merge(bugfix): 修复真实 HTTP 身份头解析 2026-07-21 11:39:44 +08:00
Codex 850bb669b7 fix(aios-v2): 规范化 HTTP 身份头大小写 2026-07-21 11:39:43 +08:00
Codex 9c20bf1362 test(aios-v2): 固化 Gate 与离线验收证据 2026-07-21 11:29:56 +08:00
Codex 09fa4f9d73 docs(aios-v2): 补齐核心模型中文追踪 2026-07-21 11:25:34 +08:00
Codex 816be7bec5 docs(agent): 固化 provenance 清障与 Gate 启动证据 2026-07-21 11:23:27 +08:00
Codex 25c25abd3e chore(aios): provenance openclaw-v2r1-20260721T031523Z heartbeat.json 2026-07-21 11:18:57 +08:00
Codex 8edb96a0fa merge(security): 纳入 provenance service-user Git guard 2026-07-21 11:18:02 +08:00
Codex 695a846645 fix(openclaw): 限定服务用户 Git safe directory 2026-07-21 11:18:02 +08:00
Codex d8f01f745d merge(security): 修复 provenance plugin runtime 合同 2026-07-21 11:11:34 +08:00
Codex c75fa77898 fix(openclaw): 声明 provenance writer 工具合同 2026-07-21 11:11:33 +08:00
Codex 9206b883ab merge(security): 纳入 OpenClaw run provenance writer 2026-07-21 11:06:34 +08:00
Codex b199935db8 feat(openclaw): 增加 Owner 签名 provenance writer 2026-07-21 11:06:17 +08:00
AIOS-Owner f7307c82b2 task(agent): 固化四项清障与 provenance 阻塞证据 2026-07-20 18:17:49 +08:00
AIOS-Owner bf01a13f3a docs(aios-v2): 记录四项阻塞清除与 provenance 剩余门禁 2026-07-20 18:17:06 +08:00
AIOS-Owner 8a36f7832b fix(aios-v2): 对齐 Ed25519 Candidate 签名合同 2026-07-20 18:14:42 +08:00
AIOS-Owner f1aeefff20 task(agent): 记录 Candidate 合并与 Worktree 修复证据 2026-07-20 17:39:16 +08:00
AIOS-Owner f55dfd975e merge(candidate): 同步最新 AIOS V2R1 Candidate 2026-07-20 17:38:42 +08:00
Windows ChatGPT Owner Supervisor bd2784015d chore(evidence): refine AIOS V2 signing-material blocker 2026-07-20 17:05:09 +08:00
Windows ChatGPT Owner Supervisor 1aa71f233d chore(evidence): block OpenClaw deployment with verified owner findings 2026-07-20 17:02:18 +08:00
Codex 558dc932fa chore(evidence): Phase 4 - Secret reference configuration complete
- Created /etc/aios-v2/aios-v2.env with credential ref (not plaintext)
- Saved Gitea deploy token to protected file under /etc/openclaw/secrets/
- Credential rotation completed: new token with minimal scope (write:repository,read:repository)
- Env file references AIOS_OPENCLAW_URL, CREDENTIAL_REF, SIGNING_MATERIAL_FILE, SIGNING_KEY_ID
2026-07-20 16:54:12 +08:00
Codex 5fd5ae0a33 chore(evidence): add claim, heartbeat, progress, update status for task AIOS-V2R1-PRODUCTION-DEPLOY-20260720
Phase 1 completed: worktree created, claim written, branches synced to Gitea, OID verified.
Phase 2-3 completed: branch sync & Gitea OID verification all matched.
Next: Phase 4 - Configure Secret references.
2026-07-20 16:48:33 +08:00
Codex 81572b8231 security(openclaw): record restored Gitea credential without secret value 2026-07-20 15:37:29 +08:00
Codex 8c3d80fe53 task(openclaw): 下发执行器启动、Secret 引用与 Gitea 同步在线指令 2026-07-20 14:34:13 +08:00
Codex 99bcc03b40 task(openclaw): 初始化无人值守任务状态 2026-07-20 12:07:46 +08:00
Codex c6e7edcf54 task(openclaw): 写入预发布无人值守任务载荷 2026-07-20 12:07:36 +08:00
Codex c464560a0f task(openclaw): launch AIOS V2R1 pre-production deployment 2026-07-20 11:49:09 +08:00
44 changed files with 2737 additions and 65 deletions
+412
View File
@@ -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;
- 正式生产部署;
- 生产流量切换;
- 接受高风险剩余问题。
+58
View File
@@ -0,0 +1,58 @@
{
"schema": "aios.v2.agent-blockers.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"updated_at": "2026-07-21T04:30:00Z",
"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"
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"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-21T04:30:00Z",
"owner_observation": "五项高风险 Blocker、Gate A/B、离线验收、OpenClaw 在线合同、双服务重启恢复与受控回滚均已形成真实证据;Hermes 完整在线 Worker 生命周期仍未暴露为服务端点。",
"evidence_writer": "windows-chatgpt-owner",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z"
,"latest_task_commit_before_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
}
+61
View File
@@ -0,0 +1,61 @@
{
"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-21T04:30:00Z",
"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-21T04:30:00Z",
"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,
"openclaw_runtime_plugin_version": "2.1.0-rc.3",
"runtime_public_key_only_hot_path": true,
"runtime_http_contract_test": "passed",
"secret_values_recorded_in_runtime_evidence": false
}
+12
View File
@@ -0,0 +1,12 @@
{
"current_step": "ONLINE_HERMES_WORKER_GAP",
"evidence_writer": "windows-chatgpt-owner",
"executor": "openclaw",
"heartbeat_at": "2026-07-21T04:30:00Z",
"heartbeat_source": "owner-live-acceptance",
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"schema": "aios.v2.agent-heartbeat.v1",
"state": "running",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"valid_for_running": true
}
+70
View File
@@ -0,0 +1,70 @@
{
"schema": "aios.v2.agent-progress.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"state": "running",
"status": "PARTIAL",
"updated_at": "2026-07-21T04:30:00Z",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"task_commit_before_owner_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
"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/Runtime Node 14/14;Candidate 10/10;Gate A 0错误0警告;Gate B 10/10;离线 8通过0失败1警告;OpenClaw 在线合同与 AIOS/Hermes 基础联调通过",
"deployment": "aios-tenant-boundary rc3 与隔离 Release b158041 已部署;仅监听 127.0.0.1:18921,未切流量",
"rollback": "Previous 2a2b702 与 Current b158041 双向切换通过;默认不恢复状态,业务状态、SQLite、Execution Evidence 均保留",
"current_step": "ONLINE_HERMES_WORKER_GAP",
"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": true
},
"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"
,"gate_a": "passed"
,"gate_b": "passed_10_of_10"
,"offline_acceptance": "passed_8_failed_0_warning_1"
,"online_openclaw_contract": "passed"
,"aios_restart_recovery": "passed"
,"openclaw_restart_idempotency": "passed"
,"rollback_drill": "passed"
,"online_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/online-runtime-openclaw-final4.log"
,"restart_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/restart-recovery.log"
,"rollback_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/rollback-drill-final3.log"
,"remaining_acceptance_gap": "Hermes claim/start/heartbeat/fail/cancel/lease-expiry/worker-crash-recovery 与 Cell/Profile 错误结果拒绝未暴露为在线服务端点"
}
+43
View File
@@ -0,0 +1,43 @@
{
"schema": "aios.v2.agent-result.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"final_status": "PARTIAL",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"task_commit_before_owner_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
"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": "Gate A、Gate B 10/10、离线 8/0/1、OpenClaw 正式在线、重启恢复和回滚通过;Hermes 完整在线 Worker 生命周期未覆盖",
"deployment_status": "OpenClaw Runtime rc3 与 AIOS 隔离 Release b158041 active;无生产流量切换",
"rollback_status": "Previous 2a2b702 与 Current b158041 双向演练通过,业务状态保留",
"documentation_sync_status": "updated and Gate A passed",
"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": [
"Hermes claim/start/heartbeat/fail/cancel/lease expiry/worker crash recovery/Cell/Profile 错误结果拒绝尚无在线服务端点证据"
],
"completed_at": "2026-07-21T04:30:00Z",
"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",
"online_openclaw_contract": "passed",
"restart_recovery": "passed",
"rollback_drill": "passed",
"formal_online_acceptance": "partial"
}
+26
View File
@@ -0,0 +1,26 @@
{
"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-21T04:30:00Z",
"last_heartbeat": "2026-07-21T04:30:00Z",
"heartbeat_valid_for_running": true,
"current_step": "ONLINE_HERMES_WORKER_GAP",
"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"
,"task_commit_before_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
,"gate_a": "passed"
,"gate_b": "passed_10_of_10"
,"offline_acceptance": "passed_8_failed_0_warning_1"
,"online_openclaw": "passed"
,"restart_recovery": "passed"
,"rollback_drill": "passed"
,"hermes_full_online_worker_lifecycle": "not_exposed"
}
+34
View File
@@ -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"
}
@@ -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,67 @@
{
"schema": "aios.v2.acceptance.v1",
"generated_at": "2026-07-21T04:24:51.586289Z",
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"commit": "2a2b702eb8f9501a637b9f9b5f033482eec7cc75",
"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/20260721T042451Z/文档与代码匹配.log"
},
{
"name": "Python 语法编译",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Python_语法编译.log"
},
{
"name": "候选单元与集成测试",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/候选单元与集成测试.log"
},
{
"name": "Shell 脚本语法",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Shell_脚本语法.log"
},
{
"name": "文档 Git 冲突标记",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/文档_Git_冲突标记.log"
},
{
"name": "明文密钥基础扫描",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/明文密钥基础扫描.log"
},
{
"name": "systemd 单元验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/systemd_单元验证.log"
},
{
"name": "Docker Compose 配置验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Docker_Compose_配置验证.log"
},
{
"name": "在线健康检查",
"status": "警告",
"exit_code": 0,
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
}
],
"final_status": "passed"
}
+10
View File
@@ -392,3 +392,13 @@ bash deploy/aios-v2/rollback/rollback.sh <previous-release>
- 文档—代码匹配状态;
- 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`。
+2 -2
View File
@@ -33,7 +33,7 @@ class CommandConflict(AdapterError):
class RuntimeTransport(Protocol):
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
def cancel(self, command_id: str) -> bool: ...
def cancel(self, command_id: str, *, tenant_id: str) -> bool: ...
def health(self) -> RuntimeHealth: ...
@@ -203,7 +203,7 @@ class OpenClawAdapter:
CommandState.REJECTED,
}:
return current
if not self.transport.cancel(command_id):
if not self.transport.cancel(command_id, tenant_id=tenant_id):
raise AdapterError("runtime rejected cancellation")
cancelled = replace(
current,
+28 -11
View File
@@ -1,8 +1,7 @@
"""Versioned AIOS to OpenClaw execution contracts."""
from __future__ import annotations
import hashlib
import hmac
import base64
import json
import uuid
from dataclasses import dataclass, field, replace
@@ -10,6 +9,10 @@ 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
@@ -179,11 +182,26 @@ def canonical_command_bytes(command: OpenClawCommand) -> bytes:
).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 = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
if not key:
raise ValueError("signing material is required")
signature = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
key = _ed25519_private_key(key_material)
signature = base64.urlsafe_b64encode(key.sign(canonical_command_bytes(command))).decode("ascii").rstrip("=")
return replace(command, signature=signature)
@@ -196,11 +214,10 @@ def verify_command_signature(
) -> bool:
if not command.signature:
return False
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
if not key:
return False
expected = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, command.signature):
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)
+12 -4
View File
@@ -19,7 +19,7 @@ from .contract import (
class RuntimeTransport(Protocol):
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
def cancel(self, command_id: str) -> bool: ...
def cancel(self, command_id: str, *, tenant_id: str) -> bool: ...
def health(self) -> RuntimeHealth: ...
@@ -50,11 +50,16 @@ class InProcessRuntimeTransport:
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,
@@ -90,8 +95,10 @@ class InProcessRuntimeTransport:
with self._lock:
self._running.discard(command.command_id)
def cancel(self, command_id: str) -> bool:
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
@@ -190,10 +197,11 @@ class HTTPRuntimeTransport:
attempts=int(value.get("attempts", 1)),
)
def cancel(self, command_id: str) -> bool:
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"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel"
f"?tenant_id={urllib.parse.quote(tenant_id, safe='')}",
payload={},
timeout_seconds=10.0,
)
+3 -1
View File
@@ -59,7 +59,7 @@ class FileSigningMaterialResolver:
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().strip()
value = self.path.read_bytes()
if len(value) < 32:
raise RuntimeError("signing material is too short")
return value
@@ -321,6 +321,7 @@ def build_application() -> CandidateApplication:
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:
@@ -341,6 +342,7 @@ def build_application() -> CandidateApplication:
openclaw=openclaw,
hermes=hermes,
openclaw_signing_material_resolver=resolver,
openclaw_key_id=openclaw_key_id,
)
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
+18 -7
View File
@@ -79,20 +79,31 @@ class GatewayApplication:
@staticmethod
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
authenticated = str(headers.get(AUTHENTICATED_HEADER, "")).strip().lower()
# 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 str(headers.get(ROLES_HEADER, "")).split(",")
for role in header(ROLES_HEADER).split(",")
if role.strip()
]
return RequestContext.create(
tenant_id=str(headers.get(TENANT_HEADER, "")),
principal_id=str(headers.get(PRINCIPAL_HEADER, "")),
trace_id=str(headers.get(TRACE_HEADER, "")).strip() or None,
session_id=str(headers.get(SESSION_HEADER, "")).strip() or None,
policy_version=str(headers.get(POLICY_HEADER, "v1")).strip() or "v1",
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,
)
+12 -1
View File
@@ -7,6 +7,7 @@ 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
@@ -129,6 +130,15 @@ class ExecutionOrchestrator:
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,
@@ -136,10 +146,11 @@ class ExecutionOrchestrator:
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=timeout_seconds)
result = self.openclaw.execute(command, timeout_seconds=effective_timeout)
self.reconcile_openclaw(context, result)
return DispatchRecord(
tenant_id=task.tenant_id,
View File
+18 -6
View File
@@ -8,12 +8,28 @@ 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}"
HEALTH_RETRIES="${AIOS_V2_ROLLBACK_HEALTH_RETRIES:-30}"
HEALTH_RETRY_DELAY="${AIOS_V2_ROLLBACK_HEALTH_RETRY_DELAY:-1}"
fail() {
echo "ERROR: $*" >&2
exit 1
}
run_healthcheck() {
local release="$1"
local attempt
for ((attempt = 1; attempt <= HEALTH_RETRIES; attempt++)); do
if AIOS_V2_SERVICE="$SERVICE" \
AIOS_V2_STATE_DIR="$STATE_DIR" \
"$release/deploy/aios-v2/healthcheck/check.sh"; then
return 0
fi
[[ "$attempt" -lt "$HEALTH_RETRIES" ]] && sleep "$HEALTH_RETRY_DELAY"
done
return 1
}
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
@@ -53,9 +69,7 @@ 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
if run_healthcheck "$TARGET_PATH"; then
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
exit 0
fi
@@ -78,9 +92,7 @@ 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
run_healthcheck "$PREVIOUS_PATH" || true
fi
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
+29
View File
@@ -419,6 +419,8 @@ X-AIOS-Policy-Version
- 代理必须删除客户端同名头后重新注入。
- 应用不得直接暴露公网端口。
- HTTP 字段名按协议不区分大小写;应用把 Header 集合转换为普通映射后,必须先统一字段名大小写再解析身份。`X-AIOS-*`、`X-Aios-*` 与全小写形式必须得到相同的 `RequestContext`,但字段值本身不得被改写。
- 真实监听器回归测试必须覆盖 `BaseHTTPRequestHandler` 的 title-case 行为,避免纯函数测试使用固定大写键而漏掉在线 401。
### 11.2 核心 API
@@ -488,6 +490,10 @@ POST /api/v2/hermes/results/reconcile
- 默认只回滚代码。
- 状态恢复需显式授权。
- 回滚后必须执行 healthcheck。
- Release Archive 必须保留 `rollback.sh` 与 `healthcheck/check.sh` 的 executable bit;
验收测试必须阻止非执行模式进入候选包。
- `systemctl start` 后的回滚健康检查必须执行有界 readiness 重试,默认最多 30 次、
每次间隔 1 秒;不得因服务尚未监听的一次瞬时拒绝误判目标 Release 失败。
## 14. 契约变更规则
@@ -500,3 +506,26 @@ POST /api/v2/hermes/results/reconcile
- 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`,不得误报为真实超时。
+18
View File
@@ -70,6 +70,20 @@
| 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. 部署追踪
| 能力 | 文件 | 验收命令 |
@@ -111,3 +125,7 @@
## 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 探针共同验证。
+22 -28
View File
@@ -109,21 +109,15 @@ AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和
### 4.4 BLK-OPENCLAW-RUN-PROVENANCE-MISSING
- 严重级别:高
- 状态:开放
- 现象:OpenClaw 写工具被 `run_provenance_missing` 策略拒绝。
- 风险:无法证明写操作来自已领取、已授权、可审计的执行会话。
- 外部受信输入:允许七个 `.agent` JSON 写入的 OpenClaw Provenance 授权。
- 解除条件:
1. 创建带任务 ID、执行器实例、Claim OID、Candidate OID 和授权主体的 Run Provenance;
2. Provenance 与七个 `.agent` JSON 写入范围绑定;
3. 权限仅覆盖当前任务分支和 Evidence 路径;
4. 由 Owner 或批准策略签发;
5. 写入、Commit、Push 和审计链验证通过。
- 状态:已解决
- 根因:旧插件只有进程内 `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 仓库不一致,可能错误阻止或错误放行发布。
- 解除条件:
@@ -152,9 +146,9 @@ AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和
| Gate | 当前状态 | 说明 |
|---|---|---|
| Gate A:文档与设计 | 未运行 | Candidate 未合并到任务,阻塞未解除 |
| Gate B:代码与测试 | 未运行 | 不能基于不完整任务分支测试 |
| Gate C:预发布 | 禁止 | Secret、签名、Provenance、Controller Health 未通过 |
| Gate A:文档与设计 | 允许启动 | 五项高风险 Blocker 已解除,等待正式执行 |
| Gate B:代码与测试 | 等待 Gate A | 等待正式执行候选测试 |
| Gate C:预发布 | 禁止 | 等待 Gate A、Gate B、离线全量验收和在线联调 |
| Gate D:发布审查 | `STOP_PREPRODUCTION` | Codex 明确停止预发布 |
| Gate E:生产发布 | 禁止 | 未满足任何生产 Gate |
@@ -190,29 +184,18 @@ AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和
## 8. 外部受信输入
目前仅有两项确实需要外部受信输入:
1. **批准的签名材料**
- 不得通过聊天、Git 或普通日志传递;
- 必须通过批准的 Secret/Key 交付链安装。
2. **OpenClaw Run Provenance 授权**
- 仅允许当前任务所需的七个 `.agent` JSON 写入;
- 必须绑定任务、执行器实例、Claim、Candidate 和分支;
- 不得授予通用生产写权限。
其余阻塞均可由 OpenClaw、Codex、Windows ChatGPT Owner 和 GitOps 运维流程处理。
五项高风险 Blocker 当前均不再需要新的外部受信输入。签名材料继续留在受保护边界,Provenance 仍须按 Run 由 Owner 限时签发,不得转化为长期通用权限。
## 9. 监督结论
当前状态保持:
```text
BLOCKED
PARTIAL
STOP_PREPRODUCTION
```
虽然三分支已经同步 Gitea、凭据已经最小权限轮换、执行服务已经恢复,但当前尚无足够证据允许执行 Gate A–D 或进入预发布。
三分支已同步 Gitea,五项高风险 Blocker 已解除,允许执行 Gate A、Gate B 和离线全量验收;在三项正式结果与后续在线验收形成证据前,不进入预发布。
任何后续状态更新必须同步:
@@ -222,3 +205,14 @@ STOP_PREPRODUCTION
- `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,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
View File
@@ -0,0 +1 @@
export { default } from "../index.js";
+1
View File
@@ -0,0 +1 @@
export * from "../policy.js";
+1
View File
@@ -0,0 +1 @@
export * from "../provenance.js";
+1
View File
@@ -0,0 +1 @@
export * from "../writer.js";
+178
View File
@@ -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 };
+68
View File
@@ -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 }
}
}
}
+24
View File
@@ -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"
]
}
}
+73
View File
@@ -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 };
}
+141
View File
@@ -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");
}
+280
View File
@@ -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");
});
+143
View File
@@ -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);
}
}
+94 -5
View File
@@ -1,8 +1,12 @@
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]
@@ -16,6 +20,7 @@ from aios_openclaw import (
InProcessRuntimeTransport,
OpenClawAdapter,
)
from aios_openclaw.contract import OpenClawCommand, sign_command, verify_command_signature
from aios_v2 import (
ControlPlaneService,
ExecutionEvidence,
@@ -26,9 +31,11 @@ from aios_v2 import (
TenantIsolationError,
)
from aios_v2.persistence import DurableControlPlaneService, SQLiteSnapshotRepository
from aios_v2.gateway import GatewayApplication
from aios_v2.app import build_application
SIGNING_MATERIAL = b"candidate-test-signing-material-0001"
SIGNING_MATERIAL = bytes(range(32))
def context(
@@ -64,6 +71,79 @@ def bootstrap(service: ControlPlaneService, tenant_id: str = "tenant-a"):
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_rollback_waits_for_bounded_service_readiness(self) -> None:
script = (REPO_ROOT / "deploy/aios-v2/rollback/rollback.sh").read_text()
self.assertIn("AIOS_V2_ROLLBACK_HEALTH_RETRIES", script)
self.assertIn("run_healthcheck", script)
self.assertIn("HEALTH_RETRY_DELAY", script)
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)
@@ -88,12 +168,16 @@ class CandidateFlowTests(unittest.TestCase):
expected_revision=task.revision,
)
transport = InProcessRuntimeTransport(
lambda command: {
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(
@@ -102,13 +186,18 @@ class CandidateFlowTests(unittest.TestCase):
openclaw_signing_material_resolver=resolver,
)
dispatch = orchestrator.dispatch(operator, task_id=approved.task_id)
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()