Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b31165b7 | ||
|
|
07b5c2c9c2 | ||
|
|
d71b7903df | ||
|
|
79fd83cd61 | ||
|
|
fedb6b08fb | ||
|
|
83f8f878c7 | ||
|
|
1352a664ab | ||
|
|
57209760cd | ||
|
|
850bb669b7 | ||
|
|
9c20bf1362 | ||
|
|
09fa4f9d73 | ||
|
|
816be7bec5 | ||
|
|
25c25abd3e | ||
|
|
8edb96a0fa | ||
|
|
695a846645 | ||
|
|
d8f01f745d | ||
|
|
c75fa77898 | ||
|
|
9206b883ab | ||
|
|
b199935db8 | ||
|
|
f7307c82b2 | ||
|
|
bf01a13f3a | ||
|
|
8a36f7832b | ||
|
|
f1aeefff20 | ||
|
|
f55dfd975e | ||
|
|
bd2784015d | ||
|
|
1aa71f233d | ||
|
|
558dc932fa | ||
|
|
5fd5ae0a33 | ||
|
|
81572b8231 | ||
|
|
8c3d80fe53 | ||
|
|
99bcc03b40 | ||
|
|
c6e7edcf54 | ||
|
|
c464560a0f |
@@ -0,0 +1,412 @@
|
||||
# OpenClaw 在线执行指令:启动执行器、配置 Secret 引用并确认 Gitea 同步
|
||||
|
||||
- 任务 ID:`AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
|
||||
- 执行器:OpenClaw
|
||||
- 任务分支:`task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
|
||||
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||
- 语言:中文
|
||||
- 执行模式:无人值守、证据驱动、默认拒绝
|
||||
|
||||
## 1. 任务目标
|
||||
|
||||
一次性完成以下工作:
|
||||
|
||||
1. 发现并启动环境中已注册的 OpenClaw 在线执行器;
|
||||
2. 领取本任务并持续写入 claim、heartbeat、progress、blocker 和 result;
|
||||
3. 将任务分支同步到最新 Candidate HEAD;
|
||||
4. 确认 Candidate、OpenClaw Task、Codex Task 三个分支已同步到 Gitea,且本地与远端 OID 完全一致;
|
||||
5. 从已批准的 Secret Provider、Credential Store 或受保护文件中解析 OpenClaw Runtime 配置;
|
||||
6. 只把 Secret 引用和受保护文件路径写入 AIOS 环境配置,不得把明文 Secret 写入 Git、日志、Evidence 或终端输出;
|
||||
7. 执行文档—代码检查、候选测试、全量验收和预发布健康检查;
|
||||
8. 对低风险部署或 Runtime 缺陷创建 Patch 并修复;
|
||||
9. 把所有修改和阻塞提交到系统,交由 Codex 统一审视。
|
||||
|
||||
## 2. 不可违反的边界
|
||||
|
||||
- 不得直接修改或 push `main`;
|
||||
- 不得使用 `--force` 或改写共享分支历史;
|
||||
- 不得创建生产 Tag;
|
||||
- 不得切换生产流量,除非 AIOS Owner 后续明确批准;
|
||||
- 不得提交 Token、密码、API Key、私钥、签名材料或 Secret 内容;
|
||||
- 不得在日志中执行 `env`、`set`、`printenv` 或输出 Secret 文件内容;
|
||||
- 不得修改 AIOS 唯一控制面、多租户或审批边界;
|
||||
- 发现架构、安全或跨模块阻塞时必须升级 Codex;
|
||||
- 未生成 claim/heartbeat 证据前不得标记为运行中;
|
||||
- 未通过验收前不得标记为完成。
|
||||
|
||||
## 3. 第一阶段:执行器发现与领取
|
||||
|
||||
### 3.1 发现执行器入口
|
||||
|
||||
使用当前环境中已经注册和批准的 OpenClaw 执行入口。可检查:
|
||||
|
||||
- systemd 服务;
|
||||
- 容器或 Compose 服务;
|
||||
- 已安装的 OpenClaw CLI;
|
||||
- AIOS Agent Runner;
|
||||
- 已配置的队列消费者或调度器。
|
||||
|
||||
不得臆造不存在的命令、服务名或路径。
|
||||
|
||||
如果找不到可执行入口,立即创建 Blocker:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-EXECUTOR-ENTRYPOINT-MISSING",
|
||||
"severity": "high",
|
||||
"description": "环境中未发现已注册的 OpenClaw 执行器入口",
|
||||
"owner": "AIOS 运维负责人",
|
||||
"exit_condition": "提供或启用已批准的 OpenClaw systemd、容器、CLI 或 Agent Runner 入口"
|
||||
}
|
||||
```
|
||||
|
||||
状态更新为 `blocked`,不得伪报已经启动。
|
||||
|
||||
### 3.2 启动执行器
|
||||
|
||||
发现入口后:
|
||||
|
||||
1. 验证配置来源和运行用户;
|
||||
2. 启动或恢复执行器;
|
||||
3. 验证进程/容器处于运行状态;
|
||||
4. 验证执行器能够读取本任务分支;
|
||||
5. 立即写入 `.agent/claim.json`;
|
||||
6. 更新 `.agent/status.json`:`queued -> claimed -> running`;
|
||||
7. 写入首次 `.agent/heartbeat.json`;
|
||||
8. 将证据提交到任务分支并同步 Gitea。
|
||||
|
||||
`claim.json` 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.v2.agent-claim.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "<领取时实际 Candidate OID>",
|
||||
"claimed_at": "<UTC ISO-8601>",
|
||||
"executor_instance": "<非敏感实例标识>"
|
||||
}
|
||||
```
|
||||
|
||||
Heartbeat 间隔不得超过 300 秒。
|
||||
|
||||
## 4. 第二阶段:同步最新 Candidate
|
||||
|
||||
### 4.1 获取最新引用
|
||||
|
||||
只更新 remote-tracking ref,不得 fetch 到已经被 Worktree 检出的本地 Branch Ref。
|
||||
|
||||
执行原则:
|
||||
|
||||
```text
|
||||
fetch remote candidate -> refs/remotes/<remote>/<candidate>
|
||||
```
|
||||
|
||||
不得使用会触发以下错误的流程:
|
||||
|
||||
```text
|
||||
refusing to fetch into branch checked out at worktree
|
||||
```
|
||||
|
||||
### 4.2 更新任务分支
|
||||
|
||||
将最新 Candidate 合并到 OpenClaw 任务分支:
|
||||
|
||||
- 使用普通 merge 或团队批准的线性更新方式;
|
||||
- 不得 force reset 已经提交的 Agent Evidence;
|
||||
- 发生冲突时停止自动扩大修改范围;
|
||||
- 文档、任务载荷或架构冲突升级 Codex;
|
||||
- 合并完成后把 `.agent/task.json` 与 `.agent/status.json` 中的 `candidate_commit` 更新为实际 Candidate OID。
|
||||
|
||||
### 4.3 必须记录
|
||||
|
||||
在 `.agent/progress.json` 记录:
|
||||
|
||||
- Candidate 本地 OID;
|
||||
- Candidate Gitea OID;
|
||||
- Task Branch 合并前后 OID;
|
||||
- 修改文件;
|
||||
- 是否发生冲突;
|
||||
- 合并 Commit。
|
||||
|
||||
## 5. 第三阶段:确认 Gitea 同步
|
||||
|
||||
需要验证以下分支:
|
||||
|
||||
```text
|
||||
integration/aios-v2r1-cto-production-candidate-20260720
|
||||
task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720
|
||||
task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720
|
||||
```
|
||||
|
||||
目标仓库:
|
||||
|
||||
```text
|
||||
g.cnnetmkt.top/git/aios/aios
|
||||
```
|
||||
|
||||
### 5.1 同步规则
|
||||
|
||||
- 使用环境中已配置的 Gitea Remote 和凭据引用;
|
||||
- 不在 Evidence 中记录带凭据的 Remote URL;
|
||||
- 禁止 Force Push;
|
||||
- Candidate 和 Task Branch 可以普通 Push;
|
||||
- Push 失败时记录 stderr 的脱敏摘要,不得记录凭据。
|
||||
|
||||
### 5.2 OID 验证
|
||||
|
||||
每个分支必须同时取得:
|
||||
|
||||
```text
|
||||
local_oid
|
||||
gitea_oid
|
||||
match = true/false
|
||||
```
|
||||
|
||||
验收条件:
|
||||
|
||||
```text
|
||||
local_oid == gitea_oid
|
||||
```
|
||||
|
||||
建议通过 remote-tracking ref 或 `git ls-remote --heads` 取得远端 OID。输出中不得泄露认证信息。
|
||||
|
||||
如果 Push 或 OID 校验失败,创建 Blocker:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-GITEA-SYNC",
|
||||
"severity": "high",
|
||||
"description": "本地与 Gitea 分支 OID 不一致或无推送权限",
|
||||
"owner": "GitOps/Gitea 运维负责人",
|
||||
"exit_condition": "三个目标分支均完成普通推送且 local_oid 等于 gitea_oid"
|
||||
}
|
||||
```
|
||||
|
||||
在 Gitea OID 一致前,不得继续预发布部署。
|
||||
|
||||
## 6. 第四阶段:配置 Secret 引用
|
||||
|
||||
### 6.1 需要解析的配置
|
||||
|
||||
至少确认:
|
||||
|
||||
```text
|
||||
AIOS_OPENCLAW_URL
|
||||
AIOS_OPENCLAW_CREDENTIAL_REF
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE
|
||||
AIOS_OPENCLAW_SIGNING_KEY_ID
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `AIOS_OPENCLAW_URL` 是经过批准的 Runtime Endpoint;
|
||||
- `AIOS_OPENCLAW_CREDENTIAL_REF` 只保存 Secret Provider 引用;
|
||||
- `AIOS_OPENCLAW_SIGNING_MATERIAL_FILE` 指向仓库外的受保护文件;
|
||||
- `AIOS_OPENCLAW_SIGNING_KEY_ID` 是 Key 标识,不是 Key 内容。
|
||||
|
||||
### 6.2 Secret 来源优先级
|
||||
|
||||
按以下顺序查找:
|
||||
|
||||
1. 已批准的运行时 Secret Store;
|
||||
2. 已批准的 Credential Provider;
|
||||
3. systemd Credential;
|
||||
4. 只读 Secret Volume;
|
||||
5. 仓库外受保护文件。
|
||||
|
||||
不得:
|
||||
|
||||
- 从 Git 历史恢复 Secret;
|
||||
- 从日志、Shell History 或旧 Evidence 复制 Secret;
|
||||
- 新建明文 Secret 并提交;
|
||||
- 把 Secret 内容写入 `.env` 模板或任务 JSON。
|
||||
|
||||
### 6.3 配置文件与权限
|
||||
|
||||
生产候选环境文件:
|
||||
|
||||
```text
|
||||
/etc/aios-v2/aios-v2.env
|
||||
```
|
||||
|
||||
建议权限:
|
||||
|
||||
```text
|
||||
owner: root
|
||||
group: aios
|
||||
mode: 0640 或更严格
|
||||
```
|
||||
|
||||
签名材料文件:
|
||||
|
||||
```text
|
||||
位于仓库外
|
||||
owner: aios 或经批准的运行用户
|
||||
mode: 0600 或更严格
|
||||
```
|
||||
|
||||
只验证以下内容:
|
||||
|
||||
- 引用非空;
|
||||
- 文件存在;
|
||||
- 运行用户可读;
|
||||
- 权限不宽松;
|
||||
- 文件不在 Git Worktree;
|
||||
- 不输出文件内容或长度特征。
|
||||
|
||||
### 6.4 缺少 Secret 时的行为
|
||||
|
||||
若无法从已批准来源解析任一必需配置,创建:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-SECRET-REF-MISSING",
|
||||
"severity": "high",
|
||||
"description": "缺少已批准的 OpenClaw Runtime Endpoint、Credential Ref、Signing File 或 Key ID",
|
||||
"owner": "AIOS Secret/运维负责人",
|
||||
"exit_condition": "四项配置均可从批准来源解析,且权限检查通过"
|
||||
}
|
||||
```
|
||||
|
||||
不得要求用户在聊天或 Git 中提供 Secret 明文。
|
||||
|
||||
## 7. 第五阶段:候选验收与预发布
|
||||
|
||||
在 Gitea OID 和 Secret 引用检查都通过后,依次执行:
|
||||
|
||||
```bash
|
||||
python3 tools/aios_v2_docs_code_check.py \
|
||||
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||
|
||||
python3 -m unittest discover \
|
||||
-s tests/aios_v2_candidate \
|
||||
-p 'test_*.py' \
|
||||
-v
|
||||
|
||||
bash deploy/aios-v2/acceptance/run-all.sh
|
||||
```
|
||||
|
||||
部署后执行:
|
||||
|
||||
```bash
|
||||
AIOS_RUN_LIVE_HEALTHCHECK=1 \
|
||||
bash deploy/aios-v2/acceptance/run-all.sh
|
||||
|
||||
bash deploy/aios-v2/healthcheck/check.sh
|
||||
```
|
||||
|
||||
必须验证:
|
||||
|
||||
- AIOS HTTP Health;
|
||||
- SQLite `quick_check=ok`;
|
||||
- GitOps Controller detached + clean;
|
||||
- OpenClaw execute/status/cancel/timeout/health;
|
||||
- Hermes claim/heartbeat/complete/fail/lease recovery;
|
||||
- 服务重启后 Tenant/Agent/Task/Audit/Idempotency 恢复;
|
||||
- 回滚后健康检查通过;
|
||||
- 日志与 Evidence 无 Secret。
|
||||
|
||||
## 8. Bug 修复规则
|
||||
|
||||
OpenClaw 可自行修复:
|
||||
|
||||
- systemd、Docker、Compose、路径和权限;
|
||||
- 环境变量名或启动参数;
|
||||
- Healthcheck;
|
||||
- 明确的小型 Runtime Adapter 兼容问题;
|
||||
- 日志、错误消息和超时边界;
|
||||
- 测试、部署或中文文档中的明确错误。
|
||||
|
||||
每个修复必须:
|
||||
|
||||
1. 创建 `patch/bugfix/*`;
|
||||
2. 记录复现步骤;
|
||||
3. 添加或更新测试;
|
||||
4. 修改代码;
|
||||
5. 同步 `docs/aios-v2/` 和追踪矩阵;
|
||||
6. 执行文档门禁和相关测试;
|
||||
7. 提交 Git;
|
||||
8. 更新 `.agent/blockers.json` 和 `.agent/progress.json`;
|
||||
9. 提交 Codex 审查。
|
||||
|
||||
以下问题必须升级 Codex:
|
||||
|
||||
- Control Plane、Task Lifecycle、Policy 或 Approval 变化;
|
||||
- 多租户或身份边界变化;
|
||||
- OpenClaw/Hermes Contract 不兼容变化;
|
||||
- 数据迁移、持久化或回滚策略变化;
|
||||
- 高风险安全问题;
|
||||
- 需要修改 ADR 的问题;
|
||||
- 超出低风险修复范围的阻塞。
|
||||
|
||||
## 9. 证据文件
|
||||
|
||||
任务分支必须持续维护:
|
||||
|
||||
```text
|
||||
.agent/claim.json
|
||||
.agent/heartbeat.json
|
||||
.agent/progress.json
|
||||
.agent/blockers.json
|
||||
.agent/result.json
|
||||
.agent/status.json
|
||||
```
|
||||
|
||||
`result.json` 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.v2.agent-result.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"final_status": "complete|failed|blocked",
|
||||
"candidate_commit": "<OID>",
|
||||
"task_commit": "<OID>",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"secret_reference_status": "configured|blocked",
|
||||
"executor_status": "running|blocked",
|
||||
"test_status": "passed|failed|not_run",
|
||||
"deployment_status": "passed|failed|not_run",
|
||||
"rollback_status": "passed|failed|not_run",
|
||||
"documentation_sync_status": "passed|failed",
|
||||
"changed_files": [],
|
||||
"commits": [],
|
||||
"open_blockers": [],
|
||||
"remaining_risks": [],
|
||||
"completed_at": "<UTC ISO-8601>"
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 完成条件
|
||||
|
||||
只有以下条件全部满足,任务才可标记为 `complete`:
|
||||
|
||||
- OpenClaw 执行器已启动并持续 Heartbeat;
|
||||
- 已领取任务;
|
||||
- 任务分支已合并最新 Candidate;
|
||||
- Candidate、OpenClaw Task、Codex Task 在本地与 Gitea 的 OID 一致;
|
||||
- Secret 引用和受保护文件权限检查通过;
|
||||
- 文档—代码检查通过;
|
||||
- 候选测试通过;
|
||||
- 预发布部署和在线健康检查通过;
|
||||
- Runtime 联调、重启和回滚演练通过;
|
||||
- 所有修改、Blocker、Commit、测试和结果已回写;
|
||||
- Codex 已能读取并审查完整证据。
|
||||
|
||||
如果缺少外部权限、执行入口或已批准 Secret,任务必须标记为 `blocked`,并准确说明 Owner 与解除条件,不得伪报完成。
|
||||
|
||||
## 11. Owner Gate
|
||||
|
||||
完成本指令不等于自动批准生产发布。以下动作仍需 AIOS Owner 明确批准:
|
||||
|
||||
- 合并 `main`;
|
||||
- 创建生产 Tag;
|
||||
- 正式生产部署;
|
||||
- 生产流量切换;
|
||||
- 接受高风险剩余问题。
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-blockers.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"updated_at": "2026-07-21T03:23:27Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"blockers": [
|
||||
{
|
||||
"blocker_id": "BLK-OPENCLAW-PLAINTEXT-SECRETS",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "OpenClaw managed config、auth profile store 与 models.json 存在三处明文凭据。",
|
||||
"fix": "使用 OpenClaw 原生 secrets apply 迁移为单一 file Secret Provider;Gateway Token 已轮换,OneAPI 使用单一 auth-profile SecretRef。",
|
||||
"verification": "audit plaintext/unresolved/shadowed/legacy 全部为 0,实际 OneAPI agent 调用通过。",
|
||||
"commit": "bf01a13f3aaf42a386232ed2f5409feb0f79fcf6",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-AIOS-V2-SIGNING-MATERIAL-MISSING",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "Key ID 标称 Ed25519,但代码实际使用 HMAC 且材料文件缺失。",
|
||||
"fix": "签名合同改为真实 Ed25519,并由 Owner 在受保护边界生成运行材料。",
|
||||
"verification": "aios:aios 0600;resolver sign/verify 与篡改拒绝通过。",
|
||||
"commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-CANDIDATE-NOT-MERGED",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "OpenClaw 任务未包含最新 Candidate。",
|
||||
"fix": "保护 Evidence 后以 --no-ff 合并 Candidate ba7669c。",
|
||||
"verification": "Candidate 为任务分支祖先,普通 Push 后 local_oid 等于 gitea_oid。",
|
||||
"commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-OPENCLAW-RUN-PROVENANCE-MISSING",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "旧插件只有进程内 runId/sender Map,缺少 Owner-signed Schema、持久 Store 和限定 Git Writer。",
|
||||
"fix": "部署 aios-tenant-boundary rc2,增加 Ed25519 issuer/store、Run/Sender/OID/期限绑定、一次性 permit 与七路径专用 Writer。",
|
||||
"verification": "Provenance openclaw-v2r1-20260721T031523Z 完成 heartbeat 写入、Commit、普通 Push 和审计;README.md 越权写入被拒绝;Node 测试 9/9。",
|
||||
"commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb",
|
||||
"resolved_at": "2026-07-21T03:23:27Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-CONTROLLER-LEGACY-GITEA-CHECK",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "Controller 硬编码旧 aios-v23 仓库与 API,无法区分本地和 Gitea OID。",
|
||||
"fix": "配置化 V2R1 仓库/分支,使用只读 systemd Credential,部署到 detached clean 工作树。",
|
||||
"verification": "12 项测试通过;gitea/branch_sync/oid_match 为 true,sync_status=fully_synced。",
|
||||
"commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-claim.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"claimed_at": "2026-07-20T08:24:30Z",
|
||||
"executor_instance": "openclaw-main-cn-network-mkt",
|
||||
"owner_observation_at": "2026-07-21T03:23:27Z",
|
||||
"owner_observation": "五项高风险 Blocker 已解除;Provenance Writer 真实写入、普通 Push、审计和越权拒绝通过。",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": "aios.v2.credential-status.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"credential_name": "openclaw-deploy-cli",
|
||||
"credential_type": "gitea-api-token-reference",
|
||||
"endpoints": [
|
||||
"http://127.0.0.1:3000",
|
||||
"https://g.cnnetmkt.top/git"
|
||||
],
|
||||
"reported_scope": "all",
|
||||
"reported_status": "working",
|
||||
"verification_source": "owner-reported",
|
||||
"security_status": "secretref_resolved",
|
||||
"reason": "令牌值曾在聊天中以明文出现,必须按已暴露凭据处理",
|
||||
"required_actions": [
|
||||
"已完成",
|
||||
"新令牌已保存到受保护文件",
|
||||
"令牌用于HTTPS Git认证"
|
||||
],
|
||||
"rotation_completed_at": "2026-07-20T08:48:00Z",
|
||||
"new_token_created": "openclaw-deploy-task-commit",
|
||||
"new_token_scope": "write:repository,read:repository",
|
||||
"token_file_path": "/etc/openclaw/secrets/openclaw-gitea-deploy-token",
|
||||
"token_file_owner": "root:root",
|
||||
"token_file_mode": "0600",
|
||||
"secret_provider_contract_path": "/etc/openclaw/SECRET_PROVIDER_CONTRACT.json",
|
||||
"last_verified": "2026-07-21T03:23:27Z",
|
||||
"verification_result": "HTTPS Git push successful with new token",
|
||||
"env_file_reference": "AIOS_OPENCLAW_CREDENTIAL_REF=file:///etc/openclaw/secrets/openclaw-gitea-deploy-token",
|
||||
"env_file_path": "/etc/aios-v2/aios-v2.env",
|
||||
"env_file_mode": "0640 root:gitops",
|
||||
"last_updated": "2026-07-21T03:23:27Z",
|
||||
"old_credential_revoked": true,
|
||||
"revoked": true,
|
||||
"new_credential_name": "openclaw-deploy-cli-v2-20260720",
|
||||
"rotated": true,
|
||||
"new_account": "gitops-bot",
|
||||
"scope": "write:repository",
|
||||
"credential_ref": "/data/aios-gitops/runtime/secrets/openclaw-deploy-cli-v2-20260720",
|
||||
"protected_file_owner": "root",
|
||||
"protected_file_group": "gitops",
|
||||
"protected_file_mode": "0640",
|
||||
"verified_at": "2026-07-20T09:02:18Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"plaintext_findings_before": 3,
|
||||
"plaintext_findings_after": 0,
|
||||
"credentials_rotated": true,
|
||||
"secret_values_recorded": false,
|
||||
"signing_material_configured": true,
|
||||
"signing_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||
"signing_permission_check": "passed",
|
||||
"runtime_sign_verify_test": "passed",
|
||||
"run_provenance_status": "resolved",
|
||||
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||
"provenance_public_key_configured": true,
|
||||
"provenance_secret_values_recorded": false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"current_step": "GATE_A_PENDING",
|
||||
"evidence_writer": "aios_evidence_write",
|
||||
"executor": "openclaw",
|
||||
"heartbeat_at": "2026-07-21T03:23:27Z",
|
||||
"heartbeat_source": "owner-signed-provenance-writer",
|
||||
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||
"schema": "aios.v2.agent-heartbeat.v1",
|
||||
"state": "running",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"valid_for_running": true
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-progress.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"state": "running",
|
||||
"status": "PARTIAL",
|
||||
"updated_at": "2026-07-21T03:23:27Z",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"task_commit_before_owner_evidence": "558dc932fa200831045e0f2edebbc12d775232bc",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match_before_owner_evidence": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"credential_rotation_status": "passed",
|
||||
"executor_model_path": "passed",
|
||||
"tests": "controller 12/12;provenance Node 9/9;真实签发/越权拒绝/heartbeat commit+push/审计通过;Gate A/B/离线 Acceptance 待执行",
|
||||
"deployment": "aios-tenant-boundary rc2 已部署,runtime doctor 与 Gateway restart 通过",
|
||||
"rollback": "两次验收脚本断言错误均实际触发备份回滚并恢复 rc1;修正后 rc2 部署成功",
|
||||
"current_step": "GATE_A_PENDING",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"aios_v2_env_status": {
|
||||
"exists": true,
|
||||
"owner": "root",
|
||||
"group": "gitops",
|
||||
"mode": "0640",
|
||||
"four_fields_nonempty": true,
|
||||
"credential_ref_target_exists": true,
|
||||
"signing_material_target_exists": false
|
||||
},
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z",
|
||||
"worktree_repair": {
|
||||
"status": "resolved",
|
||||
"branch_unique_checkout": true,
|
||||
"clean_before_merge": true,
|
||||
"stale_lock_count": 0,
|
||||
"prunable_count": 0,
|
||||
"evidence_backup": "protected external backup created before merge",
|
||||
"verified_at": "2026-07-20T09:39:16Z"
|
||||
},
|
||||
"release_recommendation": "STOP_PREPRODUCTION",
|
||||
"gate_a_allowed": true,
|
||||
"resolved_blockers": 5,
|
||||
"open_blockers": 0,
|
||||
"controller_patch_commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||
"signing_contract_commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||
"secret_audit": {
|
||||
"plaintext_before": 3,
|
||||
"plaintext_after": 0,
|
||||
"unresolved_after": 0,
|
||||
"shadowed_after": 0,
|
||||
"legacy_after": 0,
|
||||
"gateway_token_rotated": true,
|
||||
"secret_values_recorded": false
|
||||
},
|
||||
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-result.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"final_status": "PARTIAL",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"task_commit_before_owner_evidence": "558dc932fa200831045e0f2edebbc12d775232bc",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match_before_owner_evidence": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"secret_reference_status": "resolved",
|
||||
"executor_status": "running",
|
||||
"test_status": "controller 12/12;provenance 9/9;live write/push/audit/deny passed;Gate A/B/offline pending",
|
||||
"deployment_status": "provenance plugin rc2 active",
|
||||
"rollback_status": "automatic plugin/config rollback exercised and passed",
|
||||
"documentation_sync_status": "updated before Gate A",
|
||||
"changed_files": [
|
||||
".agent/claim.json",
|
||||
".agent/heartbeat.json",
|
||||
".agent/progress.json",
|
||||
".agent/blockers.json",
|
||||
".agent/result.json",
|
||||
".agent/status.json",
|
||||
".agent/credential-status.json"
|
||||
],
|
||||
"open_blockers": [],
|
||||
"remaining_risks": [
|
||||
"缺少 Owner-signed provenance issuer/store/writer 与越权拒绝证据"
|
||||
],
|
||||
"completed_at": "2026-07-21T03:23:27Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z",
|
||||
"release_recommendation": "STOP_PREPRODUCTION",
|
||||
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-status.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"state": "running",
|
||||
"status": "running",
|
||||
"claimed_at": "2026-07-20T08:24:30Z",
|
||||
"updated_at": "2026-07-21T03:23:27Z",
|
||||
"last_heartbeat": "2026-07-21T03:23:27Z",
|
||||
"heartbeat_valid_for_running": true,
|
||||
"current_step": "GATE_A_PENDING",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"release_recommendation": "STOP_PREPRODUCTION",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-task.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"display_name": "AIOS V2R1 OpenClaw 预发布部署与小型缺陷修复任务",
|
||||
"language": "zh-CN",
|
||||
"executor": "openclaw",
|
||||
"role": "deployment-operator",
|
||||
"state": "queued",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"canonical_task": "tasks/openclaw-production-deploy-task.yaml",
|
||||
"supervisor_task": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||
"claim_required": true,
|
||||
"heartbeat_interval_seconds": 300,
|
||||
"evidence_directory": ".agent/evidence",
|
||||
"instructions": [
|
||||
"领取前读取候选分支完整中文文档和生产候选报告",
|
||||
"领取后写入 .agent/claim.json 并把状态改为 claimed",
|
||||
"每五分钟或每个主要步骤写入 heartbeat 和 progress",
|
||||
"所有部署修改、小型缺陷、阻塞、测试和结果必须提交到 Git",
|
||||
"架构、控制面、多租户或安全问题必须升级 Codex",
|
||||
"禁止提交明文密钥、直接修改 main 或绕过 GitOps"
|
||||
],
|
||||
"acceptance_commands": [
|
||||
"python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"python3 -m unittest discover -s tests/aios_v2_candidate -p test_*.py -v",
|
||||
"bash deploy/aios-v2/acceptance/run-all.sh",
|
||||
"bash deploy/aios-v2/healthcheck/check.sh"
|
||||
],
|
||||
"created_at": "2026-07-20T00:00:00Z",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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`。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -419,6 +419,8 @@ X-AIOS-Policy-Version
|
||||
|
||||
- 代理必须删除客户端同名头后重新注入。
|
||||
- 应用不得直接暴露公网端口。
|
||||
- HTTP 字段名按协议不区分大小写;应用把 Header 集合转换为普通映射后,必须先统一字段名大小写再解析身份。`X-AIOS-*`、`X-Aios-*` 与全小写形式必须得到相同的 `RequestContext`,但字段值本身不得被改写。
|
||||
- 真实监听器回归测试必须覆盖 `BaseHTTPRequestHandler` 的 title-case 行为,避免纯函数测试使用固定大写键而漏掉在线 401。
|
||||
|
||||
### 11.2 核心 API
|
||||
|
||||
@@ -500,3 +502,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`,不得误报为真实超时。
|
||||
|
||||
@@ -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 探针共同验证。
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
export { default } from "../index.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../policy.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../provenance.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../writer.js";
|
||||
@@ -0,0 +1,178 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { canonicalize, loadStoredRecord, readPublicKey, validatePayload, verifyRecord } from "./provenance.js";
|
||||
import { classifyRun, decideToolCall, runKey, validateWriterParams } from "./policy.js";
|
||||
import { executeEvidenceWrite } from "./writer.js";
|
||||
import { createRuntimeApi } from "./runtime.js";
|
||||
|
||||
const activeRuns = new Map();
|
||||
const permits = new Map();
|
||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
function pluginConfig(event, api) {
|
||||
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||
}
|
||||
|
||||
function prune(now = Date.now()) {
|
||||
for (const [key, value] of activeRuns) if (now - value.recordedAt > MAX_RUN_AGE_MS) activeRuns.delete(key);
|
||||
for (const [key, value] of permits) if (now - value.recordedAt > value.ttlMs) permits.delete(key);
|
||||
}
|
||||
|
||||
function toolCallId(event) {
|
||||
return typeof event?.toolCallId === "string" && event.toolCallId.trim() ? event.toolCallId.trim() : undefined;
|
||||
}
|
||||
|
||||
function eventParams(event) {
|
||||
return event?.params ?? event?.parameters;
|
||||
}
|
||||
|
||||
function requireAbsoluteConfig(config, key) {
|
||||
const value = config[key];
|
||||
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${key}_missing`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function authorizeEvidenceCall({ event, trust, config, now = Date.now() }) {
|
||||
const callId = toolCallId(event);
|
||||
if (!callId) return { ok: false, code: "tool_call_id_missing" };
|
||||
if (!trust?.trusted) return { ok: false, code: trust?.reason || "run_provenance_missing" };
|
||||
const params = eventParams(event);
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "tool_params_missing" };
|
||||
let record;
|
||||
try { record = loadStoredRecord(requireAbsoluteConfig(config, "provenanceStoreDir"), params.provenance_id); }
|
||||
catch (error) {
|
||||
const code = error instanceof Error && ["unsafe_provenance_file", "invalid_provenance_location", "provenance_record_too_large"].includes(error.message)
|
||||
? error.message : "provenance_store_rejected";
|
||||
return { ok: false, code };
|
||||
}
|
||||
const validation = validatePayload(record.payload, now);
|
||||
if (!validation.ok) return validation;
|
||||
let publicKey;
|
||||
try { publicKey = readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")); }
|
||||
catch { return { ok: false, code: "public_key_rejected" }; }
|
||||
const signature = verifyRecord(record, publicKey, config.provenanceKeyId);
|
||||
if (!signature.ok) return signature;
|
||||
const payload = record.payload;
|
||||
const runId = runKey(event, {});
|
||||
if (!runId || payload.run_id !== runId) return { ok: false, code: "run_id_mismatch" };
|
||||
if (payload.sender_id !== trust.senderId) return { ok: false, code: "sender_id_mismatch" };
|
||||
if (config.writerTaskBranch && payload.task_branch !== config.writerTaskBranch) return { ok: false, code: "task_branch_mismatch" };
|
||||
if (config.writerTaskId && payload.task_id !== config.writerTaskId) return { ok: false, code: "task_id_mismatch" };
|
||||
if (config.provenanceIssuer && payload.issued_by !== config.provenanceIssuer) return { ok: false, code: "issuer_mismatch" };
|
||||
if (config.executor && payload.executor !== config.executor) return { ok: false, code: "executor_mismatch" };
|
||||
if (config.executorInstance && payload.executor_instance !== config.executorInstance) return { ok: false, code: "executor_instance_mismatch" };
|
||||
const paramsValidation = validateWriterParams(params, payload);
|
||||
if (!paramsValidation.ok) return paramsValidation;
|
||||
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (permits.has(callId)) return { ok: false, code: "permit_replay" };
|
||||
const ttlMs = Number.isSafeInteger(config.permitTtlMs) && config.permitTtlMs > 0
|
||||
? Math.min(config.permitTtlMs, DEFAULT_PERMIT_TTL_MS)
|
||||
: DEFAULT_PERMIT_TTL_MS;
|
||||
permits.set(callId, { payload, paramsDigest: digest, recordedAt: now, ttlMs });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function consumePermit(toolCallIdValue, params, now = Date.now()) {
|
||||
const permit = permits.get(toolCallIdValue);
|
||||
permits.delete(toolCallIdValue);
|
||||
if (!permit) throw new Error("permit_missing");
|
||||
if (now - permit.recordedAt > permit.ttlMs) throw new Error("permit_expired");
|
||||
const digest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(permit.paramsDigest))) throw new Error("permit_params_mismatch");
|
||||
return permit;
|
||||
}
|
||||
|
||||
function toolResult(details) {
|
||||
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
||||
}
|
||||
|
||||
function registerWriterTool(api) {
|
||||
api.registerTool((toolContext) => ({
|
||||
name: "aios_evidence_write",
|
||||
label: "AIOS Evidence Writer",
|
||||
description: "Write one Owner-authorized JSON evidence file and normally push the current task branch.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["provenance_id", "path", "operation", "document"],
|
||||
properties: {
|
||||
provenance_id: { type: "string" },
|
||||
path: { type: "string" },
|
||||
operation: { type: "string", enum: ["create", "update"] },
|
||||
document: { type: "object" },
|
||||
},
|
||||
},
|
||||
executionMode: "sequential",
|
||||
async execute(callId, params) {
|
||||
const permit = consumePermit(callId, params);
|
||||
const config = toolContext?.getRuntimeConfig?.()?.plugins?.entries?.["aios-tenant-boundary"]?.config
|
||||
?? api.pluginConfig ?? {};
|
||||
const result = await executeEvidenceWrite({
|
||||
repoRoot: requireAbsoluteConfig(config, "writerRepoRoot"),
|
||||
evidencePath: params.path,
|
||||
operation: params.operation,
|
||||
document: params.document,
|
||||
payload: permit.payload,
|
||||
pushRemote: config.writerPushRemote ?? "gitea-http",
|
||||
askPassPath: config.writerAskPassPath ?? "",
|
||||
auditLogPath: requireAbsoluteConfig(config, "writerAuditLogPath"),
|
||||
gitBinary: config.gitBinary ?? "/usr/bin/git",
|
||||
});
|
||||
return toolResult(result);
|
||||
},
|
||||
}), { name: "aios_evidence_write" });
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "aios-tenant-boundary",
|
||||
name: "AIOS Tenant Boundary",
|
||||
description: "Fail-closed Owner provenance and dedicated evidence-writer boundary.",
|
||||
register(api) {
|
||||
const runtimeApi = createRuntimeApi(api);
|
||||
api.registerHttpRoute({ path: "/v1/health", auth: "plugin", match: "exact", handler: runtimeApi.handleHealth });
|
||||
api.registerHttpRoute({ path: "/v1/execute", auth: "plugin", match: "exact", handler: runtimeApi.handleExecute });
|
||||
api.registerHttpRoute({ path: "/v1/commands/", auth: "plugin", match: "prefix", handler: runtimeApi.commandRequest });
|
||||
registerWriterTool(api);
|
||||
api.on("before_agent_run", async (event, ctx) => {
|
||||
prune();
|
||||
const config = pluginConfig(event, api);
|
||||
const trust = classifyRun(event, ctx, config);
|
||||
const key = runKey(event, ctx);
|
||||
if (key) activeRuns.set(key, { ...trust, recordedAt: Date.now() });
|
||||
if (config.blockUnknownAgentRuns === true && !trust.trusted && trust.reason !== "job_id_present") {
|
||||
return { outcome: "block", reason: trust.reason, message: "Authenticated Owner provenance is required." };
|
||||
}
|
||||
return { outcome: "pass" };
|
||||
}, { priority: 100 });
|
||||
|
||||
api.on("before_tool_call", async (event, ctx) => {
|
||||
prune();
|
||||
const config = pluginConfig(event, api);
|
||||
const trust = activeRuns.get(runKey(event, ctx));
|
||||
const decision = decideToolCall(event.toolName, trust, config);
|
||||
if (decision.action === "block") return { block: true, blockReason: `AIOS V2R1 policy denied: ${decision.code}` };
|
||||
if (decision.action === "verify_provenance") {
|
||||
const authorized = authorizeEvidenceCall({ event, trust, config });
|
||||
if (!authorized.ok) return { block: true, blockReason: `AIOS V2R1 provenance denied: ${authorized.code}` };
|
||||
return undefined;
|
||||
}
|
||||
if (decision.action === "approval") {
|
||||
return { requireApproval: {
|
||||
title: `Approve ${String(event.toolName)}`,
|
||||
description: "Authenticated Owner requested a non-evidence side effect.",
|
||||
severity: "warning", timeoutMs: Number(config.approvalTimeoutMs ?? 60000),
|
||||
allowedDecisions: ["allow-once", "deny"], pluginId: "aios-tenant-boundary",
|
||||
} };
|
||||
}
|
||||
return undefined;
|
||||
}, { priority: 100 });
|
||||
|
||||
api.on("agent_end", async (event, ctx) => {
|
||||
const key = runKey(event, ctx);
|
||||
if (key) activeRuns.delete(key);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const __state = { activeRuns, permits };
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { canonicalize, privateKeyFromMaterial, signRecord, validatePayload } from "./provenance.js";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!key?.startsWith("--") || value === undefined) throw new Error("invalid_arguments");
|
||||
result[key.slice(2)] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function atomicWrite(filePath, contents, mode) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
|
||||
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
|
||||
fs.renameSync(temporary, filePath);
|
||||
fs.chmodSync(filePath, mode);
|
||||
}
|
||||
|
||||
function requireProtectedPrivateKey(privateKeyPath) {
|
||||
const stat = fs.lstatSync(privateKeyPath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_private_key");
|
||||
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("unsafe_private_key_mode");
|
||||
}
|
||||
|
||||
export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId, now = Date.now() }) {
|
||||
for (const value of [payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId]) {
|
||||
if (typeof value !== "string" || !value) throw new Error("missing_argument");
|
||||
}
|
||||
const payload = JSON.parse(fs.readFileSync(payloadPath, "utf8"));
|
||||
const validated = validatePayload(payload, now);
|
||||
if (!validated.ok) throw new Error(validated.code);
|
||||
requireProtectedPrivateKey(privateKeyPath);
|
||||
const recordPath = path.join(storeDir, `${payload.provenance_id}.json`);
|
||||
if (fs.existsSync(recordPath)) throw new Error("provenance_id_already_issued");
|
||||
const privateMaterial = fs.readFileSync(privateKeyPath);
|
||||
const privateKey = privateKeyFromMaterial(privateMaterial);
|
||||
const publicPem = crypto.createPublicKey(privateKey).export({ format: "pem", type: "spki" });
|
||||
const record = signRecord(payload, privateMaterial, keyId);
|
||||
atomicWrite(publicKeyPath, publicPem, 0o644);
|
||||
atomicWrite(recordPath, `${canonicalize(record)}\n`, 0o640);
|
||||
return payload.provenance_id;
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const provenanceId = issueFromFiles({
|
||||
payloadPath: args.payload,
|
||||
privateKeyPath: args["private-key"],
|
||||
publicKeyPath: args["public-key"],
|
||||
storeDir: args["store-dir"],
|
||||
keyId: args["key-id"],
|
||||
});
|
||||
process.stdout.write(`PROVENANCE_ISSUED=${provenanceId}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`PROVENANCE_ISSUE_FAILED=${error instanceof Error ? error.message : "unknown"}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "aios-tenant-boundary",
|
||||
"name": "AIOS Tenant Boundary",
|
||||
"description": "Enforces Owner-signed run provenance, a dedicated evidence writer, and the signed AIOS runtime contract.",
|
||||
"version": "2.1.0-rc3",
|
||||
"entry": "dist/index.js",
|
||||
"contracts": {
|
||||
"tools": ["aios_evidence_write"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"requireExplicitTrustedSenders": { "type": "boolean", "default": true },
|
||||
"requireSenderIsOwner": { "type": "boolean", "default": true },
|
||||
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
||||
"requireApprovalForHumanSideEffects": { "type": "boolean", "default": true },
|
||||
"blockUnknownAgentRuns": { "type": "boolean", "default": true },
|
||||
"approvalTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
|
||||
"permitTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||
"provenanceStoreDir": { "type": "string" },
|
||||
"provenancePublicKeyPath": { "type": "string" },
|
||||
"provenanceKeyId": { "type": "string" },
|
||||
"provenanceIssuer": { "type": "string" },
|
||||
"executor": { "type": "string" },
|
||||
"executorInstance": { "type": "string" },
|
||||
"writerRepoRoot": { "type": "string" },
|
||||
"writerTaskId": { "type": "string" },
|
||||
"writerTaskBranch": { "type": "string" },
|
||||
"writerPushRemote": { "type": "string" },
|
||||
"writerAskPassPath": { "type": "string" },
|
||||
"writerAuditLogPath": { "type": "string" },
|
||||
"gitBinary": { "type": "string" },
|
||||
"runtimeStatePath": { "type": "string" },
|
||||
"runtimeSigningPublicKeyPath": { "type": "string" },
|
||||
"runtimeSigningKeyId": { "type": "string" },
|
||||
"runtimeCredentialRef": { "type": "string" },
|
||||
"runtimeMaxActive": { "type": "integer", "minimum": 1, "maximum": 16 },
|
||||
"runtimeMaxDelayMs": { "type": "integer", "minimum": 100, "maximum": 30000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@aios/openclaw-tenant-boundary",
|
||||
"version": "2.1.0-rc.3",
|
||||
"type": "module",
|
||||
"description": "AIOS V2R1 Tenant Cell provenance and side-effect policy boundary for OpenClaw.",
|
||||
"files": [
|
||||
"index.js",
|
||||
"policy.js",
|
||||
"provenance.js",
|
||||
"writer.js",
|
||||
"runtime.js",
|
||||
"issuer.mjs",
|
||||
"dist",
|
||||
"openclaw.plugin.json"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ALLOWED_OPERATIONS, ALLOWED_PATHS } from "./provenance.js";
|
||||
|
||||
const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
||||
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
|
||||
"memory_search", "sessions_list", "sessions_history", "session_status",
|
||||
]);
|
||||
const GENERIC_MUTATION_TOOLS = new Set([
|
||||
"exec", "shell", "bash", "write", "edit", "apply_patch", "file_write",
|
||||
"computer", "process", "terminal",
|
||||
]);
|
||||
|
||||
function strings(value) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
export function resolveSenderId(ctx = {}) {
|
||||
const direct = typeof ctx.senderId === "string" ? ctx.senderId.trim() : "";
|
||||
const nested = typeof ctx.channelContext?.sender?.id === "string" ? ctx.channelContext.sender.id.trim() : "";
|
||||
return direct || nested || undefined;
|
||||
}
|
||||
|
||||
export function classifyRun(event = {}, ctx = {}, config = {}) {
|
||||
const senderId = resolveSenderId({ ...ctx, senderId: event.senderId ?? ctx.senderId });
|
||||
const trusted = new Set(strings(config.trustedSenderIds));
|
||||
const blocked = new Set(strings(config.blockedSenderIds));
|
||||
if (typeof ctx.jobId === "string" && ctx.jobId) return { trusted: false, origin: "automation", reason: "job_id_present" };
|
||||
if (!senderId) return { trusted: false, origin: "system_or_unknown", reason: "sender_id_missing" };
|
||||
if (blocked.has(senderId)) return { trusted: false, origin: "blocked_sender", reason: "sender_blocked", senderId };
|
||||
if (config.requireExplicitTrustedSenders !== false && !trusted.has(senderId)) {
|
||||
return { trusted: false, origin: "untrusted_sender", reason: "sender_not_allowlisted", senderId };
|
||||
}
|
||||
if (config.requireSenderIsOwner !== false && event.senderIsOwner !== true) {
|
||||
return { trusted: false, origin: "non_owner", reason: "sender_not_owner", senderId };
|
||||
}
|
||||
return { trusted: true, origin: "authenticated_owner", reason: "owner_verified", senderId };
|
||||
}
|
||||
|
||||
export function runKey(event = {}, ctx = {}) {
|
||||
const key = event.runId || ctx.runId;
|
||||
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
||||
}
|
||||
|
||||
export function decideToolCall(toolName, runTrust, config = {}) {
|
||||
const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
|
||||
if (!name) return { action: "block", code: "tool_name_missing" };
|
||||
if (GENERIC_MUTATION_TOOLS.has(name)) return { action: "block", code: "dedicated_writer_required" };
|
||||
const configured = strings(config.readOnlyTools).map((item) => item.toLowerCase());
|
||||
const readOnly = new Set(configured.length ? configured : DEFAULT_READ_ONLY_TOOLS);
|
||||
if (readOnly.has(name)) return { action: "allow", code: "read_only" };
|
||||
if (!runTrust?.trusted) return { action: "block", code: runTrust?.reason || "run_provenance_missing" };
|
||||
if (name === "aios_evidence_write") return { action: "verify_provenance", code: "dedicated_writer" };
|
||||
if (config.requireApprovalForHumanSideEffects === false) return { action: "allow", code: "trusted_side_effect" };
|
||||
return { action: "approval", code: "human_side_effect" };
|
||||
}
|
||||
|
||||
export function validateWriterParams(params = {}, payload) {
|
||||
const keys = Object.keys(params).sort();
|
||||
const expected = ["document", "operation", "path", "provenance_id"];
|
||||
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) return { ok: false, code: "tool_params_schema_mismatch" };
|
||||
if (params.provenance_id !== payload.provenance_id) return { ok: false, code: "provenance_id_mismatch" };
|
||||
if (!ALLOWED_PATHS.includes(params.path)) return { ok: false, code: "path_not_allowed" };
|
||||
if (params.operation !== "create" && params.operation !== "update") return { ok: false, code: "operation_not_allowed" };
|
||||
if (!payload.allowed_paths.includes(params.path)) return { ok: false, code: "path_not_authorized" };
|
||||
for (const operation of [params.operation, "git add", "git commit", "normal push current task branch"]) {
|
||||
if (!payload.allowed_operations.includes(operation)) return { ok: false, code: "operation_not_authorized" };
|
||||
}
|
||||
let serialized;
|
||||
try { serialized = JSON.stringify(params.document); } catch { return { ok: false, code: "document_not_json" }; }
|
||||
if (serialized === undefined || Buffer.byteLength(serialized) > 256 * 1024) return { ok: false, code: "document_invalid_or_too_large" };
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const ALLOWED_PATHS = Object.freeze([
|
||||
".agent/claim.json",
|
||||
".agent/heartbeat.json",
|
||||
".agent/progress.json",
|
||||
".agent/status.json",
|
||||
".agent/blockers.json",
|
||||
".agent/result.json",
|
||||
".agent/credential-status.json",
|
||||
]);
|
||||
|
||||
export const ALLOWED_OPERATIONS = Object.freeze([
|
||||
"create",
|
||||
"update",
|
||||
"git add",
|
||||
"git commit",
|
||||
"normal push current task branch",
|
||||
]);
|
||||
|
||||
const PAYLOAD_FIELDS = Object.freeze([
|
||||
"schema_version", "provenance_id", "run_id", "sender_id", "task_id",
|
||||
"executor", "executor_instance", "claim_oid", "candidate_oid", "task_branch",
|
||||
"allowed_paths", "allowed_operations", "issued_by", "issued_at", "expires_at",
|
||||
]);
|
||||
const OID_RE = /^[0-9a-f]{40}$/;
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const MAX_LIFETIME_MS = 15 * 60 * 1000;
|
||||
|
||||
function normalized(value) {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(normalized);
|
||||
const result = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
if (value[key] !== undefined) result[key] = normalized(value[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canonicalize(value) {
|
||||
return JSON.stringify(normalized(value));
|
||||
}
|
||||
|
||||
function exactStringSet(actual, expected) {
|
||||
if (!Array.isArray(actual) || actual.some((v) => typeof v !== "string")) return false;
|
||||
if (actual.length !== expected.length || new Set(actual).size !== expected.length) return false;
|
||||
const set = new Set(actual);
|
||||
return expected.every((value) => set.has(value));
|
||||
}
|
||||
|
||||
function failure(code) {
|
||||
return { ok: false, code };
|
||||
}
|
||||
|
||||
export function validatePayload(payload, now = Date.now()) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return failure("payload_not_object");
|
||||
const keys = Object.keys(payload).sort();
|
||||
if (keys.length !== PAYLOAD_FIELDS.length || PAYLOAD_FIELDS.some((key) => !keys.includes(key))) {
|
||||
return failure("payload_schema_mismatch");
|
||||
}
|
||||
if (payload.schema_version !== "aios.run-provenance/v1") return failure("schema_version_mismatch");
|
||||
for (const key of ["run_id", "sender_id", "task_id", "executor", "executor_instance", "task_branch", "issued_by"]) {
|
||||
if (typeof payload[key] !== "string" || !payload[key].trim()) return failure(`invalid_${key}`);
|
||||
}
|
||||
if (!ID_RE.test(payload.provenance_id)) return failure("invalid_provenance_id");
|
||||
if (!OID_RE.test(payload.claim_oid) || !OID_RE.test(payload.candidate_oid)) return failure("invalid_oid");
|
||||
if (!exactStringSet(payload.allowed_paths, ALLOWED_PATHS)) return failure("allowed_paths_mismatch");
|
||||
if (!exactStringSet(payload.allowed_operations, ALLOWED_OPERATIONS)) return failure("allowed_operations_mismatch");
|
||||
if (!Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)) return failure("invalid_time");
|
||||
if (payload.expires_at <= payload.issued_at || payload.expires_at - payload.issued_at > MAX_LIFETIME_MS) {
|
||||
return failure("invalid_lifetime");
|
||||
}
|
||||
if (now < payload.issued_at) return failure("not_yet_valid");
|
||||
if (now >= payload.expires_at) return failure("expired");
|
||||
return { ok: true, payload };
|
||||
}
|
||||
|
||||
export function privateKeyFromMaterial(material) {
|
||||
if (!Buffer.isBuffer(material)) material = Buffer.from(material);
|
||||
if (material.length === 32) {
|
||||
const pkcs8Prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
return crypto.createPrivateKey({ key: Buffer.concat([pkcs8Prefix, material]), format: "der", type: "pkcs8" });
|
||||
}
|
||||
if (material.subarray(0, 5).toString() === "-----") return crypto.createPrivateKey(material);
|
||||
return crypto.createPrivateKey({ key: material, format: "der", type: "pkcs8" });
|
||||
}
|
||||
|
||||
export function signRecord(payload, privateMaterial, keyId) {
|
||||
const signature = crypto.sign(null, Buffer.from(canonicalize(payload)), privateKeyFromMaterial(privateMaterial));
|
||||
return {
|
||||
payload,
|
||||
signature: { algorithm: "Ed25519", key_id: keyId, value: signature.toString("base64url") },
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyRecord(record, publicKeyPem, expectedKeyId) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) return failure("record_not_object");
|
||||
const signature = record.signature;
|
||||
if (!signature || signature.algorithm !== "Ed25519" || signature.key_id !== expectedKeyId || typeof signature.value !== "string") {
|
||||
return failure("signature_contract_mismatch");
|
||||
}
|
||||
try {
|
||||
const ok = crypto.verify(
|
||||
null,
|
||||
Buffer.from(canonicalize(record.payload)),
|
||||
crypto.createPublicKey(publicKeyPem),
|
||||
Buffer.from(signature.value, "base64url"),
|
||||
);
|
||||
return ok ? { ok: true } : failure("signature_invalid");
|
||||
} catch {
|
||||
return failure("signature_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function safeRegularFile(filePath) {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe_provenance_file");
|
||||
if (process.platform !== "win32" && (stat.mode & 0o022) !== 0) throw new Error("unsafe_provenance_file");
|
||||
}
|
||||
|
||||
export function loadStoredRecord(storeDir, provenanceId) {
|
||||
if (!path.isAbsolute(storeDir) || !ID_RE.test(provenanceId)) throw new Error("invalid_provenance_location");
|
||||
const storeReal = fs.realpathSync(storeDir);
|
||||
const target = path.join(storeReal, `${provenanceId}.json`);
|
||||
safeRegularFile(target);
|
||||
const targetReal = fs.realpathSync(target);
|
||||
if (path.dirname(targetReal) !== storeReal || path.basename(targetReal) !== `${provenanceId}.json`) {
|
||||
throw new Error("unsafe_provenance_file");
|
||||
}
|
||||
const raw = fs.readFileSync(target, "utf8");
|
||||
if (Buffer.byteLength(raw) > 128 * 1024) throw new Error("provenance_record_too_large");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
export function readPublicKey(publicKeyPath) {
|
||||
if (!path.isAbsolute(publicKeyPath)) throw new Error("invalid_public_key_path");
|
||||
safeRegularFile(publicKeyPath);
|
||||
return fs.readFileSync(publicKeyPath, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const CONTRACT_VERSION = "aios.openclaw.command.v1";
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
const CLOCK_SKEW_MS = 300_000;
|
||||
const COMMAND_FIELDS = [
|
||||
"command_id", "tenant_id", "task_id", "trace_id", "action", "payload",
|
||||
"idempotency_key", "created_at", "expires_at", "contract_version", "key_id",
|
||||
];
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sorted(value) {
|
||||
if (Array.isArray(value)) return value.map(sorted);
|
||||
if (!isObject(value)) return value;
|
||||
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
|
||||
}
|
||||
|
||||
function canonical(value) {
|
||||
return JSON.stringify(sorted(value));
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
const data = Buffer.from(JSON.stringify(body));
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": String(data.length),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
res.end(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function readJson(req) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw new Error("body_too_large");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); }
|
||||
catch { throw new Error("invalid_json"); }
|
||||
if (!isObject(value)) throw new Error("invalid_json_object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireAbsolute(config, name) {
|
||||
const value = config[name];
|
||||
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`config_${name}_missing`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendRecord(statePath, record) {
|
||||
const parent = path.dirname(statePath);
|
||||
fs.mkdirSync(parent, { recursive: true, mode: 0o750 });
|
||||
const fd = fs.openSync(statePath, "a", 0o640);
|
||||
try {
|
||||
fs.writeSync(fd, `${JSON.stringify(record)}\n`, null, "utf8");
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.chmodSync(statePath, 0o640);
|
||||
}
|
||||
|
||||
function validateCommand(value, expectedKeyId) {
|
||||
const allowed = new Set([...COMMAND_FIELDS, "signature"]);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("unknown_command_field");
|
||||
for (const name of COMMAND_FIELDS.filter((name) => name !== "expires_at" && name !== "payload")) {
|
||||
if (typeof value[name] !== "string" || !value[name].trim()) throw new Error(`invalid_${name}`);
|
||||
}
|
||||
if (!isObject(value.payload)) throw new Error("invalid_payload");
|
||||
if (typeof value.expires_at !== "string" || !value.expires_at) throw new Error("expires_at_required");
|
||||
if (typeof value.signature !== "string" || !value.signature) throw new Error("signature_missing");
|
||||
if (value.contract_version !== CONTRACT_VERSION) throw new Error("contract_version_rejected");
|
||||
if (value.key_id !== expectedKeyId) throw new Error("key_id_rejected");
|
||||
const created = Date.parse(value.created_at);
|
||||
const expires = Date.parse(value.expires_at);
|
||||
if (!Number.isFinite(created) || !Number.isFinite(expires)) throw new Error("timestamp_invalid");
|
||||
if (created - Date.now() > CLOCK_SKEW_MS) throw new Error("created_at_in_future");
|
||||
if (expires <= created || expires - created > 300_000) throw new Error("expiry_window_rejected");
|
||||
if (Date.now() - expires > CLOCK_SKEW_MS) throw new Error("command_expired");
|
||||
const unsigned = Object.fromEntries(COMMAND_FIELDS.map((name) => [name, value[name]]));
|
||||
return { unsigned, expires };
|
||||
}
|
||||
|
||||
function verifySignature(command, publicKey, expectedKeyId) {
|
||||
const { unsigned, expires } = validateCommand(command, expectedKeyId);
|
||||
let signature;
|
||||
try { signature = Buffer.from(command.signature, "base64url"); }
|
||||
catch { throw new Error("signature_invalid"); }
|
||||
if (!crypto.verify(null, Buffer.from(canonical(unsigned)), publicKey, signature)) throw new Error("signature_invalid");
|
||||
return { digest: crypto.createHash("sha256").update(canonical(unsigned)).digest("hex"), expires };
|
||||
}
|
||||
|
||||
function resultFor(command, state, { output = null, error = null, startedAt, finishedAt = nowIso() } = {}) {
|
||||
return {
|
||||
command_id: command.command_id, tenant_id: command.tenant_id,
|
||||
task_id: command.task_id, trace_id: command.trace_id, state, output, error,
|
||||
started_at: startedAt ?? nowIso(), finished_at: finishedAt,
|
||||
runtime_id: "openclaw-aios-runtime-v1", attempts: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function loadState(statePath) {
|
||||
const commands = new Map();
|
||||
const idempotency = new Map();
|
||||
if (!fs.existsSync(statePath)) return { commands, idempotency };
|
||||
for (const line of fs.readFileSync(statePath, "utf8").split("\n").filter(Boolean)) {
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { throw new Error("runtime_state_corrupt"); }
|
||||
if (!isObject(record) || typeof record.command_id !== "string") throw new Error("runtime_state_corrupt");
|
||||
commands.set(record.command_id, record);
|
||||
if (record.tenant_id && record.idempotency_key) idempotency.set(`${record.tenant_id}\0${record.idempotency_key}`, record.command_id);
|
||||
}
|
||||
return { commands, idempotency };
|
||||
}
|
||||
|
||||
export function createRuntimeApi(api) {
|
||||
const config = api.pluginConfig ?? {};
|
||||
const statePath = requireAbsolute(config, "runtimeStatePath");
|
||||
const publicKeyPath = requireAbsolute(config, "runtimeSigningPublicKeyPath");
|
||||
const expectedKeyId = String(config.runtimeSigningKeyId || "");
|
||||
const credentialRef = String(config.runtimeCredentialRef || "");
|
||||
if (!expectedKeyId) throw new Error("config_runtimeSigningKeyId_missing");
|
||||
if (!credentialRef) throw new Error("config_runtimeCredentialRef_missing");
|
||||
const publicKey = crypto.createPublicKey(fs.readFileSync(publicKeyPath));
|
||||
const maxActive = Math.max(1, Math.min(Number(config.runtimeMaxActive || 4), 16));
|
||||
const maxDelayMs = Math.max(100, Math.min(Number(config.runtimeMaxDelayMs || 30_000), 30_000));
|
||||
const state = loadState(statePath);
|
||||
const active = new Map();
|
||||
|
||||
for (const record of state.commands.values()) {
|
||||
if (record.result?.state === "RUNNING") {
|
||||
const reconciled = { ...record, result: { ...record.result, state: "FAILED", finished_at: nowIso(), error: { code: "restart_interrupted", message: "execution outcome is unknown after runtime restart" } } };
|
||||
appendRecord(statePath, reconciled);
|
||||
state.commands.set(record.command_id, reconciled);
|
||||
}
|
||||
}
|
||||
|
||||
function requireCredential(req) {
|
||||
const supplied = String(req.headers["x-aios-credential-ref"] || "");
|
||||
const left = Buffer.from(supplied);
|
||||
const right = Buffer.from(credentialRef);
|
||||
if (left.length !== right.length || !crypto.timingSafeEqual(left, right)) throw new Error("credential_ref_rejected");
|
||||
}
|
||||
|
||||
function persist(command, digest, result) {
|
||||
const record = {
|
||||
command_id: command.command_id, tenant_id: command.tenant_id,
|
||||
task_id: command.task_id, trace_id: command.trace_id,
|
||||
idempotency_key: command.idempotency_key, digest, result, recorded_at: nowIso(),
|
||||
};
|
||||
appendRecord(statePath, record);
|
||||
state.commands.set(command.command_id, record);
|
||||
state.idempotency.set(`${command.tenant_id}\0${command.idempotency_key}`, command.command_id);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function execute(command, digest, expires) {
|
||||
const startedAt = nowIso();
|
||||
persist(command, digest, resultFor(command, "RUNNING", { startedAt, finishedAt: null }));
|
||||
if (command.action === "status") {
|
||||
const completed = resultFor(command, "COMPLETED", { startedAt, output: { status: "ok", runtime: "openclaw-plugin", side_effects: false } });
|
||||
persist(command, digest, completed);
|
||||
return completed;
|
||||
}
|
||||
if (command.action !== "acceptance.delay") throw new Error("unsupported_action");
|
||||
const delayMs = Number(command.payload.delay_ms);
|
||||
if (!Number.isInteger(delayMs) || delayMs < 0 || delayMs > maxDelayMs) {
|
||||
const failed = resultFor(command, "FAILED", { startedAt, error: { code: "invalid_delay", message: "delay_ms is outside the allowed range" } });
|
||||
persist(command, digest, failed);
|
||||
return failed;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
active.set(command.command_id, controller);
|
||||
let timedOut = false;
|
||||
const deadlineMs = Math.max(0, Math.min(maxDelayMs, expires - Date.now()));
|
||||
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, deadlineMs);
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delayMs);
|
||||
controller.signal.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("aborted")); }, { once: true });
|
||||
});
|
||||
const completed = resultFor(command, "COMPLETED", { startedAt, output: { delayed_ms: delayMs, side_effects: false } });
|
||||
persist(command, digest, completed);
|
||||
return completed;
|
||||
} catch {
|
||||
const terminal = resultFor(command, timedOut ? "TIMED_OUT" : "CANCELLED", {
|
||||
startedAt,
|
||||
error: timedOut ? { code: "runtime_timeout", message: "runtime deadline exceeded" } : { code: "cancelled", message: "cancelled by AIOS" },
|
||||
});
|
||||
persist(command, digest, terminal);
|
||||
return terminal;
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
active.delete(command.command_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecute(req, res) {
|
||||
if (req.method !== "POST") return sendJson(res, 405, { error: "method_not_allowed" });
|
||||
try {
|
||||
requireCredential(req);
|
||||
const command = await readJson(req);
|
||||
const { digest, expires } = verifySignature(command, publicKey, expectedKeyId);
|
||||
const prior = state.commands.get(command.command_id);
|
||||
if (prior) {
|
||||
if (prior.digest !== digest) return sendJson(res, 409, { error: "command_id_conflict" });
|
||||
return sendJson(res, 200, prior.result);
|
||||
}
|
||||
const idemId = state.idempotency.get(`${command.tenant_id}\0${command.idempotency_key}`);
|
||||
if (idemId) {
|
||||
const idem = state.commands.get(idemId);
|
||||
if (!idem || idem.digest !== digest) return sendJson(res, 409, { error: "idempotency_conflict" });
|
||||
return sendJson(res, 200, idem.result);
|
||||
}
|
||||
if (!new Set(["status", "acceptance.delay"]).has(command.action)) return sendJson(res, 422, { error: "unsupported_action" });
|
||||
if (active.size >= maxActive) return sendJson(res, 429, { error: "runtime_capacity_exceeded" });
|
||||
return sendJson(res, 200, await execute(command, digest, expires));
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "request_rejected";
|
||||
const status = code === "body_too_large" ? 413 : code.startsWith("config_") || code === "runtime_state_corrupt" ? 503 : 401;
|
||||
return sendJson(res, status, { error: code });
|
||||
}
|
||||
}
|
||||
|
||||
function commandRequest(req, res) {
|
||||
try {
|
||||
requireCredential(req);
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const match = url.pathname.match(/^\/v1\/commands\/([^/]+)(?:\/(status|cancel))?$/);
|
||||
if (!match) return false;
|
||||
const commandId = decodeURIComponent(match[1]);
|
||||
const operation = match[2] || "status";
|
||||
const record = state.commands.get(commandId);
|
||||
if (!record) return sendJson(res, 404, { error: "command_not_found" });
|
||||
const tenantId = String(url.searchParams.get("tenant_id") || "");
|
||||
if (!tenantId || tenantId !== record.tenant_id) return sendJson(res, 404, { error: "command_not_found" });
|
||||
if (operation === "status" && req.method === "GET") {
|
||||
return sendJson(res, 200, record.result);
|
||||
}
|
||||
if (operation === "cancel" && req.method === "POST") {
|
||||
const controller = active.get(commandId);
|
||||
if (controller) controller.abort();
|
||||
return sendJson(res, 200, { accepted: Boolean(controller), command_id: commandId });
|
||||
}
|
||||
return sendJson(res, 405, { error: "method_not_allowed" });
|
||||
} catch (error) {
|
||||
return sendJson(res, 401, { error: error instanceof Error ? error.message : "request_rejected" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleHealth(req, res) {
|
||||
if (req.method !== "GET") return sendJson(res, 405, { error: "method_not_allowed" });
|
||||
return sendJson(res, 200, {
|
||||
runtime: "openclaw", runtime_id: "openclaw-aios-runtime-v1", status: "healthy",
|
||||
version: "2.1.0-rc.3", heartbeat_at: nowIso(), active_tasks: active.size,
|
||||
workspace_isolated: true, credential_ref_configured: true,
|
||||
details: { transport: "openclaw_plugin_http", allowed_actions: ["status", "acceptance.delay"] },
|
||||
});
|
||||
},
|
||||
handleExecute,
|
||||
commandRequest,
|
||||
};
|
||||
}
|
||||
|
||||
export const __test = { canonical, validateCommand, verifySignature, loadState };
|
||||
@@ -0,0 +1,189 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import plugin, { authorizeEvidenceCall, consumePermit, __state } from "../index.js";
|
||||
import { decideToolCall, validateWriterParams } from "../policy.js";
|
||||
import {
|
||||
ALLOWED_OPERATIONS, ALLOWED_PATHS, canonicalize, signRecord, validatePayload, verifyRecord,
|
||||
} from "../provenance.js";
|
||||
import { buildPushArgs, executeEvidenceWrite } from "../writer.js";
|
||||
import { issueFromFiles } from "../issuer.mjs";
|
||||
|
||||
function fixture(now = Date.now()) {
|
||||
return {
|
||||
schema_version: "aios.run-provenance/v1",
|
||||
provenance_id: "prov-test-1",
|
||||
run_id: "run-test-1",
|
||||
sender_id: "owner-1",
|
||||
task_id: "task/openclaw/test",
|
||||
executor: "openclaw",
|
||||
executor_instance: "gateway-test",
|
||||
claim_oid: "a".repeat(40),
|
||||
candidate_oid: "b".repeat(40),
|
||||
task_branch: "task/openclaw/test",
|
||||
allowed_paths: [...ALLOWED_PATHS],
|
||||
allowed_operations: [...ALLOWED_OPERATIONS],
|
||||
issued_by: "AIOS_OWNER",
|
||||
issued_at: now - 1000,
|
||||
expires_at: now + 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
function keyFixture() {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
||||
return {
|
||||
privateDer: privateKey.export({ format: "der", type: "pkcs8" }),
|
||||
publicPem: publicKey.export({ format: "pem", type: "spki" }),
|
||||
};
|
||||
}
|
||||
|
||||
test("Ed25519 record verifies and tampering is rejected", () => {
|
||||
const payload = fixture();
|
||||
const keys = keyFixture();
|
||||
const record = signRecord(payload, keys.privateDer, "key-1");
|
||||
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, true);
|
||||
record.payload.task_id = "tampered";
|
||||
assert.equal(verifyRecord(record, keys.publicPem, "key-1").ok, false);
|
||||
});
|
||||
|
||||
test("payload rejects expiry and set drift", () => {
|
||||
const now = Date.now();
|
||||
const expired = fixture(now);
|
||||
expired.issued_at = now - 2000;
|
||||
expired.expires_at = now - 1000;
|
||||
assert.equal(validatePayload(expired, now).code, "expired");
|
||||
const drift = fixture(now);
|
||||
drift.allowed_paths = drift.allowed_paths.slice(1);
|
||||
assert.equal(validatePayload(drift, now).code, "allowed_paths_mismatch");
|
||||
});
|
||||
|
||||
test("issuer accepts protected raw 32-byte material and refuses duplicate ids", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-issuer-"));
|
||||
const keys = keyFixture();
|
||||
const payload = fixture();
|
||||
const payloadPath = path.join(root, "payload.json");
|
||||
const privateKeyPath = path.join(root, "private.bin");
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
const storeDir = path.join(root, "store");
|
||||
fs.writeFileSync(payloadPath, canonicalize(payload));
|
||||
fs.writeFileSync(privateKeyPath, keys.privateDer.subarray(-32), { mode: 0o600 });
|
||||
if (process.platform !== "win32") fs.chmodSync(privateKeyPath, 0o600);
|
||||
assert.equal(issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), payload.provenance_id);
|
||||
const record = JSON.parse(fs.readFileSync(path.join(storeDir, `${payload.provenance_id}.json`), "utf8"));
|
||||
assert.equal(verifyRecord(record, fs.readFileSync(publicKeyPath, "utf8"), "key-1").ok, true);
|
||||
assert.throws(() => issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId: "key-1" }), /already_issued/);
|
||||
});
|
||||
|
||||
test("generic mutation tools stay blocked", () => {
|
||||
for (const name of ["exec", "shell", "bash", "write", "edit", "apply_patch", "file_write"]) {
|
||||
assert.deepEqual(decideToolCall(name, { trusted: true }, {}), { action: "block", code: "dedicated_writer_required" });
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin registers the native writer factory and required hook names", () => {
|
||||
const hooks = [];
|
||||
const routes = [];
|
||||
let factory;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-runtime-register-"));
|
||||
const keys = keyFixture();
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
fs.writeFileSync(publicKeyPath, keys.publicPem);
|
||||
const api = {
|
||||
pluginConfig: {
|
||||
runtimeStatePath: path.join(root, "state.jsonl"),
|
||||
runtimeSigningPublicKeyPath: publicKeyPath,
|
||||
runtimeSigningKeyId: "key-1",
|
||||
runtimeCredentialRef: "secretref:test",
|
||||
},
|
||||
registerTool(value, options) { factory = value; assert.equal(options.name, "aios_evidence_write"); },
|
||||
registerHttpRoute(value) { routes.push(value); },
|
||||
on(name) { hooks.push(name); },
|
||||
};
|
||||
plugin.register(api);
|
||||
const tool = factory({});
|
||||
assert.equal(tool.name, "aios_evidence_write");
|
||||
assert.equal(tool.label, "AIOS Evidence Writer");
|
||||
assert.equal(tool.executionMode, "sequential");
|
||||
assert.deepEqual(routes.map((route) => route.path), ["/v1/health", "/v1/execute", "/v1/commands/"]);
|
||||
assert.deepEqual(hooks.sort(), ["agent_end", "before_agent_run", "before_tool_call"]);
|
||||
});
|
||||
|
||||
test("manifest explicitly declares the dedicated tool contract", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"));
|
||||
assert.deepEqual(manifest.contracts.tools, ["aios_evidence_write"]);
|
||||
});
|
||||
|
||||
test("writer params reject unauthorized paths and operations", () => {
|
||||
const payload = fixture();
|
||||
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: "README.md", operation: "update", document: {} }, payload).code, "path_not_allowed");
|
||||
assert.equal(validateWriterParams({ provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "exec", document: {} }, payload).code, "operation_not_allowed");
|
||||
});
|
||||
|
||||
test("authorization binds signature, run, sender, params, and one-use permit", () => {
|
||||
__state.permits.clear();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-prov-"));
|
||||
const store = path.join(root, "store");
|
||||
fs.mkdirSync(store);
|
||||
const keys = keyFixture();
|
||||
const payload = fixture();
|
||||
const record = signRecord(payload, keys.privateDer, "key-1");
|
||||
fs.writeFileSync(path.join(store, `${payload.provenance_id}.json`), canonicalize(record), { mode: 0o600 });
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
fs.writeFileSync(publicKeyPath, keys.publicPem, { mode: 0o600 });
|
||||
const params = { provenance_id: payload.provenance_id, path: ".agent/status.json", operation: "update", document: { state: "ok" } };
|
||||
const event = { toolName: "aios_evidence_write", toolCallId: "call-1", runId: payload.run_id, params };
|
||||
const config = {
|
||||
provenanceStoreDir: store, provenancePublicKeyPath: publicKeyPath, provenanceKeyId: "key-1",
|
||||
writerTaskBranch: payload.task_branch, writerTaskId: payload.task_id, provenanceIssuer: payload.issued_by,
|
||||
executor: payload.executor, executorInstance: payload.executor_instance,
|
||||
};
|
||||
const authorized = authorizeEvidenceCall({ event, trust: { trusted: true, senderId: payload.sender_id }, config });
|
||||
assert.equal(authorized.ok, true, authorized.code);
|
||||
assert.equal(consumePermit("call-1", params).payload.run_id, payload.run_id);
|
||||
assert.throws(() => consumePermit("call-1", params), /permit_missing/);
|
||||
const wrongRun = { ...event, toolCallId: "call-2", runId: "wrong" };
|
||||
assert.equal(authorizeEvidenceCall({ event: wrongRun, trust: { trusted: true, senderId: payload.sender_id }, config }).code, "run_id_mismatch");
|
||||
const wrongSender = { trusted: true, senderId: "wrong" };
|
||||
assert.equal(authorizeEvidenceCall({ event: { ...event, toolCallId: "call-3" }, trust: wrongSender, config }).code, "sender_id_mismatch");
|
||||
});
|
||||
|
||||
function git(cwd, args) {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8", shell: false });
|
||||
if (result.status !== 0) throw new Error(result.stderr || "git failed");
|
||||
return (result.stdout || "").trim();
|
||||
}
|
||||
|
||||
test("writer commits only one evidence path and explicitly pushes current branch", async (t) => {
|
||||
if (spawnSync("git", ["--version"], { shell: false }).status !== 0) return t.skip("git unavailable");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-writer-"));
|
||||
const repo = path.join(root, "repo");
|
||||
const bare = path.join(root, "remote.git");
|
||||
fs.mkdirSync(repo);
|
||||
git(root, ["init", "--bare", bare]);
|
||||
git(repo, ["init", "-b", "task/openclaw/test"]);
|
||||
git(repo, ["config", "user.name", "AIOS Test"]);
|
||||
git(repo, ["config", "user.email", "aios-test@example.invalid"]);
|
||||
fs.mkdirSync(path.join(repo, ".agent"));
|
||||
fs.writeFileSync(path.join(repo, ".agent", "status.json"), "{\"state\":\"before\"}\n");
|
||||
git(repo, ["add", ".agent/status.json"]);
|
||||
git(repo, ["commit", "-m", "base"]);
|
||||
const claim = git(repo, ["rev-parse", "HEAD"]);
|
||||
git(repo, ["remote", "add", "origin", bare]);
|
||||
git(repo, ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
|
||||
const payload = fixture();
|
||||
payload.claim_oid = claim;
|
||||
payload.candidate_oid = claim;
|
||||
const result = await executeEvidenceWrite({
|
||||
repoRoot: repo, evidencePath: ".agent/status.json", operation: "update",
|
||||
document: { state: "after" }, payload, pushRemote: "origin",
|
||||
auditLogPath: path.join(root, "audit", "writer.jsonl"), gitBinary: "git",
|
||||
});
|
||||
assert.equal(result.outcome, "pushed");
|
||||
assert.equal(git(root, ["--git-dir", bare, "rev-parse", "refs/heads/task/openclaw/test"]), result.commit_oid);
|
||||
assert.deepEqual(buildPushArgs("origin", payload.task_branch), ["push", "origin", "refs/heads/task/openclaw/test:refs/heads/task/openclaw/test"]);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { createRuntimeApi, __test } from "../runtime.js";
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "aios-runtime-"));
|
||||
const keys = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPath = path.join(root, "public.pem");
|
||||
fs.writeFileSync(publicKeyPath, keys.publicKey.export({ format: "pem", type: "spki" }));
|
||||
const api = createRuntimeApi({ pluginConfig: {
|
||||
runtimeStatePath: path.join(root, "state.jsonl"),
|
||||
runtimeSigningPublicKeyPath: publicKeyPath,
|
||||
runtimeSigningKeyId: "runtime-key-1",
|
||||
runtimeCredentialRef: "secretref:runtime-test",
|
||||
runtimeMaxDelayMs: 3000,
|
||||
} });
|
||||
return { root, keys, api };
|
||||
}
|
||||
|
||||
function signed(keys, overrides = {}) {
|
||||
const command = {
|
||||
command_id: `cmd-${crypto.randomUUID()}`,
|
||||
tenant_id: "tenant-a",
|
||||
task_id: "task-a",
|
||||
trace_id: "trace-a",
|
||||
action: "status",
|
||||
payload: { side_effects: false },
|
||||
idempotency_key: `idem-${crypto.randomUUID()}`,
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
contract_version: "aios.openclaw.command.v1",
|
||||
key_id: "runtime-key-1",
|
||||
...overrides,
|
||||
};
|
||||
command.signature = crypto.sign(null, Buffer.from(__test.canonical(command)), keys.privateKey).toString("base64url");
|
||||
return command;
|
||||
}
|
||||
|
||||
function request(method, url, body = null) {
|
||||
const req = Readable.from(body === null ? [] : [Buffer.from(JSON.stringify(body))]);
|
||||
req.method = method;
|
||||
req.url = url;
|
||||
req.headers = { "x-aios-credential-ref": "secretref:runtime-test" };
|
||||
return req;
|
||||
}
|
||||
|
||||
function response() {
|
||||
let resolve;
|
||||
const done = new Promise((value) => { resolve = value; });
|
||||
return {
|
||||
status: 0,
|
||||
headers: {},
|
||||
body: null,
|
||||
done,
|
||||
writeHead(status, headers) { this.status = status; this.headers = headers; },
|
||||
end(data) { this.body = JSON.parse(Buffer.from(data).toString("utf8")); resolve(this); },
|
||||
};
|
||||
}
|
||||
|
||||
async function call(handler, req) {
|
||||
const res = response();
|
||||
await handler(req, res);
|
||||
return res.done;
|
||||
}
|
||||
|
||||
test("signed status executes, persists, and replays idempotently", async () => {
|
||||
const { keys, api, root } = fixture();
|
||||
const command = signed(keys);
|
||||
const first = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.state, "COMPLETED");
|
||||
assert.equal(first.body.output.runtime, "openclaw-plugin");
|
||||
const second = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||
assert.deepEqual(second.body, first.body);
|
||||
assert.ok(fs.statSync(path.join(root, "state.jsonl")).size > 0);
|
||||
});
|
||||
|
||||
test("tamper, expiry, and conflicting identity are rejected", async () => {
|
||||
const { keys, api } = fixture();
|
||||
const tampered = signed(keys);
|
||||
tampered.tenant_id = "tenant-b";
|
||||
assert.equal((await call(api.handleExecute, request("POST", "/v1/execute", tampered))).status, 401);
|
||||
const expired = signed(keys, {
|
||||
created_at: new Date(Date.now() - 700_000).toISOString(),
|
||||
expires_at: new Date(Date.now() - 600_000).toISOString(),
|
||||
});
|
||||
assert.equal((await call(api.handleExecute, request("POST", "/v1/execute", expired))).status, 401);
|
||||
});
|
||||
|
||||
test("status is tenant-scoped and unknown actions fail closed", async () => {
|
||||
const { keys, api } = fixture();
|
||||
const rejected = signed(keys, { action: "arbitrary.exec" });
|
||||
const denied = await call(api.handleExecute, request("POST", "/v1/execute", rejected));
|
||||
assert.equal(denied.status, 422);
|
||||
assert.equal(denied.body.error, "unsupported_action");
|
||||
const command = signed(keys);
|
||||
await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||
const wrong = await call(api.commandRequest, request("GET", `/v1/commands/${command.command_id}?tenant_id=tenant-b`));
|
||||
assert.equal(wrong.status, 404);
|
||||
const correct = await call(api.commandRequest, request("GET", `/v1/commands/${command.command_id}?tenant_id=tenant-a`));
|
||||
assert.equal(correct.body.command_id, command.command_id);
|
||||
});
|
||||
|
||||
test("active delay supports deterministic cancellation", async () => {
|
||||
const { keys, api } = fixture();
|
||||
const command = signed(keys, { action: "acceptance.delay", payload: { delay_ms: 2000 } });
|
||||
const executeResponse = response();
|
||||
const execution = api.handleExecute(request("POST", "/v1/execute", command), executeResponse);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const cancelled = await call(api.commandRequest, request("POST", `/v1/commands/${command.command_id}/cancel?tenant_id=tenant-a`));
|
||||
assert.equal(cancelled.body.accepted, true);
|
||||
await execution;
|
||||
const terminal = await executeResponse.done;
|
||||
assert.equal(terminal.body.state, "CANCELLED");
|
||||
});
|
||||
|
||||
test("signed deadline produces TIMED_OUT", async () => {
|
||||
const { keys, api } = fixture();
|
||||
const command = signed(keys, {
|
||||
action: "acceptance.delay",
|
||||
payload: { delay_ms: 1000 },
|
||||
expires_at: new Date(Date.now() + 40).toISOString(),
|
||||
});
|
||||
const result = await call(api.handleExecute, request("POST", "/v1/execute", command));
|
||||
assert.equal(result.body.state, "TIMED_OUT");
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { ALLOWED_PATHS, canonicalize } from "./provenance.js";
|
||||
|
||||
function safeFsync(fd) {
|
||||
try { fs.fsyncSync(fd); }
|
||||
catch (error) {
|
||||
if (process.platform !== "win32" || error?.code !== "EPERM") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(args, { cwd, gitBinary = "/usr/bin/git", env = {} }) {
|
||||
const result = spawnSync(gitBinary, ["-c", `safe.directory=${cwd}`, ...args], {
|
||||
cwd,
|
||||
shell: false,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
|
||||
});
|
||||
if (result.error || result.status !== 0) throw new Error(`git_${args[0]}_failed`);
|
||||
return result.stdout || "";
|
||||
}
|
||||
|
||||
function cleanBranch(branch) {
|
||||
const value = typeof branch === "string" ? branch.replace(/^refs\/heads\//, "") : "";
|
||||
if (!value || value.startsWith("-") || value.includes("..") || value.endsWith(".") || value.includes("@{")) {
|
||||
throw new Error("invalid_branch");
|
||||
}
|
||||
for (const forbidden of ["~", "^", ":", "?", "*", "[", "\\", " ", "\t", "\n"]) {
|
||||
if (value.includes(forbidden)) throw new Error("invalid_branch");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildPushArgs(remote, branch) {
|
||||
const clean = cleanBranch(branch);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(remote)) throw new Error("invalid_remote");
|
||||
return ["push", remote, `refs/heads/${clean}:refs/heads/${clean}`];
|
||||
}
|
||||
|
||||
function ensureSafeTarget(repoRoot, evidencePath) {
|
||||
if (!ALLOWED_PATHS.includes(evidencePath)) throw new Error("path_not_allowed");
|
||||
const root = fs.realpathSync(repoRoot);
|
||||
const target = path.resolve(root, evidencePath);
|
||||
if (!target.startsWith(`${root}${path.sep}`)) throw new Error("path_escape");
|
||||
let cursor = root;
|
||||
for (const segment of path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean)) {
|
||||
cursor = path.join(cursor, segment);
|
||||
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) throw new Error("symlink_path");
|
||||
}
|
||||
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) throw new Error("symlink_path");
|
||||
return { root, target };
|
||||
}
|
||||
|
||||
function atomicWrite(target, document, previousMode) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o770 });
|
||||
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
|
||||
const mode = previousMode ?? 0o660;
|
||||
fs.writeFileSync(temporary, `${canonicalize(document)}\n`, { flag: "wx", mode });
|
||||
const handle = fs.openSync(temporary, "r");
|
||||
try { safeFsync(handle); } finally { fs.closeSync(handle); }
|
||||
fs.renameSync(temporary, target);
|
||||
fs.chmodSync(target, mode);
|
||||
}
|
||||
|
||||
function appendAudit(auditLogPath, entry) {
|
||||
if (!path.isAbsolute(auditLogPath)) throw new Error("invalid_audit_log_path");
|
||||
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true, mode: 0o750 });
|
||||
const fd = fs.openSync(auditLogPath, "a", 0o640);
|
||||
try {
|
||||
fs.writeSync(fd, `${canonicalize(entry)}\n`);
|
||||
safeFsync(fd);
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
function gitEnvironment(askPassPath) {
|
||||
if (!askPassPath) return {};
|
||||
const stat = fs.lstatSync(askPassPath);
|
||||
if (!path.isAbsolute(askPassPath) || !stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) {
|
||||
throw new Error("unsafe_askpass");
|
||||
}
|
||||
return { GIT_ASKPASS: askPassPath, GIT_ASKPASS_REQUIRE: "force", DISPLAY: "aios" };
|
||||
}
|
||||
|
||||
function restoreBeforeCommit(target, existed, backup, mode, root, relative, gitOptions) {
|
||||
try { runGit(["restore", "--staged", "--", relative], gitOptions); } catch { /* best effort */ }
|
||||
if (existed) {
|
||||
fs.writeFileSync(target, backup, { mode });
|
||||
} else if (fs.existsSync(target)) {
|
||||
fs.unlinkSync(target);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeEvidenceWrite({
|
||||
repoRoot, evidencePath, operation, document, payload, pushRemote = "gitea-http",
|
||||
askPassPath = "", auditLogPath, gitBinary = "/usr/bin/git",
|
||||
}) {
|
||||
const { root, target } = ensureSafeTarget(repoRoot, evidencePath);
|
||||
const relative = path.relative(root, target).replace(/\\/g, "/");
|
||||
const gitOptions = { cwd: root, gitBinary, env: gitEnvironment(askPassPath) };
|
||||
const branch = cleanBranch(payload.task_branch);
|
||||
if (runGit(["symbolic-ref", "--short", "HEAD"], gitOptions).trim() !== branch) throw new Error("branch_mismatch");
|
||||
if (runGit(["rev-parse", "HEAD"], gitOptions).trim() !== payload.claim_oid) throw new Error("head_mismatch");
|
||||
runGit(["merge-base", "--is-ancestor", payload.candidate_oid, payload.claim_oid], gitOptions);
|
||||
if (runGit(["status", "--porcelain", "--untracked-files=all"], gitOptions).trim()) throw new Error("worktree_not_clean");
|
||||
|
||||
const existed = fs.existsSync(target);
|
||||
if (operation === "create" && existed) throw new Error("path_exists_for_create");
|
||||
if (operation === "update" && !existed) throw new Error("path_missing_for_update");
|
||||
const backup = existed ? fs.readFileSync(target) : null;
|
||||
const priorMode = existed ? fs.statSync(target).mode & 0o777 : 0o660;
|
||||
let committed = false;
|
||||
let commitOid = null;
|
||||
try {
|
||||
atomicWrite(target, document, priorMode);
|
||||
runGit(["add", "--", relative], gitOptions);
|
||||
const staged = runGit(["diff", "--cached", "--name-only", "-z"], gitOptions).split("\0").filter(Boolean);
|
||||
if (staged.length !== 1 || staged[0] !== relative) throw new Error("staged_scope_mismatch");
|
||||
runGit(["commit", "-m", `chore(aios): provenance ${payload.provenance_id} ${path.basename(relative)}`], gitOptions);
|
||||
committed = true;
|
||||
commitOid = runGit(["rev-parse", "HEAD"], gitOptions).trim();
|
||||
runGit(buildPushArgs(pushRemote, branch), gitOptions);
|
||||
appendAudit(auditLogPath, {
|
||||
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
|
||||
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
|
||||
branch, commit_oid: commitOid, outcome: "pushed", recorded_at: Date.now(),
|
||||
});
|
||||
return { path: relative, operation, branch, commit_oid: commitOid, outcome: "pushed" };
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "writer_failed";
|
||||
if (!committed) restoreBeforeCommit(target, existed, backup, priorMode, root, relative, gitOptions);
|
||||
try {
|
||||
appendAudit(auditLogPath, {
|
||||
schema_version: "aios.writer-audit/v1", provenance_id: payload.provenance_id,
|
||||
task_id: payload.task_id, run_id: payload.run_id, path: relative, operation,
|
||||
branch, commit_oid: commitOid, outcome: committed ? "committed_push_blocked" : "rejected",
|
||||
error_code: code, recorded_at: Date.now(),
|
||||
});
|
||||
} catch { /* the original failure remains authoritative */ }
|
||||
throw new Error(code);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import os
|
||||
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 +19,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 +30,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 +70,65 @@ def bootstrap(service: ControlPlaneService, tenant_id: str = "tenant-a"):
|
||||
|
||||
|
||||
class CandidateFlowTests(unittest.TestCase):
|
||||
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 +153,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 +171,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()
|
||||
|
||||
Reference in New Issue
Block a user