Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e7dac80a | ||
|
|
d31dd8a31b | ||
|
|
e45a775af2 | ||
|
|
ebf33ba34a | ||
|
|
1c1aa1700b | ||
|
|
fd8bea6a23 | ||
|
|
bd9cdd8daf | ||
|
|
c48ea38711 | ||
|
|
c4f2ac8903 | ||
|
|
47af809a8b | ||
|
|
77eb2aeda9 | ||
|
|
d57772ad3e | ||
|
|
838a8ed69f | ||
|
|
51b98041d9 | ||
|
|
4398caaedf | ||
|
|
0c0be7abc3 | ||
|
|
f96b712b26 | ||
|
|
8f55eff11d | ||
|
|
56d675166a | ||
|
|
d3b0307159 | ||
|
|
0e832bbe9a | ||
|
|
04ab2db654 | ||
|
|
bc18ae4446 | ||
|
|
ea74af9751 | ||
|
|
57c3ce6e96 | ||
|
|
4b57a6268c | ||
|
|
88dcebee78 | ||
|
|
12a973c2cb | ||
|
|
0babec654b | ||
|
|
d787e98554 | ||
|
|
c0450946e4 | ||
|
|
b4c30e16e0 | ||
|
|
f04473f657 | ||
|
|
dedef75f8c | ||
|
|
18c43e42a4 | ||
|
|
95b3728abd | ||
|
|
e1659069b1 | ||
|
|
4b35eb52cb | ||
|
|
05835675b6 | ||
|
|
a1a3a021ef | ||
|
|
40e21d3c07 | ||
|
|
46932c46ca | ||
|
|
9e0a40600b | ||
|
|
d54da54033 | ||
|
|
33152a7f52 | ||
|
|
3dd622b5a5 | ||
|
|
f73052cd5c | ||
|
|
1b69e89718 | ||
|
|
f3c00d7294 | ||
|
|
80e3f65cd6 | ||
|
|
0cf769ef67 | ||
|
|
5e53203a61 | ||
|
|
cf5afdf4c0 | ||
|
|
b6e714e3fc | ||
|
|
0022adf958 | ||
|
|
22888c3d53 | ||
|
|
c2c9eb8d81 | ||
|
|
6df9c92020 | ||
|
|
6bb8c530a4 | ||
|
|
024143efa5 | ||
|
|
06c6374126 | ||
|
|
82177403f6 | ||
|
|
ef074a0c97 | ||
|
|
00fd375e4c | ||
|
|
5b0c3eb404 | ||
|
|
3161470bd2 | ||
|
|
1852701236 | ||
|
|
21fc8c60ed | ||
|
|
77395fa9a0 | ||
|
|
490ed4f65a | ||
|
|
d6d2407a0d | ||
|
|
db3fcb89a8 | ||
|
|
20b7a41104 | ||
|
|
e739e67052 | ||
|
|
1e6c7b9ad8 | ||
|
|
1afbb11d3b | ||
|
|
850beec8d7 | ||
|
|
fb11c71ec0 | ||
|
|
8acdd104bb | ||
|
|
43d77e520f | ||
|
|
a3f731d01c | ||
|
|
8dba5b8c2e | ||
|
|
74158908e8 | ||
|
|
ab36e36d5d | ||
|
|
c1b14753a3 | ||
|
|
c62c8ee9f6 | ||
|
|
9626e66e5b | ||
|
|
b68e3c85a3 | ||
|
|
74014514da | ||
|
|
4e0e6dcb90 | ||
|
|
0778a31ec0 | ||
|
|
80c4cbde03 | ||
|
|
fb739f6e8f | ||
|
|
e6da39a3cd | ||
|
|
95d2e52cb5 | ||
|
|
283dec58ab | ||
|
|
7de1fe80ac | ||
|
|
89a502b39b | ||
|
|
61bec280e2 | ||
|
|
1791c91c5b | ||
|
|
e8c0f0250b | ||
|
|
94718308fc | ||
|
|
a4cbed0f5f | ||
|
|
25c7740739 | ||
|
|
8bbb049c39 | ||
|
|
1707cc4953 | ||
|
|
e397594200 | ||
|
|
68b7f2c1d2 | ||
|
|
fda98e61f5 | ||
|
|
ea9f599522 | ||
|
|
1aabcd8b62 | ||
|
|
4d68af9524 | ||
|
|
467b127804 | ||
|
|
50a73b211b | ||
|
|
f14d4b6e14 | ||
|
|
fc8bd8c8d4 | ||
|
|
382fe662a3 | ||
|
|
4754e5762a | ||
|
|
ce7d35a811 | ||
|
|
60ac7d9d0b | ||
|
|
2a10d1cdd9 | ||
|
|
677ea738e7 | ||
|
|
7154d62cca | ||
|
|
4e154c8baf | ||
|
|
b848361079 | ||
|
|
74224cfbd5 | ||
|
|
a7c09ba646 | ||
|
|
f055cb74bb | ||
|
|
071a378de6 | ||
|
|
a492b15df6 | ||
|
|
a9f0a4359b | ||
|
|
6a9fab894a | ||
|
|
e9111dea47 | ||
|
|
d25fbfa61e | ||
|
|
6b03c47625 | ||
|
|
770fe1ef66 | ||
|
|
7a025430f9 | ||
|
|
d079da4029 | ||
|
|
b8f7dc4112 | ||
|
|
947cdaff42 | ||
|
|
83b0c8bd74 | ||
|
|
fbd880cf8c | ||
|
|
819ac94c9f | ||
|
|
b02401e60d | ||
|
|
cbb2e232f5 | ||
|
|
9e46ac0504 |
@@ -1,412 +0,0 @@
|
||||
# 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;
|
||||
- 正式生产部署;
|
||||
- 生产流量切换;
|
||||
- 接受高风险剩余问题。
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"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,3 @@
|
||||
d23bb1ec24a7f49359314e04cd2c04c8f38876e45f25a701506499d1b34f270f MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json
|
||||
5ce2894fdf8a22622954a1a10fb09fbab74af04f0260b249615c70236ff31731 RESULT-AIOS-V2R1-HERMES-LIFECYCLE-20260721.md
|
||||
ca2ab3c22c2b2b33bbc03e59f1206b999ad1271de3e1938fd3a0b4234c613eef ../../../docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema_version": "aios.evidence.manifest.v1",
|
||||
"generated_at": "2026-07-21T05:50:00Z",
|
||||
"branch": "patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721",
|
||||
"validated_oid": "95d2e52cb55c7b913e89bc6bed2bcdacf67512d4",
|
||||
"evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z",
|
||||
"evidence_hashes": {
|
||||
"steps.tsv": "b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf",
|
||||
"final-live-gate.log": "82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa",
|
||||
"16R.log": "641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf",
|
||||
"15.log": "b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e",
|
||||
"13.log": "b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710",
|
||||
"12R2.log": "0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67"
|
||||
},
|
||||
"validation": {
|
||||
"candidate_tests": {"passed": 49, "failed": 0},
|
||||
"hermes_lifecycle": {"passed": 17, "failed": 0},
|
||||
"gate_b": {"passed": 10, "failed": 0},
|
||||
"offline_acceptance": {"passed": 8, "failed": 0, "expected_warnings": 1},
|
||||
"openclaw_real_http": "PASS",
|
||||
"restart_recovery": "PASS",
|
||||
"rollback_forward": "PASS",
|
||||
"state_preservation": "PASS",
|
||||
"final_health_corrected_16R": "PASS"
|
||||
},
|
||||
"git_gate": {
|
||||
"oid_match": true,
|
||||
"candidate_clean": true,
|
||||
"active_controller_clean": true,
|
||||
"locked": 0,
|
||||
"prunable": 0,
|
||||
"worktree_total": 88,
|
||||
"worktree_dirty": 10,
|
||||
"global_worktree_clean": false
|
||||
},
|
||||
"resolved_blockers": [],
|
||||
"active_blockers": [
|
||||
"BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING",
|
||||
"BLK-GLOBAL-WORKTREE-CLEANLINESS"
|
||||
],
|
||||
"overall": "PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS",
|
||||
"release_decision": "STOP_PREPRODUCTION",
|
||||
"secrets_recorded": false
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# AIOS V2R1 Hermes Lifecycle Result
|
||||
|
||||
- Branch: `patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
|
||||
- Validated OID: `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
|
||||
- Evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
|
||||
- Hermes blocker: `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`
|
||||
- Overall: `PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
|
||||
- Release decision: `STOP_PREPRODUCTION`
|
||||
|
||||
Hermes lifecycle `17/17`, candidate `49/49`, Gate B `10/10`, offline acceptance `8/0/1`, OpenClaw real HTTP, restart recovery, rollback/forward, state preservation and corrected final health all passed at the validated OID. The Owner acceptance contract nevertheless forbids formally closing the Hermes blocker until every global gate also passes.
|
||||
|
||||
The candidate worktree and active Controller worktree are clean; local, worktree and Gitea OIDs match; locked/prunable are zero. The global authority currently has 10 dirty worktrees out of 88, so the Owner hard gate requiring every worktree to be clean fails.
|
||||
|
||||
No RC, Tag Approval Request, Tag, main mutation, force push, production traffic switch or secret disclosure was performed.
|
||||
|
||||
See `MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json` and `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`.
|
||||
@@ -0,0 +1,12 @@
|
||||
# V2R1C1 Cleanup Closure
|
||||
|
||||
- `CLEANUP_REVIEW=PASS`
|
||||
- `ARCHIVE_VALIDATION=PASS`
|
||||
- `GLOBAL_WORKTREE_CLEAN=true`
|
||||
- `global_dirty_count=0`
|
||||
- `locked=0`
|
||||
- `prunable=0`
|
||||
- Portal Archive OID: `99671cc14d2631f989e0763dc882e4001c50a1ea`
|
||||
- Spark Archive OID: `d97cd3642528bd3373fe4cf5161a5d99c0b9285f`
|
||||
|
||||
Closure evidence: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-blocker-clear-20260721T075647Z`.
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema_version": "aios.v2r1c1.deployment-manifest.v1",
|
||||
"vrc": "V2R1C1",
|
||||
"release_branch": "release/AIOS-V2R1C1-20260721",
|
||||
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
|
||||
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
|
||||
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
|
||||
"companion": {
|
||||
"branch": "patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721",
|
||||
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
|
||||
"oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
|
||||
"plugin_version": "2.1.0-rc.4",
|
||||
"contract_version": "aios.openclaw.command.v2",
|
||||
"signature_algorithm": "Ed25519",
|
||||
"verification_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671"
|
||||
},
|
||||
"isolated_validation": {
|
||||
"service": "aios-v2r1c1-openclaw-companion.service",
|
||||
"account": "aios-v2r1c1-openclaw",
|
||||
"listen": "127.0.0.1:28789",
|
||||
"accept_v2": true,
|
||||
"accept_v1": false,
|
||||
"public_key_only": true,
|
||||
"production_traffic_switched": false,
|
||||
"result": "PASS"
|
||||
},
|
||||
"previous_plugin": {
|
||||
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
|
||||
"plugin_version": "2.1.0-rc.3",
|
||||
"artifact_sha256": "d0fd11be4aec03eee9ca24bf1585d9ef3597254ad4b1db0d0137eab4456b5737"
|
||||
},
|
||||
"rollback": {
|
||||
"production_plugin_unchanged": true,
|
||||
"failed_isolated_attempts_rolled_back": true,
|
||||
"previous_plugin_evidence": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z/00-baseline",
|
||||
"previous_oid": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
|
||||
"isolated_rollback_forward": "PASS",
|
||||
"production_symlink_modified": false
|
||||
},
|
||||
"release_validation": {
|
||||
"gate_a": "PASS",
|
||||
"gate_b": "PASS",
|
||||
"openclaw_v2_live": "PASS",
|
||||
"openclaw_negative_live": "PASS",
|
||||
"hermes_live": "PASS",
|
||||
"restart_recovery": "PASS",
|
||||
"rollback_forward": "PASS",
|
||||
"final_health": "PASS",
|
||||
"secret_violations": 0,
|
||||
"global_dirty_count": 0,
|
||||
"sync_status": "fully_synced"
|
||||
},
|
||||
"tag_status": "ABSENT",
|
||||
"preproduction_status": "NOT_STARTED",
|
||||
"production_status": "NOT_STARTED"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schema_version": "aios.v2r1c1.release-evidence.v2",
|
||||
"vrc": "V2R1C1",
|
||||
"semver": "2.1.0-rc.1",
|
||||
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
|
||||
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
|
||||
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
|
||||
"release_branch": "release/AIOS-V2R1C1-20260721",
|
||||
"release_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-release-20260721",
|
||||
"companion_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z",
|
||||
"openclaw_companion": {
|
||||
"branch": "patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721",
|
||||
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
|
||||
"companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
|
||||
"plugin_version": "2.1.0-rc.4",
|
||||
"contract_version": "aios.openclaw.command.v2",
|
||||
"signature_algorithm": "Ed25519",
|
||||
"verification_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671"
|
||||
},
|
||||
"step13": {
|
||||
"original_result": "FAIL_HTTP_401",
|
||||
"original_diagnosis_manifest_sha256": "320017cbfc0f31392b4d0fb5996d0e32709982a967d102592ac45efdbdaad91f",
|
||||
"remediated_result": "PASS",
|
||||
"online_negative_evidence_sha256": "57ab8efd19d3bf659a65cbeda830785f6cea9172925a3cac9e027b9b842e644f",
|
||||
"remediated_evidence_sha256": "5db59096c0586fcc0a51851d9a25d0a0b94f2bade45063fab16cc2a729f23130",
|
||||
"task_state": "COMPLETED",
|
||||
"dispatch_state": "COMPLETED",
|
||||
"task_dispatch_audit_consistency": "PASS"
|
||||
},
|
||||
"release_validation": {
|
||||
"static_revalidation": "PASS",
|
||||
"static_evidence_sha256": "c33ffaf9a0f509d6e78bdfdc0848940c166b8a800940e259b7196adab04ec604",
|
||||
"gate_a": "PASS",
|
||||
"gate_b": "PASS",
|
||||
"documentation_sync": "PASS",
|
||||
"secret_scan_violations": 0,
|
||||
"release_metadata": "PASS",
|
||||
"hermes_live": "PASS",
|
||||
"hermes_evidence_sha256": "e6553c99db55c3cdf9e16a4c1e6712064e00c41dc9965d0d179b6b1a2a1ad3ac",
|
||||
"restart_recovery": "PASS",
|
||||
"restart_evidence_sha256": "6031c92cd890dffd1de28ba75d81bed908e7da11a24aa129bcfde8efb23f0149",
|
||||
"rollback_forward": "PASS",
|
||||
"rollback_evidence_sha256": "f3d49c54e3f722facf68df2da3d82a9c82eb576f2c7c8dacb0f62c424d25737c",
|
||||
"final_health": "PASS",
|
||||
"final_health_evidence_sha256": "121cd52c0a4852b818e06be69211ae18b9fcccf976fa064e2a09c54eb8a01300",
|
||||
"global_dirty_count": 0,
|
||||
"sync_status": "fully_synced"
|
||||
},
|
||||
"validation_status": "PASS",
|
||||
"tag_status": "ABSENT",
|
||||
"preproduction_status": "NOT_STARTED",
|
||||
"production_status": "NOT_STARTED",
|
||||
"secrets_recorded": false
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# V2R1C1 Validation Result
|
||||
|
||||
Status: `RELEASE_VALIDATION_PASS / FINAL_METADATA_COMMIT_REVALIDATION_REQUIRED`
|
||||
|
||||
Release decision: `STOP_PREPRODUCTION`
|
||||
|
||||
## OpenClaw Command v2 Companion
|
||||
|
||||
- Source OID: `83f8f878c7da5dd8263f2af2efa80750492e5cb3`
|
||||
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`
|
||||
- Plugin version: `2.1.0-rc.4`
|
||||
- Contract: `aios.openclaw.command.v2`
|
||||
- Signature: `Ed25519`
|
||||
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`
|
||||
- Companion unit/contract tests: `PASS`
|
||||
- V1 fallback: `REJECTED`
|
||||
- Isolated Gateway health: `PASS`
|
||||
- Online positive/negative contract: `PASS`
|
||||
- AIOS -> OpenClaw real HTTP: `PASS`
|
||||
- Task state: `COMPLETED`
|
||||
- Dispatch state: `COMPLETED`
|
||||
- Task/Dispatch/Audit consistency: `PASS`
|
||||
|
||||
## Evidence linkage
|
||||
|
||||
- Original failed Release OID: `80c4cbde03a82c30648a09b817bc2f03d5402936`
|
||||
- Original HTTP 401 diagnosis: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-release-20260721/pass1-80c4cbde/13-diagnosis`
|
||||
- Original manifest SHA256: `320017cbfc0f31392b4d0fb5996d0e32709982a967d102592ac45efdbdaad91f`
|
||||
- Companion evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`
|
||||
- Validated Release parent OID: `0778a31ec073e7c90003ef091f1d093c16bf22e5`
|
||||
- Online negative evidence SHA256: `57ab8efd19d3bf659a65cbeda830785f6cea9172925a3cac9e027b9b842e644f`
|
||||
- Remediated Step 13 evidence SHA256: `5db59096c0586fcc0a51851d9a25d0a0b94f2bade45063fab16cc2a729f23130`
|
||||
|
||||
## Release validation closure
|
||||
|
||||
- Gate A: `PASS`
|
||||
- Gate B: `PASS`
|
||||
- Documentation sync: `PASS`
|
||||
- Secret violations: `0`
|
||||
- Release Metadata: `PASS`
|
||||
- Hermes live: `PASS`
|
||||
- Restart recovery: `PASS`
|
||||
- Previous ↔ V2R1C1 isolated rollback/forward: `PASS`
|
||||
- Final health: `PASS`
|
||||
- Global dirty / locked / prunable: `0 / 0 / 0`
|
||||
- OID sync: `fully_synced`
|
||||
- Production traffic switched: `false`
|
||||
- Tag: `ABSENT`
|
||||
|
||||
## Remaining mandatory validation
|
||||
|
||||
本次最终元数据闭环提交将形成新的 Release OID。必须针对该最终 OID 再次复验 Gate A、Gate B、文档同步、Secret Scan、Release Metadata 和 OpenClaw 第 13 步正向/负向验证。复验通过前不得创建 Tag 或部署预生产/生产。
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-task.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"display_name": "AIOS V2R1 OpenClaw 预发布部署与小型缺陷修复任务",
|
||||
"language": "zh-CN",
|
||||
"executor": "openclaw",
|
||||
"role": "deployment-operator",
|
||||
"state": "queued",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"canonical_task": "tasks/openclaw-production-deploy-task.yaml",
|
||||
"supervisor_task": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||
"claim_required": true,
|
||||
"heartbeat_interval_seconds": 300,
|
||||
"evidence_directory": ".agent/evidence",
|
||||
"instructions": [
|
||||
"领取前读取候选分支完整中文文档和生产候选报告",
|
||||
"领取后写入 .agent/claim.json 并把状态改为 claimed",
|
||||
"每五分钟或每个主要步骤写入 heartbeat 和 progress",
|
||||
"所有部署修改、小型缺陷、阻塞、测试和结果必须提交到 Git",
|
||||
"架构、控制面、多租户或安全问题必须升级 Codex",
|
||||
"禁止提交明文密钥、直接修改 main 或绕过 GitOps"
|
||||
],
|
||||
"acceptance_commands": [
|
||||
"python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"python3 -m unittest discover -s tests/aios_v2_candidate -p test_*.py -v",
|
||||
"bash deploy/aios-v2/acceptance/run-all.sh",
|
||||
"bash deploy/aios-v2/healthcheck/check.sh"
|
||||
],
|
||||
"created_at": "2026-07-20T00:00:00Z",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -392,13 +392,3 @@ 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`。
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## V2R1C1 - 2.1.0-rc.1
|
||||
|
||||
- 固定发布基线为 `fb739f6e8ff9c031d319221ace68dce157694086`。
|
||||
- 收口 Hermes 生命周期、Grant 隔离、Dispatch 恢复、运行证据和回滚/前滚验证。
|
||||
- Portal Adapter 与 Spark Provisioning 仅保留在 Archive,不属于产品发布图。
|
||||
- Future Incubator 保持隔离,不属于 V2R1C1 发布输入。
|
||||
@@ -6,6 +6,7 @@ from .adapter import (
|
||||
HermesNotFound,
|
||||
HermesRuntimeError,
|
||||
InMemoryHermesStore,
|
||||
SQLiteHermesStore,
|
||||
)
|
||||
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
|
||||
from .worker import HermesWorker, WorkerRunResult
|
||||
@@ -21,5 +22,6 @@ __all__ = [
|
||||
"HermesRuntimeError",
|
||||
"HermesWorker",
|
||||
"InMemoryHermesStore",
|
||||
"SQLiteHermesStore",
|
||||
"WorkerRunResult",
|
||||
]
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import replace
|
||||
from dataclasses import asdict, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from .contract import (
|
||||
@@ -41,6 +43,118 @@ class InMemoryHermesStore:
|
||||
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
|
||||
|
||||
def persist_locked(self) -> None:
|
||||
"""Persist a mutation while ``lock`` is held; memory stores are a no-op."""
|
||||
|
||||
|
||||
class SQLiteHermesStore(InMemoryHermesStore):
|
||||
"""Durable snapshot for the existing Hermes queue state machine.
|
||||
|
||||
The adapter remains the sole owner of transitions and lease rules. This
|
||||
store only makes its jobs, results, idempotency keys and worker heartbeats
|
||||
survive a process restart.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
super().__init__()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS hermes_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._load()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _record(value: HermesJob | HermesResult) -> dict[str, object]:
|
||||
record = asdict(value)
|
||||
record["state"] = value.state.value
|
||||
return record
|
||||
|
||||
def _load(self) -> None:
|
||||
with self.lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload FROM hermes_state WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
payload = json.loads(str(row[0]))
|
||||
self.jobs = {
|
||||
str(item["job_id"]): HermesJob(**item)
|
||||
for item in payload.get("jobs", [])
|
||||
}
|
||||
self.results = {
|
||||
str(item["job_id"]): HermesResult(**item)
|
||||
for item in payload.get("results", [])
|
||||
}
|
||||
self.idempotency = {
|
||||
(str(item["tenant_id"]), str(item["idempotency_key"])): (
|
||||
str(item["job_id"]),
|
||||
str(item["digest"]),
|
||||
)
|
||||
for item in payload.get("idempotency", [])
|
||||
}
|
||||
self.worker_heartbeats = {
|
||||
str(item["worker_id"]): (
|
||||
str(item["heartbeat_at"]),
|
||||
str(item["job_id"]) if item.get("job_id") is not None else None,
|
||||
)
|
||||
for item in payload.get("worker_heartbeats", [])
|
||||
}
|
||||
|
||||
def persist_locked(self) -> None:
|
||||
payload = {
|
||||
"version": 1,
|
||||
"jobs": [self._record(item) for item in self.jobs.values()],
|
||||
"results": [self._record(item) for item in self.results.values()],
|
||||
"idempotency": [
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"job_id": job_id,
|
||||
"digest": digest,
|
||||
}
|
||||
for (tenant_id, idempotency_key), (job_id, digest)
|
||||
in self.idempotency.items()
|
||||
],
|
||||
"worker_heartbeats": [
|
||||
{
|
||||
"worker_id": worker_id,
|
||||
"heartbeat_at": heartbeat_at,
|
||||
"job_id": job_id,
|
||||
}
|
||||
for worker_id, (heartbeat_at, job_id)
|
||||
in self.worker_heartbeats.items()
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO hermes_state(singleton, payload) VALUES(1, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET payload=excluded.payload
|
||||
""",
|
||||
(encoded,),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
class HermesAdapter:
|
||||
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
|
||||
@@ -118,6 +232,7 @@ class HermesAdapter:
|
||||
return self.store.jobs[prior_id]
|
||||
self.store.jobs[job.job_id] = job
|
||||
self.store.idempotency[idem_ref] = (job.job_id, digest)
|
||||
self.store.persist_locked()
|
||||
return job
|
||||
|
||||
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
|
||||
@@ -138,7 +253,8 @@ class HermesAdapter:
|
||||
) -> list[HermesJob]:
|
||||
state_filter = {HermesJobState(item) for item in states} if states else None
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
if self._requeue_expired_locked():
|
||||
self.store.persist_locked()
|
||||
jobs = [
|
||||
job
|
||||
for job in self.store.jobs.values()
|
||||
@@ -157,6 +273,7 @@ class HermesAdapter:
|
||||
profile_id: str,
|
||||
worker_id: str,
|
||||
lease_seconds: int | None = None,
|
||||
job_id: str | None = None,
|
||||
) -> HermesJob | None:
|
||||
worker_id = str(worker_id or "").strip()
|
||||
if not worker_id:
|
||||
@@ -172,10 +289,12 @@ class HermesAdapter:
|
||||
if job.tenant_id == tenant_id
|
||||
and job.cell_id == cell_id
|
||||
and job.profile_id == profile_id
|
||||
and (job_id is None or job.job_id == job_id)
|
||||
and job.state == HermesJobState.QUEUED
|
||||
]
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||
if not candidates:
|
||||
self.store.persist_locked()
|
||||
return None
|
||||
current = sorted(
|
||||
candidates,
|
||||
@@ -190,6 +309,7 @@ class HermesAdapter:
|
||||
),
|
||||
)
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
|
||||
self.store.persist_locked()
|
||||
return claimed
|
||||
|
||||
def start(
|
||||
@@ -201,9 +321,12 @@ class HermesAdapter:
|
||||
expected_revision: int | None = None,
|
||||
) -> HermesJob:
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
self._assert_revision(current, expected_revision)
|
||||
return self._transition_locked(current, HermesJobState.RUNNING)
|
||||
updated = self._transition_locked(current, HermesJobState.RUNNING)
|
||||
self.store.persist_locked()
|
||||
return updated
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
@@ -214,7 +337,10 @@ class HermesAdapter:
|
||||
lease_seconds: int | None = None,
|
||||
) -> HermesJob:
|
||||
duration = int(lease_seconds or self.default_lease_seconds)
|
||||
if duration < 5:
|
||||
raise ValueError("lease_seconds must be >= 5")
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
|
||||
raise HermesConflict("job does not accept heartbeat")
|
||||
@@ -228,6 +354,7 @@ class HermesAdapter:
|
||||
)
|
||||
self.store.jobs[job_id] = updated
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
|
||||
self.store.persist_locked()
|
||||
return updated
|
||||
|
||||
def complete(
|
||||
@@ -283,6 +410,8 @@ class HermesAdapter:
|
||||
result = self.store.results.get(job_id)
|
||||
if result is None:
|
||||
raise HermesConflict("terminal result missing")
|
||||
if result.state != HermesJobState.CANCELLED:
|
||||
raise HermesConflict("job already has a different terminal result")
|
||||
return result
|
||||
updated = self._transition_locked(current, HermesJobState.CANCELLED)
|
||||
result = HermesResult(
|
||||
@@ -296,6 +425,7 @@ class HermesAdapter:
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.store.results[job_id] = result
|
||||
self.store.persist_locked()
|
||||
return result
|
||||
|
||||
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
|
||||
@@ -309,9 +439,18 @@ class HermesAdapter:
|
||||
raise HermesConflict("job has no terminal result")
|
||||
return result
|
||||
|
||||
def requeue_expired(self) -> tuple[str, ...]:
|
||||
"""Persistently release expired leases without changing policy state."""
|
||||
with self.store.lock:
|
||||
changed = self._requeue_expired_locked()
|
||||
if changed:
|
||||
self.store.persist_locked()
|
||||
return tuple(sorted(changed))
|
||||
|
||||
def health(self) -> HermesHealth:
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
if self._requeue_expired_locked():
|
||||
self.store.persist_locked()
|
||||
active_jobs = sum(
|
||||
1 for job in self.store.jobs.values()
|
||||
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||
@@ -354,7 +493,20 @@ class HermesAdapter:
|
||||
expected_revision: int | None,
|
||||
) -> HermesResult:
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
if current.state in TERMINAL_STATES:
|
||||
existing = self.store.results.get(job_id)
|
||||
if existing is None:
|
||||
raise HermesConflict("terminal result missing")
|
||||
if (
|
||||
existing.state == state
|
||||
and existing.worker_id == worker_id
|
||||
and existing.output == output
|
||||
and existing.error == error
|
||||
):
|
||||
return existing
|
||||
raise HermesConflict("job already has a different terminal result")
|
||||
self._assert_revision(current, expected_revision)
|
||||
updated = self._transition_locked(current, state)
|
||||
result = HermesResult(
|
||||
@@ -371,6 +523,7 @@ class HermesAdapter:
|
||||
)
|
||||
self.store.results[job_id] = result
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||
self.store.persist_locked()
|
||||
return result
|
||||
|
||||
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
|
||||
@@ -418,8 +571,9 @@ class HermesAdapter:
|
||||
self.store.jobs[current.job_id] = updated
|
||||
return updated
|
||||
|
||||
def _requeue_expired_locked(self) -> None:
|
||||
def _requeue_expired_locked(self) -> set[str]:
|
||||
now = datetime.now(timezone.utc)
|
||||
changed: set[str] = set()
|
||||
for current in list(self.store.jobs.values()):
|
||||
expiry = self._parse(current.lease_expires_at)
|
||||
if (
|
||||
@@ -435,3 +589,5 @@ class HermesAdapter:
|
||||
lease_expires_at=None,
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
changed.add(current.job_id)
|
||||
return changed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AIOS-managed OpenClaw execution adapter."""
|
||||
"""AIOS-managed OpenClaw Ed25519 execution adapter."""
|
||||
|
||||
from .adapter import (
|
||||
AdapterError,
|
||||
@@ -9,7 +9,11 @@ from .adapter import (
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from .contract import (
|
||||
CONTRACT_VERSION,
|
||||
SIGNATURE_ALGORITHM,
|
||||
CommandState,
|
||||
Ed25519CommandSigner,
|
||||
Ed25519CommandVerifier,
|
||||
OpenClawCommand,
|
||||
OpenClawResult,
|
||||
RuntimeHealth,
|
||||
@@ -20,10 +24,13 @@ from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeT
|
||||
|
||||
__all__ = [
|
||||
"AdapterError",
|
||||
"CONTRACT_VERSION",
|
||||
"CommandConflict",
|
||||
"CommandNotFound",
|
||||
"CommandRejected",
|
||||
"CommandState",
|
||||
"Ed25519CommandSigner",
|
||||
"Ed25519CommandVerifier",
|
||||
"HTTPRuntimeTransport",
|
||||
"InMemoryCallbackSink",
|
||||
"InProcessRuntimeTransport",
|
||||
@@ -32,6 +39,7 @@ __all__ = [
|
||||
"OpenClawResult",
|
||||
"RuntimeHealth",
|
||||
"RuntimeTransport",
|
||||
"SIGNATURE_ALGORITHM",
|
||||
"sign_command",
|
||||
"verify_command_signature",
|
||||
]
|
||||
|
||||
@@ -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, *, tenant_id: str) -> bool: ...
|
||||
def cancel(self, command_id: str) -> bool: ...
|
||||
def health(self) -> RuntimeHealth: ...
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ class OpenClawAdapter:
|
||||
CommandState.REJECTED,
|
||||
}:
|
||||
return current
|
||||
if not self.transport.cancel(command_id, tenant_id=tenant_id):
|
||||
if not self.transport.cancel(command_id):
|
||||
raise AdapterError("runtime rejected cancellation")
|
||||
cancelled = replace(
|
||||
current,
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"""Versioned AIOS to OpenClaw execution contracts."""
|
||||
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
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"
|
||||
CONTRACT_VERSION = "aios.openclaw.command.v2"
|
||||
SIGNATURE_ALGORITHM = "Ed25519"
|
||||
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
@@ -41,7 +40,106 @@ def _required(value: str, label: str) -> str:
|
||||
|
||||
|
||||
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||
return json.loads(
|
||||
json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str)
|
||||
)
|
||||
|
||||
|
||||
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError("cryptography with Ed25519 support is required") from exc
|
||||
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
|
||||
class CommandSigner(Protocol):
|
||||
key_id: str
|
||||
fingerprint: str
|
||||
|
||||
def sign(self, payload: bytes) -> bytes: ...
|
||||
|
||||
|
||||
class CommandVerifier(Protocol):
|
||||
key_id: str
|
||||
fingerprint: str
|
||||
|
||||
def verify(self, payload: bytes, signature: bytes) -> bool: ...
|
||||
|
||||
|
||||
class Ed25519CommandSigner:
|
||||
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||
serialization, private_cls, _ = _load_crypto()
|
||||
if not isinstance(private_key, private_cls):
|
||||
raise ValueError("private key is not Ed25519")
|
||||
self.private_key = private_key
|
||||
self.key_id = _required(key_id, "key_id")
|
||||
raw = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandSigner":
|
||||
serialization, private_cls, _ = _load_crypto()
|
||||
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||
if raw.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_private_key(raw, password=None)
|
||||
elif len(raw) == 32:
|
||||
key = private_cls.from_private_bytes(raw)
|
||||
else:
|
||||
raise ValueError("Ed25519 private key must be PEM or a raw 32-byte seed")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def sign(self, payload: bytes) -> bytes:
|
||||
return self.private_key.sign(bytes(payload))
|
||||
|
||||
def verifier(self) -> "Ed25519CommandVerifier":
|
||||
return Ed25519CommandVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||
|
||||
|
||||
class Ed25519CommandVerifier:
|
||||
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||
serialization, _, public_cls = _load_crypto()
|
||||
if not isinstance(public_key, public_cls):
|
||||
raise ValueError("public key is not Ed25519")
|
||||
self.public_key = public_key
|
||||
self.key_id = _required(key_id, "key_id")
|
||||
raw = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandVerifier":
|
||||
serialization, private_cls, public_cls = _load_crypto()
|
||||
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||
if raw.startswith(b"-----BEGIN"):
|
||||
try:
|
||||
key = serialization.load_pem_public_key(raw)
|
||||
except ValueError:
|
||||
private = serialization.load_pem_private_key(raw, password=None)
|
||||
if not isinstance(private, private_cls):
|
||||
raise ValueError("key is not Ed25519")
|
||||
key = private.public_key()
|
||||
elif len(raw) == 32:
|
||||
key = public_cls.from_public_bytes(raw)
|
||||
else:
|
||||
raise ValueError("Ed25519 public key must be PEM or raw 32-byte key")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def verify(self, payload: bytes, signature: bytes) -> bool:
|
||||
try:
|
||||
self.public_key.verify(bytes(signature), bytes(payload))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class CommandState(str, Enum):
|
||||
@@ -66,22 +164,43 @@ class OpenClawCommand:
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
expires_at: str | None = None
|
||||
contract_version: str = CONTRACT_VERSION
|
||||
signature_algorithm: str = SIGNATURE_ALGORITHM
|
||||
key_id: str = "default"
|
||||
public_key_fingerprint: str = ""
|
||||
signature: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in (
|
||||
"command_id", "tenant_id", "task_id", "trace_id", "action",
|
||||
"idempotency_key", "created_at", "contract_version", "key_id",
|
||||
"command_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"trace_id",
|
||||
"action",
|
||||
"idempotency_key",
|
||||
"created_at",
|
||||
"contract_version",
|
||||
"signature_algorithm",
|
||||
"key_id",
|
||||
):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
if self.contract_version != CONTRACT_VERSION:
|
||||
raise ValueError(f"unsupported contract_version: {self.contract_version}")
|
||||
if self.signature_algorithm != SIGNATURE_ALGORITHM:
|
||||
raise ValueError(
|
||||
f"unsupported signature_algorithm: {self.signature_algorithm}"
|
||||
)
|
||||
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||
object.__setattr__(
|
||||
self, "public_key_fingerprint", str(self.public_key_fingerprint or "").strip()
|
||||
)
|
||||
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||
_parse_timestamp(self.created_at)
|
||||
if self.expires_at is not None:
|
||||
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
||||
_parse_timestamp(self.expires_at)
|
||||
if _parse_timestamp(self.expires_at) <= _parse_timestamp(self.created_at):
|
||||
raise ValueError("expires_at must be later than created_at")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -121,7 +240,9 @@ class OpenClawCommand:
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
"contract_version": self.contract_version,
|
||||
"signature_algorithm": self.signature_algorithm,
|
||||
"key_id": self.key_id,
|
||||
"public_key_fingerprint": self.public_key_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +262,9 @@ class OpenClawResult:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
object.__setattr__(self, "state", CommandState(self.state))
|
||||
if self.output is not None:
|
||||
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||
@@ -168,8 +291,16 @@ class RuntimeHealth:
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("runtime", "runtime_id", "status", "version", "heartbeat_at"):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
for field_name in (
|
||||
"runtime",
|
||||
"runtime_id",
|
||||
"status",
|
||||
"version",
|
||||
"heartbeat_at",
|
||||
):
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
_parse_timestamp(self.heartbeat_at)
|
||||
if self.active_tasks < 0:
|
||||
raise ValueError("active_tasks must be >= 0")
|
||||
@@ -178,46 +309,68 @@ class RuntimeHealth:
|
||||
|
||||
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||
return json.dumps(
|
||||
command.unsigned_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
command.unsigned_dict(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _ed25519_private_key(key_material: bytes | str) -> Ed25519PrivateKey:
|
||||
material = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if material.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_private_key(material, password=None)
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise ValueError("signing material is not an Ed25519 private key")
|
||||
return key
|
||||
if len(material) != 32:
|
||||
raise ValueError("Ed25519 raw private signing material must be exactly 32 bytes")
|
||||
return Ed25519PrivateKey.from_private_bytes(material)
|
||||
def _as_signer(command: OpenClawCommand, value: Any) -> CommandSigner:
|
||||
if hasattr(value, "sign") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||
return value
|
||||
return Ed25519CommandSigner.from_bytes(value, key_id=command.key_id)
|
||||
|
||||
|
||||
def _decode_signature(value: str) -> bytes:
|
||||
encoded = value.encode("ascii")
|
||||
return base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
|
||||
def _as_verifier(command: OpenClawCommand, value: Any) -> CommandVerifier:
|
||||
if isinstance(value, Ed25519CommandSigner):
|
||||
return value.verifier()
|
||||
if hasattr(value, "verify") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||
return value
|
||||
return Ed25519CommandVerifier.from_bytes(value, key_id=command.key_id)
|
||||
|
||||
|
||||
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
|
||||
key = _ed25519_private_key(key_material)
|
||||
signature = base64.urlsafe_b64encode(key.sign(canonical_command_bytes(command))).decode("ascii").rstrip("=")
|
||||
return replace(command, signature=signature)
|
||||
def sign_command(command: OpenClawCommand, signing_key: Any) -> OpenClawCommand:
|
||||
signer = _as_signer(command, signing_key)
|
||||
if signer.key_id != command.key_id:
|
||||
raise ValueError("command key_id does not match signer")
|
||||
prepared = replace(
|
||||
command,
|
||||
signature_algorithm=SIGNATURE_ALGORITHM,
|
||||
public_key_fingerprint=signer.fingerprint,
|
||||
signature="",
|
||||
)
|
||||
signature = base64.b64encode(
|
||||
signer.sign(canonical_command_bytes(prepared))
|
||||
).decode("ascii")
|
||||
return replace(prepared, signature=signature)
|
||||
|
||||
|
||||
def verify_command_signature(
|
||||
command: OpenClawCommand,
|
||||
key_material: bytes | str,
|
||||
verification_key: Any,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||
) -> bool:
|
||||
if not command.signature:
|
||||
if (
|
||||
not command.signature
|
||||
or command.contract_version != CONTRACT_VERSION
|
||||
or command.signature_algorithm != SIGNATURE_ALGORITHM
|
||||
):
|
||||
return False
|
||||
try:
|
||||
key = _ed25519_private_key(key_material)
|
||||
key.public_key().verify(_decode_signature(command.signature), canonical_command_bytes(command))
|
||||
except (InvalidSignature, ValueError, TypeError, UnicodeError):
|
||||
verifier = _as_verifier(command, verification_key)
|
||||
signature = base64.b64decode(command.signature, validate=True)
|
||||
except Exception:
|
||||
return False
|
||||
if verifier.key_id != command.key_id:
|
||||
return False
|
||||
if not command.public_key_fingerprint or not hmac.compare_digest(
|
||||
command.public_key_fingerprint, verifier.fingerprint
|
||||
):
|
||||
return False
|
||||
if not verifier.verify(canonical_command_bytes(command), signature):
|
||||
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, *, tenant_id: str) -> bool: ...
|
||||
def cancel(self, command_id: str) -> bool: ...
|
||||
def health(self) -> RuntimeHealth: ...
|
||||
|
||||
|
||||
@@ -50,16 +50,11 @@ 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,
|
||||
@@ -95,10 +90,8 @@ class InProcessRuntimeTransport:
|
||||
with self._lock:
|
||||
self._running.discard(command.command_id)
|
||||
|
||||
def cancel(self, command_id: str, *, tenant_id: str) -> bool:
|
||||
def cancel(self, command_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
|
||||
|
||||
@@ -197,11 +190,10 @@ class HTTPRuntimeTransport:
|
||||
attempts=int(value.get("attempts", 1)),
|
||||
)
|
||||
|
||||
def cancel(self, command_id: str, *, tenant_id: str) -> bool:
|
||||
def cancel(self, command_id: str) -> bool:
|
||||
value = self._request(
|
||||
"POST",
|
||||
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel"
|
||||
f"?tenant_id={urllib.parse.quote(tenant_id, safe='')}",
|
||||
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel",
|
||||
payload={},
|
||||
timeout_seconds=10.0,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AIOS V2 control plane and execution orchestration."""
|
||||
"""AIOS V2 控制面、执行编排与可信运行来源证明。"""
|
||||
|
||||
from .control_plane import (
|
||||
AgentRecord,
|
||||
@@ -20,6 +20,41 @@ from .control_plane import (
|
||||
)
|
||||
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
|
||||
from .provenance import (
|
||||
Ed25519PrivateKeySigner,
|
||||
Ed25519PublicKeyVerifier,
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
PRODUCTION_POLICY_VERSION,
|
||||
ProvenanceAuthorization,
|
||||
ProvenanceControlledWriter,
|
||||
ProvenanceDenied,
|
||||
ProvenanceError,
|
||||
ProvenanceExpired,
|
||||
ProvenanceIssuer,
|
||||
ProvenanceNotFound,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceSignatureError,
|
||||
ProvenanceStatus,
|
||||
ProvenanceVerifier,
|
||||
RunProvenance,
|
||||
SQLiteProvenanceStore,
|
||||
digest_payload,
|
||||
)
|
||||
from .provenance_git import (
|
||||
GitCommandResult,
|
||||
GitExecutionContext,
|
||||
ProvenanceGitError,
|
||||
ProvenanceGitWriter,
|
||||
)
|
||||
from .provenance_operations import (
|
||||
OperationContext,
|
||||
OperationReceipt,
|
||||
ProvenanceOperationGate,
|
||||
build_provenance_writer_from_env,
|
||||
context_from_environment,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentRecord",
|
||||
@@ -27,20 +62,49 @@ __all__ = [
|
||||
"ControlPlaneError",
|
||||
"ControlPlaneService",
|
||||
"DispatchRecord",
|
||||
"Ed25519PrivateKeySigner",
|
||||
"Ed25519PublicKeyVerifier",
|
||||
"ExecutionEvidence",
|
||||
"ExecutionOrchestrator",
|
||||
"ExecutionPath",
|
||||
"ExecutionTracker",
|
||||
"GitCommandResult",
|
||||
"GitExecutionContext",
|
||||
"GitOperation",
|
||||
"IdempotencyConflict",
|
||||
"InMemoryControlPlaneStore",
|
||||
"InvalidTransition",
|
||||
"NotFound",
|
||||
"OperationContext",
|
||||
"OperationReceipt",
|
||||
"OrchestrationError",
|
||||
"PRODUCTION_POLICY_VERSION",
|
||||
"PolicyDenied",
|
||||
"ProvenanceAuthorization",
|
||||
"ProvenanceControlledWriter",
|
||||
"ProvenanceDenied",
|
||||
"ProvenanceError",
|
||||
"ProvenanceExpired",
|
||||
"ProvenanceGitError",
|
||||
"ProvenanceGitWriter",
|
||||
"ProvenanceIssuer",
|
||||
"ProvenanceNotFound",
|
||||
"ProvenanceOperationGate",
|
||||
"ProvenanceReplay",
|
||||
"ProvenanceRevoked",
|
||||
"ProvenanceSignatureError",
|
||||
"ProvenanceStatus",
|
||||
"ProvenanceVerifier",
|
||||
"RequestContext",
|
||||
"RevisionConflict",
|
||||
"RunProvenance",
|
||||
"SQLiteProvenanceStore",
|
||||
"TaskRecord",
|
||||
"TaskState",
|
||||
"TenantIsolationError",
|
||||
"TenantRecord",
|
||||
"Unauthorized",
|
||||
"build_provenance_writer_from_env",
|
||||
"context_from_environment",
|
||||
"digest_payload",
|
||||
]
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Production-candidate AIOS V2 application entrypoint.
|
||||
|
||||
This process owns the public control-plane API and read-only web console. It
|
||||
loads durable control-plane state, dispatches approved work through runtime
|
||||
adapters, exposes tenant-scoped execution evidence and sanitized GitOps status,
|
||||
and never exposes runtime credentials to the browser.
|
||||
"""
|
||||
"""AIOS V2 production-candidate application entrypoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
@@ -18,17 +12,23 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesResult
|
||||
from aios_openclaw import HTTPRuntimeTransport, OpenClawAdapter
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesResult, SQLiteHermesStore
|
||||
from aios_openclaw import (
|
||||
Ed25519CommandSigner,
|
||||
HTTPRuntimeTransport,
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
||||
|
||||
from .console import CONSOLE_HTML
|
||||
from .control_plane import AuditEvent, RequestContext
|
||||
from .execution_tracking import ExecutionTracker
|
||||
from .control_plane import AuditEvent, RequestContext, Unauthorized
|
||||
from .dispatch_store import SQLiteDispatchRepository
|
||||
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||
from .gateway import GatewayApplication, error_response
|
||||
from .gitops_status import collect_gitops_status
|
||||
from .orchestrator import ExecutionOrchestrator
|
||||
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||
from .provenance_operations import build_provenance_writer_from_env
|
||||
|
||||
|
||||
def _json_value(value: Any) -> Any:
|
||||
@@ -46,27 +46,90 @@ def _json_value(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
class FileSigningMaterialResolver:
|
||||
"""Resolve runtime signing material from a root-owned file reference."""
|
||||
def _enabled(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
|
||||
def __call__(self, tenant_id: str, key_id: str) -> bytes:
|
||||
del tenant_id, key_id
|
||||
metadata = self.path.stat()
|
||||
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
||||
target = name.lower()
|
||||
for key, value in headers.items():
|
||||
if str(key).lower() == target:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
return None
|
||||
|
||||
|
||||
def _provenance_context(
|
||||
headers: Mapping[str, str],
|
||||
payload: Mapping[str, Any],
|
||||
) -> tuple[str | None, str | None]:
|
||||
header_provenance = _header(headers, "X-AIOS-Provenance-ID")
|
||||
header_run = _header(headers, "X-AIOS-Run-ID")
|
||||
body_provenance = str(payload.get("provenance_id") or "").strip() or None
|
||||
body_run = str(payload.get("run_id") or "").strip() or None
|
||||
if header_provenance and body_provenance and header_provenance != body_provenance:
|
||||
raise ValueError("provenance_id header/body mismatch")
|
||||
if header_run and body_run and header_run != body_run:
|
||||
raise ValueError("run_id header/body mismatch")
|
||||
return header_provenance or body_provenance, header_run or body_run
|
||||
|
||||
|
||||
def _read_protected_file(path: Path, *, private: bool) -> bytes:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(str(path), flags)
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError("signing material reference is not a regular file")
|
||||
if metadata.st_mode & 0o077:
|
||||
raise RuntimeError("signing material file permissions must be 0600 or stricter")
|
||||
value = self.path.read_bytes()
|
||||
if len(value) < 32:
|
||||
raise RuntimeError("signing material is too short")
|
||||
raise RuntimeError("key reference is not a regular file")
|
||||
denied = 0o077 if private else 0o022
|
||||
if metadata.st_mode & denied:
|
||||
raise RuntimeError(
|
||||
"private key file permissions must be 0600 or stricter"
|
||||
if private
|
||||
else "public key file must not be group/world writable"
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = os.read(descriptor, 65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
value = b"".join(chunks)
|
||||
if not value:
|
||||
raise RuntimeError("key file is empty")
|
||||
return value
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
class FileCommandSignerResolver:
|
||||
"""Resolve tenant/key-id scoped OpenClaw Ed25519 signing material."""
|
||||
|
||||
def __init__(self, path: str | Path, *, configured_key_id: str) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.configured_key_id = str(configured_key_id or "").strip()
|
||||
if not self.configured_key_id:
|
||||
raise ValueError("configured_key_id is required")
|
||||
|
||||
def __call__(self, tenant_id: str, key_id: str) -> Ed25519CommandSigner:
|
||||
if not str(tenant_id or "").strip():
|
||||
raise RuntimeError("tenant_id is required for command signing")
|
||||
if key_id != self.configured_key_id:
|
||||
raise RuntimeError("OpenClaw signing key_id mismatch")
|
||||
return Ed25519CommandSigner.from_bytes(
|
||||
_read_protected_file(self.path, private=True),
|
||||
key_id=key_id,
|
||||
)
|
||||
|
||||
|
||||
class ControlPlaneEventPublisher(EventPublisher):
|
||||
"""Append OpenClaw runtime events to the tenant audit stream."""
|
||||
"""Append Runtime callback events to the tenant audit stream.
|
||||
|
||||
Callback audit remains informational. Authoritative task reconciliation is
|
||||
performed only by the orchestrator after dispatch/result validation.
|
||||
"""
|
||||
|
||||
def __init__(self, service: DurableControlPlaneService) -> None:
|
||||
self.service = service
|
||||
@@ -80,16 +143,20 @@ class ControlPlaneEventPublisher(EventPublisher):
|
||||
raise ValueError("runtime audit event is missing tenant identity")
|
||||
state_value = str(event.get("state") or "unknown").lower()
|
||||
outcome = "allowed" if state_value == "completed" else state_value
|
||||
runtime = str(event.get("runtime") or "openclaw").strip().lower()
|
||||
principal_id = str(
|
||||
event.get("principal_id") or f"{runtime}-runtime"
|
||||
).strip()
|
||||
audit = AuditEvent(
|
||||
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
|
||||
tenant_id=tenant_id,
|
||||
principal_id="openclaw-runtime",
|
||||
principal_id=principal_id,
|
||||
trace_id=trace_id,
|
||||
action=event_type,
|
||||
resource_type="openclaw_command",
|
||||
resource_type=f"{runtime}_execution",
|
||||
resource_id=command_id,
|
||||
outcome=outcome,
|
||||
policy_version="runtime-v1",
|
||||
policy_version="runtime-v2",
|
||||
details=dict(event.get("details") or {}),
|
||||
)
|
||||
with self.service.store.lock:
|
||||
@@ -98,8 +165,6 @@ class ControlPlaneEventPublisher(EventPublisher):
|
||||
|
||||
|
||||
class CandidateApplication:
|
||||
"""Compose durable control plane, runtimes, evidence and console routes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gateway: GatewayApplication,
|
||||
@@ -107,16 +172,127 @@ class CandidateApplication:
|
||||
tracker: ExecutionTracker,
|
||||
hermes: HermesAdapter,
|
||||
openclaw: OpenClawAdapter | None,
|
||||
*,
|
||||
hermes_lifecycle_api_enabled: bool = False,
|
||||
) -> None:
|
||||
self.gateway = gateway
|
||||
self.orchestrator = orchestrator
|
||||
self.tracker = tracker
|
||||
self.hermes = hermes
|
||||
self.openclaw = openclaw
|
||||
self.hermes_lifecycle_api_enabled = bool(hermes_lifecycle_api_enabled)
|
||||
self.runtime_events = ControlPlaneEventPublisher(orchestrator.control_plane)
|
||||
|
||||
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||
return self.gateway.context_from_headers(headers)
|
||||
|
||||
@staticmethod
|
||||
def _require_runtime_role(context: RequestContext) -> None:
|
||||
if not ({"runtime", "platform_admin"} & set(context.roles)):
|
||||
raise Unauthorized("runtime reconciliation role is required")
|
||||
|
||||
@staticmethod
|
||||
def _require_control_plane_cancel(context: RequestContext) -> None:
|
||||
required = {"runtime", "platform_admin"}
|
||||
if not required.issubset(set(context.roles)):
|
||||
raise Unauthorized(
|
||||
"Hermes cancellation requires trusted AIOS control-plane authority"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _required_identity(payload: Mapping[str, Any], name: str) -> str:
|
||||
value = str(payload.get(name) or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{name} is required")
|
||||
return value
|
||||
|
||||
def _record_hermes_lifecycle(
|
||||
self,
|
||||
context: RequestContext,
|
||||
dispatch: Any,
|
||||
job: Any,
|
||||
*,
|
||||
action: str,
|
||||
worker_id: str | None,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
) -> None:
|
||||
executor = worker_id or "aios-control-plane"
|
||||
evidence = ExecutionEvidence(
|
||||
execution_id=job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
task_id=job.task_id,
|
||||
agent_id=dispatch.agent_id,
|
||||
executor=executor,
|
||||
branch="runtime/hermes",
|
||||
status=job.state.value.lower(),
|
||||
progress_completed=0,
|
||||
progress_total=3,
|
||||
current_step=action,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
current_evidence = self.tracker.claim(
|
||||
evidence,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if job.state in {
|
||||
HermesJobState.COMPLETED,
|
||||
HermesJobState.FAILED,
|
||||
HermesJobState.CANCELLED,
|
||||
}:
|
||||
if current_evidence.completed_at is None:
|
||||
self.tracker.complete(
|
||||
tenant_id=job.tenant_id,
|
||||
execution_id=job.job_id,
|
||||
status=job.state.value.lower(),
|
||||
test_status="runtime_terminal",
|
||||
test_summary={"lifecycle_action": action},
|
||||
result_summary={
|
||||
"state": job.state.value,
|
||||
"worker_id": worker_id,
|
||||
"dispatch_id": dispatch.dispatch_id,
|
||||
},
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
else:
|
||||
self.tracker.heartbeat(
|
||||
tenant_id=job.tenant_id,
|
||||
execution_id=job.job_id,
|
||||
status=job.state.value.lower(),
|
||||
current_step=action,
|
||||
progress_completed=(1 if job.state == HermesJobState.RUNNING else 0),
|
||||
progress_total=3,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
self.runtime_events.publish(
|
||||
{
|
||||
"runtime": "hermes",
|
||||
"tenant_id": job.tenant_id,
|
||||
"principal_id": context.principal_id,
|
||||
"trace_id": job.trace_id,
|
||||
"command_id": job.job_id,
|
||||
"event_type": f"HERMES_{action.upper()}",
|
||||
"state": job.state.value,
|
||||
"details": {
|
||||
"dispatch_id": dispatch.dispatch_id,
|
||||
"task_id": job.task_id,
|
||||
"worker_id": worker_id,
|
||||
"cell_id": job.cell_id,
|
||||
"profile_id": job.profile_id,
|
||||
"revision": job.revision,
|
||||
"provenance_id": provenance_id,
|
||||
"run_id": run_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def handle_json(
|
||||
self,
|
||||
method: str,
|
||||
@@ -186,25 +362,261 @@ class CandidateApplication:
|
||||
}
|
||||
|
||||
dispatch_match = __import__("re").fullmatch(
|
||||
r"/api/v2/tasks/([^/]+)/dispatch",
|
||||
path,
|
||||
r"/api/v2/tasks/([^/]+)/dispatch", path
|
||||
)
|
||||
if method == "POST" and dispatch_match:
|
||||
context = self.context(headers)
|
||||
task_id = urllib.parse.unquote(dispatch_match.group(1))
|
||||
payload = body if isinstance(body, Mapping) else {}
|
||||
timeout = payload.get("timeout_seconds")
|
||||
provenance_id, run_id = _provenance_context(headers, payload)
|
||||
record = self.orchestrator.dispatch(
|
||||
context,
|
||||
task_id=task_id,
|
||||
timeout_seconds=float(timeout) if timeout is not None else None,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
||||
|
||||
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||
lifecycle_match = __import__("re").fullmatch(
|
||||
r"/api/v2/internal/runtime/hermes/lifecycle/"
|
||||
r"(claim|start|heartbeat|complete|fail|cancel|status)",
|
||||
path,
|
||||
)
|
||||
if method == "POST" and lifecycle_match:
|
||||
if not self.hermes_lifecycle_api_enabled:
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "route not found",
|
||||
}
|
||||
context = self.context(headers)
|
||||
self._require_runtime_role(context)
|
||||
if not isinstance(body, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
payload = body
|
||||
action = lifecycle_match.group(1)
|
||||
identity = {
|
||||
name: self._required_identity(payload, name)
|
||||
for name in (
|
||||
"dispatch_id",
|
||||
"job_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"trace_id",
|
||||
"principal_id",
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"cell_id",
|
||||
"profile_id",
|
||||
)
|
||||
}
|
||||
header_provenance, header_run = _provenance_context(headers, payload)
|
||||
if header_provenance != identity["provenance_id"]:
|
||||
raise ValueError("provenance_id context mismatch")
|
||||
if header_run != identity["run_id"]:
|
||||
raise ValueError("run_id context mismatch")
|
||||
requeued_job_ids = self.hermes.requeue_expired()
|
||||
dispatch, job = self.orchestrator.hermes_lifecycle_context(
|
||||
context,
|
||||
dispatch_id=identity["dispatch_id"],
|
||||
job_id=identity["job_id"],
|
||||
tenant_id=identity["tenant_id"],
|
||||
task_id=identity["task_id"],
|
||||
trace_id=identity["trace_id"],
|
||||
principal_id=identity["principal_id"],
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
if job.cell_id != identity["cell_id"]:
|
||||
raise ValueError("cell_id mismatch")
|
||||
if job.profile_id != identity["profile_id"]:
|
||||
raise ValueError("profile_id mismatch")
|
||||
|
||||
if action == "status":
|
||||
if job.job_id in requeued_job_ids:
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
job,
|
||||
action="lease_expired_requeue",
|
||||
worker_id=None,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
task = self.orchestrator.control_plane.get_task(
|
||||
context, dispatch.task_id
|
||||
)
|
||||
response: dict[str, Any] = {
|
||||
"job": _json_value(job),
|
||||
"dispatch": _json_value(dispatch),
|
||||
"task": _json_value(task),
|
||||
}
|
||||
if job.state in {
|
||||
HermesJobState.COMPLETED,
|
||||
HermesJobState.FAILED,
|
||||
HermesJobState.CANCELLED,
|
||||
}:
|
||||
response["result"] = _json_value(
|
||||
self.hermes.result(job.job_id, tenant_id=job.tenant_id)
|
||||
)
|
||||
return HTTPStatus.OK, response
|
||||
|
||||
expected_revision = payload.get("expected_revision")
|
||||
expected = (
|
||||
int(expected_revision) if expected_revision is not None else None
|
||||
)
|
||||
if action == "cancel":
|
||||
self._require_control_plane_cancel(context)
|
||||
if payload.get("worker_id") is not None:
|
||||
raise Unauthorized("Hermes workers may observe but not initiate cancel")
|
||||
if payload.get("cancellation_source") != "aios-control-plane":
|
||||
raise ValueError(
|
||||
"cancellation_source must be aios-control-plane"
|
||||
)
|
||||
result = self.hermes.cancel(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
expected_revision=expected,
|
||||
)
|
||||
else:
|
||||
worker_id = self._required_identity(payload, "worker_id")
|
||||
if action == "claim":
|
||||
claimed = self.hermes.claim(
|
||||
tenant_id=job.tenant_id,
|
||||
cell_id=job.cell_id,
|
||||
profile_id=job.profile_id,
|
||||
worker_id=worker_id,
|
||||
lease_seconds=(
|
||||
int(payload["lease_seconds"])
|
||||
if payload.get("lease_seconds") is not None
|
||||
else None
|
||||
),
|
||||
job_id=job.job_id,
|
||||
)
|
||||
if claimed is None:
|
||||
raise ValueError("Hermes job is not claimable")
|
||||
bound = self.orchestrator.bind_hermes_worker(
|
||||
context,
|
||||
claimed,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
worker_id=worker_id,
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
bound,
|
||||
claimed,
|
||||
action="claim",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"job": _json_value(claimed),
|
||||
"dispatch": _json_value(bound),
|
||||
}
|
||||
if action == "start":
|
||||
started = self.hermes.start(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
expected_revision=expected,
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
started,
|
||||
action="start",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {"job": _json_value(started)}
|
||||
if action == "heartbeat":
|
||||
heartbeat = self.hermes.heartbeat(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
lease_seconds=(
|
||||
int(payload["lease_seconds"])
|
||||
if payload.get("lease_seconds") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
heartbeat,
|
||||
action="heartbeat",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {"job": _json_value(heartbeat)}
|
||||
if action == "complete":
|
||||
output = payload.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
raise ValueError("output must be an object")
|
||||
result = self.hermes.complete(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
output=output,
|
||||
expected_revision=expected,
|
||||
)
|
||||
elif action == "fail":
|
||||
error = payload.get("error")
|
||||
if not isinstance(error, Mapping):
|
||||
raise ValueError("error must be an object")
|
||||
result = self.hermes.fail(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
error=error,
|
||||
expected_revision=expected,
|
||||
)
|
||||
else:
|
||||
raise ValueError("unsupported Hermes lifecycle action")
|
||||
|
||||
task = self.orchestrator.reconcile_hermes(
|
||||
context,
|
||||
result,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
updated_dispatch = self.orchestrator.dispatch_repository.get(
|
||||
dispatch.dispatch_id, tenant_id=dispatch.tenant_id
|
||||
)
|
||||
terminal_job = self.hermes.get(job.job_id, tenant_id=job.tenant_id)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
updated_dispatch,
|
||||
terminal_job,
|
||||
action=action,
|
||||
worker_id=result.worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"result": _json_value(result),
|
||||
"task": _json_value(task),
|
||||
"dispatch": _json_value(updated_dispatch),
|
||||
}
|
||||
|
||||
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
|
||||
context = self.context(headers)
|
||||
self._require_runtime_role(context)
|
||||
if not isinstance(body, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
provenance_id, run_id = _provenance_context(headers, body)
|
||||
if not provenance_id or not run_id:
|
||||
raise ValueError("provenance_id and run_id are required")
|
||||
dispatch_id = str(body.get("dispatch_id") or "").strip()
|
||||
if not dispatch_id:
|
||||
raise ValueError("dispatch_id is required")
|
||||
result = HermesResult(
|
||||
job_id=str(body["job_id"]),
|
||||
tenant_id=str(body["tenant_id"]),
|
||||
@@ -221,14 +633,26 @@ class CandidateApplication:
|
||||
started_at=body.get("started_at"),
|
||||
finished_at=body.get("finished_at"),
|
||||
)
|
||||
task = self.orchestrator.reconcile_hermes(context, result)
|
||||
task = self.orchestrator.reconcile_hermes(
|
||||
context,
|
||||
result,
|
||||
dispatch_id=dispatch_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||
|
||||
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||
return HTTPStatus.GONE, {
|
||||
"error": "RuntimeReconcileEndpointMoved",
|
||||
"message": "use /api/v2/internal/runtime/hermes/reconcile",
|
||||
}
|
||||
|
||||
return self.gateway.handle(method, raw_path, headers, body)
|
||||
|
||||
|
||||
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "AIOSV2Candidate/1.0"
|
||||
server_version = "AIOSV2Candidate/2.0"
|
||||
max_body_bytes = 1_048_576
|
||||
|
||||
@property
|
||||
@@ -258,9 +682,7 @@ class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(
|
||||
_json_value(payload),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
self.send_response(int(status_code))
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -291,7 +713,7 @@ class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
{key: value for key, value in self.headers.items()},
|
||||
body,
|
||||
)
|
||||
except BaseException as exc:
|
||||
except Exception as exc:
|
||||
status_code, payload = error_response(exc)
|
||||
self._send_json(status_code, payload)
|
||||
|
||||
@@ -315,16 +737,31 @@ def build_application() -> CandidateApplication:
|
||||
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
|
||||
dispatch_repository = SQLiteDispatchRepository(state_dir / "dispatches.sqlite3")
|
||||
service = DurableControlPlaneService(repository)
|
||||
tracker = ExecutionTracker(state_dir / "executions")
|
||||
hermes = HermesAdapter()
|
||||
require_provenance = _enabled(os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0"))
|
||||
provenance_writer = build_provenance_writer_from_env(
|
||||
state_dir=state_dir, required=require_provenance
|
||||
)
|
||||
tracker = ExecutionTracker(
|
||||
state_dir / "executions",
|
||||
provenance_writer=provenance_writer,
|
||||
require_provenance=require_provenance,
|
||||
)
|
||||
hermes = HermesAdapter(store=SQLiteHermesStore(state_dir / "hermes.sqlite3"))
|
||||
|
||||
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
||||
material_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||
signing_key_id = os.getenv(
|
||||
"AIOS_OPENCLAW_SIGNING_KEY_ID", "openclaw-v2r1-candidate-ed25519"
|
||||
).strip()
|
||||
openclaw = None
|
||||
resolver = None
|
||||
if openclaw_url and material_file:
|
||||
resolver = FileSigningMaterialResolver(material_file)
|
||||
if openclaw_url and signing_file:
|
||||
resolver = FileCommandSignerResolver(
|
||||
signing_file,
|
||||
configured_key_id=signing_key_id,
|
||||
)
|
||||
transport = HTTPRuntimeTransport(
|
||||
openclaw_url,
|
||||
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
||||
@@ -341,8 +778,21 @@ def build_application() -> CandidateApplication:
|
||||
openclaw=openclaw,
|
||||
hermes=hermes,
|
||||
openclaw_signing_material_resolver=resolver,
|
||||
openclaw_key_id=signing_key_id,
|
||||
provenance_writer=provenance_writer,
|
||||
require_provenance=require_provenance,
|
||||
dispatch_repository=dispatch_repository,
|
||||
)
|
||||
return CandidateApplication(
|
||||
gateway,
|
||||
orchestrator,
|
||||
tracker,
|
||||
hermes,
|
||||
openclaw,
|
||||
hermes_lifecycle_api_enabled=_enabled(
|
||||
os.getenv("AIOS_HERMES_LIFECYCLE_API_ENABLED", "0")
|
||||
),
|
||||
)
|
||||
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -1,69 +1,83 @@
|
||||
"""Dependency-free AIOS V2 web console assets.
|
||||
"""Dependency-free, role-aware AIOS unified portal.
|
||||
|
||||
Authentication and tenant identity are injected by the trusted reverse proxy.
|
||||
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
|
||||
The browser receives only server-authorized capabilities and tenant-scoped data.
|
||||
It never selects a tenant, signs Provenance, mutates Git tags, or calls Runtime
|
||||
services directly.
|
||||
"""
|
||||
|
||||
CONSOLE_HTML = r'''<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>AIOS V2 Console</title>
|
||||
<title>AIOS Unified Portal</title>
|
||||
<style>
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
|
||||
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
|
||||
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
|
||||
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
|
||||
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
|
||||
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",ui-sans-serif,system-ui,sans-serif;color:#172033;background:#eef2f7;--nav:#0f172a;--card:#fff;--line:#dfe6ef;--muted:#667085;--blue:#2563eb;--green:#047857;--red:#b42318;--amber:#b54708}
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh}button,input,textarea,select{font:inherit}
|
||||
.shell{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--nav);color:#e2e8f0;padding:22px 16px;position:sticky;top:0;height:100vh;overflow:auto}.brand{font-weight:800;font-size:20px;letter-spacing:.2px}.version{font-size:12px;color:#94a3b8;margin:5px 0 22px}.identity{padding:12px;background:#182338;border:1px solid #2b3950;border-radius:12px;margin-bottom:18px}.identity strong{display:block;color:#fff}.identity span{font-size:12px;color:#aebbd0;display:block;margin-top:3px;overflow-wrap:anywhere}.nav button{width:100%;display:flex;gap:10px;align-items:center;border:0;background:transparent;color:#cbd5e1;padding:11px 12px;border-radius:9px;text-align:left;cursor:pointer;margin:2px 0}.nav button:hover,.nav button.active{background:#24324b;color:#fff}.nav .dot{width:8px;height:8px;border-radius:50%;background:#60a5fa}.main{min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 26px;position:sticky;top:0;z-index:4}.topbar h1{font-size:19px;margin:0}.top-actions{display:flex;align-items:center;gap:10px}.timestamp{font-size:12px;color:var(--muted)}.content{padding:24px;max-width:1680px;margin:auto}.notice{background:#eff6ff;border:1px solid #bfdbfe;border-radius:12px;padding:12px 14px;color:#1e3a8a;font-size:13px;margin-bottom:18px}.error{background:#fef3f2;border:1px solid #fecdca;color:var(--red);border-radius:10px;padding:10px 12px;white-space:pre-wrap;margin-bottom:14px;display:none}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:18px}.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 1px 3px rgba(16,24,40,.05)}.card{padding:16px}.card small{color:var(--muted);font-weight:600}.card b{display:block;font-size:28px;margin-top:7px}.panel{padding:17px;margin-bottom:16px;overflow:auto}.panel h2{font-size:16px;margin:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:13px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(410px,1fr));gap:16px}.wide{grid-column:1/-1}.muted{color:var(--muted)}.good{color:var(--green);font-weight:700}.bad{color:var(--red);font-weight:700}.warn{color:var(--amber);font-weight:700}.pill{display:inline-block;border:1px solid #cbd5e1;border-radius:999px;padding:3px 8px;font-size:11px;margin:2px;background:#f8fafc}.pill.good{background:#ecfdf3;border-color:#a6f4c5}.pill.bad{background:#fef3f2;border-color:#fecdca}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn{border:0;border-radius:9px;padding:9px 12px;background:var(--blue);color:#fff;cursor:pointer;font-weight:650}.btn.secondary{background:#fff;color:#344054;border:1px solid #d0d5dd}.btn.danger{background:#d92d20}.btn:disabled{opacity:.45;cursor:not-allowed}.icon-btn{padding:7px 10px}.view{display:none}.view.active{display:block}table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;padding:9px 8px;border-bottom:1px solid #edf0f4;vertical-align:top}th{color:#667085;font-weight:700;white-space:nowrap}td{overflow-wrap:anywhere}.empty{padding:24px;text-align:center;color:var(--muted)}.timeline{border-left:2px solid #dbe4f0;margin-left:8px;padding-left:20px}.event{position:relative;padding:0 0 17px}.event:before{content:"";position:absolute;left:-27px;top:4px;width:11px;height:11px;border-radius:50%;background:#3b82f6;border:3px solid #fff;box-shadow:0 0 0 1px #93c5fd}.event strong{font-size:13px}.event p{margin:4px 0;color:var(--muted);font-size:12px}.chain{display:flex;align-items:stretch;gap:7px;overflow:auto;padding:5px 0}.chain-item{min-width:135px;border:1px solid var(--line);border-radius:10px;padding:10px;background:#f8fafc}.chain-arrow{display:flex;align-items:center;color:#94a3b8}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px}.field label{font-size:12px;color:#475467;font-weight:650;display:block;margin-bottom:5px}.field input,.field textarea,.field select{width:100%;border:1px solid #d0d5dd;border-radius:9px;padding:9px;background:#fff}.field textarea{min-height:90px;resize:vertical}.dialog{border:0;border-radius:14px;padding:0;max-width:650px;width:calc(100% - 32px);box-shadow:0 20px 60px rgba(15,23,42,.28)}.dialog::backdrop{background:rgba(15,23,42,.6)}.dialog-head{padding:16px 18px;border-bottom:1px solid var(--line);font-weight:750}.dialog-body{padding:18px}.dialog-actions{padding:14px 18px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}.kv{display:grid;grid-template-columns:150px minmax(0,1fr);gap:6px 12px;font-size:12px}.kv div:nth-child(odd){color:var(--muted)}pre{white-space:pre-wrap;word-break:break-word;font-size:11px;background:#0f172a;color:#dbeafe;border-radius:10px;padding:12px;max-height:300px;overflow:auto}
|
||||
@media(max-width:900px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav{display:flex;overflow:auto}.nav button{min-width:max-content}.topbar{position:static}.grid{grid-template-columns:1fr}.content{padding:16px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
|
||||
<main>
|
||||
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
|
||||
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
|
||||
<section class="cards">
|
||||
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
|
||||
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
|
||||
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
|
||||
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">AIOS Unified Portal</div><div class="version">V2R1C1 · Role-aware Control Plane</div>
|
||||
<div class="identity"><strong id="principal">加载身份…</strong><span id="tenant"></span><span id="roles"></span></div>
|
||||
<nav id="navigation" class="nav"></nav>
|
||||
</aside>
|
||||
<section class="main">
|
||||
<header class="topbar"><h1 id="pageTitle">系统总览</h1><div class="top-actions"><span id="updated" class="timestamp">尚未加载</span><button class="btn secondary icon-btn" id="refresh">刷新</button></div></header>
|
||||
<main class="content">
|
||||
<div class="notice">身份、租户和角色由可信反向代理注入。门户只调用 AIOS Control Plane;Provenance 签发、Tag、生产部署与 Runtime 权威状态仍由受控流程执行。</div>
|
||||
<div id="error" class="error"></div>
|
||||
<section id="overview" class="view active"></section>
|
||||
<section id="tasks" class="view"></section>
|
||||
<section id="authorization" class="view"></section>
|
||||
<section id="runtime" class="view"></section>
|
||||
<section id="evidence" class="view"></section>
|
||||
<section id="audit" class="view"></section>
|
||||
<section id="release" class="view"></section>
|
||||
</main>
|
||||
</section>
|
||||
<section class="grid">
|
||||
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
|
||||
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
|
||||
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
|
||||
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
|
||||
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<dialog id="taskDialog" class="dialog">
|
||||
<div class="dialog-head">创建任务</div><form id="taskForm"><div class="dialog-body form-grid">
|
||||
<div class="field"><label>Agent ID</label><input name="agent_id" required></div>
|
||||
<div class="field"><label>Action</label><input name="action" required></div>
|
||||
<div class="field"><label>Idempotency Key</label><input name="idempotency_key" required></div>
|
||||
<div class="field wide"><label>Payload JSON</label><textarea name="payload">{}</textarea></div>
|
||||
</div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="taskDialog">取消</button><button class="btn">创建</button></div></form>
|
||||
</dialog>
|
||||
<dialog id="confirmDialog" class="dialog"><div class="dialog-head" id="confirmTitle">确认操作</div><form id="confirmForm"><div class="dialog-body"><p id="confirmText"></p><div class="field"><label>原因</label><textarea id="confirmReason" required></textarea></div></div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="confirmDialog">取消</button><button class="btn danger">确认</button></div></form></dialog>
|
||||
<script>
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
|
||||
async function refresh(){
|
||||
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
|
||||
try{
|
||||
const [agents,tasks,audit,runtime,executions]=await Promise.all([
|
||||
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
|
||||
]);
|
||||
document.getElementById('agentCount').textContent=agents.agents.length;
|
||||
document.getElementById('taskCount').textContent=tasks.tasks.length;
|
||||
document.getElementById('auditCount').textContent=audit.events.length;
|
||||
document.getElementById('executionCount').textContent=executions.executions.length;
|
||||
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
|
||||
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
|
||||
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
|
||||
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
|
||||
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
|
||||
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
|
||||
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
|
||||
}
|
||||
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
|
||||
const $=s=>document.querySelector(s), esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const state={identity:null,current:'overview',cache:{},confirm:null};
|
||||
const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心',runtime:'运行与运维',evidence:'执行证据',audit:'审计中心',release:'GitOps 与发布'};
|
||||
function has(c){return !!state.identity?.capabilities?.includes(c)}
|
||||
async function api(path,options={}){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/json',...(options.headers||{})},...options});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${d.message||d.error||path}`);return d}
|
||||
function err(e){const el=$('#error');el.textContent=e?.message||String(e);el.style.display='block'}function clearErr(){$('#error').style.display='none';$('#error').textContent=''}
|
||||
function pill(v){const s=String(v??'unknown'),c=/complete|pass|healthy|active|synced|approved|used/i.test(s)?'good':/fail|deny|reject|revoke|expire|cancel|unavailable/i.test(s)?'bad':'';return `<span class="pill ${c}">${esc(s)}</span>`}
|
||||
function table(cols,rows){if(!rows?.length)return '<div class="empty">暂无数据</div>';return `<table><thead><tr>${cols.map(x=>`<th>${esc(x[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(x=>`<td>${x[1](r)}</td>`).join('')}</tr>`).join('')}</tbody></table>`}
|
||||
function card(label,value,detail=''){return `<div class="card"><small>${esc(label)}</small><b>${esc(value)}</b>${detail?`<span class="muted">${esc(detail)}</span>`:''}</div>`}
|
||||
function json(v){return `<pre>${esc(JSON.stringify(v,null,2))}</pre>`}
|
||||
function show(view){state.current=view;document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('.nav button').forEach(x=>x.classList.toggle('active',x.dataset.view===view));$('#pageTitle').textContent=labels[view]||view;load(view)}
|
||||
function navigation(){const nav=$('#navigation');nav.innerHTML=(state.identity.navigation||[]).map(x=>`<button data-view="${esc(x.id)}"><span class="dot"></span>${esc(x.label)}</button>`).join('');nav.querySelectorAll('button').forEach(b=>b.onclick=()=>show(b.dataset.view));}
|
||||
async function identity(){state.identity=await api('/api/v2/me/capabilities');$('#principal').textContent=state.identity.principal_id;$('#tenant').textContent=`租户:${state.identity.tenant_id}`;$('#roles').textContent=`角色:${state.identity.roles.join(', ')||'无'}`;navigation();if(!state.identity.navigation.some(x=>x.id===state.current))state.current=state.identity.navigation[0]?.id||'overview';show(state.current)}
|
||||
async function load(view){clearErr();try{if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();if(view==='runtime')await runtime();if(view==='evidence')await evidence();if(view==='audit')await audit();if(view==='release')await release();$('#updated').textContent=`更新于 ${new Date().toLocaleString()}`}catch(e){err(e)}}
|
||||
async function overview(){const d=await api('/api/v2/portal/overview');state.cache.overview=d;const s=d.summary||{},rt=d.sections?.runtime?.runtimes||[],rel=d.sections?.release||{};$('#overview').innerHTML=`<div class="cards">${card('任务',s.tasks||0)}${card('活动 Dispatch',s.active_dispatches||0,`总数 ${s.dispatches||0}`)}${card('执行证据',s.evidence||0)}${card('审计事件',s.audit_events||0)}${card('当前版本',rel.version||'—',rel.status||'')}${card('授权 Grant',d.sections?.authorization?.active_grants||0,`总数 ${d.sections?.authorization?.grants||0}`)}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime 健康</h2></div>${table([['Runtime',x=>esc(x.name)],['状态',x=>pill(x.status)],['版本/详情',x=>esc(x.details?.version||x.details?.runtime_id||'—')]],rt)}</div><div class="panel"><div class="panel-head"><h2>任务状态</h2></div>${table([['状态',x=>pill(x[0])],['数量',x=>esc(x[1])]],Object.entries(s.task_states||{}))}</div><div class="panel wide"><div class="panel-head"><h2>最近任务</h2></div>${table([['任务',x=>esc(x.task_id)],['Agent',x=>esc(x.agent_id)],['动作',x=>esc(x.action)],['状态',x=>pill(x.state)],['更新时间',x=>esc(x.updated_at)]],d.recent?.tasks||[])}</div></div>`}
|
||||
async function tasks(){const d=await api('/api/v2/portal/tasks');state.cache.tasks=d.items||[];const create=has('task.create')?'<button class="btn" id="newTask">创建任务</button>':'';$('#tasks').innerHTML=`<div class="panel"><div class="panel-head"><h2>任务全链路</h2><div class="toolbar">${create}</div></div>${(d.items||[]).map(taskBlock).join('')||'<div class="empty">暂无任务</div>'}</div>`;$('#newTask')?.addEventListener('click',()=>$('#taskDialog').showModal());document.querySelectorAll('[data-task-action]').forEach(b=>b.onclick=()=>taskAction(b.dataset.taskId,b.dataset.taskAction,b.dataset.revision))}
|
||||
function taskBlock(x){const t=x.task||{},d=x.dispatch,e=x.evidence||[],a=x.audit||[],actions=(x.available_actions||[]).map(v=>`<button class="btn ${v==='cancel'?'danger':'secondary'}" data-task-action="${v}" data-task-id="${esc(t.task_id)}" data-revision="${esc(t.revision)}">${{approve:'审批',cancel:'取消',dispatch:'调度'}[v]||v}</button>`).join('');return `<div class="panel" style="background:#fbfcfe"><div class="panel-head"><h2>${esc(t.task_id)} · ${esc(t.action)}</h2><div class="toolbar">${pill(t.state)}${actions}</div></div><div class="chain"><div class="chain-item"><b>Task</b><div>${esc(t.agent_id)}</div><small>${esc(t.created_by)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Approval</b><div>${esc(t.approved_by||'待审批')}</div><small>revision ${esc(t.revision)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Provenance</b><div>${esc(d?.provenance_id||'未调度')}</div><small>${esc(d?.authorization_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Dispatch</b><div>${esc(d?.dispatch_id||'—')}</div><small>${pill(d?.state||'not_created')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Runtime</b><div>${esc(d?.runtime||'—')}</div><small>${esc(d?.runtime_execution_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Evidence</b><div>${e.length} 条</div><small>Audit ${a.length} 条</small></div></div></div>`}
|
||||
async function taskAction(id,action,revision){clearErr();try{let body={expected_revision:Number(revision)};if(action==='dispatch'){body={};const p=prompt('Provenance ID(生产调度必填)','');const r=prompt('Run ID(生产调度必填)','');if(p)body.provenance_id=p;if(r)body.run_id=r}await api(`/api/v2/portal/tasks/${encodeURIComponent(id)}/${action}`,{method:'POST',body:JSON.stringify(body)});await tasks()}catch(e){err(e)}}
|
||||
async function authorizations(){const d=await api('/api/v2/portal/authorizations');state.cache.authorization=d;const workflows=(d.workflows||[]),grants=(d.grants||[]);$('#authorization').innerHTML=`<div class="cards">${card('Workflow',d.summary?.workflows||0)}${card('Grant',d.summary?.grants||0)}${card('有效 Grant',d.summary?.active_grants||0)}${card('已撤销',d.summary?.revoked_grants||0)}</div><div class="panel"><div class="panel-head"><h2>Workflow Provenance</h2></div>${table([['ID',x=>esc(x.provenance_id)],['Run / Task',x=>`${esc(x.run_id)}<br>${esc(x.task_id)}`],['Principal / Agent',x=>`${esc(x.principal_id)}<br>${esc(x.agent_id)}`],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-workflow="${esc(x.provenance_id)}">撤销</button>`:'—']],workflows)}</div><div class="panel"><div class="panel-head"><h2>Child Grants</h2></div>${table([['Grant',x=>esc(x.grant_id)],['类型 / Scope',x=>`${esc(x.grant_type)}<br>${esc(x.execution_path)}`],['Git 操作',x=>esc((x.allowed_git_operations||[]).join(', ')||'—')],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-grant="${esc(x.grant_id)}">撤销</button>`:'—']],grants)}</div>`;document.querySelectorAll('[data-revoke-workflow]').forEach(b=>b.onclick=()=>confirmOp('撤销 Workflow',`撤销 ${b.dataset.revokeWorkflow} 将使所有子 Grant 失效。`,reason=>api(`/api/v2/portal/provenance/${encodeURIComponent(b.dataset.revokeWorkflow)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)));document.querySelectorAll('[data-revoke-grant]').forEach(b=>b.onclick=()=>confirmOp('撤销 Grant',`撤销 ${b.dataset.revokeGrant}。`,reason=>api(`/api/v2/portal/grants/${encodeURIComponent(b.dataset.revokeGrant)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)))}
|
||||
async function runtime(){const d=await api('/api/v2/portal/runtime');const runtimes=d.runtimes||[],dispatches=d.dispatches||[],jobs=d.hermes_jobs||[];$('#runtime').innerHTML=`<div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime</h2></div>${table([['名称',x=>esc(x.name)],['状态',x=>pill(x.status)],['详情',x=>esc(JSON.stringify(x.details||{}))]],runtimes)}</div><div class="panel"><div class="panel-head"><h2>Hermes Queue / Lease</h2></div>${table([['Job',x=>esc(x.job_id)],['Cell / Profile',x=>`${esc(x.cell_id)} / ${esc(x.profile_id)}`],['Worker',x=>esc(x.assigned_worker_id||'—')],['状态',x=>pill(x.state)],['Lease',x=>esc(x.lease_expires_at||'—')]],jobs)}</div><div class="panel wide"><div class="panel-head"><h2>Dispatch</h2></div>${table([['Dispatch',x=>esc(x.dispatch_id)],['Task',x=>esc(x.task_id)],['Runtime',x=>esc(x.runtime)],['执行 ID',x=>esc(x.runtime_execution_id)],['Principal',x=>esc(x.principal_id)],['状态',x=>pill(x.state)]],dispatches)}</div></div>`}
|
||||
async function evidence(){const d=await api('/api/v2/portal/evidence');$('#evidence').innerHTML=`<div class="panel"><div class="panel-head"><h2>Execution Evidence</h2></div>${table([['Execution',x=>esc(x.execution_id)],['Task / Agent',x=>`${esc(x.task_id)}<br>${esc(x.agent_id)}`],['Executor',x=>esc(x.executor)],['状态',x=>pill(x.status)],['进度',x=>`${esc(x.progress_completed)}/${esc(x.progress_total)}`],['Test',x=>pill(x.test_status)],['Commit',x=>esc(x.last_commit||'—')],['授权',x=>esc(x.last_authorization_id||'—')]],d.executions||[])}</div>`}
|
||||
async function audit(){const d=await api('/api/v2/portal/audit');const events=(d.events||[]).slice().reverse();$('#audit').innerHTML=`<div class="panel"><div class="panel-head"><h2>统一审计时间线</h2></div><div class="timeline">${events.map(x=>`<div class="event"><strong>${esc(x.action)} · ${pill(x.outcome)}</strong><p>${esc(x.created_at)} · ${esc(x.principal_id)} · ${esc(x.resource_type)}/${esc(x.resource_id)}</p><div class="muted">Trace ${esc(x.trace_id)} · Policy ${esc(x.policy_version)}</div></div>`).join('')||'<div class="empty">暂无审计事件</div>'}</div></div>`}
|
||||
async function release(){const d=await api('/api/v2/portal/release');const g=d.gitops||{};$('#release').innerHTML=`<div class="cards">${card('版本',d.version||'—')}${card('Baseline',d.baseline||'—')}${card('发布状态',d.status||'—')}${card('Git 同步',g.sync_status||'unknown')}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>当前 Release</h2></div><div class="kv"><div>Release OID</div><div>${esc(d.release_oid)}</div><div>Branch</div><div>${esc(g.workspace_branch||g.branch||'—')}</div><div>Workspace</div><div>${pill(g.workspace_clean===true?'clean':g.workspace_clean===false?'dirty':'unknown')}</div><div>Local Sync</div><div>${pill(g.local_sync||'unknown')}</div><div>Gitea Sync</div><div>${pill(g.gitea_sync||'unknown')}</div></div></div><div class="panel"><div class="panel-head"><h2>治理操作</h2></div><p class="muted">门户不直接移动 Tag、合并主线或部署生产。以下仅为可发起的 GitOps 请求目录。</p>${(d.operations||[]).map(x=>pill(x)).join('')||'<span class="muted">当前角色只读</span>'}</div><div class="panel wide"><div class="panel-head"><h2>原始 GitOps 状态</h2></div>${json(g)}</div></div>`}
|
||||
function confirmOp(title,text,fn){state.confirm=fn;$('#confirmTitle').textContent=title;$('#confirmText').textContent=text;$('#confirmReason').value='';$('#confirmDialog').showModal()}
|
||||
$('#taskForm').onsubmit=async e=>{e.preventDefault();clearErr();try{const fd=new FormData(e.target),payload=JSON.parse(fd.get('payload')||'{}');await api('/api/v2/portal/tasks',{method:'POST',body:JSON.stringify({agent_id:fd.get('agent_id'),action:fd.get('action'),idempotency_key:fd.get('idempotency_key'),payload})});$('#taskDialog').close();e.target.reset();await tasks()}catch(e){err(e)}};
|
||||
$('#confirmForm').onsubmit=async e=>{e.preventDefault();const fn=state.confirm;$('#confirmDialog').close();state.confirm=null;try{await fn?.($('#confirmReason').value)}catch(e){err(e)}};
|
||||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>document.getElementById(b.dataset.close).close());$('#refresh').onclick=()=>load(state.current);
|
||||
identity().catch(err);setInterval(()=>load(state.current),30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>'''
|
||||
</body></html>'''
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""AIOS V2 持久化 Dispatch 记录。
|
||||
|
||||
Dispatch 是 AIOS Task 与 Runtime command/job 之间的唯一可信绑定。所有结果回写
|
||||
必须匹配 tenant/task/trace/runtime_execution/provenance/run/principal。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
class DispatchState(str, Enum):
|
||||
PREPARED = "PREPARED"
|
||||
RUNNING = "RUNNING"
|
||||
QUEUED = "QUEUED"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
TERMINAL_DISPATCH_STATES = frozenset(
|
||||
{DispatchState.COMPLETED, DispatchState.FAILED, DispatchState.CANCELLED}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispatchRecord:
|
||||
dispatch_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
runtime: str
|
||||
runtime_execution_id: str
|
||||
trace_id: str
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
authorization_id: str
|
||||
state: DispatchState = DispatchState.PREPARED
|
||||
expected_worker_id: str | None = None
|
||||
result_digest: str | None = None
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
completed_at: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"dispatch_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"agent_id",
|
||||
"principal_id",
|
||||
"runtime",
|
||||
"runtime_execution_id",
|
||||
"trace_id",
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"authorization_id",
|
||||
):
|
||||
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||
object.__setattr__(self, "state", DispatchState(self.state))
|
||||
now = utc_now()
|
||||
object.__setattr__(self, "created_at", self.created_at or now)
|
||||
object.__setattr__(self, "updated_at", self.updated_at or now)
|
||||
if self.expected_worker_id is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"expected_worker_id",
|
||||
_required(self.expected_worker_id, "expected_worker_id"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
runtime: str,
|
||||
runtime_execution_id: str,
|
||||
trace_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
authorization_id: str,
|
||||
) -> "DispatchRecord":
|
||||
return cls(
|
||||
dispatch_id=f"dispatch_{uuid.uuid4().hex}",
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
principal_id=principal_id,
|
||||
runtime=runtime,
|
||||
runtime_execution_id=runtime_execution_id,
|
||||
trace_id=trace_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
authorization_id=authorization_id,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["state"] = self.state.value
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "DispatchRecord":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
class DispatchConflict(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DispatchNotFound(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DispatchRepository(Protocol):
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord: ...
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord: ...
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None: ...
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None: ...
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord: ...
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]: ...
|
||||
|
||||
|
||||
class InMemoryDispatchRepository:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._records: dict[str, DispatchRecord] = {}
|
||||
self._by_task: dict[tuple[str, str], str] = {}
|
||||
self._by_runtime: dict[tuple[str, str], str] = {}
|
||||
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||
with self._lock:
|
||||
task_ref = (record.tenant_id, record.task_id)
|
||||
runtime_ref = (record.tenant_id, record.runtime_execution_id)
|
||||
if task_ref in self._by_task or runtime_ref in self._by_runtime:
|
||||
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||
if existing == record:
|
||||
return record
|
||||
raise DispatchConflict("task or runtime execution already has a dispatch")
|
||||
self._records[record.dispatch_id] = record
|
||||
self._by_task[task_ref] = record.dispatch_id
|
||||
self._by_runtime[runtime_ref] = record.dispatch_id
|
||||
return record
|
||||
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||
with self._lock:
|
||||
record = self._records.get(dispatch_id)
|
||||
if record is None or record.tenant_id != tenant_id:
|
||||
raise DispatchNotFound("dispatch not found")
|
||||
return record
|
||||
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||
with self._lock:
|
||||
dispatch_id = self._by_task.get((tenant_id, task_id))
|
||||
return self._records.get(dispatch_id) if dispatch_id else None
|
||||
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None:
|
||||
with self._lock:
|
||||
dispatch_id = self._by_runtime.get((tenant_id, runtime_execution_id))
|
||||
return self._records.get(dispatch_id) if dispatch_id else None
|
||||
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord:
|
||||
with self._lock:
|
||||
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||
target = DispatchState(state) if state is not None else current.state
|
||||
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||
raise DispatchConflict("terminal dispatch cannot transition")
|
||||
updated = replace(
|
||||
current,
|
||||
state=target,
|
||||
expected_worker_id=(
|
||||
expected_worker_id
|
||||
if expected_worker_id is not None
|
||||
else current.expected_worker_id
|
||||
),
|
||||
result_digest=result_digest or current.result_digest,
|
||||
updated_at=utc_now(),
|
||||
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||
)
|
||||
self._records[dispatch_id] = updated
|
||||
return updated
|
||||
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||
with self._lock:
|
||||
return sorted(
|
||||
[item for item in self._records.values() if item.tenant_id == tenant_id],
|
||||
key=lambda item: (item.created_at, item.dispatch_id),
|
||||
)
|
||||
|
||||
|
||||
class SQLiteDispatchRepository:
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dispatch_records (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
runtime_execution_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(tenant_id, task_id),
|
||||
UNIQUE(tenant_id, runtime_execution_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_tenant_state
|
||||
ON dispatch_records(tenant_id, state, updated_at);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _encode(record: DispatchRecord) -> str:
|
||||
return json.dumps(
|
||||
record.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decode(row: sqlite3.Row) -> DispatchRecord:
|
||||
value = json.loads(row["payload_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeError("invalid dispatch record")
|
||||
return DispatchRecord.from_dict(value)
|
||||
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO dispatch_records VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
record.dispatch_id,
|
||||
record.tenant_id,
|
||||
record.task_id,
|
||||
record.runtime_execution_id,
|
||||
self._encode(record),
|
||||
record.state.value,
|
||||
record.updated_at,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return record
|
||||
except sqlite3.IntegrityError as exc:
|
||||
connection.rollback()
|
||||
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||
if existing is not None and existing == record:
|
||||
return existing
|
||||
raise DispatchConflict("task or runtime execution already has a dispatch") from exc
|
||||
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE dispatch_id = ? AND tenant_id = ?",
|
||||
(_required(dispatch_id, "dispatch_id"), _required(tenant_id, "tenant_id")),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise DispatchNotFound("dispatch not found")
|
||||
return self._decode(row)
|
||||
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE task_id = ? AND tenant_id = ?",
|
||||
(_required(task_id, "task_id"), _required(tenant_id, "tenant_id")),
|
||||
).fetchone()
|
||||
return self._decode(row) if row is not None else None
|
||||
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE runtime_execution_id = ? AND tenant_id = ?",
|
||||
(
|
||||
_required(runtime_execution_id, "runtime_execution_id"),
|
||||
_required(tenant_id, "tenant_id"),
|
||||
),
|
||||
).fetchone()
|
||||
return self._decode(row) if row is not None else None
|
||||
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord:
|
||||
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||
target = DispatchState(state) if state is not None else current.state
|
||||
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||
raise DispatchConflict("terminal dispatch cannot transition")
|
||||
updated = replace(
|
||||
current,
|
||||
state=target,
|
||||
expected_worker_id=(
|
||||
expected_worker_id if expected_worker_id is not None else current.expected_worker_id
|
||||
),
|
||||
result_digest=result_digest or current.result_digest,
|
||||
updated_at=utc_now(),
|
||||
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||
)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"UPDATE dispatch_records SET payload_json = ?, state = ?, updated_at = ? WHERE dispatch_id = ? AND tenant_id = ?",
|
||||
(
|
||||
self._encode(updated),
|
||||
updated.state.value,
|
||||
updated.updated_at,
|
||||
updated.dispatch_id,
|
||||
updated.tenant_id,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE tenant_id = ? ORDER BY updated_at, dispatch_id",
|
||||
(_required(tenant_id, "tenant_id"),),
|
||||
).fetchall()
|
||||
return [self._decode(row) for row in rows]
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Portable multi-provider execution plans and aggregate release gates.
|
||||
|
||||
A plan is a DAG of executor-neutral stages. Only root stages are declared at
|
||||
plan creation. A dependent stage is declared atomically after every dependency
|
||||
passes its Truth + notification gate, so its start deadline cannot expire while
|
||||
it is legitimately waiting for upstream work.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .executor_protocol import ExecutorProviderRegistry, normalize_executor
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .external_task_query import list_records
|
||||
from .task_execution_registry import (
|
||||
ExecutionTaskManifest,
|
||||
ExecutionTruthRecord,
|
||||
ExecutionTruthState,
|
||||
required,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
PLAN_SCHEMA_VERSION = "aios.execution-plan.v2"
|
||||
WAITING_DEPENDENCY = "WAITING_DEPENDENCY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionStageManifest:
|
||||
stage_id: str
|
||||
task_id: str
|
||||
executor_type: str
|
||||
role: str
|
||||
steps: Sequence[str]
|
||||
depends_on: Sequence[str] = ()
|
||||
required_evidence: Sequence[str] = ()
|
||||
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
|
||||
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
|
||||
start_deadline_seconds: int = 300
|
||||
heartbeat_timeout_seconds: int = 180
|
||||
required: bool = True
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("stage_id", "task_id", "role", "owner_notification_ref"):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
|
||||
steps = tuple(required(item, "step") for item in self.steps)
|
||||
if not steps:
|
||||
raise ValueError("execution stage requires at least one step")
|
||||
object.__setattr__(self, "steps", steps)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"depends_on",
|
||||
tuple(sorted({required(item, "depends_on") for item in self.depends_on})),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"required_evidence",
|
||||
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"notification_events",
|
||||
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
|
||||
)
|
||||
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
|
||||
raise ValueError("stage deadlines must be positive")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata",
|
||||
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["steps"] = list(self.steps)
|
||||
value["depends_on"] = list(self.depends_on)
|
||||
value["required_evidence"] = list(self.required_evidence)
|
||||
value["notification_events"] = list(self.notification_events)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionStageManifest":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPlanManifest:
|
||||
plan_id: str
|
||||
source_branch: str
|
||||
source_oid: str
|
||||
target_version: str
|
||||
stages: Sequence[ExecutionStageManifest]
|
||||
release_gate_policy: str = "all_required_stages"
|
||||
schema_version: str = PLAN_SCHEMA_VERSION
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("plan_id", "source_branch", "source_oid", "target_version"):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
if self.schema_version != PLAN_SCHEMA_VERSION:
|
||||
raise ValueError("unsupported execution plan schema")
|
||||
if self.release_gate_policy != "all_required_stages":
|
||||
raise ValueError("unsupported release gate policy")
|
||||
stages = tuple(self.stages)
|
||||
if not stages:
|
||||
raise ValueError("execution plan requires stages")
|
||||
stage_ids = [item.stage_id for item in stages]
|
||||
task_ids = [item.task_id for item in stages]
|
||||
if len(stage_ids) != len(set(stage_ids)):
|
||||
raise ValueError("execution plan stage_id values must be unique")
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise ValueError("execution plan task_id values must be unique")
|
||||
known = set(stage_ids)
|
||||
for stage in stages:
|
||||
unknown = sorted(set(stage.depends_on) - known)
|
||||
if unknown:
|
||||
raise ValueError(f"stage {stage.stage_id} has unknown dependencies: {unknown}")
|
||||
if stage.stage_id in stage.depends_on:
|
||||
raise ValueError("stage cannot depend on itself")
|
||||
self._assert_acyclic(stages)
|
||||
object.__setattr__(self, "stages", stages)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata",
|
||||
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _assert_acyclic(stages: Sequence[ExecutionStageManifest]) -> None:
|
||||
dependencies = {item.stage_id: set(item.depends_on) for item in stages}
|
||||
resolved: set[str] = set()
|
||||
while len(resolved) < len(stages):
|
||||
ready = {
|
||||
stage_id
|
||||
for stage_id, deps in dependencies.items()
|
||||
if stage_id not in resolved and deps.issubset(resolved)
|
||||
}
|
||||
if not ready:
|
||||
raise ValueError("execution plan contains a dependency cycle")
|
||||
resolved.update(ready)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"plan_id": self.plan_id,
|
||||
"source_branch": self.source_branch,
|
||||
"source_oid": self.source_oid,
|
||||
"target_version": self.target_version,
|
||||
"release_gate_policy": self.release_gate_policy,
|
||||
"stages": [item.as_dict() for item in self.stages],
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionPlanManifest":
|
||||
raw = dict(value)
|
||||
raw["stages"] = tuple(
|
||||
ExecutionStageManifest.from_dict(item) for item in raw.get("stages") or ()
|
||||
)
|
||||
return cls(**raw)
|
||||
|
||||
def stage(self, stage_id: str) -> ExecutionStageManifest:
|
||||
normalized = required(stage_id, "stage_id")
|
||||
for stage in self.stages:
|
||||
if stage.stage_id == normalized:
|
||||
return stage
|
||||
raise KeyError(f"execution stage not found: {normalized}")
|
||||
|
||||
|
||||
class SQLiteExecutionPlanStore:
|
||||
"""Durable plan declarations; stage state remains in Truth Registry."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS execution_plans (
|
||||
plan_id TEXT PRIMARY KEY,
|
||||
manifest_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _encode(plan: ExecutionPlanManifest) -> str:
|
||||
return json.dumps(
|
||||
plan.as_dict(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
def save(
|
||||
self,
|
||||
plan: ExecutionPlanManifest,
|
||||
*,
|
||||
allow_source_oid_supersession: bool = False,
|
||||
) -> None:
|
||||
payload = self._encode(plan)
|
||||
now = utc_now()
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
|
||||
(plan.plan_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing["manifest_json"] != payload:
|
||||
previous = json.loads(existing["manifest_json"])
|
||||
replacement = plan.as_dict()
|
||||
previous_oid = previous.pop("source_oid", None)
|
||||
replacement_oid = replacement.pop("source_oid", None)
|
||||
if (
|
||||
not allow_source_oid_supersession
|
||||
or previous != replacement
|
||||
or previous_oid == replacement_oid
|
||||
):
|
||||
connection.rollback()
|
||||
raise RuntimeError(
|
||||
"plan_id already exists with another manifest"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE execution_plans SET manifest_json = ?, updated_at = ? WHERE plan_id = ?",
|
||||
(payload, now, plan.plan_id),
|
||||
)
|
||||
connection.commit()
|
||||
return
|
||||
connection.rollback()
|
||||
return
|
||||
connection.execute(
|
||||
"INSERT INTO execution_plans VALUES (?, ?, ?, ?)",
|
||||
(plan.plan_id, payload, now, now),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def get(self, plan_id: str) -> ExecutionPlanManifest:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
|
||||
(required(plan_id, "plan_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("execution plan not found")
|
||||
value = json.loads(row["manifest_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("stored execution plan is invalid")
|
||||
return ExecutionPlanManifest.from_dict(value)
|
||||
|
||||
def list(self) -> list[ExecutionPlanManifest]:
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans ORDER BY created_at, plan_id"
|
||||
).fetchall()
|
||||
return [
|
||||
ExecutionPlanManifest.from_dict(json.loads(row["manifest_json"]))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
class ExecutionPlanCoordinator:
|
||||
"""Declares dependency-ready stages and evaluates aggregate release gates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
task_coordinator: ExternalTaskCoordinator,
|
||||
plan_store: SQLiteExecutionPlanStore,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> None:
|
||||
self.task_coordinator = task_coordinator
|
||||
self.plan_store = plan_store
|
||||
self.providers = providers
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls,
|
||||
path: str | Path,
|
||||
*,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> "ExecutionPlanCoordinator":
|
||||
return cls(
|
||||
task_coordinator=ExternalTaskCoordinator.from_file(path),
|
||||
plan_store=SQLiteExecutionPlanStore(path),
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _task_manifest(
|
||||
plan: ExecutionPlanManifest,
|
||||
stage: ExecutionStageManifest,
|
||||
) -> ExecutionTaskManifest:
|
||||
return ExecutionTaskManifest(
|
||||
task_id=stage.task_id,
|
||||
executor=stage.executor_type,
|
||||
source_branch=plan.source_branch,
|
||||
source_oid=plan.source_oid,
|
||||
target_version=plan.target_version,
|
||||
steps=stage.steps,
|
||||
start_deadline_seconds=stage.start_deadline_seconds,
|
||||
heartbeat_timeout_seconds=stage.heartbeat_timeout_seconds,
|
||||
required_evidence=stage.required_evidence,
|
||||
notification_events=stage.notification_events,
|
||||
owner_notification_ref=stage.owner_notification_ref,
|
||||
manifest_version="aios.external-task.v2",
|
||||
)
|
||||
|
||||
def _record(self, task_id: str) -> ExecutionTruthRecord | None:
|
||||
try:
|
||||
return self.task_coordinator.registry.get(task_id)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def _declare_stage(
|
||||
self,
|
||||
plan: ExecutionPlanManifest,
|
||||
stage: ExecutionStageManifest,
|
||||
) -> ExecutionTruthRecord:
|
||||
self.providers.validate_manifest(self._task_manifest(plan, stage))
|
||||
return self.task_coordinator.declare(self._task_manifest(plan, stage))
|
||||
|
||||
def declare(self, plan: ExecutionPlanManifest) -> Mapping[str, Any]:
|
||||
for stage in plan.stages:
|
||||
self.providers.get(stage.executor_type)
|
||||
allow_source_oid_supersession = False
|
||||
try:
|
||||
previous = self.plan_store.get(plan.plan_id)
|
||||
except KeyError:
|
||||
previous = None
|
||||
if previous is not None and previous.source_oid != plan.source_oid:
|
||||
previous_value = previous.as_dict()
|
||||
replacement_value = plan.as_dict()
|
||||
previous_value.pop("source_oid")
|
||||
replacement_value.pop("source_oid")
|
||||
root_records = [
|
||||
self._record(stage.task_id)
|
||||
for stage in previous.stages
|
||||
if not stage.depends_on
|
||||
]
|
||||
allow_source_oid_supersession = bool(
|
||||
previous_value == replacement_value
|
||||
and root_records
|
||||
and all(
|
||||
record is not None
|
||||
and record.state == ExecutionTruthState.NOT_STARTED
|
||||
for record in root_records
|
||||
)
|
||||
)
|
||||
self.plan_store.save(
|
||||
plan,
|
||||
allow_source_oid_supersession=allow_source_oid_supersession,
|
||||
)
|
||||
for stage in plan.stages:
|
||||
if not stage.depends_on:
|
||||
self._declare_stage(plan, stage)
|
||||
return self.snapshot(plan.plan_id)
|
||||
|
||||
def declare_file(self, path: str | Path) -> Mapping[str, Any]:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("execution plan file must contain a JSON object")
|
||||
return self.declare(ExecutionPlanManifest.from_dict(value))
|
||||
|
||||
def dependency_gate(self, plan_id: str, stage_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
stage = plan.stage(stage_id)
|
||||
dependencies: list[Mapping[str, Any]] = []
|
||||
passed = True
|
||||
for dependency_id in stage.depends_on:
|
||||
dependency = plan.stage(dependency_id)
|
||||
record = self._record(dependency.task_id)
|
||||
if record is None:
|
||||
gate: Mapping[str, Any] = {
|
||||
"task_id": dependency.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
}
|
||||
else:
|
||||
gate = self.task_coordinator.gate(dependency.task_id)
|
||||
dependencies.append(
|
||||
{
|
||||
"stage_id": dependency.stage_id,
|
||||
"task_id": dependency.task_id,
|
||||
"gate": gate,
|
||||
}
|
||||
)
|
||||
if not gate["passed"]:
|
||||
passed = False
|
||||
return {
|
||||
"plan_id": plan.plan_id,
|
||||
"stage_id": stage.stage_id,
|
||||
"passed": passed,
|
||||
"release_blocking": not passed,
|
||||
"dependencies": dependencies,
|
||||
}
|
||||
|
||||
def activate_ready(self, plan_id: str) -> list[Mapping[str, Any]]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
activated: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
if self._record(stage.task_id) is not None:
|
||||
continue
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if not dependency["passed"]:
|
||||
continue
|
||||
record = self._declare_stage(plan, stage)
|
||||
activated.append(
|
||||
{
|
||||
"stage_id": stage.stage_id,
|
||||
"task_id": record.task_id,
|
||||
"executor_type": stage.executor_type,
|
||||
"state": record.state.value,
|
||||
}
|
||||
)
|
||||
return activated
|
||||
|
||||
def discover_ready(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
executor_type: str,
|
||||
) -> list[Mapping[str, Any]]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
self.activate_ready(plan.plan_id)
|
||||
normalized = normalize_executor(executor_type)
|
||||
records = {
|
||||
item.task_id: item
|
||||
for item in list_records(
|
||||
self.task_coordinator.registry.path,
|
||||
executor=normalized,
|
||||
states=(
|
||||
ExecutionTruthState.DECLARED.value,
|
||||
ExecutionTruthState.REGISTERED.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
ready: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
if stage.executor_type != normalized or stage.task_id not in records:
|
||||
continue
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if dependency["passed"]:
|
||||
ready.append(
|
||||
{
|
||||
"plan_id": plan.plan_id,
|
||||
"stage": stage.as_dict(),
|
||||
"record": records[stage.task_id].as_dict(),
|
||||
}
|
||||
)
|
||||
return ready
|
||||
|
||||
def snapshot(self, plan_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
stages: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
record = self._record(stage.task_id)
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if record is None:
|
||||
truth: Mapping[str, Any] = {
|
||||
"task_id": stage.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
"executor_run_id": None,
|
||||
"executor_id": None,
|
||||
"current_step": 0,
|
||||
"total_steps": len(stage.steps),
|
||||
"evidence": {},
|
||||
"error": {},
|
||||
"notifications": [],
|
||||
}
|
||||
else:
|
||||
truth = self.task_coordinator.status(stage.task_id)
|
||||
stages.append(
|
||||
{
|
||||
"stage": stage.as_dict(),
|
||||
"dependency_gate": dependency,
|
||||
"truth": truth,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"manifest": plan.as_dict(),
|
||||
"stages": stages,
|
||||
}
|
||||
|
||||
def gate(self, plan_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
self.activate_ready(plan.plan_id)
|
||||
stages: list[Mapping[str, Any]] = []
|
||||
required_passed = True
|
||||
for stage in plan.stages:
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
record = self._record(stage.task_id)
|
||||
if record is None:
|
||||
truth_gate: Mapping[str, Any] = {
|
||||
"task_id": stage.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
}
|
||||
else:
|
||||
truth_gate = self.task_coordinator.gate(stage.task_id)
|
||||
stage_passed = bool(truth_gate["passed"] and dependency["passed"])
|
||||
if stage.required and not stage_passed:
|
||||
required_passed = False
|
||||
stages.append(
|
||||
{
|
||||
"stage_id": stage.stage_id,
|
||||
"task_id": stage.task_id,
|
||||
"executor_type": stage.executor_type,
|
||||
"role": stage.role,
|
||||
"required": stage.required,
|
||||
"passed": stage_passed,
|
||||
"truth_gate": truth_gate,
|
||||
"dependency_gate": dependency,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"plan_id": plan.plan_id,
|
||||
"target_version": plan.target_version,
|
||||
"policy": plan.release_gate_policy,
|
||||
"passed": required_passed,
|
||||
"release_blocking": not required_passed,
|
||||
"stages": stages,
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Git-backed agent execution evidence for AIOS V2.
|
||||
"""AIOS V2 Agent 执行证据。
|
||||
|
||||
Evidence is tenant-scoped, append-friendly, and contains no runtime credentials.
|
||||
It provides a deterministic source for claim, heartbeat, progress, result and
|
||||
verification state across Codex, OpenClaw and Hermes executors.
|
||||
Evidence 按租户隔离并使用原子 JSON 写入。生产模式下,claim、heartbeat
|
||||
和 complete 在落盘前必须通过最小权限 Evidence Grant 授权。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,7 +11,13 @@ import threading
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from .provenance import ProvenanceDenied, digest_payload
|
||||
|
||||
|
||||
class EvidenceAuthorizationWriter(Protocol):
|
||||
def authorize_evidence_write(self, **values: Any) -> Any: ...
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -49,6 +54,9 @@ class ExecutionEvidence:
|
||||
claimed_at: str = field(default_factory=utc_now)
|
||||
heartbeat_at: str = field(default_factory=utc_now)
|
||||
completed_at: str | None = None
|
||||
provenance_id: str | None = None
|
||||
run_id: str | None = None
|
||||
last_authorization_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
@@ -70,14 +78,27 @@ class ExecutionEvidence:
|
||||
raise ValueError("progress_completed cannot exceed progress_total")
|
||||
object.__setattr__(self, "test_summary", _safe(self.test_summary))
|
||||
object.__setattr__(self, "result_summary", _safe(self.result_summary))
|
||||
for optional in ("provenance_id", "run_id", "last_authorization_id"):
|
||||
value = getattr(self, optional)
|
||||
object.__setattr__(self, optional, str(value).strip() if value else None)
|
||||
|
||||
|
||||
class ExecutionTracker:
|
||||
"""Persist one JSON evidence record per tenant and execution."""
|
||||
"""每个租户、每次执行持久保存一份证据记录。"""
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
root: str | Path,
|
||||
*,
|
||||
provenance_writer: EvidenceAuthorizationWriter | None = None,
|
||||
require_provenance: bool = False,
|
||||
) -> None:
|
||||
self.root = Path(root).resolve()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.provenance_writer = provenance_writer
|
||||
self.require_provenance = bool(require_provenance)
|
||||
if self.require_provenance and self.provenance_writer is None:
|
||||
raise ValueError("production evidence tracking requires a provenance writer")
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
@@ -92,16 +113,92 @@ class ExecutionTracker:
|
||||
execution = self._component(execution_id)
|
||||
return self.root / tenant / f"{execution}.json"
|
||||
|
||||
def claim(self, evidence: ExecutionEvidence) -> ExecutionEvidence:
|
||||
def _authorize(
|
||||
self,
|
||||
evidence: ExecutionEvidence,
|
||||
*,
|
||||
operation: str,
|
||||
principal_id: str | None,
|
||||
provenance_id: str | None,
|
||||
run_id: str | None,
|
||||
changes: Mapping[str, Any],
|
||||
) -> str | None:
|
||||
if not self.require_provenance:
|
||||
return None
|
||||
if self.provenance_writer is None:
|
||||
raise RuntimeError("Run Provenance writer is not configured")
|
||||
effective_provenance = provenance_id or evidence.provenance_id
|
||||
effective_run = run_id or evidence.run_id
|
||||
if not effective_provenance or not effective_run or not principal_id:
|
||||
raise RuntimeError(
|
||||
"production evidence write requires provenance_id, run_id and principal_id"
|
||||
)
|
||||
request_digest = digest_payload(
|
||||
{
|
||||
"operation": operation,
|
||||
"execution_id": evidence.execution_id,
|
||||
"tenant_id": evidence.tenant_id,
|
||||
"task_id": evidence.task_id,
|
||||
"agent_id": evidence.agent_id,
|
||||
"executor": evidence.executor,
|
||||
"branch": evidence.branch,
|
||||
"changes": dict(changes),
|
||||
}
|
||||
)
|
||||
try:
|
||||
authorization = self.provenance_writer.authorize_evidence_write(
|
||||
provenance_id=effective_provenance,
|
||||
run_id=effective_run,
|
||||
tenant_id=evidence.tenant_id,
|
||||
task_id=evidence.task_id,
|
||||
agent_id=evidence.agent_id,
|
||||
principal_id=principal_id,
|
||||
request_digest=request_digest,
|
||||
)
|
||||
except ProvenanceDenied as exc:
|
||||
raise RuntimeError(f"Run Provenance denied evidence write: {exc}") from exc
|
||||
return authorization.authorization_id
|
||||
|
||||
def claim(
|
||||
self,
|
||||
evidence: ExecutionEvidence,
|
||||
*,
|
||||
principal_id: str | None = None,
|
||||
provenance_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> ExecutionEvidence:
|
||||
path = self._path(evidence.tenant_id, evidence.execution_id)
|
||||
with self._lock:
|
||||
if path.exists():
|
||||
current = self._read(path)
|
||||
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
|
||||
raise RuntimeError("execution_id already belongs to another task")
|
||||
if self.require_provenance and (
|
||||
current.provenance_id != (provenance_id or evidence.provenance_id)
|
||||
or current.run_id != (run_id or evidence.run_id)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"execution_id already belongs to another provenance context"
|
||||
)
|
||||
return current
|
||||
self._write(path, evidence)
|
||||
return evidence
|
||||
effective_provenance = provenance_id or evidence.provenance_id
|
||||
effective_run = run_id or evidence.run_id
|
||||
authorization_id = self._authorize(
|
||||
evidence,
|
||||
operation="claim",
|
||||
principal_id=principal_id,
|
||||
provenance_id=effective_provenance,
|
||||
run_id=effective_run,
|
||||
changes={"status": evidence.status, "current_step": evidence.current_step},
|
||||
)
|
||||
persisted = replace(
|
||||
evidence,
|
||||
provenance_id=effective_provenance,
|
||||
run_id=effective_run,
|
||||
last_authorization_id=authorization_id,
|
||||
)
|
||||
self._write(path, persisted)
|
||||
return persisted
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
@@ -113,23 +210,38 @@ class ExecutionTracker:
|
||||
progress_completed: int | None = None,
|
||||
progress_total: int | None = None,
|
||||
last_commit: str | None = None,
|
||||
principal_id: str | None = None,
|
||||
provenance_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> ExecutionEvidence:
|
||||
path = self._path(tenant_id, execution_id)
|
||||
with self._lock:
|
||||
current = self._read(path)
|
||||
updated = replace(
|
||||
current,
|
||||
status=status or current.status,
|
||||
current_step=current_step or current.current_step,
|
||||
progress_completed=(
|
||||
changes = {
|
||||
"status": status or current.status,
|
||||
"current_step": current_step or current.current_step,
|
||||
"progress_completed": (
|
||||
progress_completed
|
||||
if progress_completed is not None
|
||||
else current.progress_completed
|
||||
),
|
||||
progress_total=(
|
||||
"progress_total": (
|
||||
progress_total if progress_total is not None else current.progress_total
|
||||
),
|
||||
last_commit=last_commit or current.last_commit,
|
||||
"last_commit": last_commit or current.last_commit,
|
||||
}
|
||||
authorization_id = self._authorize(
|
||||
current,
|
||||
operation="heartbeat",
|
||||
principal_id=principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
changes=changes,
|
||||
)
|
||||
updated = replace(
|
||||
current,
|
||||
**changes,
|
||||
last_authorization_id=authorization_id or current.last_authorization_id,
|
||||
heartbeat_at=utc_now(),
|
||||
)
|
||||
self._write(path, updated)
|
||||
@@ -145,19 +257,34 @@ class ExecutionTracker:
|
||||
test_summary: Mapping[str, Any] | None = None,
|
||||
result_summary: Mapping[str, Any] | None = None,
|
||||
last_commit: str | None = None,
|
||||
principal_id: str | None = None,
|
||||
provenance_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> ExecutionEvidence:
|
||||
path = self._path(tenant_id, execution_id)
|
||||
with self._lock:
|
||||
current = self._read(path)
|
||||
changes = {
|
||||
"status": _required(status, "status"),
|
||||
"current_step": "complete",
|
||||
"progress_completed": current.progress_total,
|
||||
"last_commit": last_commit or current.last_commit,
|
||||
"test_status": _required(test_status, "test_status"),
|
||||
"test_summary": _safe(test_summary),
|
||||
"result_summary": _safe(result_summary),
|
||||
}
|
||||
authorization_id = self._authorize(
|
||||
current,
|
||||
operation="complete",
|
||||
principal_id=principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
changes=changes,
|
||||
)
|
||||
updated = replace(
|
||||
current,
|
||||
status=_required(status, "status"),
|
||||
current_step="complete",
|
||||
progress_completed=current.progress_total,
|
||||
last_commit=last_commit or current.last_commit,
|
||||
test_status=_required(test_status, "test_status"),
|
||||
test_summary=_safe(test_summary),
|
||||
result_summary=_safe(result_summary),
|
||||
**changes,
|
||||
last_authorization_id=authorization_id or current.last_authorization_id,
|
||||
heartbeat_at=utc_now(),
|
||||
completed_at=utc_now(),
|
||||
)
|
||||
@@ -192,4 +319,5 @@ class ExecutionTracker:
|
||||
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, path)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Persistent and portable AIOS executor provider catalog.
|
||||
|
||||
The catalog contains descriptors only: provider names, protocol/adapter versions,
|
||||
trust roles, capabilities and configuration schema identifiers. Credentials,
|
||||
endpoints and runtime secrets are intentionally excluded and must be resolved in
|
||||
the destination environment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .executor_protocol import (
|
||||
EXECUTOR_PROTOCOL_VERSION,
|
||||
DescriptorOnlyProvider,
|
||||
ExecutorProvider,
|
||||
ExecutorProviderDescriptor,
|
||||
ExecutorProviderRegistry,
|
||||
default_provider_registry,
|
||||
)
|
||||
|
||||
CATALOG_SCHEMA_VERSION = "aios.executor.catalog.v1"
|
||||
MIGRATION_SCHEMA_VERSION = "aios.executor.migration.v1"
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_payload(value: Mapping[str, Any]) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutorCatalogSnapshot:
|
||||
catalog: Mapping[str, Any]
|
||||
catalog_sha256: str
|
||||
path: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"catalog": dict(self.catalog),
|
||||
"catalog_sha256": self.catalog_sha256,
|
||||
"path": self.path,
|
||||
}
|
||||
|
||||
|
||||
class FileExecutorCatalogStore:
|
||||
"""Atomic file store for provider descriptors and migration packages."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _validate_catalog(value: Mapping[str, Any]) -> ExecutorProviderRegistry:
|
||||
if value.get("schema_version") != CATALOG_SCHEMA_VERSION:
|
||||
raise ValueError("unsupported executor catalog schema")
|
||||
if value.get("protocol_version") != EXECUTOR_PROTOCOL_VERSION:
|
||||
raise ValueError("executor catalog protocol mismatch")
|
||||
providers = value.get("providers")
|
||||
if not isinstance(providers, Sequence) or isinstance(providers, (str, bytes)):
|
||||
raise ValueError("executor catalog providers must be an array")
|
||||
return ExecutorProviderRegistry.import_catalog(value)
|
||||
|
||||
def initialize_defaults(self) -> ExecutorCatalogSnapshot:
|
||||
with self._lock:
|
||||
if self.path.exists():
|
||||
return self.load()
|
||||
return self.save(default_provider_registry())
|
||||
|
||||
def load(self) -> ExecutorCatalogSnapshot:
|
||||
with self._lock:
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("executor catalog must contain a JSON object")
|
||||
registry = self._validate_catalog(value)
|
||||
normalized = registry.export_catalog()
|
||||
return ExecutorCatalogSnapshot(
|
||||
catalog=normalized,
|
||||
catalog_sha256=sha256_payload(normalized),
|
||||
path=str(self.path),
|
||||
)
|
||||
|
||||
def registry(
|
||||
self,
|
||||
*,
|
||||
provider_factories: Mapping[str, ExecutorProvider] | None = None,
|
||||
) -> ExecutorProviderRegistry:
|
||||
snapshot = self.load()
|
||||
return ExecutorProviderRegistry.import_catalog(
|
||||
snapshot.catalog,
|
||||
provider_factories=provider_factories,
|
||||
)
|
||||
|
||||
def save(self, registry: ExecutorProviderRegistry) -> ExecutorCatalogSnapshot:
|
||||
catalog = registry.export_catalog()
|
||||
self._validate_catalog(catalog)
|
||||
encoded = json.dumps(
|
||||
catalog,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2,
|
||||
) + "\n"
|
||||
with self._lock:
|
||||
temporary = self.path.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(encoded, encoding="utf-8")
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, self.path)
|
||||
return ExecutorCatalogSnapshot(
|
||||
catalog=catalog,
|
||||
catalog_sha256=sha256_payload(catalog),
|
||||
path=str(self.path),
|
||||
)
|
||||
|
||||
def upsert_descriptor(
|
||||
self,
|
||||
descriptor: ExecutorProviderDescriptor,
|
||||
) -> ExecutorCatalogSnapshot:
|
||||
with self._lock:
|
||||
if self.path.exists():
|
||||
registry = self.registry()
|
||||
else:
|
||||
registry = default_provider_registry()
|
||||
try:
|
||||
registry.register(DescriptorOnlyProvider(descriptor), replace=True)
|
||||
except KeyError:
|
||||
registry.register(DescriptorOnlyProvider(descriptor))
|
||||
return self.save(registry)
|
||||
|
||||
def export_migration_package(
|
||||
self,
|
||||
*,
|
||||
source_environment: str,
|
||||
required_executor_types: Sequence[str] = (),
|
||||
) -> Mapping[str, Any]:
|
||||
snapshot = self.load()
|
||||
registry = ExecutorProviderRegistry.import_catalog(snapshot.catalog)
|
||||
required = tuple(sorted({str(item).strip().lower() for item in required_executor_types if str(item).strip()}))
|
||||
if required:
|
||||
available = {item.executor_type for item in registry.list_descriptors()}
|
||||
missing = sorted(set(required) - available)
|
||||
if missing:
|
||||
raise ValueError(f"migration package is missing providers: {missing}")
|
||||
package = {
|
||||
"schema_version": MIGRATION_SCHEMA_VERSION,
|
||||
"source_environment": str(source_environment or "").strip(),
|
||||
"catalog": snapshot.catalog,
|
||||
"catalog_sha256": snapshot.catalog_sha256,
|
||||
"required_executor_types": list(required),
|
||||
"secret_values_included": False,
|
||||
}
|
||||
if not package["source_environment"]:
|
||||
raise ValueError("source_environment is required")
|
||||
return {
|
||||
**package,
|
||||
"package_sha256": sha256_payload(package),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def import_migration_package(
|
||||
cls,
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
destination_path: str | Path,
|
||||
destination_environment: str,
|
||||
provider_factories: Mapping[str, ExecutorProvider] | None = None,
|
||||
) -> ExecutorCatalogSnapshot:
|
||||
if value.get("schema_version") != MIGRATION_SCHEMA_VERSION:
|
||||
raise ValueError("unsupported executor migration schema")
|
||||
if value.get("secret_values_included") is not False:
|
||||
raise ValueError("executor migration package must not contain secrets")
|
||||
package_without_hash = {key: item for key, item in value.items() if key != "package_sha256"}
|
||||
if sha256_payload(package_without_hash) != value.get("package_sha256"):
|
||||
raise ValueError("executor migration package hash mismatch")
|
||||
catalog = value.get("catalog")
|
||||
if not isinstance(catalog, Mapping):
|
||||
raise ValueError("executor migration package has no catalog")
|
||||
if sha256_payload(catalog) != value.get("catalog_sha256"):
|
||||
raise ValueError("executor catalog hash mismatch")
|
||||
registry = ExecutorProviderRegistry.import_catalog(
|
||||
catalog,
|
||||
provider_factories=provider_factories,
|
||||
)
|
||||
required = tuple(value.get("required_executor_types") or ())
|
||||
available = {item.executor_type for item in registry.list_descriptors()}
|
||||
missing = sorted(set(required) - available)
|
||||
if missing:
|
||||
raise ValueError(f"destination provider factories are missing: {missing}")
|
||||
if not str(destination_environment or "").strip():
|
||||
raise ValueError("destination_environment is required")
|
||||
return cls(destination_path).save(registry)
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Versioned, extensible executor protocol for AIOS external tasks.
|
||||
|
||||
The core lifecycle is executor-neutral. OpenClaw, Hermes, Codex and the scoped
|
||||
ChatGPT supervisor are built-in
|
||||
providers, not hard-coded protocol branches. Future providers register a
|
||||
versioned descriptor and use the same Truth Registry, evidence, notification
|
||||
and release-gate mechanisms without modifying the lifecycle state machine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .task_execution_registry import ExecutionTaskManifest, ExecutionTruthRecord
|
||||
|
||||
EXECUTOR_PROTOCOL_VERSION = "aios.executor.v1"
|
||||
_PROVIDER_NAME = re.compile(r"^[a-z][a-z0-9._-]{1,63}$")
|
||||
_VERSION = re.compile(r"^[0-9]+(?:\.[0-9]+){0,2}(?:[-+][A-Za-z0-9._-]+)?$")
|
||||
DISCOVERABLE_STATES: Sequence[str] = ("DECLARED", "REGISTERED")
|
||||
|
||||
|
||||
def normalize_executor(value: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if not _PROVIDER_NAME.fullmatch(normalized):
|
||||
raise ValueError("executor type must be a lowercase provider identifier")
|
||||
return normalized
|
||||
|
||||
|
||||
def _major(version: str) -> int:
|
||||
normalized = str(version or "").strip()
|
||||
if not _VERSION.fullmatch(normalized):
|
||||
raise ValueError(f"invalid version: {version}")
|
||||
return int(normalized.split(".", 1)[0])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutorProviderDescriptor:
|
||||
"""Portable provider metadata stored without credentials or runtime secrets."""
|
||||
|
||||
executor_type: str
|
||||
adapter_version: str
|
||||
protocol_version: str = EXECUTOR_PROTOCOL_VERSION
|
||||
trust_roles: Sequence[str] = ()
|
||||
capabilities: Sequence[str] = ()
|
||||
configuration_schema: str = "aios.executor.config.v1"
|
||||
portable: bool = True
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
|
||||
adapter_version = str(self.adapter_version or "").strip()
|
||||
if not _VERSION.fullmatch(adapter_version):
|
||||
raise ValueError("adapter_version must be version-like")
|
||||
object.__setattr__(self, "adapter_version", adapter_version)
|
||||
protocol_version = str(self.protocol_version or "").strip()
|
||||
if not protocol_version.startswith("aios.executor.v"):
|
||||
raise ValueError("unsupported executor protocol namespace")
|
||||
int(protocol_version.rsplit("v", 1)[1])
|
||||
object.__setattr__(self, "protocol_version", protocol_version)
|
||||
roles = tuple(sorted({str(item).strip() for item in self.trust_roles if str(item).strip()}))
|
||||
if not roles:
|
||||
raise ValueError("provider must declare at least one trust role")
|
||||
capabilities = tuple(
|
||||
sorted({str(item).strip() for item in self.capabilities if str(item).strip()})
|
||||
)
|
||||
object.__setattr__(self, "trust_roles", roles)
|
||||
object.__setattr__(self, "capabilities", capabilities)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"configuration_schema",
|
||||
str(self.configuration_schema or "").strip(),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata",
|
||||
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["trust_roles"] = list(self.trust_roles)
|
||||
value["capabilities"] = list(self.capabilities)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutorProviderDescriptor":
|
||||
return cls(**dict(value))
|
||||
|
||||
def protocol_compatible_with(self, other: "ExecutorProviderDescriptor") -> bool:
|
||||
return (
|
||||
self.executor_type == other.executor_type
|
||||
and self.protocol_version == other.protocol_version
|
||||
and _major(self.adapter_version) == _major(other.adapter_version)
|
||||
)
|
||||
|
||||
|
||||
class ExternalExecutorAdapter(Protocol):
|
||||
"""Runtime-specific implementation behind the common lifecycle."""
|
||||
|
||||
descriptor: ExecutorProviderDescriptor
|
||||
|
||||
def discover(self) -> Sequence[ExecutionTruthRecord]: ...
|
||||
def execute(self, manifest: ExecutionTaskManifest) -> Mapping[str, Any]: ...
|
||||
def notify_owner(self, payload: Mapping[str, Any]) -> str: ...
|
||||
|
||||
|
||||
class ExecutorProvider(Protocol):
|
||||
descriptor: ExecutorProviderDescriptor
|
||||
|
||||
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None: ...
|
||||
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter: ...
|
||||
|
||||
|
||||
class DescriptorOnlyProvider:
|
||||
"""Default provider used when lifecycle integration is supplied externally."""
|
||||
|
||||
def __init__(self, descriptor: ExecutorProviderDescriptor) -> None:
|
||||
self.descriptor = descriptor
|
||||
|
||||
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
|
||||
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
|
||||
raise ValueError("manifest executor does not match provider")
|
||||
|
||||
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter:
|
||||
raise RuntimeError(
|
||||
f"provider {self.descriptor.executor_type} requires an external adapter factory"
|
||||
)
|
||||
|
||||
|
||||
class ExecutorProviderRegistry:
|
||||
"""Thread-safe registry supporting runtime registration and catalog migration."""
|
||||
|
||||
def __init__(self, providers: Sequence[ExecutorProvider] = ()) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._providers: dict[str, ExecutorProvider] = {}
|
||||
for provider in providers:
|
||||
self.register(provider)
|
||||
|
||||
def register(self, provider: ExecutorProvider, *, replace: bool = False) -> None:
|
||||
descriptor = provider.descriptor
|
||||
key = normalize_executor(descriptor.executor_type)
|
||||
with self._lock:
|
||||
current = self._providers.get(key)
|
||||
if current is not None and not replace:
|
||||
raise ValueError(f"executor provider already registered: {key}")
|
||||
if current is not None and not descriptor.protocol_compatible_with(
|
||||
current.descriptor
|
||||
):
|
||||
raise ValueError(
|
||||
f"replacement provider is incompatible with registered provider: {key}"
|
||||
)
|
||||
self._providers[key] = provider
|
||||
|
||||
def unregister(self, executor_type: str) -> ExecutorProvider:
|
||||
key = normalize_executor(executor_type)
|
||||
with self._lock:
|
||||
try:
|
||||
return self._providers.pop(key)
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"executor provider is not registered: {key}") from exc
|
||||
|
||||
def get(self, executor_type: str) -> ExecutorProvider:
|
||||
key = normalize_executor(executor_type)
|
||||
with self._lock:
|
||||
provider = self._providers.get(key)
|
||||
if provider is None:
|
||||
raise KeyError(f"executor provider is not registered: {key}")
|
||||
return provider
|
||||
|
||||
def descriptor(self, executor_type: str) -> ExecutorProviderDescriptor:
|
||||
return self.get(executor_type).descriptor
|
||||
|
||||
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
|
||||
self.get(manifest.executor).validate_manifest(manifest)
|
||||
|
||||
def trusted(
|
||||
self,
|
||||
*,
|
||||
executor_type: str,
|
||||
roles: Sequence[str],
|
||||
platform_admin: bool = False,
|
||||
) -> bool:
|
||||
if platform_admin or "platform_admin" in set(roles):
|
||||
return True
|
||||
descriptor = self.descriptor(executor_type)
|
||||
return bool(set(descriptor.trust_roles).intersection(set(roles)))
|
||||
|
||||
def list_descriptors(self) -> tuple[ExecutorProviderDescriptor, ...]:
|
||||
with self._lock:
|
||||
return tuple(
|
||||
self._providers[key].descriptor for key in sorted(self._providers)
|
||||
)
|
||||
|
||||
def export_catalog(self) -> Mapping[str, Any]:
|
||||
return {
|
||||
"schema_version": "aios.executor.catalog.v1",
|
||||
"protocol_version": EXECUTOR_PROTOCOL_VERSION,
|
||||
"providers": [item.as_dict() for item in self.list_descriptors()],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def import_catalog(
|
||||
cls,
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
provider_factories: Mapping[str, ExecutorProvider] | None = None,
|
||||
) -> "ExecutorProviderRegistry":
|
||||
if value.get("schema_version") != "aios.executor.catalog.v1":
|
||||
raise ValueError("unsupported executor catalog schema")
|
||||
if value.get("protocol_version") != EXECUTOR_PROTOCOL_VERSION:
|
||||
raise ValueError("executor catalog protocol mismatch")
|
||||
factories = dict(provider_factories or {})
|
||||
providers: list[ExecutorProvider] = []
|
||||
for raw in value.get("providers") or ():
|
||||
descriptor = ExecutorProviderDescriptor.from_dict(raw)
|
||||
provider = factories.get(descriptor.executor_type)
|
||||
if provider is not None:
|
||||
if not provider.descriptor.protocol_compatible_with(descriptor):
|
||||
raise ValueError(
|
||||
f"provider factory does not match imported descriptor: {descriptor.executor_type}"
|
||||
)
|
||||
providers.append(provider)
|
||||
else:
|
||||
providers.append(DescriptorOnlyProvider(descriptor))
|
||||
return cls(providers)
|
||||
|
||||
|
||||
BUILTIN_PROVIDER_DESCRIPTORS: Sequence[ExecutorProviderDescriptor] = (
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="openclaw",
|
||||
adapter_version="2.1.0",
|
||||
trust_roles=("runtime", "openclaw_executor"),
|
||||
capabilities=(
|
||||
"deploy",
|
||||
"http_runtime",
|
||||
"owner_notification",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
),
|
||||
configuration_schema="aios.executor.openclaw.config.v1",
|
||||
metadata={"official": True},
|
||||
),
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="hermes",
|
||||
adapter_version="2.1.0",
|
||||
trust_roles=("runtime", "hermes_executor"),
|
||||
capabilities=(
|
||||
"queue",
|
||||
"lease",
|
||||
"worker",
|
||||
"runtime_validation",
|
||||
"restart_recovery",
|
||||
),
|
||||
configuration_schema="aios.executor.hermes.config.v1",
|
||||
metadata={"official": True},
|
||||
),
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="codex",
|
||||
adapter_version="2.1.0",
|
||||
trust_roles=("codex", "codex_executor"),
|
||||
capabilities=("code", "test", "docs", "gitops_patch", "security_review"),
|
||||
configuration_schema="aios.executor.codex.config.v1",
|
||||
metadata={"official": True},
|
||||
),
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="chatgpt-supervisor",
|
||||
adapter_version="2.1.0",
|
||||
trust_roles=("chatgpt_supervisor",),
|
||||
capabilities=(
|
||||
"acceptance",
|
||||
"deploy",
|
||||
"evidence",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
"wechat_simulation",
|
||||
),
|
||||
configuration_schema="aios.executor.chatgpt-supervisor.config.v1",
|
||||
metadata={"official": True, "production_mutation_allowed": False},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def default_provider_registry() -> ExecutorProviderRegistry:
|
||||
return ExecutorProviderRegistry(
|
||||
[DescriptorOnlyProvider(item) for item in BUILTIN_PROVIDER_DESCRIPTORS]
|
||||
)
|
||||
|
||||
|
||||
def trusted_for_executor(
|
||||
*,
|
||||
executor: str,
|
||||
roles: Sequence[str],
|
||||
platform_admin: bool = False,
|
||||
registry: ExecutorProviderRegistry | None = None,
|
||||
) -> bool:
|
||||
return (registry or default_provider_registry()).trusted(
|
||||
executor_type=executor,
|
||||
roles=roles,
|
||||
platform_admin=platform_admin,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutorIdentity:
|
||||
executor_type: str
|
||||
executor_id: str
|
||||
executor_run_id: str
|
||||
protocol_version: str = EXECUTOR_PROTOCOL_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
|
||||
for name in ("executor_id", "executor_run_id"):
|
||||
value = str(getattr(self, name) or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{name} is required")
|
||||
object.__setattr__(self, name, value)
|
||||
if self.protocol_version != EXECUTOR_PROTOCOL_VERSION:
|
||||
raise ValueError("executor identity protocol mismatch")
|
||||
|
||||
|
||||
class ExecutorLifecycleSession:
|
||||
"""Executor-neutral lifecycle facade used by every registered provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ExternalTaskCoordinator,
|
||||
identity: ExecutorIdentity,
|
||||
*,
|
||||
registry: ExecutorProviderRegistry | None = None,
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.identity = identity
|
||||
self.registry = registry or default_provider_registry()
|
||||
self.registry.get(identity.executor_type)
|
||||
|
||||
def _assert_manifest(self, task_id: str) -> ExecutionTaskManifest:
|
||||
record = self.coordinator.registry.get(task_id)
|
||||
manifest = record.manifest
|
||||
self.registry.validate_manifest(manifest)
|
||||
if normalize_executor(manifest.executor) != self.identity.executor_type:
|
||||
raise PermissionError(
|
||||
f"task executor is {manifest.executor}, not {self.identity.executor_type}"
|
||||
)
|
||||
return manifest
|
||||
|
||||
def register(self, task_id: str) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.register(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
)
|
||||
|
||||
def retry_not_started(self, task_id: str) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.retry_not_started(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
)
|
||||
|
||||
def claim(self, task_id: str) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.claim(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
)
|
||||
|
||||
def start(self, task_id: str) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.start(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
)
|
||||
|
||||
def heartbeat(self, task_id: str, *, current_step: int) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.heartbeat(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
evidence: Mapping[str, str],
|
||||
) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.succeed(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
def fail(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
error: Mapping[str, Any],
|
||||
evidence: Mapping[str, str] | None = None,
|
||||
) -> ExecutionTruthRecord:
|
||||
self._assert_manifest(task_id)
|
||||
return self.coordinator.fail(
|
||||
task_id,
|
||||
executor_run_id=self.identity.executor_run_id,
|
||||
executor_id=self.identity.executor_id,
|
||||
error=error,
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
|
||||
class OpenClawExecutorSession(ExecutorLifecycleSession):
|
||||
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
|
||||
super().__init__(
|
||||
coordinator,
|
||||
ExecutorIdentity("openclaw", executor_id, executor_run_id),
|
||||
)
|
||||
|
||||
|
||||
class HermesExecutorSession(ExecutorLifecycleSession):
|
||||
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
|
||||
super().__init__(
|
||||
coordinator,
|
||||
ExecutorIdentity("hermes", executor_id, executor_run_id),
|
||||
)
|
||||
|
||||
|
||||
class CodexExecutorSession(ExecutorLifecycleSession):
|
||||
def __init__(self, coordinator: ExternalTaskCoordinator, *, executor_id: str, executor_run_id: str) -> None:
|
||||
super().__init__(
|
||||
coordinator,
|
||||
ExecutorIdentity("codex", executor_id, executor_run_id),
|
||||
)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Provider-neutral worker runtime for AIOS external executors.
|
||||
|
||||
Providers implement only step execution and notification delivery. Discovery,
|
||||
dependency gates, lifecycle truth, heartbeat, evidence validation and durable
|
||||
notification acknowledgement are identical for every current and future
|
||||
executor Provider.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .execution_plan import ExecutionPlanCoordinator, ExecutionStageManifest
|
||||
from .executor_protocol import (
|
||||
ExecutorIdentity,
|
||||
ExecutorLifecycleSession,
|
||||
ExecutorProviderRegistry,
|
||||
normalize_executor,
|
||||
)
|
||||
from .external_task_control import ExternalTaskCoordinator, NotificationRecord
|
||||
from .task_execution_registry import ExecutionTruthState
|
||||
|
||||
|
||||
class StepRunner(Protocol):
|
||||
def run_step(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
stage: ExecutionStageManifest,
|
||||
step: str,
|
||||
step_number: int,
|
||||
executor_id: str,
|
||||
executor_run_id: str,
|
||||
) -> Mapping[str, str]: ...
|
||||
|
||||
|
||||
class NotificationSender(Protocol):
|
||||
def send(self, notification: NotificationRecord) -> str: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageRunResult:
|
||||
plan_id: str
|
||||
stage_id: str
|
||||
task_id: str
|
||||
executor_type: str
|
||||
executor_id: str
|
||||
executor_run_id: str
|
||||
state: str
|
||||
evidence: Mapping[str, str] = field(default_factory=dict)
|
||||
error: Mapping[str, Any] = field(default_factory=dict)
|
||||
delivered_notifications: Sequence[str] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"plan_id": self.plan_id,
|
||||
"stage_id": self.stage_id,
|
||||
"task_id": self.task_id,
|
||||
"executor_type": self.executor_type,
|
||||
"executor_id": self.executor_id,
|
||||
"executor_run_id": self.executor_run_id,
|
||||
"state": self.state,
|
||||
"evidence": dict(self.evidence),
|
||||
"error": dict(self.error),
|
||||
"delivered_notifications": list(self.delivered_notifications),
|
||||
}
|
||||
|
||||
|
||||
class UnifiedExecutorWorker:
|
||||
"""Dependency-aware worker shared by every registered Provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plan_coordinator: ExecutionPlanCoordinator,
|
||||
task_coordinator: ExternalTaskCoordinator,
|
||||
providers: ExecutorProviderRegistry,
|
||||
executor_type: str,
|
||||
executor_id: str,
|
||||
step_runner: StepRunner,
|
||||
notification_sender: NotificationSender,
|
||||
) -> None:
|
||||
self.plan_coordinator = plan_coordinator
|
||||
self.task_coordinator = task_coordinator
|
||||
self.providers = providers
|
||||
self.executor_type = normalize_executor(executor_type)
|
||||
self.executor_id = str(executor_id or "").strip()
|
||||
if not self.executor_id:
|
||||
raise ValueError("executor_id is required")
|
||||
self.providers.get(self.executor_type)
|
||||
self.step_runner = step_runner
|
||||
self.notification_sender = notification_sender
|
||||
|
||||
def _session(self, executor_run_id: str) -> ExecutorLifecycleSession:
|
||||
return ExecutorLifecycleSession(
|
||||
self.task_coordinator,
|
||||
ExecutorIdentity(
|
||||
executor_type=self.executor_type,
|
||||
executor_id=self.executor_id,
|
||||
executor_run_id=executor_run_id,
|
||||
),
|
||||
registry=self.providers,
|
||||
)
|
||||
|
||||
def _notification_candidates(self, task_id: str) -> tuple[NotificationRecord, ...]:
|
||||
"""Return idempotent delivery candidates, including prior failures.
|
||||
|
||||
The outbox has a unique ``(task_id, event_type)`` key. Retrying therefore
|
||||
reuses the same notification ID and cannot create duplicate STARTED or
|
||||
SUCCEEDED events. A later successful acknowledgement replaces FAILED
|
||||
with DELIVERED and records the receipt hash.
|
||||
"""
|
||||
candidates: dict[str, NotificationRecord] = {}
|
||||
for status in ("PENDING", "FAILED"):
|
||||
for item in self.task_coordinator.notifications.list(
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
):
|
||||
candidates[item.notification_id] = item
|
||||
return tuple(
|
||||
candidates[key]
|
||||
for key in sorted(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
candidates[item].created_at,
|
||||
candidates[item].notification_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def _deliver_notifications(self, task_id: str) -> tuple[str, ...]:
|
||||
delivered: list[str] = []
|
||||
for item in self._notification_candidates(task_id):
|
||||
try:
|
||||
receipt = self.notification_sender.send(item)
|
||||
acknowledged = self.task_coordinator.notifications.acknowledge(
|
||||
item.notification_id,
|
||||
receipt_sha256=receipt,
|
||||
)
|
||||
delivered.append(acknowledged.event_type)
|
||||
except Exception as exc:
|
||||
self.task_coordinator.notifications.fail(
|
||||
item.notification_id,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
return tuple(sorted(set(delivered)))
|
||||
|
||||
def retry_failed_notifications(self, *, task_id: str) -> tuple[str, ...]:
|
||||
"""Retry failed owner notifications without changing task truth."""
|
||||
return self._deliver_notifications(task_id)
|
||||
|
||||
def run_ready(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
max_stages: int = 1,
|
||||
) -> list[StageRunResult]:
|
||||
if max_stages < 1:
|
||||
raise ValueError("max_stages must be positive")
|
||||
ready = self.plan_coordinator.discover_ready(
|
||||
plan_id=plan_id,
|
||||
executor_type=self.executor_type,
|
||||
)[:max_stages]
|
||||
return [
|
||||
self.run_stage(
|
||||
plan_id=plan_id,
|
||||
stage_id=item["stage"]["stage_id"],
|
||||
)
|
||||
for item in ready
|
||||
]
|
||||
|
||||
def run_stage(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
stage_id: str,
|
||||
executor_run_id: str | None = None,
|
||||
) -> StageRunResult:
|
||||
plan = self.plan_coordinator.plan_store.get(plan_id)
|
||||
stage = plan.stage(stage_id)
|
||||
if stage.executor_type != self.executor_type:
|
||||
raise PermissionError(
|
||||
f"stage executor is {stage.executor_type}, not {self.executor_type}"
|
||||
)
|
||||
dependency = self.plan_coordinator.dependency_gate(plan_id, stage_id)
|
||||
if not dependency["passed"]:
|
||||
raise RuntimeError("stage dependencies are not satisfied")
|
||||
|
||||
run_id = executor_run_id or f"{self.executor_type}_run_{uuid.uuid4().hex}"
|
||||
session = self._session(run_id)
|
||||
record = self.task_coordinator.registry.get(stage.task_id)
|
||||
evidence: dict[str, str] = dict(record.evidence)
|
||||
delivered: list[str] = []
|
||||
try:
|
||||
if record.state == ExecutionTruthState.DECLARED:
|
||||
record = session.register(stage.task_id)
|
||||
if record.state == ExecutionTruthState.REGISTERED:
|
||||
record = session.claim(stage.task_id)
|
||||
if record.state == ExecutionTruthState.CLAIMED:
|
||||
record = session.start(stage.task_id)
|
||||
delivered.extend(self._deliver_notifications(stage.task_id))
|
||||
if record.state != ExecutionTruthState.RUNNING:
|
||||
raise RuntimeError(
|
||||
f"stage cannot execute from state {record.state.value}"
|
||||
)
|
||||
|
||||
# Re-running the current step is intentional: Provider steps must be
|
||||
# idempotent and a crash between side effect and heartbeat cannot be
|
||||
# safely interpreted as completed work.
|
||||
start_step = max(1, record.current_step)
|
||||
for number, step in enumerate(stage.steps, start=1):
|
||||
if number < start_step:
|
||||
continue
|
||||
produced = self.step_runner.run_step(
|
||||
plan_id=plan_id,
|
||||
stage=stage,
|
||||
step=step,
|
||||
step_number=number,
|
||||
executor_id=self.executor_id,
|
||||
executor_run_id=run_id,
|
||||
)
|
||||
if not isinstance(produced, Mapping):
|
||||
raise TypeError("step runner must return an evidence mapping")
|
||||
evidence.update(
|
||||
{
|
||||
str(key).strip(): str(value).strip().lower()
|
||||
for key, value in produced.items()
|
||||
}
|
||||
)
|
||||
record = session.heartbeat(stage.task_id, current_step=number)
|
||||
|
||||
record = session.succeed(stage.task_id, evidence=evidence)
|
||||
delivered.extend(self._deliver_notifications(stage.task_id))
|
||||
return StageRunResult(
|
||||
plan_id=plan_id,
|
||||
stage_id=stage.stage_id,
|
||||
task_id=stage.task_id,
|
||||
executor_type=self.executor_type,
|
||||
executor_id=self.executor_id,
|
||||
executor_run_id=run_id,
|
||||
state=record.state.value,
|
||||
evidence=dict(record.evidence),
|
||||
delivered_notifications=tuple(sorted(set(delivered))),
|
||||
)
|
||||
except Exception as exc:
|
||||
current = self.task_coordinator.registry.get(stage.task_id)
|
||||
if current.state in {
|
||||
ExecutionTruthState.REGISTERED,
|
||||
ExecutionTruthState.CLAIMED,
|
||||
ExecutionTruthState.RUNNING,
|
||||
}:
|
||||
current = session.fail(
|
||||
stage.task_id,
|
||||
error={
|
||||
"code": "executor_stage_failed",
|
||||
"error_type": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
},
|
||||
evidence=evidence or None,
|
||||
)
|
||||
delivered.extend(self._deliver_notifications(stage.task_id))
|
||||
return StageRunResult(
|
||||
plan_id=plan_id,
|
||||
stage_id=stage.stage_id,
|
||||
task_id=stage.task_id,
|
||||
executor_type=self.executor_type,
|
||||
executor_id=self.executor_id,
|
||||
executor_run_id=run_id,
|
||||
state=current.state.value,
|
||||
evidence=dict(current.evidence),
|
||||
error=dict(current.error),
|
||||
delivered_notifications=tuple(sorted(set(delivered))),
|
||||
)
|
||||
|
||||
|
||||
class JsonEvidenceStepRunner:
|
||||
"""Adapter for handlers returning evidence-name to SHA256 mappings."""
|
||||
|
||||
def __init__(self, handler) -> None:
|
||||
self.handler = handler
|
||||
|
||||
def run_step(self, **values: Any) -> Mapping[str, str]:
|
||||
result = self.handler(**values)
|
||||
if not isinstance(result, Mapping):
|
||||
raise TypeError("step handler must return a mapping")
|
||||
return {str(key): str(value) for key, value in result.items()}
|
||||
|
||||
|
||||
class JsonReceiptNotificationSender:
|
||||
"""Adapter for senders returning a SHA256 or receipt JSON object."""
|
||||
|
||||
def __init__(self, sender) -> None:
|
||||
self.sender = sender
|
||||
|
||||
def send(self, notification: NotificationRecord) -> str:
|
||||
result = self.sender(notification.as_dict())
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
if not isinstance(result, Mapping):
|
||||
raise TypeError(
|
||||
"notification sender must return SHA256 or a receipt object"
|
||||
)
|
||||
encoded = json.dumps(
|
||||
dict(result),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for durable external-task truth and notification receipts.
|
||||
|
||||
The CLI is suitable for OpenClaw workers, systemd jobs and release gates. It
|
||||
never infers execution from the presence of a manifest: each lifecycle action
|
||||
must be recorded explicitly in the shared SQLite registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .external_task_query import list_records
|
||||
|
||||
|
||||
def _json_mapping(value: str | None, path: str | None) -> Mapping[str, Any]:
|
||||
if value and path:
|
||||
raise ValueError("inline JSON and JSON file are mutually exclusive")
|
||||
if path:
|
||||
parsed = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
else:
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, Mapping):
|
||||
raise ValueError("JSON value must be an object")
|
||||
return dict(parsed)
|
||||
|
||||
|
||||
def _coordinator(args: argparse.Namespace) -> ExternalTaskCoordinator:
|
||||
return ExternalTaskCoordinator.from_file(args.store_file)
|
||||
|
||||
|
||||
def _print(value: Any) -> None:
|
||||
print(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, default=str))
|
||||
|
||||
|
||||
def cmd_declare(args: argparse.Namespace) -> int:
|
||||
record = _coordinator(args).declare_file(args.manifest)
|
||||
_print(record.as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_discover(args: argparse.Namespace) -> int:
|
||||
records = list_records(
|
||||
args.store_file,
|
||||
executor=args.executor,
|
||||
states=tuple(args.state or ()),
|
||||
)
|
||||
_print({"count": len(records), "items": [item.as_dict() for item in records]})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_register(args: argparse.Namespace) -> int:
|
||||
_print(_coordinator(args).register(args.task_id, executor_run_id=args.executor_run_id).as_dict())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_claim(args: argparse.Namespace) -> int:
|
||||
_print(
|
||||
_coordinator(args).claim(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_start(args: argparse.Namespace) -> int:
|
||||
_print(
|
||||
_coordinator(args).start(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_heartbeat(args: argparse.Namespace) -> int:
|
||||
_print(
|
||||
_coordinator(args).heartbeat(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
current_step=args.current_step,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_succeed(args: argparse.Namespace) -> int:
|
||||
evidence = _json_mapping(args.evidence_json, args.evidence_file)
|
||||
_print(
|
||||
_coordinator(args).succeed(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
evidence={str(key): str(value) for key, value in evidence.items()},
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_fail(args: argparse.Namespace) -> int:
|
||||
error = _json_mapping(args.error_json, args.error_file)
|
||||
evidence = None
|
||||
if args.evidence_json or args.evidence_file:
|
||||
evidence_value = _json_mapping(args.evidence_json, args.evidence_file)
|
||||
evidence = {str(key): str(value) for key, value in evidence_value.items()}
|
||||
_print(
|
||||
_coordinator(args).fail(
|
||||
args.task_id,
|
||||
executor_run_id=args.executor_run_id,
|
||||
executor_id=args.executor_id,
|
||||
error=error,
|
||||
evidence=evidence,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
_print(_coordinator(args).status(args.task_id))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_gate(args: argparse.Namespace) -> int:
|
||||
value = _coordinator(args).gate(args.task_id)
|
||||
_print(value)
|
||||
return 0 if value["passed"] else 3
|
||||
|
||||
|
||||
def cmd_notifications(args: argparse.Namespace) -> int:
|
||||
items = _coordinator(args).notifications.list(task_id=args.task_id, status=args.status)
|
||||
_print({"count": len(items), "items": [item.as_dict() for item in items]})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_ack(args: argparse.Namespace) -> int:
|
||||
_print(
|
||||
_coordinator(args).notifications.acknowledge(
|
||||
args.notification_id,
|
||||
receipt_sha256=args.receipt_sha256,
|
||||
).as_dict()
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def common(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--store-file",
|
||||
default=os.getenv(
|
||||
"AIOS_EXTERNAL_TASK_STORE_FILE",
|
||||
"/var/lib/aios-v2/external-task-truth.sqlite3",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def lifecycle(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--executor-run-id", required=True)
|
||||
parser.add_argument("--executor-id", required=True)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="AIOS external task truth CLI")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
declare = commands.add_parser("declare")
|
||||
common(declare)
|
||||
declare.add_argument("--manifest", required=True)
|
||||
declare.set_defaults(handler=cmd_declare)
|
||||
|
||||
discover = commands.add_parser("discover")
|
||||
common(discover)
|
||||
discover.add_argument("--executor", default="openclaw")
|
||||
discover.add_argument("--state", action="append")
|
||||
discover.set_defaults(handler=cmd_discover)
|
||||
|
||||
register = commands.add_parser("register")
|
||||
common(register)
|
||||
register.add_argument("--task-id", required=True)
|
||||
register.add_argument("--executor-run-id", required=True)
|
||||
register.set_defaults(handler=cmd_register)
|
||||
|
||||
claim = commands.add_parser("claim")
|
||||
common(claim)
|
||||
lifecycle(claim)
|
||||
claim.set_defaults(handler=cmd_claim)
|
||||
|
||||
start = commands.add_parser("start")
|
||||
common(start)
|
||||
lifecycle(start)
|
||||
start.set_defaults(handler=cmd_start)
|
||||
|
||||
heartbeat = commands.add_parser("heartbeat")
|
||||
common(heartbeat)
|
||||
lifecycle(heartbeat)
|
||||
heartbeat.add_argument("--current-step", type=int, required=True)
|
||||
heartbeat.set_defaults(handler=cmd_heartbeat)
|
||||
|
||||
succeed = commands.add_parser("succeed")
|
||||
common(succeed)
|
||||
lifecycle(succeed)
|
||||
evidence = succeed.add_mutually_exclusive_group(required=True)
|
||||
evidence.add_argument("--evidence-json")
|
||||
evidence.add_argument("--evidence-file")
|
||||
succeed.set_defaults(handler=cmd_succeed)
|
||||
|
||||
fail = commands.add_parser("fail")
|
||||
common(fail)
|
||||
lifecycle(fail)
|
||||
error = fail.add_mutually_exclusive_group(required=True)
|
||||
error.add_argument("--error-json")
|
||||
error.add_argument("--error-file")
|
||||
fail_evidence = fail.add_mutually_exclusive_group()
|
||||
fail_evidence.add_argument("--evidence-json")
|
||||
fail_evidence.add_argument("--evidence-file")
|
||||
fail.set_defaults(handler=cmd_fail)
|
||||
|
||||
status = commands.add_parser("status")
|
||||
common(status)
|
||||
status.add_argument("--task-id", required=True)
|
||||
status.set_defaults(handler=cmd_status)
|
||||
|
||||
gate = commands.add_parser("gate")
|
||||
common(gate)
|
||||
gate.add_argument("--task-id", required=True)
|
||||
gate.set_defaults(handler=cmd_gate)
|
||||
|
||||
notifications = commands.add_parser("notifications")
|
||||
common(notifications)
|
||||
notifications.add_argument("--task-id")
|
||||
notifications.add_argument("--status")
|
||||
notifications.set_defaults(handler=cmd_notifications)
|
||||
|
||||
ack = commands.add_parser("ack-notification")
|
||||
common(ack)
|
||||
ack.add_argument("--notification-id", required=True)
|
||||
ack.add_argument("--receipt-sha256", required=True)
|
||||
ack.set_defaults(handler=cmd_ack)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return int(args.handler(args))
|
||||
except Exception as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "error", "error": type(exc).__name__, "message": str(exc)},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,420 @@
|
||||
"""External executor coordination, notification outbox and release truth gate.
|
||||
|
||||
OpenClaw/Codex task declarations are not execution evidence. This module wraps
|
||||
``SQLiteExecutionTruthRegistry`` with a durable notification outbox. A successful
|
||||
release gate requires both terminal task evidence and acknowledged STARTED /
|
||||
SUCCEEDED owner notifications when those events are required by the manifest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .task_execution_registry import (
|
||||
ExecutionTaskManifest,
|
||||
ExecutionTruthRecord,
|
||||
ExecutionTruthState,
|
||||
SQLiteExecutionTruthRegistry,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def sha256_value(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if not _SHA256.fullmatch(normalized):
|
||||
raise ValueError(f"{label} must be a lowercase SHA256 hex digest")
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationRecord:
|
||||
notification_id: str
|
||||
task_id: str
|
||||
event_type: str
|
||||
target_ref: str
|
||||
payload: Mapping[str, Any]
|
||||
status: str
|
||||
created_at: str
|
||||
delivered_at: str | None = None
|
||||
receipt_sha256: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SQLiteNotificationOutbox:
|
||||
"""Durable owner-notification delivery and acknowledgement records."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS external_task_notifications (
|
||||
notification_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
receipt_sha256 TEXT,
|
||||
error TEXT,
|
||||
UNIQUE(task_id, event_type)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_external_notification_status
|
||||
ON external_task_notifications(status, created_at);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _decode(row: sqlite3.Row) -> NotificationRecord:
|
||||
return NotificationRecord(
|
||||
notification_id=row["notification_id"],
|
||||
task_id=row["task_id"],
|
||||
event_type=row["event_type"],
|
||||
target_ref=row["target_ref"],
|
||||
payload=json.loads(row["payload_json"]),
|
||||
status=row["status"],
|
||||
created_at=row["created_at"],
|
||||
delivered_at=row["delivered_at"],
|
||||
receipt_sha256=row["receipt_sha256"],
|
||||
error=row["error"],
|
||||
)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
event_type: str,
|
||||
target_ref: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> NotificationRecord:
|
||||
now = utc_now()
|
||||
record = NotificationRecord(
|
||||
notification_id=f"notify_{uuid.uuid4().hex}",
|
||||
task_id=str(task_id).strip(),
|
||||
event_type=str(event_type).strip().upper(),
|
||||
target_ref=str(target_ref).strip(),
|
||||
payload=json.loads(json.dumps(dict(payload), ensure_ascii=False, default=str)),
|
||||
status="PENDING",
|
||||
created_at=now,
|
||||
)
|
||||
if not record.task_id or not record.event_type or not record.target_ref:
|
||||
raise ValueError("task_id, event_type and target_ref are required")
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM external_task_notifications WHERE task_id = ? AND event_type = ?",
|
||||
(record.task_id, record.event_type),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
return self._decode(existing)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO external_task_notifications
|
||||
(notification_id, task_id, event_type, target_ref, payload_json,
|
||||
status, created_at, delivered_at, receipt_sha256, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
||||
""",
|
||||
(
|
||||
record.notification_id,
|
||||
record.task_id,
|
||||
record.event_type,
|
||||
record.target_ref,
|
||||
json.dumps(record.payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
|
||||
record.status,
|
||||
record.created_at,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return record
|
||||
|
||||
def get(self, notification_id: str) -> NotificationRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM external_task_notifications WHERE notification_id = ?",
|
||||
(str(notification_id).strip(),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("notification not found")
|
||||
return self._decode(row)
|
||||
|
||||
def acknowledge(self, notification_id: str, *, receipt_sha256: str) -> NotificationRecord:
|
||||
digest = sha256_value(receipt_sha256, "receipt_sha256")
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT * FROM external_task_notifications WHERE notification_id = ?",
|
||||
(str(notification_id).strip(),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.rollback()
|
||||
raise KeyError("notification not found")
|
||||
current = self._decode(row)
|
||||
if current.status == "DELIVERED":
|
||||
if current.receipt_sha256 != digest:
|
||||
connection.rollback()
|
||||
raise RuntimeError("notification already acknowledged with another receipt")
|
||||
connection.rollback()
|
||||
return current
|
||||
delivered_at = utc_now()
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE external_task_notifications
|
||||
SET status = 'DELIVERED', delivered_at = ?, receipt_sha256 = ?, error = NULL
|
||||
WHERE notification_id = ?
|
||||
""",
|
||||
(delivered_at, digest, current.notification_id),
|
||||
)
|
||||
connection.commit()
|
||||
return self.get(current.notification_id)
|
||||
|
||||
def fail(self, notification_id: str, *, error: str) -> NotificationRecord:
|
||||
message = str(error or "").strip()
|
||||
if not message:
|
||||
raise ValueError("notification error is required")
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE external_task_notifications SET status = 'FAILED', error = ? WHERE notification_id = ?",
|
||||
(message, str(notification_id).strip()),
|
||||
)
|
||||
connection.commit()
|
||||
return self.get(notification_id)
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[NotificationRecord]:
|
||||
conditions: list[str] = []
|
||||
values: list[str] = []
|
||||
if task_id:
|
||||
conditions.append("task_id = ?")
|
||||
values.append(str(task_id).strip())
|
||||
if status:
|
||||
conditions.append("status = ?")
|
||||
values.append(str(status).strip().upper())
|
||||
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
f"SELECT * FROM external_task_notifications{where} ORDER BY created_at, notification_id",
|
||||
tuple(values),
|
||||
).fetchall()
|
||||
return [self._decode(row) for row in rows]
|
||||
|
||||
|
||||
class ExternalTaskCoordinator:
|
||||
"""Single safe lifecycle used by external executors and release gates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: SQLiteExecutionTruthRegistry,
|
||||
notifications: SQLiteNotificationOutbox,
|
||||
) -> None:
|
||||
self.registry = registry
|
||||
self.notifications = notifications
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path) -> "ExternalTaskCoordinator":
|
||||
return cls(
|
||||
SQLiteExecutionTruthRegistry(path),
|
||||
SQLiteNotificationOutbox(path),
|
||||
)
|
||||
|
||||
def declare(self, manifest: ExecutionTaskManifest) -> ExecutionTruthRecord:
|
||||
return self.registry.declare(manifest)
|
||||
|
||||
def declare_file(self, path: str | Path) -> ExecutionTruthRecord:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("external task manifest must be a JSON object")
|
||||
return self.declare(ExecutionTaskManifest.from_dict(value))
|
||||
|
||||
def register(self, task_id: str, *, executor_run_id: str) -> ExecutionTruthRecord:
|
||||
return self.registry.register(task_id, executor_run_id=executor_run_id)
|
||||
|
||||
def retry_not_started(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self.registry.retry_not_started(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
)
|
||||
|
||||
def claim(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self.registry.claim(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
|
||||
def _notify(self, record: ExecutionTruthRecord, event_type: str) -> NotificationRecord | None:
|
||||
event = event_type.upper()
|
||||
if event not in set(record.manifest.notification_events):
|
||||
return None
|
||||
return self.notifications.enqueue(
|
||||
task_id=record.task_id,
|
||||
event_type=event,
|
||||
target_ref=record.manifest.owner_notification_ref,
|
||||
payload={
|
||||
"task_id": record.task_id,
|
||||
"target_version": record.manifest.target_version,
|
||||
"executor": record.manifest.executor,
|
||||
"executor_id": record.executor_id,
|
||||
"executor_run_id": record.executor_run_id,
|
||||
"state": record.state.value,
|
||||
"current_step": record.current_step,
|
||||
"total_steps": len(record.manifest.steps),
|
||||
"timestamp": utc_now(),
|
||||
},
|
||||
)
|
||||
|
||||
def start(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
) -> ExecutionTruthRecord:
|
||||
record = self.registry.start(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
self._notify(record, "STARTED")
|
||||
return record
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
current_step: int,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self.registry.heartbeat(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
evidence: Mapping[str, str],
|
||||
) -> ExecutionTruthRecord:
|
||||
normalized = {
|
||||
str(key).strip(): sha256_value(value, f"evidence[{key}]")
|
||||
for key, value in evidence.items()
|
||||
}
|
||||
record = self.registry.succeed(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
evidence=normalized,
|
||||
)
|
||||
self._notify(record, "SUCCEEDED")
|
||||
return record
|
||||
|
||||
def fail(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
error: Mapping[str, Any],
|
||||
evidence: Mapping[str, str] | None = None,
|
||||
) -> ExecutionTruthRecord:
|
||||
normalized = None
|
||||
if evidence is not None:
|
||||
normalized = {
|
||||
str(key).strip(): sha256_value(value, f"evidence[{key}]")
|
||||
for key, value in evidence.items()
|
||||
}
|
||||
record = self.registry.fail(
|
||||
task_id,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
error=error,
|
||||
evidence=normalized,
|
||||
)
|
||||
self._notify(record, "FAILED")
|
||||
return record
|
||||
|
||||
def gate(self, task_id: str) -> Mapping[str, Any]:
|
||||
before = self.registry.get(task_id)
|
||||
truth = self.registry.gate(task_id)
|
||||
after = self.registry.get(task_id)
|
||||
if after.state != before.state and after.state in {
|
||||
ExecutionTruthState.NOT_STARTED,
|
||||
ExecutionTruthState.STALLED,
|
||||
}:
|
||||
self._notify(after, after.state.value)
|
||||
notifications = self.notifications.list(task_id=task_id)
|
||||
delivered = {item.event_type for item in notifications if item.status == "DELIVERED"}
|
||||
required_success_notifications = {
|
||||
item
|
||||
for item in ("STARTED", "SUCCEEDED")
|
||||
if item in set(after.manifest.notification_events)
|
||||
}
|
||||
notification_complete = required_success_notifications.issubset(delivered)
|
||||
passed = bool(truth["passed"] and notification_complete)
|
||||
return {
|
||||
**dict(truth),
|
||||
"passed": passed,
|
||||
"release_blocking": not passed,
|
||||
"notification_complete": notification_complete,
|
||||
"required_success_notifications": sorted(required_success_notifications),
|
||||
"delivered_notifications": sorted(delivered),
|
||||
"notifications": [item.as_dict() for item in notifications],
|
||||
}
|
||||
|
||||
def status(self, task_id: str) -> Mapping[str, Any]:
|
||||
gate = self.gate(task_id)
|
||||
record = self.registry.get(task_id)
|
||||
return {
|
||||
"record": record.as_dict(),
|
||||
"gate": gate,
|
||||
"events": self.registry.events(task_id),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def content_receipt(value: Mapping[str, Any]) -> str:
|
||||
encoded = json.dumps(dict(value), ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Read models for the durable external-task truth registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from .task_execution_registry import ExecutionTruthRecord, ExecutionTruthState
|
||||
|
||||
|
||||
def _decode(value: str) -> ExecutionTruthRecord:
|
||||
payload = json.loads(value)
|
||||
payload["state"] = ExecutionTruthState(payload["state"])
|
||||
payload["evidence"] = dict(payload.get("evidence") or {})
|
||||
payload["error"] = dict(payload.get("error") or {})
|
||||
return ExecutionTruthRecord(**payload)
|
||||
|
||||
|
||||
def list_records(
|
||||
path: str | Path,
|
||||
*,
|
||||
executor: str | None = None,
|
||||
states: Sequence[str] = (),
|
||||
) -> list[ExecutionTruthRecord]:
|
||||
database = Path(path).resolve()
|
||||
if not database.exists():
|
||||
return []
|
||||
conditions: list[str] = []
|
||||
values: list[str] = []
|
||||
normalized_states = tuple(sorted({ExecutionTruthState(item).value for item in states}))
|
||||
if normalized_states:
|
||||
conditions.append(f"state IN ({','.join('?' for _ in normalized_states)})")
|
||||
values.extend(normalized_states)
|
||||
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=10.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = connection.execute(
|
||||
f"SELECT record_json FROM execution_truth{where} ORDER BY updated_at, task_id",
|
||||
tuple(values),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return []
|
||||
finally:
|
||||
connection.close()
|
||||
records = [_decode(row["record_json"]) for row in rows]
|
||||
if executor:
|
||||
normalized = str(executor).strip().lower()
|
||||
records = [item for item in records if item.manifest.executor.lower() == normalized]
|
||||
return records
|
||||
@@ -1,9 +1,9 @@
|
||||
"""HTTP/JSON gateway for the AIOS V2 control plane.
|
||||
|
||||
The gateway is intentionally runtime-neutral: browsers and external clients
|
||||
call this API, while OpenClaw and Hermes remain behind authenticated adapters.
|
||||
Identity headers are accepted only from a trusted reverse proxy in v1; direct
|
||||
public exposure is prohibited until signed identity middleware is enabled.
|
||||
The public gateway owns user-facing control operations only. Runtime lifecycle
|
||||
transitions are deliberately absent: external callers cannot directly move a
|
||||
task to RUNNING, COMPLETED or FAILED. Those transitions are available only
|
||||
through the provenance-protected orchestrator and internal reconciliation API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,6 +17,8 @@ from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_hermes import HermesConflict, HermesNotFound
|
||||
|
||||
from .control_plane import (
|
||||
ControlPlaneError,
|
||||
ControlPlaneService,
|
||||
@@ -30,7 +32,9 @@ from .control_plane import (
|
||||
TenantIsolationError,
|
||||
Unauthorized,
|
||||
)
|
||||
|
||||
from .dispatch_store import DispatchConflict, DispatchNotFound
|
||||
from .orchestrator import OrchestrationError
|
||||
from .provenance import ProvenanceDenied, ProvenanceReplay
|
||||
|
||||
TENANT_HEADER = "X-AIOS-Tenant-ID"
|
||||
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
|
||||
@@ -72,17 +76,13 @@ def _require_resource_id(value: Any, *, label: str) -> str:
|
||||
|
||||
|
||||
class GatewayApplication:
|
||||
"""Pure request router that can be tested without a listening socket."""
|
||||
"""Pure user-facing request router that can be tested without a socket."""
|
||||
|
||||
def __init__(self, service: ControlPlaneService | None = None) -> None:
|
||||
self.service = service or ControlPlaneService()
|
||||
|
||||
@staticmethod
|
||||
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
|
||||
# HTTP field names are case-insensitive. BaseHTTPRequestHandler commonly
|
||||
# presents them as title-cased (for example ``X-Aios-Authenticated``),
|
||||
# so copying the message into a plain dict must not make identity lookup
|
||||
# case-sensitive.
|
||||
normalized_headers = {
|
||||
str(key).strip().lower(): value for key, value in headers.items()
|
||||
}
|
||||
@@ -139,8 +139,7 @@ class GatewayApplication:
|
||||
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/tenants/current":
|
||||
tenant = self.service.get_tenant(context)
|
||||
return HTTPStatus.OK, {"tenant": _json_value(tenant)}
|
||||
return HTTPStatus.OK, {"tenant": _json_value(self.service.get_tenant(context))}
|
||||
|
||||
if method == "POST" and path == "/api/v2/agents":
|
||||
agent = self.service.register_agent(
|
||||
@@ -154,9 +153,7 @@ class GatewayApplication:
|
||||
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/agents":
|
||||
return HTTPStatus.OK, {
|
||||
"agents": _json_value(self.service.list_agents(context))
|
||||
}
|
||||
return HTTPStatus.OK, {"agents": _json_value(self.service.list_agents(context))}
|
||||
|
||||
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
|
||||
if method == "GET" and agent_match:
|
||||
@@ -182,8 +179,7 @@ class GatewayApplication:
|
||||
return HTTPStatus.CREATED, {"task": _json_value(task)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/tasks":
|
||||
requested_states = query.get("state")
|
||||
tasks = self.service.list_tasks(context, states=requested_states)
|
||||
tasks = self.service.list_tasks(context, states=query.get("state"))
|
||||
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
|
||||
|
||||
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
|
||||
@@ -195,38 +191,37 @@ class GatewayApplication:
|
||||
"task": _json_value(self.service.get_task(context, task_id))
|
||||
}
|
||||
|
||||
bypass_match = re.fullmatch(
|
||||
r"/api/v2/tasks/([^/]+)/(start|complete|fail)", path
|
||||
)
|
||||
if method == "POST" and bypass_match:
|
||||
return HTTPStatus.FORBIDDEN, {
|
||||
"error": "RuntimeLifecycleBypassDenied",
|
||||
"message": (
|
||||
"RUNNING, COMPLETED and FAILED transitions are internal; "
|
||||
"use the provenance-protected dispatch/reconcile flow"
|
||||
),
|
||||
}
|
||||
|
||||
transition_match = re.fullmatch(
|
||||
r"/api/v2/tasks/([^/]+)/(approve|start|complete|fail|cancel)", path
|
||||
r"/api/v2/tasks/([^/]+)/(approve|cancel)", path
|
||||
)
|
||||
if method == "POST" and transition_match:
|
||||
task_id = _require_resource_id(
|
||||
urllib.parse.unquote(transition_match.group(1)), label="task_id"
|
||||
)
|
||||
transition = transition_match.group(2)
|
||||
target = {
|
||||
"approve": TaskState.APPROVED,
|
||||
"start": TaskState.RUNNING,
|
||||
"complete": TaskState.COMPLETED,
|
||||
"fail": TaskState.FAILED,
|
||||
"cancel": TaskState.CANCELLED,
|
||||
}[transition]
|
||||
target = (
|
||||
TaskState.APPROVED
|
||||
if transition_match.group(2) == "approve"
|
||||
else TaskState.CANCELLED
|
||||
)
|
||||
expected_revision = payload.get("expected_revision")
|
||||
if expected_revision is not None:
|
||||
expected_revision = int(expected_revision)
|
||||
task = self.service.transition_task(
|
||||
context,
|
||||
task_id=task_id,
|
||||
target_state=target,
|
||||
expected_revision=expected_revision,
|
||||
result=(
|
||||
_require_mapping(payload.get("result"), label="result")
|
||||
if payload.get("result") is not None
|
||||
else None
|
||||
),
|
||||
error=(
|
||||
_require_mapping(payload.get("error"), label="error")
|
||||
if payload.get("error") is not None
|
||||
else None
|
||||
expected_revision=(
|
||||
int(expected_revision) if expected_revision is not None else None
|
||||
),
|
||||
)
|
||||
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||
@@ -252,11 +247,18 @@ class GatewayApplication:
|
||||
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
|
||||
(Unauthorized, HTTPStatus.UNAUTHORIZED),
|
||||
(PolicyDenied, HTTPStatus.FORBIDDEN),
|
||||
(ProvenanceDenied, HTTPStatus.FORBIDDEN),
|
||||
(TenantIsolationError, HTTPStatus.NOT_FOUND),
|
||||
(NotFound, HTTPStatus.NOT_FOUND),
|
||||
(DispatchNotFound, HTTPStatus.NOT_FOUND),
|
||||
(HermesNotFound, HTTPStatus.NOT_FOUND),
|
||||
(ProvenanceReplay, HTTPStatus.CONFLICT),
|
||||
(IdempotencyConflict, HTTPStatus.CONFLICT),
|
||||
(RevisionConflict, HTTPStatus.CONFLICT),
|
||||
(InvalidTransition, HTTPStatus.CONFLICT),
|
||||
(DispatchConflict, HTTPStatus.CONFLICT),
|
||||
(HermesConflict, HTTPStatus.CONFLICT),
|
||||
(OrchestrationError, HTTPStatus.CONFLICT),
|
||||
(ValueError, HTTPStatus.BAD_REQUEST),
|
||||
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
|
||||
)
|
||||
@@ -265,10 +267,7 @@ _ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
|
||||
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
|
||||
for error_type, status in _ERROR_STATUS:
|
||||
if isinstance(exc, error_type):
|
||||
return status, {
|
||||
"error": error_type.__name__,
|
||||
"message": str(exc),
|
||||
}
|
||||
return status, {"error": error_type.__name__, "message": str(exc)}
|
||||
return HTTPStatus.INTERNAL_SERVER_ERROR, {
|
||||
"error": "InternalServerError",
|
||||
"message": "internal server error",
|
||||
@@ -292,8 +291,7 @@ class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||
if content_type != "application/json":
|
||||
raise ValueError("Content-Type must be application/json")
|
||||
raw = self.rfile.read(length)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
|
||||
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(
|
||||
@@ -316,7 +314,7 @@ class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||
{key: value for key, value in self.headers.items()},
|
||||
body,
|
||||
)
|
||||
except BaseException as exc: # handler boundary; sanitized below
|
||||
except Exception as exc:
|
||||
status, payload = error_response(exc)
|
||||
self._send_json(status, payload)
|
||||
|
||||
@@ -327,8 +325,6 @@ class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||
self._dispatch("POST")
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
# Production deployment routes structured access logs through the
|
||||
# reverse proxy; do not log identity headers or request bodies here.
|
||||
super().log_message(format, *args)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Official AIOS executor providers built on the portable provider protocol.
|
||||
|
||||
The official providers contain no lifecycle implementation. They validate their
|
||||
manifests, resolve a generic ``UnifiedExecutorWorker`` from runtime-supplied
|
||||
boundaries and expose the same adapter contract. Future providers can use
|
||||
``ConfiguredExecutorProvider`` directly without changing AIOS core modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .execution_plan import ExecutionPlanCoordinator, ExecutionStageManifest
|
||||
from .executor_protocol import (
|
||||
EXECUTOR_PROTOCOL_VERSION,
|
||||
ExternalExecutorAdapter,
|
||||
ExecutorProviderDescriptor,
|
||||
normalize_executor,
|
||||
)
|
||||
from .executor_worker import NotificationSender, StepRunner, UnifiedExecutorWorker
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .task_execution_registry import ExecutionTaskManifest, ExecutionTruthRecord
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderRuntimeConfiguration:
|
||||
plan_id: str
|
||||
executor_id: str
|
||||
plan_coordinator: ExecutionPlanCoordinator
|
||||
task_coordinator: ExternalTaskCoordinator
|
||||
providers: Any
|
||||
step_runner: StepRunner
|
||||
notification_sender: NotificationSender
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ProviderRuntimeConfiguration":
|
||||
required = (
|
||||
"plan_id",
|
||||
"executor_id",
|
||||
"plan_coordinator",
|
||||
"task_coordinator",
|
||||
"providers",
|
||||
"step_runner",
|
||||
"notification_sender",
|
||||
)
|
||||
missing = [name for name in required if value.get(name) is None]
|
||||
if missing:
|
||||
raise ValueError(f"provider runtime configuration is missing: {missing}")
|
||||
return cls(**{name: value[name] for name in required})
|
||||
|
||||
|
||||
class ConfiguredExecutorAdapter:
|
||||
"""Generic adapter produced by every official or third-party provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
descriptor: ExecutorProviderDescriptor,
|
||||
configuration: ProviderRuntimeConfiguration,
|
||||
) -> None:
|
||||
self.descriptor = descriptor
|
||||
self.configuration = configuration
|
||||
self.worker = UnifiedExecutorWorker(
|
||||
plan_coordinator=configuration.plan_coordinator,
|
||||
task_coordinator=configuration.task_coordinator,
|
||||
providers=configuration.providers,
|
||||
executor_type=descriptor.executor_type,
|
||||
executor_id=configuration.executor_id,
|
||||
step_runner=configuration.step_runner,
|
||||
notification_sender=configuration.notification_sender,
|
||||
)
|
||||
|
||||
def discover(self) -> Sequence[ExecutionTruthRecord]:
|
||||
ready = self.configuration.plan_coordinator.discover_ready(
|
||||
plan_id=self.configuration.plan_id,
|
||||
executor_type=self.descriptor.executor_type,
|
||||
)
|
||||
return tuple(item["record"] for item in ready)
|
||||
|
||||
def execute(self, manifest: ExecutionTaskManifest) -> Mapping[str, Any]:
|
||||
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
|
||||
raise PermissionError("manifest executor does not match adapter provider")
|
||||
plan = self.configuration.plan_coordinator.plan_store.get(
|
||||
self.configuration.plan_id
|
||||
)
|
||||
stage: ExecutionStageManifest | None = None
|
||||
for item in plan.stages:
|
||||
if item.task_id == manifest.task_id:
|
||||
stage = item
|
||||
break
|
||||
if stage is None:
|
||||
raise KeyError("manifest task is not part of configured execution plan")
|
||||
return self.worker.run_stage(
|
||||
plan_id=plan.plan_id,
|
||||
stage_id=stage.stage_id,
|
||||
).as_dict()
|
||||
|
||||
def notify_owner(self, payload: Mapping[str, Any]) -> str:
|
||||
# External callers normally use the durable NotificationOutbox. This
|
||||
# method remains part of the portable adapter contract for providers
|
||||
# that need to send a standalone operational notice.
|
||||
from .external_task_control import NotificationRecord
|
||||
from .task_execution_registry import utc_now
|
||||
|
||||
notification = NotificationRecord(
|
||||
notification_id="provider_direct_notice",
|
||||
task_id=str(payload.get("task_id") or "provider-notice"),
|
||||
event_type=str(payload.get("event_type") or "INFO"),
|
||||
target_ref=str(
|
||||
payload.get("target_ref") or "AIOS_OWNER_WECHAT_TARGET_REF"
|
||||
),
|
||||
payload=dict(payload),
|
||||
status="PENDING",
|
||||
created_at=utc_now(),
|
||||
)
|
||||
return self.configuration.notification_sender.send(notification)
|
||||
|
||||
|
||||
class ConfiguredExecutorProvider:
|
||||
"""Reusable provider implementation for current and future executors."""
|
||||
|
||||
descriptor: ExecutorProviderDescriptor
|
||||
|
||||
def __init__(self, descriptor: ExecutorProviderDescriptor) -> None:
|
||||
self.descriptor = descriptor
|
||||
|
||||
def validate_manifest(self, manifest: ExecutionTaskManifest) -> None:
|
||||
if normalize_executor(manifest.executor) != self.descriptor.executor_type:
|
||||
raise ValueError("manifest executor does not match provider")
|
||||
if manifest.manifest_version not in {
|
||||
"aios.external-task.v1",
|
||||
"aios.external-task.v2",
|
||||
}:
|
||||
raise ValueError("unsupported external task manifest version")
|
||||
if not manifest.steps:
|
||||
raise ValueError("executor manifest has no steps")
|
||||
|
||||
def build_adapter(self, configuration: Mapping[str, Any]) -> ExternalExecutorAdapter:
|
||||
return ConfiguredExecutorAdapter(
|
||||
self.descriptor,
|
||||
ProviderRuntimeConfiguration.from_mapping(configuration),
|
||||
)
|
||||
|
||||
|
||||
class OpenClawProvider(ConfiguredExecutorProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="openclaw",
|
||||
adapter_version="2.1.0",
|
||||
protocol_version=EXECUTOR_PROTOCOL_VERSION,
|
||||
trust_roles=("runtime", "openclaw_executor"),
|
||||
capabilities=(
|
||||
"deploy",
|
||||
"http_runtime",
|
||||
"owner_notification",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
),
|
||||
configuration_schema="aios.executor.openclaw.config.v1",
|
||||
metadata={"official": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class HermesProvider(ConfiguredExecutorProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="hermes",
|
||||
adapter_version="2.1.0",
|
||||
protocol_version=EXECUTOR_PROTOCOL_VERSION,
|
||||
trust_roles=("runtime", "hermes_executor"),
|
||||
capabilities=(
|
||||
"queue",
|
||||
"lease",
|
||||
"worker",
|
||||
"runtime_validation",
|
||||
"restart_recovery",
|
||||
),
|
||||
configuration_schema="aios.executor.hermes.config.v1",
|
||||
metadata={"official": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CodexProvider(ConfiguredExecutorProvider):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="codex",
|
||||
adapter_version="2.1.0",
|
||||
protocol_version=EXECUTOR_PROTOCOL_VERSION,
|
||||
trust_roles=("codex", "codex_executor"),
|
||||
capabilities=(
|
||||
"code",
|
||||
"test",
|
||||
"docs",
|
||||
"gitops_patch",
|
||||
"security_review",
|
||||
),
|
||||
configuration_schema="aios.executor.codex.config.v1",
|
||||
metadata={"official": True},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ChatGPTSupervisorProvider(ConfiguredExecutorProvider):
|
||||
"""Explicit takeover provider; it does not inherit Codex permissions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
ExecutorProviderDescriptor(
|
||||
executor_type="chatgpt-supervisor",
|
||||
adapter_version="2.1.0",
|
||||
protocol_version=EXECUTOR_PROTOCOL_VERSION,
|
||||
trust_roles=("chatgpt_supervisor",),
|
||||
capabilities=(
|
||||
"acceptance",
|
||||
"deploy",
|
||||
"evidence",
|
||||
"healthcheck",
|
||||
"rollback",
|
||||
"wechat_simulation",
|
||||
),
|
||||
configuration_schema="aios.executor.chatgpt-supervisor.config.v1",
|
||||
metadata={"official": True, "production_mutation_allowed": False},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def official_provider_factories() -> Mapping[str, ConfiguredExecutorProvider]:
|
||||
providers = (
|
||||
OpenClawProvider(),
|
||||
HermesProvider(),
|
||||
CodexProvider(),
|
||||
ChatGPTSupervisorProvider(),
|
||||
)
|
||||
return {item.descriptor.executor_type: item for item in providers}
|
||||
@@ -1,16 +1,15 @@
|
||||
"""AIOS V2 execution orchestrator.
|
||||
|
||||
The orchestrator is the only component allowed to translate an approved AIOS
|
||||
task into an OpenClaw command or Hermes job. OpenClaw and Hermes remain pure
|
||||
execution runtimes and never own tenant policy or task lifecycle.
|
||||
Task into an OpenClaw command or Hermes job. Every dispatch is persisted before
|
||||
Runtime side effects. Runtime results must bind to the exact dispatch,
|
||||
command/job, tenant, task, trace, principal and provenance context.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from aios_hermes import HermesAdapter, HermesJob, HermesResult
|
||||
from aios_hermes import HermesAdapter, HermesJob, HermesJobState, HermesResult
|
||||
from aios_openclaw import (
|
||||
CommandState,
|
||||
OpenClawAdapter,
|
||||
@@ -26,25 +25,27 @@ from .control_plane import (
|
||||
TaskRecord,
|
||||
TaskState,
|
||||
)
|
||||
from .dispatch_store import (
|
||||
DispatchConflict,
|
||||
DispatchRecord,
|
||||
DispatchRepository,
|
||||
DispatchState,
|
||||
InMemoryDispatchRepository,
|
||||
TERMINAL_DISPATCH_STATES,
|
||||
)
|
||||
from .provenance import ProvenanceDenied, digest_payload
|
||||
|
||||
|
||||
class RuntimeAuthorizationWriter(Protocol):
|
||||
def authorize_runtime_execute(self, **values: Any) -> Any: ...
|
||||
|
||||
|
||||
class OrchestrationError(RuntimeError):
|
||||
"""AIOS failed to dispatch or reconcile a runtime execution."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispatchRecord:
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
runtime: str
|
||||
runtime_execution_id: str
|
||||
state: str
|
||||
trace_id: str
|
||||
|
||||
|
||||
class ExecutionOrchestrator:
|
||||
"""Dispatch approved AIOS tasks to runtime adapters and reconcile results."""
|
||||
"""Dispatch approved AIOS tasks and strictly reconcile Runtime results."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -56,15 +57,76 @@ class ExecutionOrchestrator:
|
||||
openclaw_key_id: str = "default",
|
||||
default_cell_id: str = "default",
|
||||
default_profile_id: str = "default",
|
||||
provenance_writer: RuntimeAuthorizationWriter | None = None,
|
||||
require_provenance: bool = False,
|
||||
dispatch_repository: DispatchRepository | None = None,
|
||||
) -> None:
|
||||
self.control_plane = control_plane
|
||||
self.openclaw = openclaw
|
||||
self.hermes = hermes
|
||||
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
|
||||
self.openclaw_key_id = openclaw_key_id
|
||||
self.openclaw_key_id = str(openclaw_key_id or "").strip() or "default"
|
||||
self.default_cell_id = default_cell_id
|
||||
self.default_profile_id = default_profile_id
|
||||
self._dispatches: dict[tuple[str, str], DispatchRecord] = {}
|
||||
self.provenance_writer = provenance_writer
|
||||
self.require_provenance = bool(require_provenance)
|
||||
self.dispatch_repository = dispatch_repository or InMemoryDispatchRepository()
|
||||
if self.require_provenance and self.provenance_writer is None:
|
||||
raise ValueError("production orchestration requires a provenance writer")
|
||||
|
||||
@staticmethod
|
||||
def _record_provenance(value: str | None, *, required: bool, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if required and not normalized:
|
||||
raise OrchestrationError(f"production dispatch requires {label}")
|
||||
return normalized or f"{label}-not-required"
|
||||
|
||||
def _authorize_dispatch(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
task: TaskRecord,
|
||||
agent: AgentRecord,
|
||||
provenance_id: str | None,
|
||||
run_id: str | None,
|
||||
timeout_seconds: float | None,
|
||||
) -> str:
|
||||
if not self.require_provenance:
|
||||
return "authorization-not-required"
|
||||
if self.provenance_writer is None:
|
||||
raise OrchestrationError("Run Provenance writer is not configured")
|
||||
if not provenance_id or not run_id:
|
||||
raise OrchestrationError(
|
||||
"production dispatch requires provenance_id and run_id"
|
||||
)
|
||||
request_digest = digest_payload(
|
||||
{
|
||||
"operation": "dispatch",
|
||||
"tenant_id": task.tenant_id,
|
||||
"task_id": task.task_id,
|
||||
"agent_id": agent.agent_id,
|
||||
"principal_id": context.principal_id,
|
||||
"runtime": agent.runtime.strip().lower(),
|
||||
"action": task.action,
|
||||
"payload": task.payload,
|
||||
"trace_id": context.trace_id,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"task_revision": task.revision,
|
||||
}
|
||||
)
|
||||
try:
|
||||
authorization = self.provenance_writer.authorize_runtime_execute(
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
principal_id=context.principal_id,
|
||||
request_digest=request_digest,
|
||||
)
|
||||
return authorization.authorization_id
|
||||
except ProvenanceDenied as exc:
|
||||
raise OrchestrationError(f"Run Provenance denied dispatch: {exc}") from exc
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
@@ -72,36 +134,146 @@ class ExecutionOrchestrator:
|
||||
*,
|
||||
task_id: str,
|
||||
timeout_seconds: float | None = None,
|
||||
provenance_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> DispatchRecord:
|
||||
task = self.control_plane.get_task(context, task_id)
|
||||
if task.state != TaskState.APPROVED:
|
||||
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||
agent = self.control_plane.get_agent(context, task.agent_id)
|
||||
existing = self._dispatches.get((task.tenant_id, task.task_id))
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
running = self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.RUNNING,
|
||||
expected_revision=task.revision,
|
||||
persisted_provenance = self._record_provenance(
|
||||
provenance_id, required=self.require_provenance, label="provenance_id"
|
||||
)
|
||||
persisted_run = self._record_provenance(
|
||||
run_id, required=self.require_provenance, label="run_id"
|
||||
)
|
||||
|
||||
existing = self.dispatch_repository.get_by_task(
|
||||
task.task_id, tenant_id=task.tenant_id
|
||||
)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.provenance_id != persisted_provenance
|
||||
or existing.run_id != persisted_run
|
||||
or existing.principal_id != context.principal_id
|
||||
):
|
||||
raise OrchestrationError(
|
||||
"existing dispatch belongs to another provenance or principal context"
|
||||
)
|
||||
return existing
|
||||
|
||||
if task.state != TaskState.APPROVED:
|
||||
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||
|
||||
authorization_id = self._authorize_dispatch(
|
||||
context,
|
||||
task=task,
|
||||
agent=agent,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
runtime = agent.runtime.strip().lower()
|
||||
prepared: DispatchRecord | None = None
|
||||
|
||||
try:
|
||||
if runtime == "openclaw":
|
||||
record = self._dispatch_openclaw(
|
||||
context,
|
||||
agent,
|
||||
running,
|
||||
timeout_seconds=timeout_seconds,
|
||||
command = self._create_openclaw_command(context, task)
|
||||
prepared = self.dispatch_repository.create(
|
||||
DispatchRecord.create(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
principal_id=context.principal_id,
|
||||
runtime="openclaw",
|
||||
runtime_execution_id=command.command_id,
|
||||
trace_id=context.trace_id,
|
||||
provenance_id=persisted_provenance,
|
||||
run_id=persisted_run,
|
||||
authorization_id=authorization_id,
|
||||
)
|
||||
)
|
||||
elif runtime == "hermes":
|
||||
record = self._dispatch_hermes(context, agent, running)
|
||||
else:
|
||||
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
|
||||
self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.RUNNING,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
self.dispatch_repository.update(
|
||||
prepared.dispatch_id,
|
||||
tenant_id=prepared.tenant_id,
|
||||
state=DispatchState.RUNNING,
|
||||
)
|
||||
if self.openclaw is None:
|
||||
raise OrchestrationError("OpenClaw runtime is not configured")
|
||||
result = self.openclaw.execute(
|
||||
command, timeout_seconds=timeout_seconds
|
||||
)
|
||||
self.reconcile_openclaw(
|
||||
context,
|
||||
result,
|
||||
dispatch_id=prepared.dispatch_id,
|
||||
provenance_id=persisted_provenance,
|
||||
run_id=persisted_run,
|
||||
)
|
||||
return self.dispatch_repository.get(
|
||||
prepared.dispatch_id, tenant_id=prepared.tenant_id
|
||||
)
|
||||
|
||||
if runtime == "hermes":
|
||||
if self.hermes is None:
|
||||
raise OrchestrationError("Hermes runtime is not configured")
|
||||
job = self._create_hermes_job(context, agent, task)
|
||||
prepared = self.dispatch_repository.create(
|
||||
DispatchRecord.create(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
principal_id=context.principal_id,
|
||||
runtime="hermes",
|
||||
runtime_execution_id=job.job_id,
|
||||
trace_id=context.trace_id,
|
||||
provenance_id=persisted_provenance,
|
||||
run_id=persisted_run,
|
||||
authorization_id=authorization_id,
|
||||
)
|
||||
)
|
||||
self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.RUNNING,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
queued = self.hermes.enqueue(job)
|
||||
return self.dispatch_repository.update(
|
||||
prepared.dispatch_id,
|
||||
tenant_id=prepared.tenant_id,
|
||||
state=DispatchState.QUEUED,
|
||||
result_digest=digest_payload(
|
||||
{
|
||||
"queued_job_id": queued.job_id,
|
||||
"state": queued.state.value,
|
||||
"revision": queued.revision,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
|
||||
except Exception as exc:
|
||||
if prepared is not None:
|
||||
try:
|
||||
self.dispatch_repository.update(
|
||||
prepared.dispatch_id,
|
||||
tenant_id=prepared.tenant_id,
|
||||
state=DispatchState.FAILED,
|
||||
result_digest=digest_payload(
|
||||
{
|
||||
"code": "dispatch_failed",
|
||||
"error_type": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
}
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
current = self.control_plane.get_task(context, task.task_id)
|
||||
if current.state == TaskState.RUNNING:
|
||||
self.control_plane.transition_task(
|
||||
@@ -113,32 +285,17 @@ class ExecutionOrchestrator:
|
||||
)
|
||||
raise
|
||||
|
||||
self._dispatches[(task.tenant_id, task.task_id)] = record
|
||||
return record
|
||||
|
||||
def _dispatch_openclaw(
|
||||
def _create_openclaw_command(
|
||||
self,
|
||||
context: RequestContext,
|
||||
agent: AgentRecord,
|
||||
task: TaskRecord,
|
||||
*,
|
||||
timeout_seconds: float | None,
|
||||
) -> DispatchRecord:
|
||||
if self.openclaw is None or self.openclaw_signing_material_resolver is None:
|
||||
raise OrchestrationError("OpenClaw runtime is not configured")
|
||||
) -> OpenClawCommand:
|
||||
if self.openclaw_signing_material_resolver is None:
|
||||
raise OrchestrationError("OpenClaw signing material is not configured")
|
||||
key_material = self.openclaw_signing_material_resolver(
|
||||
task.tenant_id,
|
||||
self.openclaw_key_id,
|
||||
)
|
||||
requested_timeout = (
|
||||
self.openclaw.default_timeout_seconds
|
||||
if timeout_seconds is None
|
||||
else float(timeout_seconds)
|
||||
)
|
||||
effective_timeout = min(requested_timeout, 300.0)
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=effective_timeout)
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
command = OpenClawCommand.create(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
@@ -146,32 +303,18 @@ 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=effective_timeout)
|
||||
self.reconcile_openclaw(context, result)
|
||||
return DispatchRecord(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
runtime="openclaw",
|
||||
runtime_execution_id=command.command_id,
|
||||
state=result.state.value,
|
||||
trace_id=context.trace_id,
|
||||
)
|
||||
return sign_command(command, key_material)
|
||||
|
||||
def _dispatch_hermes(
|
||||
def _create_hermes_job(
|
||||
self,
|
||||
context: RequestContext,
|
||||
agent: AgentRecord,
|
||||
task: TaskRecord,
|
||||
) -> DispatchRecord:
|
||||
if self.hermes is None:
|
||||
raise OrchestrationError("Hermes runtime is not configured")
|
||||
) -> HermesJob:
|
||||
metadata = dict(agent.metadata)
|
||||
job = HermesJob.create(
|
||||
return HermesJob.create(
|
||||
tenant_id=task.tenant_id,
|
||||
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
|
||||
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
|
||||
@@ -182,94 +325,275 @@ class ExecutionOrchestrator:
|
||||
idempotency_key=task.idempotency_key or task.task_id,
|
||||
priority=int(metadata.get("priority", 100)),
|
||||
)
|
||||
queued = self.hermes.enqueue(job)
|
||||
return DispatchRecord(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
|
||||
@staticmethod
|
||||
def _assert_dispatch_context(
|
||||
dispatch: DispatchRecord,
|
||||
*,
|
||||
runtime: str,
|
||||
runtime_execution_id: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
trace_id: str,
|
||||
principal_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
) -> None:
|
||||
expected = {
|
||||
"runtime": runtime,
|
||||
"runtime_execution_id": runtime_execution_id,
|
||||
"tenant_id": tenant_id,
|
||||
"task_id": task_id,
|
||||
"trace_id": trace_id,
|
||||
"principal_id": principal_id,
|
||||
"provenance_id": provenance_id,
|
||||
"run_id": run_id,
|
||||
}
|
||||
for field_name, expected_value in expected.items():
|
||||
if getattr(dispatch, field_name) != expected_value:
|
||||
raise OrchestrationError(f"dispatch {field_name} mismatch")
|
||||
|
||||
def hermes_lifecycle_context(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
dispatch_id: str,
|
||||
job_id: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
trace_id: str,
|
||||
principal_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
) -> tuple[DispatchRecord, HermesJob]:
|
||||
"""Resolve a runtime lifecycle request against its durable dispatch."""
|
||||
dispatch = self.dispatch_repository.get(
|
||||
dispatch_id, tenant_id=context.tenant_id
|
||||
)
|
||||
self._assert_dispatch_context(
|
||||
dispatch,
|
||||
runtime="hermes",
|
||||
runtime_execution_id=queued.job_id,
|
||||
state=queued.state.value,
|
||||
trace_id=context.trace_id,
|
||||
runtime_execution_id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
trace_id=trace_id,
|
||||
principal_id=principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if context.tenant_id != tenant_id:
|
||||
raise OrchestrationError("runtime context tenant_id mismatch")
|
||||
if context.principal_id != principal_id:
|
||||
raise OrchestrationError("runtime context principal_id mismatch")
|
||||
if context.trace_id != trace_id:
|
||||
raise OrchestrationError("runtime context trace_id mismatch")
|
||||
if self.hermes is None:
|
||||
raise OrchestrationError("Hermes runtime is not configured")
|
||||
job = self.hermes.get(job_id, tenant_id=tenant_id)
|
||||
if job.task_id != task_id or job.trace_id != trace_id:
|
||||
raise OrchestrationError("Hermes job identity mismatch")
|
||||
return dispatch, job
|
||||
|
||||
def bind_hermes_worker(
|
||||
self,
|
||||
context: RequestContext,
|
||||
job: HermesJob,
|
||||
*,
|
||||
dispatch_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
worker_id: str,
|
||||
) -> DispatchRecord:
|
||||
dispatch, current = self.hermes_lifecycle_context(
|
||||
context,
|
||||
dispatch_id=dispatch_id,
|
||||
job_id=job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
task_id=job.task_id,
|
||||
trace_id=job.trace_id,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if current.assigned_worker_id != worker_id:
|
||||
raise OrchestrationError("Hermes worker does not own claimed job")
|
||||
return self.dispatch_repository.update(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id=dispatch.tenant_id,
|
||||
expected_worker_id=worker_id,
|
||||
)
|
||||
|
||||
def reconcile_openclaw(
|
||||
self,
|
||||
context: RequestContext,
|
||||
result: OpenClawResult,
|
||||
*,
|
||||
dispatch_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
) -> TaskRecord:
|
||||
task = self.control_plane.get_task(context, result.task_id)
|
||||
if result.tenant_id != task.tenant_id:
|
||||
raise OrchestrationError("OpenClaw result tenant mismatch")
|
||||
dispatch = self.dispatch_repository.get(
|
||||
dispatch_id, tenant_id=context.tenant_id
|
||||
)
|
||||
self._assert_dispatch_context(
|
||||
dispatch,
|
||||
runtime="openclaw",
|
||||
runtime_execution_id=result.command_id,
|
||||
tenant_id=result.tenant_id,
|
||||
task_id=result.task_id,
|
||||
trace_id=result.trace_id,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
result_digest = digest_payload(
|
||||
{
|
||||
"command_id": result.command_id,
|
||||
"state": result.state.value,
|
||||
"output": result.output,
|
||||
"error": result.error,
|
||||
"runtime_id": result.runtime_id,
|
||||
"attempts": result.attempts,
|
||||
"finished_at": result.finished_at,
|
||||
}
|
||||
)
|
||||
if dispatch.state in TERMINAL_DISPATCH_STATES:
|
||||
if dispatch.result_digest != result_digest:
|
||||
raise DispatchConflict("terminal dispatch received a different result")
|
||||
return self.control_plane.get_task(context, dispatch.task_id)
|
||||
|
||||
task = self.control_plane.get_task(context, dispatch.task_id)
|
||||
if task.state != TaskState.RUNNING:
|
||||
return task
|
||||
raise OrchestrationError("task is not RUNNING for active dispatch")
|
||||
if result.state == CommandState.COMPLETED:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.COMPLETED,
|
||||
expected_revision=task.revision,
|
||||
result=result.output or {},
|
||||
)
|
||||
if result.state in {
|
||||
target = TaskState.COMPLETED
|
||||
values = {"result": result.output or {}}
|
||||
dispatch_state = DispatchState.COMPLETED
|
||||
elif result.state in {
|
||||
CommandState.FAILED,
|
||||
CommandState.TIMED_OUT,
|
||||
CommandState.REJECTED,
|
||||
}:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.FAILED,
|
||||
expected_revision=task.revision,
|
||||
error=result.error or {"code": result.state.value.lower()},
|
||||
target = TaskState.FAILED
|
||||
values = {
|
||||
"error": result.error or {"code": result.state.value.lower()}
|
||||
}
|
||||
dispatch_state = DispatchState.FAILED
|
||||
elif result.state == CommandState.CANCELLED:
|
||||
target = TaskState.CANCELLED
|
||||
values = {}
|
||||
dispatch_state = DispatchState.CANCELLED
|
||||
else:
|
||||
self.dispatch_repository.update(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id=dispatch.tenant_id,
|
||||
state=DispatchState.RUNNING,
|
||||
result_digest=result_digest,
|
||||
)
|
||||
if result.state == CommandState.CANCELLED:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.CANCELLED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
return task
|
||||
return task
|
||||
|
||||
updated_task = self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=target,
|
||||
expected_revision=task.revision,
|
||||
**values,
|
||||
)
|
||||
self.dispatch_repository.update(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id=dispatch.tenant_id,
|
||||
state=dispatch_state,
|
||||
result_digest=result_digest,
|
||||
)
|
||||
return updated_task
|
||||
|
||||
def reconcile_hermes(
|
||||
self,
|
||||
context: RequestContext,
|
||||
result: HermesResult,
|
||||
*,
|
||||
dispatch_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
) -> TaskRecord:
|
||||
task = self.control_plane.get_task(context, result.task_id)
|
||||
if result.tenant_id != task.tenant_id:
|
||||
raise OrchestrationError("Hermes result tenant mismatch")
|
||||
dispatch = self.dispatch_repository.get(
|
||||
dispatch_id, tenant_id=context.tenant_id
|
||||
)
|
||||
self._assert_dispatch_context(
|
||||
dispatch,
|
||||
runtime="hermes",
|
||||
runtime_execution_id=result.job_id,
|
||||
tenant_id=result.tenant_id,
|
||||
task_id=result.task_id,
|
||||
trace_id=result.trace_id,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if self.hermes is None:
|
||||
raise OrchestrationError("Hermes runtime is not configured")
|
||||
job = self.hermes.get(result.job_id, tenant_id=result.tenant_id)
|
||||
if result.state in {HermesJobState.COMPLETED, HermesJobState.FAILED}:
|
||||
if not result.worker_id:
|
||||
raise OrchestrationError("terminal Hermes result requires worker_id")
|
||||
if job.assigned_worker_id != result.worker_id:
|
||||
raise OrchestrationError("Hermes result worker does not own job lease")
|
||||
if (
|
||||
dispatch.expected_worker_id is not None
|
||||
and dispatch.expected_worker_id != result.worker_id
|
||||
):
|
||||
raise OrchestrationError("Hermes result worker mismatch")
|
||||
result_digest = digest_payload(
|
||||
{
|
||||
"job_id": result.job_id,
|
||||
"worker_id": result.worker_id,
|
||||
"state": result.state.value,
|
||||
"output": result.output,
|
||||
"error": result.error,
|
||||
"finished_at": result.finished_at,
|
||||
}
|
||||
)
|
||||
if dispatch.state in TERMINAL_DISPATCH_STATES:
|
||||
if dispatch.result_digest != result_digest:
|
||||
raise DispatchConflict("terminal dispatch received a different result")
|
||||
return self.control_plane.get_task(context, dispatch.task_id)
|
||||
|
||||
task = self.control_plane.get_task(context, dispatch.task_id)
|
||||
if task.state != TaskState.RUNNING:
|
||||
return task
|
||||
if result.state.value == "COMPLETED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.COMPLETED,
|
||||
expected_revision=task.revision,
|
||||
result=result.output or {},
|
||||
)
|
||||
if result.state.value == "FAILED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.FAILED,
|
||||
expected_revision=task.revision,
|
||||
error=result.error or {"code": "hermes_failed"},
|
||||
)
|
||||
if result.state.value == "CANCELLED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.CANCELLED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
return task
|
||||
raise OrchestrationError("task is not RUNNING for active dispatch")
|
||||
if result.state == HermesJobState.COMPLETED:
|
||||
target = TaskState.COMPLETED
|
||||
values = {"result": result.output or {}}
|
||||
dispatch_state = DispatchState.COMPLETED
|
||||
elif result.state == HermesJobState.FAILED:
|
||||
target = TaskState.FAILED
|
||||
values = {"error": result.error or {"code": "hermes_failed"}}
|
||||
dispatch_state = DispatchState.FAILED
|
||||
elif result.state == HermesJobState.CANCELLED:
|
||||
target = TaskState.CANCELLED
|
||||
values = {}
|
||||
dispatch_state = DispatchState.CANCELLED
|
||||
else:
|
||||
raise OrchestrationError("Hermes reconcile only accepts terminal results")
|
||||
|
||||
updated_task = self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=target,
|
||||
expected_revision=task.revision,
|
||||
**values,
|
||||
)
|
||||
self.dispatch_repository.update(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id=dispatch.tenant_id,
|
||||
state=dispatch_state,
|
||||
expected_worker_id=result.worker_id,
|
||||
result_digest=result_digest,
|
||||
)
|
||||
return updated_task
|
||||
|
||||
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
|
||||
return {
|
||||
task_id: record
|
||||
for (record_tenant, task_id), record in self._dispatches.items()
|
||||
if record_tenant == tenant_id
|
||||
item.task_id: item
|
||||
for item in self.dispatch_repository.list(tenant_id=tenant_id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
"""AIOS V2 可信 Run Provenance 签发、持久化与受控写入。
|
||||
|
||||
生产策略要求 Provenance 同时覆盖七类路径:代码变更、Git 操作、部署、
|
||||
Runtime 执行、Evidence 写入、回滚和审计。任何缺失、篡改、过期、撤销、
|
||||
重放或上下文不匹配都默认拒绝。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
PROVENANCE_VERSION = "aios.v2.run-provenance.v1"
|
||||
PRODUCTION_POLICY_VERSION = "aios.v2.production.v1"
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> datetime:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("timestamp is required")
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp must include timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def digest_payload(value: Mapping[str, Any]) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _validate_digest(value: str) -> str:
|
||||
normalized = required(value, "request_digest").lower()
|
||||
if len(normalized) != 64 or any(ch not in "0123456789abcdef" for ch in normalized):
|
||||
raise ValueError("request_digest must be a SHA-256 hexadecimal digest")
|
||||
return normalized
|
||||
|
||||
|
||||
class ExecutionPath(str, Enum):
|
||||
CODE_CHANGE = "code_change"
|
||||
GIT_OPERATION = "git_operation"
|
||||
DEPLOYMENT = "deployment"
|
||||
RUNTIME_EXECUTE = "runtime_execute"
|
||||
EVIDENCE_WRITE = "evidence_write"
|
||||
ROLLBACK = "rollback"
|
||||
AUDIT = "audit"
|
||||
|
||||
|
||||
REQUIRED_PRODUCTION_PATHS = frozenset(item.value for item in ExecutionPath)
|
||||
|
||||
|
||||
class GitOperation(str, Enum):
|
||||
READ_REF = "read_ref"
|
||||
FETCH = "fetch"
|
||||
CREATE_BRANCH = "create_branch"
|
||||
COMMIT = "commit"
|
||||
MERGE = "merge"
|
||||
PUSH = "push"
|
||||
UPDATE_EVIDENCE = "update_evidence"
|
||||
|
||||
|
||||
class ProvenanceStatus(str, Enum):
|
||||
ISSUED = "issued"
|
||||
VERIFIED = "verified"
|
||||
USED = "used"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class ProvenanceError(RuntimeError):
|
||||
"""Run Provenance 基础错误。"""
|
||||
|
||||
|
||||
class ProvenanceDenied(ProvenanceError):
|
||||
"""策略拒绝当前操作。"""
|
||||
|
||||
|
||||
class ProvenanceNotFound(ProvenanceDenied):
|
||||
"""未找到 Provenance。"""
|
||||
|
||||
|
||||
class ProvenanceSignatureError(ProvenanceDenied):
|
||||
"""签名无效。"""
|
||||
|
||||
|
||||
class ProvenanceExpired(ProvenanceDenied):
|
||||
"""Provenance 已过期。"""
|
||||
|
||||
|
||||
class ProvenanceRevoked(ProvenanceDenied):
|
||||
"""Provenance 已撤销。"""
|
||||
|
||||
|
||||
class ProvenanceReplay(ProvenanceDenied):
|
||||
"""检测到相同授权摘要重放。"""
|
||||
|
||||
|
||||
class Signer(Protocol):
|
||||
key_id: str
|
||||
|
||||
def sign(self, payload: bytes) -> str: ...
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
key_id: str
|
||||
fingerprint: str
|
||||
|
||||
def verify(self, payload: bytes, signature: str) -> bool: ...
|
||||
|
||||
|
||||
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
except ImportError as exc: # pragma: no cover - depends on deployment image
|
||||
raise RuntimeError(
|
||||
"cryptography package with Ed25519 support is required"
|
||||
) from exc
|
||||
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
|
||||
def _check_private_key_file(path: Path) -> bytes:
|
||||
resolved = path.resolve()
|
||||
metadata = resolved.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError("private key reference is not a regular file")
|
||||
if metadata.st_mode & 0o077:
|
||||
raise RuntimeError("private key file permissions must be 0600 or stricter")
|
||||
return resolved.read_bytes()
|
||||
|
||||
|
||||
def _check_public_key_file(path: Path) -> bytes:
|
||||
resolved = path.resolve()
|
||||
metadata = resolved.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError("public key reference is not a regular file")
|
||||
if metadata.st_mode & 0o022:
|
||||
raise RuntimeError("public key file must not be group/world writable")
|
||||
return resolved.read_bytes()
|
||||
|
||||
|
||||
class Ed25519PrivateKeySigner:
|
||||
"""Ed25519 Owner/Control Plane 签发器。"""
|
||||
|
||||
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||
self.private_key = private_key
|
||||
self.key_id = required(key_id, "key_id")
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PrivateKeySigner":
|
||||
serialization, private_cls, _ = _load_crypto()
|
||||
value = _check_private_key_file(Path(path))
|
||||
if value.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_private_key(value, password=None)
|
||||
elif len(value) == 32:
|
||||
key = private_cls.from_private_bytes(value)
|
||||
else:
|
||||
raise RuntimeError("private key must be PEM or a raw 32-byte Ed25519 seed")
|
||||
if not isinstance(key, private_cls):
|
||||
raise RuntimeError("private key is not Ed25519")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def sign(self, payload: bytes) -> str:
|
||||
return base64.b64encode(self.private_key.sign(bytes(payload))).decode("ascii")
|
||||
|
||||
def public_verifier(self) -> "Ed25519PublicKeyVerifier":
|
||||
return Ed25519PublicKeyVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||
|
||||
|
||||
class Ed25519PublicKeyVerifier:
|
||||
"""Ed25519 签名验证器。"""
|
||||
|
||||
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||
serialization, _, public_cls = _load_crypto()
|
||||
if not isinstance(public_key, public_cls):
|
||||
raise RuntimeError("public key is not Ed25519")
|
||||
self.public_key = public_key
|
||||
self.key_id = required(key_id, "key_id")
|
||||
raw = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PublicKeyVerifier":
|
||||
serialization, _, public_cls = _load_crypto()
|
||||
value = _check_public_key_file(Path(path))
|
||||
if value.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_public_key(value)
|
||||
elif len(value) == 32:
|
||||
key = public_cls.from_public_bytes(value)
|
||||
else:
|
||||
raise RuntimeError("public key must be PEM or raw 32-byte Ed25519 key")
|
||||
if not isinstance(key, public_cls):
|
||||
raise RuntimeError("public key is not Ed25519")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def verify(self, payload: bytes, signature: str) -> bool:
|
||||
try:
|
||||
decoded = base64.b64decode(required(signature, "signature"), validate=True)
|
||||
self.public_key.verify(decoded, bytes(payload))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunProvenance:
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
issuer: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
allowed_paths: Sequence[str]
|
||||
allowed_git_operations: Sequence[str]
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
policy_version: str
|
||||
key_id: str
|
||||
nonce: str
|
||||
signature: str = ""
|
||||
schema_version: str = PROVENANCE_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"issuer",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"agent_id",
|
||||
"principal_id",
|
||||
"issued_at",
|
||||
"expires_at",
|
||||
"policy_version",
|
||||
"key_id",
|
||||
"nonce",
|
||||
"schema_version",
|
||||
):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
if self.schema_version != PROVENANCE_VERSION:
|
||||
raise ValueError(f"unsupported schema_version: {self.schema_version}")
|
||||
issued = parse_timestamp(self.issued_at)
|
||||
expires = parse_timestamp(self.expires_at)
|
||||
if expires <= issued:
|
||||
raise ValueError("expires_at must be later than issued_at")
|
||||
paths = tuple(sorted({ExecutionPath(item).value for item in self.allowed_paths}))
|
||||
operations = tuple(
|
||||
sorted({GitOperation(item).value for item in self.allowed_git_operations})
|
||||
)
|
||||
object.__setattr__(self, "allowed_paths", paths)
|
||||
object.__setattr__(self, "allowed_git_operations", operations)
|
||||
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||
|
||||
def unsigned_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"provenance_id": self.provenance_id,
|
||||
"run_id": self.run_id,
|
||||
"issuer": self.issuer,
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": self.task_id,
|
||||
"agent_id": self.agent_id,
|
||||
"principal_id": self.principal_id,
|
||||
"allowed_paths": list(self.allowed_paths),
|
||||
"allowed_git_operations": list(self.allowed_git_operations),
|
||||
"issued_at": self.issued_at,
|
||||
"expires_at": self.expires_at,
|
||||
"policy_version": self.policy_version,
|
||||
"key_id": self.key_id,
|
||||
"nonce": self.nonce,
|
||||
}
|
||||
|
||||
def canonical_bytes(self) -> bytes:
|
||||
return canonical_json_bytes(self.unsigned_dict())
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = self.unsigned_dict()
|
||||
value["signature"] = self.signature
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "RunProvenance":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
class ProvenanceIssuer:
|
||||
"""只由 Owner 或 AIOS Control Plane 持有的签发服务。"""
|
||||
|
||||
def __init__(self, signer: Signer, store: "SQLiteProvenanceStore") -> None:
|
||||
self.signer = signer
|
||||
self.store = store
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
issuer: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
allowed_paths: Sequence[str] = tuple(REQUIRED_PRODUCTION_PATHS),
|
||||
allowed_git_operations: Sequence[str] = tuple(item.value for item in GitOperation),
|
||||
ttl_seconds: int = 3600,
|
||||
policy_version: str = PRODUCTION_POLICY_VERSION,
|
||||
provenance_id: str | None = None,
|
||||
nonce: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> RunProvenance:
|
||||
if ttl_seconds <= 0:
|
||||
raise ValueError("ttl_seconds must be positive")
|
||||
normalized_paths = frozenset(ExecutionPath(item).value for item in allowed_paths)
|
||||
if policy_version == PRODUCTION_POLICY_VERSION and normalized_paths != REQUIRED_PRODUCTION_PATHS:
|
||||
missing = sorted(REQUIRED_PRODUCTION_PATHS - normalized_paths)
|
||||
extra = sorted(normalized_paths - REQUIRED_PRODUCTION_PATHS)
|
||||
raise ProvenanceDenied(
|
||||
f"production provenance must cover exactly seven paths; missing={missing}, extra={extra}"
|
||||
)
|
||||
normalized_operations = tuple(
|
||||
sorted({GitOperation(item).value for item in allowed_git_operations})
|
||||
)
|
||||
if ExecutionPath.GIT_OPERATION.value in normalized_paths and not normalized_operations:
|
||||
raise ProvenanceDenied("git_operation path requires explicit allowed Git operations")
|
||||
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
unsigned = RunProvenance(
|
||||
provenance_id=provenance_id or f"prov_{uuid.uuid4().hex}",
|
||||
run_id=run_id,
|
||||
issuer=issuer,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
principal_id=principal_id,
|
||||
allowed_paths=tuple(normalized_paths),
|
||||
allowed_git_operations=normalized_operations,
|
||||
issued_at=issued.isoformat().replace("+00:00", "Z"),
|
||||
expires_at=(issued + timedelta(seconds=ttl_seconds))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
policy_version=policy_version,
|
||||
key_id=self.signer.key_id,
|
||||
nonce=nonce or uuid.uuid4().hex,
|
||||
)
|
||||
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
|
||||
self.store.save_issued(signed)
|
||||
return signed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenanceAuthorization:
|
||||
authorization_id: str
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
execution_path: str
|
||||
git_operation: str | None
|
||||
request_digest: str
|
||||
policy_version: str
|
||||
key_id: str
|
||||
public_key_fingerprint: str
|
||||
authorized_at: str = field(default_factory=utc_now)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class SQLiteProvenanceStore:
|
||||
"""持久化 Provenance、状态、授权使用和不可变事件。"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._connection = sqlite3.connect(
|
||||
str(self.path), timeout=30.0, check_same_thread=False
|
||||
)
|
||||
self._connection.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||
self._connection.execute("PRAGMA synchronous=FULL")
|
||||
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||
self._connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS provenance_records (
|
||||
provenance_id TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
signature_verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provenance_usage (
|
||||
authorization_id TEXT PRIMARY KEY,
|
||||
provenance_id TEXT NOT NULL,
|
||||
execution_path TEXT NOT NULL,
|
||||
git_operation TEXT NOT NULL DEFAULT '',
|
||||
request_digest TEXT NOT NULL,
|
||||
authorized_at TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
UNIQUE(provenance_id, execution_path, git_operation, request_digest),
|
||||
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provenance_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
provenance_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_provenance_usage_id
|
||||
ON provenance_usage(provenance_id, authorized_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_provenance_events_id
|
||||
ON provenance_events(provenance_id, created_at);
|
||||
"""
|
||||
)
|
||||
self._connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._connection.close()
|
||||
|
||||
def _event(
|
||||
self,
|
||||
provenance_id: str,
|
||||
event_type: str,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_events VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
f"pevt_{uuid.uuid4().hex}",
|
||||
provenance_id,
|
||||
event_type,
|
||||
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
def save_issued(self, provenance: RunProvenance) -> None:
|
||||
payload = json.dumps(
|
||||
provenance.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
now = utc_now()
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
prior = self._connection.execute(
|
||||
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
|
||||
(provenance.provenance_id,),
|
||||
).fetchone()
|
||||
if prior is not None:
|
||||
if prior["payload_json"] != payload:
|
||||
raise ProvenanceDenied("provenance_id reused with different payload")
|
||||
self._connection.rollback()
|
||||
return
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_records VALUES (?, ?, ?, 0, ?, ?)",
|
||||
(
|
||||
provenance.provenance_id,
|
||||
payload,
|
||||
ProvenanceStatus.ISSUED.value,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(provenance.provenance_id, "issued")
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def get(self, provenance_id: str) -> RunProvenance:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
|
||||
(required(provenance_id, "provenance_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProvenanceNotFound("provenance not found")
|
||||
value = json.loads(row["payload_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise ProvenanceError("stored provenance payload is invalid")
|
||||
return RunProvenance.from_dict(value)
|
||||
|
||||
def status(self, provenance_id: str) -> ProvenanceStatus:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT status FROM provenance_records WHERE provenance_id = ?",
|
||||
(required(provenance_id, "provenance_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProvenanceNotFound("provenance not found")
|
||||
return ProvenanceStatus(row["status"])
|
||||
|
||||
def mark_verified(self, provenance_id: str, *, fingerprint: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(provenance_id)
|
||||
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
|
||||
raise ProvenanceDenied(f"cannot verify provenance in state {current.value}")
|
||||
next_status = (
|
||||
ProvenanceStatus.USED.value
|
||||
if current == ProvenanceStatus.USED
|
||||
else ProvenanceStatus.VERIFIED.value
|
||||
)
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_records SET status = ?, signature_verified = 1, updated_at = ? WHERE provenance_id = ?",
|
||||
(next_status, utc_now(), provenance_id),
|
||||
)
|
||||
self._event(
|
||||
provenance_id,
|
||||
"verified",
|
||||
{"public_key_fingerprint": fingerprint},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def mark_expired(self, provenance_id: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(provenance_id)
|
||||
if current == ProvenanceStatus.REVOKED:
|
||||
self._connection.rollback()
|
||||
return
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||
(ProvenanceStatus.EXPIRED.value, utc_now(), provenance_id),
|
||||
)
|
||||
self._event(provenance_id, "expired")
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def revoke(self, provenance_id: str, *, reason: str, principal_id: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self.get(provenance_id)
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||
(ProvenanceStatus.REVOKED.value, utc_now(), provenance_id),
|
||||
)
|
||||
self._event(
|
||||
provenance_id,
|
||||
"revoked",
|
||||
{
|
||||
"reason": required(reason, "reason"),
|
||||
"principal_id": required(principal_id, "principal_id"),
|
||||
},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def record_authorization(self, authorization: ProvenanceAuthorization) -> None:
|
||||
receipt = json.dumps(
|
||||
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(authorization.provenance_id)
|
||||
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
|
||||
raise ProvenanceDenied(f"provenance state is {current.value}")
|
||||
try:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
authorization.authorization_id,
|
||||
authorization.provenance_id,
|
||||
authorization.execution_path,
|
||||
authorization.git_operation or "",
|
||||
authorization.request_digest,
|
||||
authorization.authorized_at,
|
||||
receipt,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ProvenanceReplay(
|
||||
"identical provenance path/operation/request digest already used"
|
||||
) from exc
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||
(
|
||||
ProvenanceStatus.USED.value,
|
||||
authorization.authorized_at,
|
||||
authorization.provenance_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
authorization.provenance_id,
|
||||
"used",
|
||||
{
|
||||
"authorization_id": authorization.authorization_id,
|
||||
"execution_path": authorization.execution_path,
|
||||
"git_operation": authorization.git_operation,
|
||||
"request_digest": authorization.request_digest,
|
||||
},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def authorization_count(self, provenance_id: str) -> int:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT COUNT(*) AS total FROM provenance_usage WHERE provenance_id = ?",
|
||||
(required(provenance_id, "provenance_id"),),
|
||||
).fetchone()
|
||||
return int(row["total"])
|
||||
|
||||
def events(self, provenance_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self._connection.execute(
|
||||
"SELECT event_type, details_json, created_at FROM provenance_events WHERE provenance_id = ? ORDER BY created_at, event_id",
|
||||
(required(provenance_id, "provenance_id"),),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_type": row["event_type"],
|
||||
"details": json.loads(row["details_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
class ProvenanceVerifier:
|
||||
"""验证 Owner 签名、上下文、路径、Git 操作、时效和重放。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: SQLiteProvenanceStore,
|
||||
verifiers: Mapping[str, SignatureVerifier],
|
||||
) -> None:
|
||||
if not verifiers:
|
||||
raise ValueError("at least one public key verifier is required")
|
||||
self.store = store
|
||||
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
*,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
execution_path: str,
|
||||
request_digest: str,
|
||||
git_operation: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> ProvenanceAuthorization:
|
||||
provenance = self.store.get(provenance_id)
|
||||
status = self.store.status(provenance.provenance_id)
|
||||
if status == ProvenanceStatus.REVOKED:
|
||||
raise ProvenanceRevoked("provenance is revoked")
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
if current >= parse_timestamp(provenance.expires_at):
|
||||
self.store.mark_expired(provenance.provenance_id)
|
||||
raise ProvenanceExpired("provenance is expired")
|
||||
verifier = self.verifiers.get(provenance.key_id)
|
||||
if verifier is None:
|
||||
raise ProvenanceSignatureError("no trusted public key for provenance key_id")
|
||||
if not provenance.signature or not verifier.verify(
|
||||
provenance.canonical_bytes(), provenance.signature
|
||||
):
|
||||
raise ProvenanceSignatureError("invalid provenance signature")
|
||||
expected = {
|
||||
"run_id": required(run_id, "run_id"),
|
||||
"tenant_id": required(tenant_id, "tenant_id"),
|
||||
"task_id": required(task_id, "task_id"),
|
||||
"agent_id": required(agent_id, "agent_id"),
|
||||
"principal_id": required(principal_id, "principal_id"),
|
||||
}
|
||||
for field_name, expected_value in expected.items():
|
||||
if getattr(provenance, field_name) != expected_value:
|
||||
raise ProvenanceDenied(f"provenance {field_name} mismatch")
|
||||
path = ExecutionPath(execution_path).value
|
||||
if path not in provenance.allowed_paths:
|
||||
raise ProvenanceDenied(f"execution path is not allowed: {path}")
|
||||
operation: str | None = None
|
||||
if path == ExecutionPath.GIT_OPERATION.value:
|
||||
if git_operation is None:
|
||||
raise ProvenanceDenied("git operation is required for git_operation path")
|
||||
operation = GitOperation(git_operation).value
|
||||
if operation not in provenance.allowed_git_operations:
|
||||
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
|
||||
elif git_operation is not None:
|
||||
raise ProvenanceDenied("git_operation is only valid for git_operation path")
|
||||
digest = _validate_digest(request_digest)
|
||||
self.store.mark_verified(
|
||||
provenance.provenance_id, fingerprint=verifier.fingerprint
|
||||
)
|
||||
authorization = ProvenanceAuthorization(
|
||||
authorization_id=f"pauth_{uuid.uuid4().hex}",
|
||||
provenance_id=provenance.provenance_id,
|
||||
run_id=provenance.run_id,
|
||||
tenant_id=provenance.tenant_id,
|
||||
task_id=provenance.task_id,
|
||||
agent_id=provenance.agent_id,
|
||||
principal_id=provenance.principal_id,
|
||||
execution_path=path,
|
||||
git_operation=operation,
|
||||
request_digest=digest,
|
||||
policy_version=provenance.policy_version,
|
||||
key_id=provenance.key_id,
|
||||
public_key_fingerprint=verifier.fingerprint,
|
||||
)
|
||||
self.store.record_authorization(authorization)
|
||||
return authorization
|
||||
|
||||
|
||||
class ProvenanceControlledWriter:
|
||||
"""七路径统一策略入口并持久写入授权回执。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: ProvenanceVerifier,
|
||||
receipt_root: str | Path,
|
||||
*,
|
||||
writer_id: str = "aios-v2-provenance-writer",
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.receipt_root = Path(receipt_root).resolve()
|
||||
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||
self.writer_id = required(writer_id, "writer_id")
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _component(value: str) -> str:
|
||||
normalized = required(value, "path component")
|
||||
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||
raise ProvenanceDenied("invalid receipt path component")
|
||||
return normalized
|
||||
|
||||
def authorize(self, **values: Any) -> ProvenanceAuthorization:
|
||||
authorization = self.verifier.authorize(**values)
|
||||
tenant = self._component(authorization.tenant_id)
|
||||
provenance_id = self._component(authorization.provenance_id)
|
||||
target_dir = self.receipt_root / tenant / provenance_id
|
||||
target = target_dir / f"{authorization.authorization_id}.json"
|
||||
payload = authorization.as_dict()
|
||||
payload["writer_id"] = self.writer_id
|
||||
with self._lock:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, target)
|
||||
return authorization
|
||||
|
||||
def authorize_code_change(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
|
||||
|
||||
def authorize_git_operation(
|
||||
self, *, git_operation: str, **values: Any
|
||||
) -> ProvenanceAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||
git_operation=git_operation,
|
||||
**values,
|
||||
)
|
||||
|
||||
def authorize_deployment(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
|
||||
|
||||
def authorize_runtime_execute(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
|
||||
|
||||
def authorize_evidence_write(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
|
||||
|
||||
def authorize_rollback(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
|
||||
|
||||
def authorize_audit(self, **values: Any) -> ProvenanceAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS V2 Run Provenance 与最小权限 Grant 运维 CLI。
|
||||
|
||||
私钥只用于签发;授权命令只加载一个与 ``--path`` 对应的 Grant。所有输出均为
|
||||
不含 Secret 的 JSON。旧 ``issue`` 仅为候选兼容,生产必须使用
|
||||
``issue-workflow`` 与 ``issue-grant``。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .provenance import (
|
||||
Ed25519PrivateKeySigner,
|
||||
Ed25519PublicKeyVerifier,
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
ProvenanceIssuer,
|
||||
SQLiteProvenanceStore,
|
||||
)
|
||||
from .provenance_grants import (
|
||||
GrantIssuer,
|
||||
GrantType,
|
||||
GrantVerifier,
|
||||
SQLiteGrantStore,
|
||||
WorkflowProvenanceIssuer,
|
||||
)
|
||||
from .provenance_operations import (
|
||||
OperationContext,
|
||||
ProvenanceOperationGate,
|
||||
context_from_environment,
|
||||
load_request_file,
|
||||
)
|
||||
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||
|
||||
|
||||
def _json_object(value: str | None, file_path: str | None) -> Mapping[str, Any]:
|
||||
if value and file_path:
|
||||
raise ValueError("request-json and request-file are mutually exclusive")
|
||||
if file_path:
|
||||
return load_request_file(file_path)
|
||||
parsed = json.loads(value or "{}")
|
||||
if not isinstance(parsed, Mapping):
|
||||
raise ValueError("request JSON must be an object")
|
||||
return dict(parsed)
|
||||
|
||||
|
||||
def _csv(value: str | None) -> tuple[str, ...]:
|
||||
return tuple(item.strip() for item in str(value or "").split(",") if item.strip())
|
||||
|
||||
|
||||
def _write_json(path: str | None, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||
if path:
|
||||
target = Path(path).resolve()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(encoded, encoding="utf-8")
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, target)
|
||||
print(encoded, end="")
|
||||
|
||||
|
||||
def _parent_store(args: argparse.Namespace) -> SQLiteProvenanceStore:
|
||||
return SQLiteProvenanceStore(args.store_file)
|
||||
|
||||
|
||||
def _grant_store(args: argparse.Namespace) -> SQLiteGrantStore:
|
||||
return SQLiteGrantStore(args.store_file)
|
||||
|
||||
|
||||
def _context(args: argparse.Namespace) -> OperationContext:
|
||||
if args.context_from_env:
|
||||
return context_from_environment()
|
||||
values = {
|
||||
"provenance_id": args.provenance_id,
|
||||
"run_id": args.run_id,
|
||||
"tenant_id": args.tenant_id,
|
||||
"task_id": args.task_id,
|
||||
"agent_id": args.agent_id,
|
||||
"principal_id": args.principal_id,
|
||||
}
|
||||
missing = [name for name, value in values.items() if not str(value or "").strip()]
|
||||
if missing:
|
||||
raise ValueError(f"missing context arguments: {missing}")
|
||||
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
|
||||
|
||||
|
||||
def command_issue_legacy(args: argparse.Namespace) -> int:
|
||||
store = _parent_store(args)
|
||||
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||
provenance = ProvenanceIssuer(signer, store).issue(
|
||||
run_id=args.run_id,
|
||||
issuer=args.issuer,
|
||||
tenant_id=args.tenant_id,
|
||||
task_id=args.task_id,
|
||||
agent_id=args.agent_id,
|
||||
principal_id=args.principal_id,
|
||||
allowed_paths=_csv(args.allowed_paths) or tuple(item.value for item in ExecutionPath),
|
||||
allowed_git_operations=_csv(args.allowed_git_operations)
|
||||
or tuple(item.value for item in GitOperation),
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
policy_version=args.policy_version,
|
||||
)
|
||||
_write_json(
|
||||
args.output,
|
||||
{
|
||||
"mode": "legacy-full-rights-candidate-only",
|
||||
"provenance": provenance.as_dict(),
|
||||
"status": store.status(provenance.provenance_id).value,
|
||||
"secret_value_stored": False,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_issue_workflow(args: argparse.Namespace) -> int:
|
||||
store = _parent_store(args)
|
||||
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||
workflow = WorkflowProvenanceIssuer(signer, store).issue(
|
||||
run_id=args.run_id,
|
||||
issuer=args.issuer,
|
||||
tenant_id=args.tenant_id,
|
||||
task_id=args.task_id,
|
||||
agent_id=args.agent_id,
|
||||
principal_id=args.principal_id,
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
)
|
||||
_write_json(
|
||||
args.output,
|
||||
{
|
||||
"mode": "least-privilege-workflow",
|
||||
"workflow_provenance": workflow.as_dict(),
|
||||
"status": store.status(workflow.provenance_id).value,
|
||||
"secret_value_stored": False,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_issue_grant(args: argparse.Namespace) -> int:
|
||||
parent_store = _parent_store(args)
|
||||
grant_store = _grant_store(args)
|
||||
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||
grant = GrantIssuer(signer, parent_store, grant_store).issue(
|
||||
parent_provenance_id=args.parent_provenance_id,
|
||||
grant_type=GrantType(args.grant_type),
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
allowed_git_operations=_csv(args.allowed_git_operations),
|
||||
)
|
||||
_write_json(
|
||||
args.output,
|
||||
{
|
||||
"grant": grant.as_dict(),
|
||||
"status": grant_store.status(grant.grant_id).value,
|
||||
"secret_value_stored": False,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _single_scope_writer(args: argparse.Namespace) -> ScopedLeastPrivilegeProvenanceWriter:
|
||||
path = ExecutionPath(args.path).value
|
||||
parent_store = _parent_store(args)
|
||||
grant_store = _grant_store(args)
|
||||
verifier = Ed25519PublicKeyVerifier.from_file(args.public_key_file, key_id=args.key_id)
|
||||
trusted = GrantVerifier(parent_store, grant_store, {args.key_id: verifier})
|
||||
return ScopedLeastPrivilegeProvenanceWriter(
|
||||
trusted,
|
||||
args.receipt_dir,
|
||||
{path: args.grant_id},
|
||||
required_paths=(path,),
|
||||
writer_id=f"aios-v2-cli-{path}",
|
||||
)
|
||||
|
||||
|
||||
def command_authorize(args: argparse.Namespace) -> int:
|
||||
writer = _single_scope_writer(args)
|
||||
context = _context(args)
|
||||
receipt = ProvenanceOperationGate(writer).authorize(
|
||||
context,
|
||||
execution_path=args.path,
|
||||
operation=args.operation,
|
||||
request=_json_object(args.request_json, args.request_file),
|
||||
)
|
||||
_write_json(args.output, {"authorization": receipt.as_dict()})
|
||||
return 0
|
||||
|
||||
|
||||
def command_revoke(args: argparse.Namespace) -> int:
|
||||
store = _parent_store(args)
|
||||
store.revoke(args.provenance_id, reason=args.reason, principal_id=args.principal_id)
|
||||
_write_json(
|
||||
args.output,
|
||||
{"provenance_id": args.provenance_id, "status": store.status(args.provenance_id).value},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_status(args: argparse.Namespace) -> int:
|
||||
store = _parent_store(args)
|
||||
provenance = store.get(args.provenance_id)
|
||||
grant_store = _grant_store(args)
|
||||
_write_json(
|
||||
args.output,
|
||||
{
|
||||
"provenance": provenance.as_dict(),
|
||||
"status": store.status(args.provenance_id).value,
|
||||
"authorization_count": store.authorization_count(args.provenance_id),
|
||||
"events": store.events(args.provenance_id),
|
||||
"grants": [item.as_dict() for item in grant_store.list_for_parent(args.provenance_id)],
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_grant_revoke(args: argparse.Namespace) -> int:
|
||||
store = _grant_store(args)
|
||||
store.revoke(args.grant_id, reason=args.reason, principal_id=args.principal_id)
|
||||
_write_json(
|
||||
args.output,
|
||||
{"grant_id": args.grant_id, "status": store.status(args.grant_id).value},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def command_grant_status(args: argparse.Namespace) -> int:
|
||||
store = _grant_store(args)
|
||||
grant = store.get(args.grant_id)
|
||||
_write_json(
|
||||
args.output,
|
||||
{"grant": grant.as_dict(), "status": store.status(args.grant_id).value},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def add_common_store(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--store-file",
|
||||
default=os.getenv(
|
||||
"AIOS_PROVENANCE_STORE_FILE",
|
||||
"/var/lib/aios-v2/run-provenance.sqlite3",
|
||||
),
|
||||
)
|
||||
parser.add_argument("--output", default=None)
|
||||
|
||||
|
||||
def add_signing_context(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--private-key-file", required=True)
|
||||
parser.add_argument("--key-id", required=True)
|
||||
|
||||
|
||||
def add_workflow_identity(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--issuer", required=True)
|
||||
parser.add_argument("--tenant-id", required=True)
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--agent-id", required=True)
|
||||
parser.add_argument("--principal-id", required=True)
|
||||
|
||||
|
||||
def add_context(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--context-from-env", action="store_true")
|
||||
parser.add_argument("--provenance-id")
|
||||
parser.add_argument("--run-id")
|
||||
parser.add_argument("--tenant-id")
|
||||
parser.add_argument("--task-id")
|
||||
parser.add_argument("--agent-id")
|
||||
parser.add_argument("--principal-id")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="AIOS V2 Run Provenance CLI")
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
issue = subcommands.add_parser("issue", help="候选兼容:签发全权限 Provenance;生产禁止")
|
||||
add_common_store(issue)
|
||||
add_signing_context(issue)
|
||||
add_workflow_identity(issue)
|
||||
issue.add_argument("--allowed-paths")
|
||||
issue.add_argument("--allowed-git-operations")
|
||||
issue.add_argument("--ttl-seconds", type=int, default=3600)
|
||||
issue.add_argument("--policy-version", default="aios.v2.candidate.legacy")
|
||||
issue.set_defaults(handler=command_issue_legacy)
|
||||
|
||||
workflow = subcommands.add_parser(
|
||||
"issue-workflow", help="签发不含操作权限的父 Workflow Provenance"
|
||||
)
|
||||
add_common_store(workflow)
|
||||
add_signing_context(workflow)
|
||||
add_workflow_identity(workflow)
|
||||
workflow.add_argument("--ttl-seconds", type=int, default=14400)
|
||||
workflow.set_defaults(handler=command_issue_workflow)
|
||||
|
||||
grant = subcommands.add_parser("issue-grant", help="签发单一路径最小权限 Grant")
|
||||
add_common_store(grant)
|
||||
add_signing_context(grant)
|
||||
grant.add_argument("--parent-provenance-id", required=True)
|
||||
grant.add_argument("--grant-type", choices=[item.value for item in GrantType], required=True)
|
||||
grant.add_argument("--allowed-git-operations")
|
||||
grant.add_argument("--ttl-seconds", type=int, default=900)
|
||||
grant.set_defaults(handler=command_issue_grant)
|
||||
|
||||
authorize = subcommands.add_parser("authorize", help="使用单一子 Grant 授权一次运维操作")
|
||||
add_common_store(authorize)
|
||||
add_context(authorize)
|
||||
authorize.add_argument("--public-key-file", required=True)
|
||||
authorize.add_argument("--key-id", required=True)
|
||||
authorize.add_argument("--grant-id", required=True)
|
||||
authorize.add_argument(
|
||||
"--receipt-dir",
|
||||
default=os.getenv(
|
||||
"AIOS_PROVENANCE_RECEIPT_DIR",
|
||||
"/var/lib/aios-v2/provenance-receipts",
|
||||
),
|
||||
)
|
||||
authorize.add_argument(
|
||||
"--path",
|
||||
required=True,
|
||||
choices=[
|
||||
ExecutionPath.CODE_CHANGE.value,
|
||||
ExecutionPath.DEPLOYMENT.value,
|
||||
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
ExecutionPath.EVIDENCE_WRITE.value,
|
||||
ExecutionPath.ROLLBACK.value,
|
||||
ExecutionPath.AUDIT.value,
|
||||
],
|
||||
)
|
||||
authorize.add_argument("--operation", required=True)
|
||||
request_group = authorize.add_mutually_exclusive_group()
|
||||
request_group.add_argument("--request-json")
|
||||
request_group.add_argument("--request-file")
|
||||
authorize.set_defaults(handler=command_authorize)
|
||||
|
||||
revoke = subcommands.add_parser("revoke", help="撤销父 Workflow Provenance")
|
||||
add_common_store(revoke)
|
||||
revoke.add_argument("--provenance-id", required=True)
|
||||
revoke.add_argument("--reason", required=True)
|
||||
revoke.add_argument("--principal-id", required=True)
|
||||
revoke.set_defaults(handler=command_revoke)
|
||||
|
||||
status = subcommands.add_parser("status", help="查询父 Workflow 与子 Grant")
|
||||
add_common_store(status)
|
||||
status.add_argument("--provenance-id", required=True)
|
||||
status.set_defaults(handler=command_status)
|
||||
|
||||
grant_revoke = subcommands.add_parser("grant-revoke", help="单独撤销子 Grant")
|
||||
add_common_store(grant_revoke)
|
||||
grant_revoke.add_argument("--grant-id", required=True)
|
||||
grant_revoke.add_argument("--reason", required=True)
|
||||
grant_revoke.add_argument("--principal-id", required=True)
|
||||
grant_revoke.set_defaults(handler=command_grant_revoke)
|
||||
|
||||
grant_status = subcommands.add_parser("grant-status", help="查询子 Grant 状态")
|
||||
add_common_store(grant_status)
|
||||
grant_status.add_argument("--grant-id", required=True)
|
||||
grant_status.set_defaults(handler=command_grant_status)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return int(args.handler(args))
|
||||
except Exception as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "denied", "error": type(exc).__name__, "message": str(exc)},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,416 @@
|
||||
"""受最小权限 Run Provenance Grant 约束的 Git 写入器。
|
||||
|
||||
所有 Git 操作都使用参数数组执行。对于 Commit 和 Evidence Commit,先计算
|
||||
工作树内容摘要并完成 Provenance 授权,之后才允许执行 ``git add``,避免未授权
|
||||
请求改变 Git Index。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .provenance import GitOperation, ProvenanceDenied, digest_payload, utc_now
|
||||
|
||||
_BRANCH_PATTERN = re.compile(r"^(?![-./])(?!.*\.\.)(?!.*//)[A-Za-z0-9._/-]{1,240}$")
|
||||
_SECRET_URL = re.compile(r"(?i)(https?://)([^/@\s]+)@")
|
||||
_TOKENISH = re.compile(r"(?i)(token|password|secret|authorization)([=:]\s*)([^\s]+)")
|
||||
|
||||
|
||||
class GitAuthorizationWriter(Protocol):
|
||||
def authorize_code_change(self, **values: Any) -> Any: ...
|
||||
|
||||
def authorize_git_operation(self, *, git_operation: str, **values: Any) -> Any: ...
|
||||
|
||||
|
||||
class ProvenanceGitError(RuntimeError):
|
||||
"""Git 操作被拒绝或执行失败。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitExecutionContext:
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitCommandResult:
|
||||
operation_id: str
|
||||
operation: str
|
||||
authorization_ids: Sequence[str]
|
||||
command: Sequence[str]
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
head_before: str | None
|
||||
head_after: str | None
|
||||
started_at: str
|
||||
completed_at: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ProvenanceGitWriter:
|
||||
"""执行经过单路径 Grant 授权的窄范围 Git 操作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: str | Path,
|
||||
writer: GitAuthorizationWriter,
|
||||
*,
|
||||
timeout_seconds: float = 120.0,
|
||||
output_limit: int = 16000,
|
||||
) -> None:
|
||||
self.repo = Path(repo).resolve()
|
||||
self.writer = writer
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self.output_limit = int(output_limit)
|
||||
if self.timeout_seconds <= 0 or self.output_limit < 1024:
|
||||
raise ValueError("invalid Git writer limits")
|
||||
self._run_raw(["rev-parse", "--is-inside-work-tree"], check=True)
|
||||
|
||||
@staticmethod
|
||||
def _validate_branch(branch: str) -> str:
|
||||
normalized = str(branch or "").strip()
|
||||
if not _BRANCH_PATTERN.fullmatch(normalized) or normalized.endswith((".", "/")):
|
||||
raise ProvenanceGitError("invalid Git branch name")
|
||||
if normalized in {"main", "master"}:
|
||||
raise ProvenanceGitError("direct main/master operation is prohibited")
|
||||
return normalized
|
||||
|
||||
def _validate_path(self, value: str | Path) -> str:
|
||||
raw = Path(value)
|
||||
candidate = (self.repo / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
||||
try:
|
||||
relative = candidate.relative_to(self.repo)
|
||||
except ValueError as exc:
|
||||
raise ProvenanceGitError("Git path escapes repository") from exc
|
||||
if str(relative) in {"", "."}:
|
||||
raise ProvenanceGitError("repository root is not a valid file path")
|
||||
return relative.as_posix()
|
||||
|
||||
@staticmethod
|
||||
def _redact(value: str, limit: int) -> str:
|
||||
text = _SECRET_URL.sub(r"\1***@", str(value or ""))
|
||||
text = _TOKENISH.sub(r"\1\2***", text)
|
||||
if len(text) > limit:
|
||||
return text[:limit] + "\n...[truncated]"
|
||||
return text
|
||||
|
||||
def _head(self) -> str | None:
|
||||
process = self._run_raw(["rev-parse", "HEAD"], check=False)
|
||||
return process.stdout.strip() if process.returncode == 0 else None
|
||||
|
||||
def _run_raw(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
check: bool,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = ["git", "-C", str(self.repo), *[str(item) for item in args]]
|
||||
safe_env = {
|
||||
**os.environ,
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_CONFIG_NOSYSTEM": os.environ.get("GIT_CONFIG_NOSYSTEM", "1"),
|
||||
}
|
||||
if env:
|
||||
safe_env.update({str(key): str(value) for key, value in env.items()})
|
||||
process = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
env=safe_env,
|
||||
)
|
||||
if check and process.returncode != 0:
|
||||
raise ProvenanceGitError(
|
||||
self._redact(process.stderr or process.stdout, self.output_limit)
|
||||
or f"Git command failed with exit code {process.returncode}"
|
||||
)
|
||||
return process
|
||||
|
||||
def _path_digest(self, relative: str) -> str:
|
||||
target = self.repo / relative
|
||||
if target.exists():
|
||||
if not target.is_file():
|
||||
raise ProvenanceGitError("Git writer accepts file paths only")
|
||||
hasher = hashlib.sha256()
|
||||
with target.open("rb") as handle:
|
||||
while True:
|
||||
block = handle.read(65536)
|
||||
if not block:
|
||||
break
|
||||
hasher.update(block)
|
||||
return hasher.hexdigest()
|
||||
tracked = self._run_raw(["ls-files", "--error-unmatch", "--", relative], check=False)
|
||||
if tracked.returncode != 0:
|
||||
raise ProvenanceGitError(f"path does not exist and is not tracked: {relative}")
|
||||
return "deleted"
|
||||
|
||||
def _content_manifest(self, paths: Sequence[str]) -> Mapping[str, str]:
|
||||
return {path: self._path_digest(path) for path in sorted(set(paths))}
|
||||
|
||||
def _authorize(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
operation: GitOperation,
|
||||
request: Mapping[str, Any],
|
||||
code_change: bool,
|
||||
) -> list[str]:
|
||||
request_digest = digest_payload(
|
||||
{
|
||||
"operation": operation.value,
|
||||
"repo": self.repo.name,
|
||||
"request": dict(request),
|
||||
"head": self._head(),
|
||||
}
|
||||
)
|
||||
ids: list[str] = []
|
||||
try:
|
||||
if code_change:
|
||||
code_authorization = self.writer.authorize_code_change(
|
||||
provenance_id=context.provenance_id,
|
||||
run_id=context.run_id,
|
||||
tenant_id=context.tenant_id,
|
||||
task_id=context.task_id,
|
||||
agent_id=context.agent_id,
|
||||
principal_id=context.principal_id,
|
||||
request_digest=digest_payload(
|
||||
{
|
||||
"git_operation": operation.value,
|
||||
"request_digest": request_digest,
|
||||
}
|
||||
),
|
||||
)
|
||||
ids.append(code_authorization.authorization_id)
|
||||
git_authorization = self.writer.authorize_git_operation(
|
||||
provenance_id=context.provenance_id,
|
||||
run_id=context.run_id,
|
||||
tenant_id=context.tenant_id,
|
||||
task_id=context.task_id,
|
||||
agent_id=context.agent_id,
|
||||
principal_id=context.principal_id,
|
||||
git_operation=operation.value,
|
||||
request_digest=request_digest,
|
||||
)
|
||||
ids.append(git_authorization.authorization_id)
|
||||
return ids
|
||||
except ProvenanceDenied as exc:
|
||||
raise ProvenanceGitError(f"Run Provenance denied Git operation: {exc}") from exc
|
||||
|
||||
def _run_authorized(
|
||||
self,
|
||||
*,
|
||||
operation: GitOperation,
|
||||
args: Sequence[str],
|
||||
authorization_ids: Sequence[str],
|
||||
) -> GitCommandResult:
|
||||
started = utc_now()
|
||||
before = self._head()
|
||||
process = self._run_raw(args, check=False)
|
||||
after = self._head()
|
||||
result = GitCommandResult(
|
||||
operation_id=f"gitop_{uuid.uuid4().hex}",
|
||||
operation=operation.value,
|
||||
authorization_ids=tuple(authorization_ids),
|
||||
command=tuple(["git", "-C", "<repo>", *[str(item) for item in args]]),
|
||||
exit_code=int(process.returncode),
|
||||
stdout=self._redact(process.stdout, self.output_limit),
|
||||
stderr=self._redact(process.stderr, self.output_limit),
|
||||
head_before=before,
|
||||
head_after=after,
|
||||
started_at=started,
|
||||
completed_at=utc_now(),
|
||||
)
|
||||
if process.returncode != 0:
|
||||
raise ProvenanceGitError(
|
||||
result.stderr or result.stdout or f"Git {operation.value} failed"
|
||||
)
|
||||
return result
|
||||
|
||||
def _execute(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
operation: GitOperation,
|
||||
args: Sequence[str],
|
||||
request: Mapping[str, Any],
|
||||
code_change: bool,
|
||||
) -> GitCommandResult:
|
||||
authorization_ids = self._authorize(
|
||||
context,
|
||||
operation=operation,
|
||||
request=request,
|
||||
code_change=code_change,
|
||||
)
|
||||
return self._run_authorized(
|
||||
operation=operation,
|
||||
args=args,
|
||||
authorization_ids=authorization_ids,
|
||||
)
|
||||
|
||||
def read_ref(self, context: GitExecutionContext, ref: str) -> GitCommandResult:
|
||||
normalized = str(ref or "").strip()
|
||||
if not normalized or any(ch.isspace() for ch in normalized):
|
||||
raise ProvenanceGitError("invalid Git ref")
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.READ_REF,
|
||||
args=["rev-parse", "--verify", normalized],
|
||||
request={"ref": normalized},
|
||||
code_change=False,
|
||||
)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
remote: str,
|
||||
refspecs: Sequence[str],
|
||||
) -> GitCommandResult:
|
||||
normalized_remote = str(remote or "").strip()
|
||||
normalized_refspecs = tuple(str(item).strip() for item in refspecs if str(item).strip())
|
||||
if not normalized_remote or not normalized_refspecs:
|
||||
raise ProvenanceGitError("remote and refspecs are required")
|
||||
if any(item.startswith("+") and ":refs/heads/" in item for item in normalized_refspecs):
|
||||
raise ProvenanceGitError(
|
||||
"fetching directly into local branch refs is prohibited; use remote-tracking refs"
|
||||
)
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.FETCH,
|
||||
args=["fetch", "--prune", normalized_remote, *normalized_refspecs],
|
||||
request={"remote": normalized_remote, "refspecs": normalized_refspecs},
|
||||
code_change=False,
|
||||
)
|
||||
|
||||
def create_branch(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
branch: str,
|
||||
start_point: str,
|
||||
) -> GitCommandResult:
|
||||
normalized = self._validate_branch(branch)
|
||||
start = str(start_point or "").strip()
|
||||
if not start:
|
||||
raise ProvenanceGitError("start_point is required")
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.CREATE_BRANCH,
|
||||
args=["branch", normalized, start],
|
||||
request={"branch": normalized, "start_point": start},
|
||||
code_change=False,
|
||||
)
|
||||
|
||||
def commit(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
paths: Sequence[str | Path],
|
||||
message: str,
|
||||
) -> GitCommandResult:
|
||||
normalized_paths = tuple(self._validate_path(item) for item in paths)
|
||||
normalized_message = str(message or "").strip()
|
||||
if not normalized_paths or not normalized_message:
|
||||
raise ProvenanceGitError("commit paths and message are required")
|
||||
if len(normalized_message) > 500:
|
||||
raise ProvenanceGitError("commit message is too long")
|
||||
request = {
|
||||
"paths": normalized_paths,
|
||||
"message": normalized_message,
|
||||
"content_manifest": self._content_manifest(normalized_paths),
|
||||
}
|
||||
authorization_ids = self._authorize(
|
||||
context,
|
||||
operation=GitOperation.COMMIT,
|
||||
request=request,
|
||||
code_change=True,
|
||||
)
|
||||
self._run_raw(["add", "--", *normalized_paths], check=True)
|
||||
return self._run_authorized(
|
||||
operation=GitOperation.COMMIT,
|
||||
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
|
||||
authorization_ids=authorization_ids,
|
||||
)
|
||||
|
||||
def merge(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
source_ref: str,
|
||||
message: str,
|
||||
) -> GitCommandResult:
|
||||
source = str(source_ref or "").strip()
|
||||
normalized_message = str(message or "").strip()
|
||||
if not source or not normalized_message:
|
||||
raise ProvenanceGitError("source_ref and message are required")
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.MERGE,
|
||||
args=["merge", "--no-ff", "--no-edit", "-m", normalized_message, source],
|
||||
request={"source_ref": source, "message": normalized_message},
|
||||
code_change=True,
|
||||
)
|
||||
|
||||
def push(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
remote: str,
|
||||
branch: str,
|
||||
) -> GitCommandResult:
|
||||
normalized_remote = str(remote or "").strip()
|
||||
normalized_branch = self._validate_branch(branch)
|
||||
if not normalized_remote:
|
||||
raise ProvenanceGitError("remote is required")
|
||||
return self._execute(
|
||||
context,
|
||||
operation=GitOperation.PUSH,
|
||||
args=["push", normalized_remote, f"refs/heads/{normalized_branch}:refs/heads/{normalized_branch}"],
|
||||
request={"remote": normalized_remote, "branch": normalized_branch},
|
||||
code_change=False,
|
||||
)
|
||||
|
||||
def update_evidence(
|
||||
self,
|
||||
context: GitExecutionContext,
|
||||
*,
|
||||
paths: Sequence[str | Path],
|
||||
message: str,
|
||||
) -> GitCommandResult:
|
||||
normalized_paths = tuple(self._validate_path(item) for item in paths)
|
||||
normalized_message = str(message or "").strip()
|
||||
if not normalized_paths or not normalized_message:
|
||||
raise ProvenanceGitError("evidence paths and message are required")
|
||||
if any(not path.startswith(".agent/") for path in normalized_paths):
|
||||
raise ProvenanceGitError("UPDATE_EVIDENCE may only commit .agent/ paths")
|
||||
request = {
|
||||
"paths": normalized_paths,
|
||||
"message": normalized_message,
|
||||
"content_manifest": self._content_manifest(normalized_paths),
|
||||
}
|
||||
authorization_ids = self._authorize(
|
||||
context,
|
||||
operation=GitOperation.UPDATE_EVIDENCE,
|
||||
request=request,
|
||||
code_change=False,
|
||||
)
|
||||
self._run_raw(["add", "--", *normalized_paths], check=True)
|
||||
return self._run_authorized(
|
||||
operation=GitOperation.UPDATE_EVIDENCE,
|
||||
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
|
||||
authorization_ids=authorization_ids,
|
||||
)
|
||||
@@ -0,0 +1,744 @@
|
||||
"""AIOS V2 最小权限 Run Provenance 子 Grant。
|
||||
|
||||
父 Workflow Provenance 只绑定 run/tenant/task/agent/principal 身份,不直接授予
|
||||
操作权限。每个子 Grant 只允许一个 ExecutionPath;Git Grant 还必须限定具体
|
||||
GitOperation。子 Grant 具备独立 TTL、nonce、状态、撤销和重放防护。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .provenance import (
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
ProvenanceDenied,
|
||||
ProvenanceExpired,
|
||||
ProvenanceIssuer,
|
||||
ProvenanceNotFound,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceSignatureError,
|
||||
ProvenanceStatus,
|
||||
ProvenanceVerifier,
|
||||
RunProvenance,
|
||||
SQLiteProvenanceStore,
|
||||
Signer,
|
||||
SignatureVerifier,
|
||||
_validate_digest,
|
||||
canonical_json_bytes,
|
||||
parse_timestamp,
|
||||
required,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
WORKFLOW_POLICY_VERSION = "aios.v2.workflow.v1"
|
||||
GRANT_SCHEMA_VERSION = "aios.v2.provenance-grant.v1"
|
||||
|
||||
|
||||
class GrantType(str, Enum):
|
||||
CODE_CHANGE = "code_change"
|
||||
GIT = "git"
|
||||
DEPLOYMENT = "deployment"
|
||||
RUNTIME = "runtime"
|
||||
EVIDENCE = "evidence"
|
||||
ROLLBACK = "rollback"
|
||||
AUDIT = "audit"
|
||||
|
||||
|
||||
GRANT_PATHS: Mapping[GrantType, ExecutionPath] = {
|
||||
GrantType.CODE_CHANGE: ExecutionPath.CODE_CHANGE,
|
||||
GrantType.GIT: ExecutionPath.GIT_OPERATION,
|
||||
GrantType.DEPLOYMENT: ExecutionPath.DEPLOYMENT,
|
||||
GrantType.RUNTIME: ExecutionPath.RUNTIME_EXECUTE,
|
||||
GrantType.EVIDENCE: ExecutionPath.EVIDENCE_WRITE,
|
||||
GrantType.ROLLBACK: ExecutionPath.ROLLBACK,
|
||||
GrantType.AUDIT: ExecutionPath.AUDIT,
|
||||
}
|
||||
|
||||
|
||||
class GrantStatus(str, Enum):
|
||||
ISSUED = "issued"
|
||||
VERIFIED = "verified"
|
||||
USED = "used"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenanceGrant:
|
||||
grant_id: str
|
||||
parent_provenance_id: str
|
||||
grant_type: GrantType
|
||||
execution_path: ExecutionPath
|
||||
run_id: str
|
||||
issuer: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
allowed_git_operations: Sequence[str]
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
policy_version: str
|
||||
key_id: str
|
||||
nonce: str
|
||||
signature: str = ""
|
||||
schema_version: str = GRANT_SCHEMA_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"grant_id",
|
||||
"parent_provenance_id",
|
||||
"run_id",
|
||||
"issuer",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"agent_id",
|
||||
"principal_id",
|
||||
"issued_at",
|
||||
"expires_at",
|
||||
"policy_version",
|
||||
"key_id",
|
||||
"nonce",
|
||||
"schema_version",
|
||||
):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
if self.schema_version != GRANT_SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported grant schema: {self.schema_version}")
|
||||
grant_type = GrantType(self.grant_type)
|
||||
path = ExecutionPath(self.execution_path)
|
||||
if GRANT_PATHS[grant_type] != path:
|
||||
raise ValueError("grant_type does not match execution_path")
|
||||
object.__setattr__(self, "grant_type", grant_type)
|
||||
object.__setattr__(self, "execution_path", path)
|
||||
operations = tuple(
|
||||
sorted({GitOperation(item).value for item in self.allowed_git_operations})
|
||||
)
|
||||
if grant_type == GrantType.GIT and not operations:
|
||||
raise ValueError("Git Grant requires explicit Git operations")
|
||||
if grant_type != GrantType.GIT and operations:
|
||||
raise ValueError("only Git Grant may contain Git operations")
|
||||
object.__setattr__(self, "allowed_git_operations", operations)
|
||||
issued = parse_timestamp(self.issued_at)
|
||||
expires = parse_timestamp(self.expires_at)
|
||||
if expires <= issued:
|
||||
raise ValueError("grant expires_at must be later than issued_at")
|
||||
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||
|
||||
def unsigned_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"grant_id": self.grant_id,
|
||||
"parent_provenance_id": self.parent_provenance_id,
|
||||
"grant_type": self.grant_type.value,
|
||||
"execution_path": self.execution_path.value,
|
||||
"run_id": self.run_id,
|
||||
"issuer": self.issuer,
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": self.task_id,
|
||||
"agent_id": self.agent_id,
|
||||
"principal_id": self.principal_id,
|
||||
"allowed_git_operations": list(self.allowed_git_operations),
|
||||
"issued_at": self.issued_at,
|
||||
"expires_at": self.expires_at,
|
||||
"policy_version": self.policy_version,
|
||||
"key_id": self.key_id,
|
||||
"nonce": self.nonce,
|
||||
}
|
||||
|
||||
def canonical_bytes(self) -> bytes:
|
||||
return canonical_json_bytes(self.unsigned_dict())
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = self.unsigned_dict()
|
||||
value["signature"] = self.signature
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ProvenanceGrant":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrantAuthorization:
|
||||
authorization_id: str
|
||||
grant_id: str
|
||||
parent_provenance_id: str
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
execution_path: str
|
||||
git_operation: str | None
|
||||
request_digest: str
|
||||
policy_version: str
|
||||
key_id: str
|
||||
public_key_fingerprint: str
|
||||
authorized_at: str = field(default_factory=utc_now)
|
||||
|
||||
@property
|
||||
def provenance_id(self) -> str:
|
||||
return self.parent_provenance_id
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class WorkflowProvenanceIssuer:
|
||||
"""签发不携带操作权限的父 Workflow Provenance。"""
|
||||
|
||||
def __init__(self, signer: Signer, store: SQLiteProvenanceStore) -> None:
|
||||
self._issuer = ProvenanceIssuer(signer, store)
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
issuer: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
ttl_seconds: int = 14400,
|
||||
provenance_id: str | None = None,
|
||||
nonce: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> RunProvenance:
|
||||
return self._issuer.issue(
|
||||
run_id=run_id,
|
||||
issuer=issuer,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
principal_id=principal_id,
|
||||
allowed_paths=(),
|
||||
allowed_git_operations=(),
|
||||
ttl_seconds=ttl_seconds,
|
||||
policy_version=WORKFLOW_POLICY_VERSION,
|
||||
provenance_id=provenance_id,
|
||||
nonce=nonce,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
class SQLiteGrantStore:
|
||||
"""在 Provenance SQLite 数据库中持久化子 Grant 与使用记录。"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._connection = sqlite3.connect(
|
||||
str(self.path), timeout=30.0, check_same_thread=False
|
||||
)
|
||||
self._connection.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||
self._connection.execute("PRAGMA synchronous=FULL")
|
||||
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||
self._connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS provenance_grants (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
parent_provenance_id TEXT NOT NULL,
|
||||
grant_type TEXT NOT NULL,
|
||||
execution_path TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
signature_verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grants_parent
|
||||
ON provenance_grants(parent_provenance_id, grant_type, status);
|
||||
CREATE TABLE IF NOT EXISTS provenance_grant_usage (
|
||||
authorization_id TEXT PRIMARY KEY,
|
||||
grant_id TEXT NOT NULL,
|
||||
execution_path TEXT NOT NULL,
|
||||
git_operation TEXT NOT NULL DEFAULT '',
|
||||
request_digest TEXT NOT NULL,
|
||||
authorized_at TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
UNIQUE(grant_id, execution_path, git_operation, request_digest),
|
||||
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provenance_grant_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
grant_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._connection.close()
|
||||
|
||||
def _event(
|
||||
self,
|
||||
grant_id: str,
|
||||
event_type: str,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_grant_events VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
f"gevt_{uuid.uuid4().hex}",
|
||||
grant_id,
|
||||
event_type,
|
||||
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
def save_issued(self, grant: ProvenanceGrant) -> None:
|
||||
payload = json.dumps(
|
||||
grant.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
now = utc_now()
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_grants VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
(
|
||||
grant.grant_id,
|
||||
grant.parent_provenance_id,
|
||||
grant.grant_type.value,
|
||||
grant.execution_path.value,
|
||||
payload,
|
||||
GrantStatus.ISSUED.value,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(grant.grant_id, "issued")
|
||||
self._connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
self._connection.rollback()
|
||||
raise ProvenanceDenied("grant_id already exists") from exc
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def get(self, grant_id: str) -> ProvenanceGrant:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT payload_json FROM provenance_grants WHERE grant_id = ?",
|
||||
(required(grant_id, "grant_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProvenanceNotFound("provenance grant not found")
|
||||
value = json.loads(row["payload_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise ProvenanceDenied("stored grant is invalid")
|
||||
return ProvenanceGrant.from_dict(value)
|
||||
|
||||
def status(self, grant_id: str) -> GrantStatus:
|
||||
with self._lock:
|
||||
row = self._connection.execute(
|
||||
"SELECT status FROM provenance_grants WHERE grant_id = ?",
|
||||
(required(grant_id, "grant_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProvenanceNotFound("provenance grant not found")
|
||||
return GrantStatus(row["status"])
|
||||
|
||||
def mark_verified(self, grant_id: str, *, fingerprint: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(grant_id)
|
||||
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
|
||||
raise ProvenanceDenied(f"cannot verify grant in state {current.value}")
|
||||
next_status = (
|
||||
GrantStatus.USED.value
|
||||
if current == GrantStatus.USED
|
||||
else GrantStatus.VERIFIED.value
|
||||
)
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_grants SET status = ?, signature_verified = 1, updated_at = ? WHERE grant_id = ?",
|
||||
(next_status, utc_now(), grant_id),
|
||||
)
|
||||
self._event(
|
||||
grant_id,
|
||||
"verified",
|
||||
{"public_key_fingerprint": fingerprint},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def mark_expired(self, grant_id: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
if self.status(grant_id) == GrantStatus.REVOKED:
|
||||
self._connection.rollback()
|
||||
return
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||
(GrantStatus.EXPIRED.value, utc_now(), grant_id),
|
||||
)
|
||||
self._event(grant_id, "expired")
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def revoke(self, grant_id: str, *, reason: str, principal_id: str) -> None:
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self.get(grant_id)
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||
(GrantStatus.REVOKED.value, utc_now(), grant_id),
|
||||
)
|
||||
self._event(
|
||||
grant_id,
|
||||
"revoked",
|
||||
{
|
||||
"reason": required(reason, "reason"),
|
||||
"principal_id": required(principal_id, "principal_id"),
|
||||
},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def record_authorization(self, authorization: GrantAuthorization) -> None:
|
||||
receipt = json.dumps(
|
||||
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
with self._lock:
|
||||
self._connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.status(authorization.grant_id)
|
||||
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
|
||||
raise ProvenanceDenied(f"grant state is {current.value}")
|
||||
try:
|
||||
self._connection.execute(
|
||||
"INSERT INTO provenance_grant_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
authorization.authorization_id,
|
||||
authorization.grant_id,
|
||||
authorization.execution_path,
|
||||
authorization.git_operation or "",
|
||||
authorization.request_digest,
|
||||
authorization.authorized_at,
|
||||
receipt,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ProvenanceReplay(
|
||||
"identical grant path/operation/request digest already used"
|
||||
) from exc
|
||||
self._connection.execute(
|
||||
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||
(GrantStatus.USED.value, authorization.authorized_at, authorization.grant_id),
|
||||
)
|
||||
self._event(
|
||||
authorization.grant_id,
|
||||
"used",
|
||||
{
|
||||
"authorization_id": authorization.authorization_id,
|
||||
"execution_path": authorization.execution_path,
|
||||
"git_operation": authorization.git_operation,
|
||||
"request_digest": authorization.request_digest,
|
||||
},
|
||||
)
|
||||
self._connection.commit()
|
||||
except Exception:
|
||||
self._connection.rollback()
|
||||
raise
|
||||
|
||||
def list_for_parent(self, parent_provenance_id: str) -> list[ProvenanceGrant]:
|
||||
with self._lock:
|
||||
rows = self._connection.execute(
|
||||
"SELECT payload_json FROM provenance_grants WHERE parent_provenance_id = ? ORDER BY grant_type, grant_id",
|
||||
(required(parent_provenance_id, "parent_provenance_id"),),
|
||||
).fetchall()
|
||||
return [ProvenanceGrant.from_dict(json.loads(row["payload_json"])) for row in rows]
|
||||
|
||||
|
||||
class GrantIssuer:
|
||||
"""从父 Workflow Provenance 派生单一路径最小权限 Grant。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
signer: Signer,
|
||||
parent_store: SQLiteProvenanceStore,
|
||||
grant_store: SQLiteGrantStore,
|
||||
) -> None:
|
||||
self.signer = signer
|
||||
self.parent_store = parent_store
|
||||
self.grant_store = grant_store
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
parent_provenance_id: str,
|
||||
grant_type: GrantType | str,
|
||||
ttl_seconds: int = 900,
|
||||
allowed_git_operations: Sequence[str] = (),
|
||||
grant_id: str | None = None,
|
||||
nonce: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> ProvenanceGrant:
|
||||
if ttl_seconds <= 0:
|
||||
raise ValueError("grant ttl_seconds must be positive")
|
||||
parent = self.parent_store.get(parent_provenance_id)
|
||||
if parent.policy_version != WORKFLOW_POLICY_VERSION:
|
||||
raise ProvenanceDenied("child grants require a workflow provenance parent")
|
||||
if parent.allowed_paths or parent.allowed_git_operations:
|
||||
raise ProvenanceDenied("workflow provenance parent must not contain operation rights")
|
||||
parent_status = self.parent_store.status(parent.provenance_id)
|
||||
if parent_status == ProvenanceStatus.REVOKED:
|
||||
raise ProvenanceRevoked("workflow provenance is revoked")
|
||||
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
parent_expiry = parse_timestamp(parent.expires_at)
|
||||
expires = min(issued + timedelta(seconds=ttl_seconds), parent_expiry)
|
||||
if expires <= issued:
|
||||
raise ProvenanceExpired("workflow provenance has expired")
|
||||
normalized_type = GrantType(grant_type)
|
||||
operations = tuple(allowed_git_operations)
|
||||
unsigned = ProvenanceGrant(
|
||||
grant_id=grant_id or f"grant_{uuid.uuid4().hex}",
|
||||
parent_provenance_id=parent.provenance_id,
|
||||
grant_type=normalized_type,
|
||||
execution_path=GRANT_PATHS[normalized_type],
|
||||
run_id=parent.run_id,
|
||||
issuer=parent.issuer,
|
||||
tenant_id=parent.tenant_id,
|
||||
task_id=parent.task_id,
|
||||
agent_id=parent.agent_id,
|
||||
principal_id=parent.principal_id,
|
||||
allowed_git_operations=operations,
|
||||
issued_at=issued.isoformat().replace("+00:00", "Z"),
|
||||
expires_at=expires.isoformat().replace("+00:00", "Z"),
|
||||
policy_version=f"{WORKFLOW_POLICY_VERSION}.{normalized_type.value}",
|
||||
key_id=self.signer.key_id,
|
||||
nonce=nonce or uuid.uuid4().hex,
|
||||
)
|
||||
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
|
||||
self.grant_store.save_issued(signed)
|
||||
return signed
|
||||
|
||||
|
||||
class GrantVerifier:
|
||||
"""验证父 Workflow 与子 Grant,确保单一路径最小权限。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent_store: SQLiteProvenanceStore,
|
||||
grant_store: SQLiteGrantStore,
|
||||
verifiers: Mapping[str, SignatureVerifier],
|
||||
) -> None:
|
||||
if not verifiers:
|
||||
raise ValueError("at least one trusted verifier is required")
|
||||
self.parent_store = parent_store
|
||||
self.grant_store = grant_store
|
||||
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
|
||||
|
||||
def _verify_parent(self, parent: RunProvenance, *, now: datetime) -> SignatureVerifier:
|
||||
status = self.parent_store.status(parent.provenance_id)
|
||||
if status == ProvenanceStatus.REVOKED:
|
||||
raise ProvenanceRevoked("workflow provenance is revoked")
|
||||
if now >= parse_timestamp(parent.expires_at):
|
||||
self.parent_store.mark_expired(parent.provenance_id)
|
||||
raise ProvenanceExpired("workflow provenance is expired")
|
||||
verifier = self.verifiers.get(parent.key_id)
|
||||
if verifier is None or not parent.signature or not verifier.verify(
|
||||
parent.canonical_bytes(), parent.signature
|
||||
):
|
||||
raise ProvenanceSignatureError("invalid workflow provenance signature")
|
||||
if parent.policy_version != WORKFLOW_POLICY_VERSION:
|
||||
raise ProvenanceDenied("parent is not a workflow provenance")
|
||||
if parent.allowed_paths or parent.allowed_git_operations:
|
||||
raise ProvenanceDenied("workflow provenance must not contain operation rights")
|
||||
return verifier
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
*,
|
||||
grant_id: str,
|
||||
parent_provenance_id: str,
|
||||
run_id: str,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
execution_path: str,
|
||||
request_digest: str,
|
||||
git_operation: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> GrantAuthorization:
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
parent = self.parent_store.get(parent_provenance_id)
|
||||
self._verify_parent(parent, now=current)
|
||||
grant = self.grant_store.get(grant_id)
|
||||
grant_status = self.grant_store.status(grant.grant_id)
|
||||
if grant_status == GrantStatus.REVOKED:
|
||||
raise ProvenanceRevoked("provenance grant is revoked")
|
||||
if current >= parse_timestamp(grant.expires_at):
|
||||
self.grant_store.mark_expired(grant.grant_id)
|
||||
raise ProvenanceExpired("provenance grant is expired")
|
||||
verifier = self.verifiers.get(grant.key_id)
|
||||
if verifier is None or not grant.signature or not verifier.verify(
|
||||
grant.canonical_bytes(), grant.signature
|
||||
):
|
||||
raise ProvenanceSignatureError("invalid provenance grant signature")
|
||||
expected = {
|
||||
"parent_provenance_id": parent.provenance_id,
|
||||
"run_id": required(run_id, "run_id"),
|
||||
"tenant_id": required(tenant_id, "tenant_id"),
|
||||
"task_id": required(task_id, "task_id"),
|
||||
"agent_id": required(agent_id, "agent_id"),
|
||||
"principal_id": required(principal_id, "principal_id"),
|
||||
}
|
||||
for field_name, expected_value in expected.items():
|
||||
if getattr(grant, field_name) != expected_value:
|
||||
raise ProvenanceDenied(f"grant {field_name} mismatch")
|
||||
if field_name != "parent_provenance_id" and getattr(parent, field_name) != expected_value:
|
||||
raise ProvenanceDenied(f"workflow {field_name} mismatch")
|
||||
path = ExecutionPath(execution_path)
|
||||
if grant.execution_path != path:
|
||||
raise ProvenanceDenied(
|
||||
f"grant permits {grant.execution_path.value}, not {path.value}"
|
||||
)
|
||||
operation: str | None = None
|
||||
if path == ExecutionPath.GIT_OPERATION:
|
||||
if git_operation is None:
|
||||
raise ProvenanceDenied("Git operation is required")
|
||||
operation = GitOperation(git_operation).value
|
||||
if operation not in grant.allowed_git_operations:
|
||||
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
|
||||
elif git_operation is not None:
|
||||
raise ProvenanceDenied("git_operation is only valid for a Git Grant")
|
||||
digest = _validate_digest(request_digest)
|
||||
self.grant_store.mark_verified(grant.grant_id, fingerprint=verifier.fingerprint)
|
||||
authorization = GrantAuthorization(
|
||||
authorization_id=f"gauth_{uuid.uuid4().hex}",
|
||||
grant_id=grant.grant_id,
|
||||
parent_provenance_id=parent.provenance_id,
|
||||
run_id=grant.run_id,
|
||||
tenant_id=grant.tenant_id,
|
||||
task_id=grant.task_id,
|
||||
agent_id=grant.agent_id,
|
||||
principal_id=grant.principal_id,
|
||||
execution_path=path.value,
|
||||
git_operation=operation,
|
||||
request_digest=digest,
|
||||
policy_version=grant.policy_version,
|
||||
key_id=grant.key_id,
|
||||
public_key_fingerprint=verifier.fingerprint,
|
||||
)
|
||||
self.grant_store.record_authorization(authorization)
|
||||
return authorization
|
||||
|
||||
|
||||
class LeastPrivilegeProvenanceWriter:
|
||||
"""按路径选择独立子 Grant 的生产 Writer。
|
||||
|
||||
``provenance_id`` 参数始终指父 Workflow Provenance;Grant ID 来自受保护
|
||||
配置映射,调用方无法通过请求体临时扩大权限。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: GrantVerifier,
|
||||
receipt_root: str | Path,
|
||||
grant_ids: Mapping[str, str],
|
||||
*,
|
||||
writer_id: str = "aios-v2-least-privilege-writer",
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.receipt_root = Path(receipt_root).resolve()
|
||||
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||
self.grant_ids = {
|
||||
ExecutionPath(path).value: required(grant_id, f"grant_id[{path}]")
|
||||
for path, grant_id in grant_ids.items()
|
||||
}
|
||||
missing = sorted(path.value for path in ExecutionPath if path.value not in self.grant_ids)
|
||||
if missing:
|
||||
raise ValueError(f"missing least-privilege Grant mappings: {missing}")
|
||||
self.writer_id = required(writer_id, "writer_id")
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _component(value: str) -> str:
|
||||
normalized = required(value, "path component")
|
||||
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||
raise ProvenanceDenied("invalid receipt path component")
|
||||
return normalized
|
||||
|
||||
def authorize(self, **values: Any) -> GrantAuthorization:
|
||||
path = ExecutionPath(values["execution_path"]).value
|
||||
grant_id = self.grant_ids[path]
|
||||
parent_provenance_id = values.pop("provenance_id")
|
||||
authorization = self.verifier.authorize(
|
||||
grant_id=grant_id,
|
||||
parent_provenance_id=parent_provenance_id,
|
||||
**values,
|
||||
)
|
||||
tenant = self._component(authorization.tenant_id)
|
||||
parent = self._component(authorization.parent_provenance_id)
|
||||
grant = self._component(authorization.grant_id)
|
||||
target_dir = self.receipt_root / tenant / parent / grant
|
||||
target = target_dir / f"{authorization.authorization_id}.json"
|
||||
payload = authorization.as_dict()
|
||||
payload["writer_id"] = self.writer_id
|
||||
with self._lock:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, target)
|
||||
return authorization
|
||||
|
||||
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
|
||||
|
||||
def authorize_git_operation(
|
||||
self, *, git_operation: str, **values: Any
|
||||
) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||
git_operation=git_operation,
|
||||
**values,
|
||||
)
|
||||
|
||||
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
|
||||
|
||||
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
|
||||
|
||||
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
|
||||
|
||||
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
|
||||
|
||||
def authorize_audit(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||
@@ -0,0 +1,265 @@
|
||||
"""AIOS V2 运维操作 Provenance Gate 与服务级 Scoped Writer 工厂。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .provenance import (
|
||||
Ed25519PublicKeyVerifier,
|
||||
ExecutionPath,
|
||||
ProvenanceAuthorization,
|
||||
ProvenanceControlledWriter,
|
||||
ProvenanceDenied,
|
||||
ProvenanceVerifier,
|
||||
SQLiteProvenanceStore,
|
||||
digest_payload,
|
||||
)
|
||||
from .provenance_grants import GrantAuthorization, GrantVerifier, SQLiteGrantStore
|
||||
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||
|
||||
|
||||
APPLICATION_PROVENANCE_SCOPE = (
|
||||
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
ExecutionPath.EVIDENCE_WRITE.value,
|
||||
ExecutionPath.AUDIT.value,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationWriter(Protocol):
|
||||
def authorize_deployment(self, **values: Any) -> Any: ...
|
||||
def authorize_rollback(self, **values: Any) -> Any: ...
|
||||
def authorize_audit(self, **values: Any) -> Any: ...
|
||||
def authorize_code_change(self, **values: Any) -> Any: ...
|
||||
def authorize_evidence_write(self, **values: Any) -> Any: ...
|
||||
def authorize_runtime_execute(self, **values: Any) -> Any: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationContext:
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationReceipt:
|
||||
operation: str
|
||||
request_digest: str
|
||||
authorization_id: str
|
||||
provenance_id: str
|
||||
grant_id: str | None
|
||||
run_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
execution_path: str
|
||||
authorized_at: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ProvenanceOperationGate:
|
||||
"""部署、回滚、审计和运行操作的统一授权入口。"""
|
||||
|
||||
def __init__(self, writer: AuthorizationWriter) -> None:
|
||||
self.writer = writer
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
context: OperationContext,
|
||||
*,
|
||||
execution_path: str,
|
||||
operation: str,
|
||||
request: Mapping[str, Any],
|
||||
) -> OperationReceipt:
|
||||
path = ExecutionPath(execution_path).value
|
||||
normalized_operation = str(operation or "").strip()
|
||||
if not normalized_operation:
|
||||
raise ValueError("operation is required")
|
||||
digest = digest_payload(
|
||||
{
|
||||
"operation": normalized_operation,
|
||||
"execution_path": path,
|
||||
"request": dict(request),
|
||||
}
|
||||
)
|
||||
values = {
|
||||
"provenance_id": context.provenance_id,
|
||||
"run_id": context.run_id,
|
||||
"tenant_id": context.tenant_id,
|
||||
"task_id": context.task_id,
|
||||
"agent_id": context.agent_id,
|
||||
"principal_id": context.principal_id,
|
||||
"request_digest": digest,
|
||||
}
|
||||
if path == ExecutionPath.DEPLOYMENT.value:
|
||||
authorization = self.writer.authorize_deployment(**values)
|
||||
elif path == ExecutionPath.ROLLBACK.value:
|
||||
authorization = self.writer.authorize_rollback(**values)
|
||||
elif path == ExecutionPath.AUDIT.value:
|
||||
authorization = self.writer.authorize_audit(**values)
|
||||
elif path == ExecutionPath.CODE_CHANGE.value:
|
||||
authorization = self.writer.authorize_code_change(**values)
|
||||
elif path == ExecutionPath.EVIDENCE_WRITE.value:
|
||||
authorization = self.writer.authorize_evidence_write(**values)
|
||||
elif path == ExecutionPath.RUNTIME_EXECUTE.value:
|
||||
authorization = self.writer.authorize_runtime_execute(**values)
|
||||
else:
|
||||
raise ProvenanceDenied(
|
||||
"generic operation gate does not authorize git_operation; use ProvenanceGitWriter"
|
||||
)
|
||||
return self._receipt(normalized_operation, digest, authorization)
|
||||
|
||||
@staticmethod
|
||||
def _receipt(
|
||||
operation: str,
|
||||
request_digest: str,
|
||||
authorization: ProvenanceAuthorization | GrantAuthorization | Any,
|
||||
) -> OperationReceipt:
|
||||
return OperationReceipt(
|
||||
operation=operation,
|
||||
request_digest=request_digest,
|
||||
authorization_id=authorization.authorization_id,
|
||||
provenance_id=authorization.provenance_id,
|
||||
grant_id=getattr(authorization, "grant_id", None),
|
||||
run_id=authorization.run_id,
|
||||
tenant_id=authorization.tenant_id,
|
||||
task_id=authorization.task_id,
|
||||
agent_id=authorization.agent_id,
|
||||
principal_id=authorization.principal_id,
|
||||
execution_path=authorization.execution_path,
|
||||
authorized_at=authorization.authorized_at,
|
||||
)
|
||||
|
||||
|
||||
_GRANT_ENV_BY_PATH: Mapping[str, str] = {
|
||||
ExecutionPath.CODE_CHANGE.value: "AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID",
|
||||
ExecutionPath.GIT_OPERATION.value: "AIOS_PROVENANCE_GRANT_GIT_ID",
|
||||
ExecutionPath.DEPLOYMENT.value: "AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID",
|
||||
ExecutionPath.RUNTIME_EXECUTE.value: "AIOS_PROVENANCE_GRANT_RUNTIME_ID",
|
||||
ExecutionPath.EVIDENCE_WRITE.value: "AIOS_PROVENANCE_GRANT_EVIDENCE_ID",
|
||||
ExecutionPath.ROLLBACK.value: "AIOS_PROVENANCE_GRANT_ROLLBACK_ID",
|
||||
ExecutionPath.AUDIT.value: "AIOS_PROVENANCE_GRANT_AUDIT_ID",
|
||||
}
|
||||
|
||||
|
||||
def grant_ids_from_environment(
|
||||
*,
|
||||
required_paths: Sequence[str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
paths = tuple(
|
||||
sorted(
|
||||
{
|
||||
ExecutionPath(path).value
|
||||
for path in (required_paths or tuple(item.value for item in ExecutionPath))
|
||||
}
|
||||
)
|
||||
)
|
||||
values = {path: os.getenv(_GRANT_ENV_BY_PATH[path], "").strip() for path in paths}
|
||||
missing = [_GRANT_ENV_BY_PATH[path] for path, value in values.items() if not value]
|
||||
if missing:
|
||||
raise RuntimeError(f"missing scoped Provenance Grant configuration: {missing}")
|
||||
return values
|
||||
|
||||
|
||||
def build_provenance_writer_from_env(
|
||||
*,
|
||||
state_dir: str | Path | None = None,
|
||||
required: bool | None = None,
|
||||
required_paths: Sequence[str] | None = None,
|
||||
writer_id: str = "aios-v2-application-grant-writer",
|
||||
) -> AuthorizationWriter | None:
|
||||
"""从环境构建服务级最小权限 Writer。
|
||||
|
||||
生产应用默认只加载 Runtime、Evidence、Audit Grant。Git、部署和回滚进程
|
||||
必须显式传入自己的 ``required_paths``,不能继承应用默认范围。旧全权限 Writer
|
||||
只允许在显式关闭强制模式的候选/测试环境中使用。
|
||||
"""
|
||||
|
||||
require_value = (
|
||||
bool(required)
|
||||
if required is not None
|
||||
else os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
)
|
||||
public_key_file = os.getenv("AIOS_PROVENANCE_PUBLIC_KEY_FILE", "").strip()
|
||||
key_id = os.getenv("AIOS_PROVENANCE_KEY_ID", "").strip()
|
||||
base = Path(state_dir or os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||
store_file = os.getenv(
|
||||
"AIOS_PROVENANCE_STORE_FILE", str(base / "run-provenance.sqlite3")
|
||||
).strip()
|
||||
receipt_root = os.getenv(
|
||||
"AIOS_PROVENANCE_RECEIPT_DIR", str(base / "provenance-receipts")
|
||||
).strip()
|
||||
configured = bool(public_key_file and key_id)
|
||||
if not configured:
|
||||
if require_value:
|
||||
raise RuntimeError(
|
||||
"Run Provenance is required but AIOS_PROVENANCE_PUBLIC_KEY_FILE or AIOS_PROVENANCE_KEY_ID is missing"
|
||||
)
|
||||
return None
|
||||
|
||||
verifier = Ed25519PublicKeyVerifier.from_file(public_key_file, key_id=key_id)
|
||||
parent_store = SQLiteProvenanceStore(store_file)
|
||||
normalized_paths = tuple(
|
||||
sorted(
|
||||
{
|
||||
ExecutionPath(path).value
|
||||
for path in (
|
||||
required_paths
|
||||
if required_paths is not None
|
||||
else APPLICATION_PROVENANCE_SCOPE
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if require_value:
|
||||
if not normalized_paths:
|
||||
raise RuntimeError("production Run Provenance requires a non-empty service scope")
|
||||
grant_ids = grant_ids_from_environment(required_paths=normalized_paths)
|
||||
grant_store = SQLiteGrantStore(store_file)
|
||||
trusted_grants = GrantVerifier(parent_store, grant_store, {key_id: verifier})
|
||||
return ScopedLeastPrivilegeProvenanceWriter(
|
||||
trusted_grants,
|
||||
receipt_root,
|
||||
grant_ids,
|
||||
required_paths=normalized_paths,
|
||||
writer_id=writer_id,
|
||||
)
|
||||
|
||||
legacy = ProvenanceVerifier(parent_store, {key_id: verifier})
|
||||
return ProvenanceControlledWriter(legacy, receipt_root)
|
||||
|
||||
|
||||
def context_from_environment() -> OperationContext:
|
||||
values = {
|
||||
"provenance_id": os.getenv("AIOS_PROVENANCE_ID", ""),
|
||||
"run_id": os.getenv("AIOS_RUN_ID", ""),
|
||||
"tenant_id": os.getenv("AIOS_TENANT_ID", ""),
|
||||
"task_id": os.getenv("AIOS_TASK_ID", ""),
|
||||
"agent_id": os.getenv("AIOS_AGENT_ID", ""),
|
||||
"principal_id": os.getenv("AIOS_PRINCIPAL_ID", ""),
|
||||
}
|
||||
missing = [name for name, value in values.items() if not str(value).strip()]
|
||||
if missing:
|
||||
raise RuntimeError(f"missing Provenance context environment variables: {missing}")
|
||||
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
|
||||
|
||||
|
||||
def load_request_file(path: str | Path) -> Mapping[str, Any]:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("request file must contain a JSON object")
|
||||
return dict(value)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""按服务范围加载的最小权限 Provenance Writer。
|
||||
|
||||
该 Writer 只持有调用进程需要的 Grant ID。未配置路径的方法即使被调用也会
|
||||
默认拒绝,避免应用进程获得 Git、部署或回滚权限。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .provenance import ExecutionPath, ProvenanceDenied, required
|
||||
from .provenance_grants import GrantAuthorization, GrantVerifier
|
||||
|
||||
|
||||
class ScopedLeastPrivilegeProvenanceWriter:
|
||||
"""只暴露指定 ExecutionPath 的最小权限 Writer。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: GrantVerifier,
|
||||
receipt_root: str | Path,
|
||||
grant_ids: Mapping[str, str],
|
||||
*,
|
||||
required_paths: Sequence[str],
|
||||
writer_id: str = "aios-v2-scoped-grant-writer",
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.receipt_root = Path(receipt_root).resolve()
|
||||
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||
normalized_required = tuple(
|
||||
sorted({ExecutionPath(path).value for path in required_paths})
|
||||
)
|
||||
if not normalized_required:
|
||||
raise ValueError("required_paths must not be empty")
|
||||
self.required_paths = frozenset(normalized_required)
|
||||
self.grant_ids = {
|
||||
path: required(grant_ids.get(path, ""), f"grant_id[{path}]")
|
||||
for path in normalized_required
|
||||
}
|
||||
unexpected = sorted(set(grant_ids) - self.required_paths)
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
f"scoped writer received unnecessary Grant mappings: {unexpected}"
|
||||
)
|
||||
self.writer_id = required(writer_id, "writer_id")
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _component(value: str) -> str:
|
||||
normalized = required(value, "path component")
|
||||
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||
raise ProvenanceDenied("invalid receipt path component")
|
||||
return normalized
|
||||
|
||||
def _grant_for(self, path: str) -> str:
|
||||
normalized = ExecutionPath(path).value
|
||||
grant_id = self.grant_ids.get(normalized)
|
||||
if grant_id is None:
|
||||
raise ProvenanceDenied(
|
||||
f"this process is not allowed to authorize {normalized}"
|
||||
)
|
||||
return grant_id
|
||||
|
||||
def authorize(self, **values: Any) -> GrantAuthorization:
|
||||
path = ExecutionPath(values["execution_path"]).value
|
||||
grant_id = self._grant_for(path)
|
||||
parent_provenance_id = values.pop("provenance_id")
|
||||
authorization = self.verifier.authorize(
|
||||
grant_id=grant_id,
|
||||
parent_provenance_id=parent_provenance_id,
|
||||
**values,
|
||||
)
|
||||
tenant = self._component(authorization.tenant_id)
|
||||
parent = self._component(authorization.parent_provenance_id)
|
||||
grant = self._component(authorization.grant_id)
|
||||
target_dir = self.receipt_root / tenant / parent / grant
|
||||
target = target_dir / f"{authorization.authorization_id}.json"
|
||||
payload = authorization.as_dict()
|
||||
payload["writer_id"] = self.writer_id
|
||||
payload["scope"] = sorted(self.required_paths)
|
||||
with self._lock:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, target)
|
||||
return authorization
|
||||
|
||||
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.CODE_CHANGE.value, **values
|
||||
)
|
||||
|
||||
def authorize_git_operation(
|
||||
self, *, git_operation: str, **values: Any
|
||||
) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||
git_operation=git_operation,
|
||||
**values,
|
||||
)
|
||||
|
||||
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.DEPLOYMENT.value, **values
|
||||
)
|
||||
|
||||
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values
|
||||
)
|
||||
|
||||
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values
|
||||
)
|
||||
|
||||
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(
|
||||
execution_path=ExecutionPath.ROLLBACK.value, **values
|
||||
)
|
||||
|
||||
def authorize_audit(self, **values: Any) -> GrantAuthorization:
|
||||
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||
@@ -0,0 +1,607 @@
|
||||
"""Durable execution truth registry for externally executed AIOS tasks.
|
||||
|
||||
A task manifest being present in Git is not evidence that an executor discovered
|
||||
or ran it. This registry separates declaration, registration, claim, start,
|
||||
heartbeat and terminal evidence. Release gates must require a terminal success
|
||||
record with immutable evidence hashes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_time(value: str) -> datetime:
|
||||
normalized = str(value or "").strip()
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp requires timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
class ExecutionTruthState(str, Enum):
|
||||
DECLARED = "DECLARED"
|
||||
REGISTERED = "REGISTERED"
|
||||
CLAIMED = "CLAIMED"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
NOT_STARTED = "NOT_STARTED"
|
||||
STALLED = "STALLED"
|
||||
|
||||
|
||||
TERMINAL_STATES = frozenset(
|
||||
{
|
||||
ExecutionTruthState.SUCCEEDED,
|
||||
ExecutionTruthState.FAILED,
|
||||
ExecutionTruthState.CANCELLED,
|
||||
ExecutionTruthState.NOT_STARTED,
|
||||
ExecutionTruthState.STALLED,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionTaskManifest:
|
||||
task_id: str
|
||||
executor: str
|
||||
source_branch: str
|
||||
source_oid: str
|
||||
target_version: str
|
||||
steps: Sequence[str]
|
||||
start_deadline_seconds: int = 300
|
||||
heartbeat_timeout_seconds: int = 180
|
||||
required_evidence: Sequence[str] = ()
|
||||
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
|
||||
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
|
||||
manifest_version: str = "aios.external-task.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"task_id",
|
||||
"executor",
|
||||
"source_branch",
|
||||
"source_oid",
|
||||
"target_version",
|
||||
"manifest_version",
|
||||
"owner_notification_ref",
|
||||
):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
steps = tuple(required(item, "step") for item in self.steps)
|
||||
if not steps:
|
||||
raise ValueError("at least one execution step is required")
|
||||
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
|
||||
raise ValueError("execution deadlines must be positive")
|
||||
object.__setattr__(self, "steps", steps)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"required_evidence",
|
||||
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"notification_events",
|
||||
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["steps"] = list(self.steps)
|
||||
value["required_evidence"] = list(self.required_evidence)
|
||||
value["notification_events"] = list(self.notification_events)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionTaskManifest":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionTruthRecord:
|
||||
task_id: str
|
||||
manifest_json: str
|
||||
state: ExecutionTruthState
|
||||
executor_run_id: str | None = None
|
||||
executor_id: str | None = None
|
||||
declared_at: str = field(default_factory=utc_now)
|
||||
registered_at: str | None = None
|
||||
claimed_at: str | None = None
|
||||
started_at: str | None = None
|
||||
heartbeat_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
current_step: int = 0
|
||||
evidence: Mapping[str, str] = field(default_factory=dict)
|
||||
error: Mapping[str, Any] = field(default_factory=dict)
|
||||
revision: int = 1
|
||||
|
||||
@property
|
||||
def manifest(self) -> ExecutionTaskManifest:
|
||||
return ExecutionTaskManifest.from_dict(json.loads(self.manifest_json))
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
**asdict(self),
|
||||
"state": self.state.value,
|
||||
"manifest": self.manifest.as_dict(),
|
||||
"evidence": dict(self.evidence),
|
||||
"error": dict(self.error),
|
||||
}
|
||||
|
||||
|
||||
class ExecutionTruthConflict(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SQLiteExecutionTruthRegistry:
|
||||
"""Persistent registration and run-evidence store."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS execution_truth (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
manifest_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS execution_truth_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(task_id) REFERENCES execution_truth(task_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_execution_truth_state
|
||||
ON execution_truth(state, updated_at);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _manifest_json(manifest: ExecutionTaskManifest) -> str:
|
||||
return json.dumps(manifest.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
@staticmethod
|
||||
def _record_json(record: ExecutionTruthRecord) -> str:
|
||||
value = record.as_dict()
|
||||
value.pop("manifest", None)
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
@staticmethod
|
||||
def _decode(row: sqlite3.Row) -> ExecutionTruthRecord:
|
||||
value = json.loads(row["record_json"])
|
||||
value["state"] = ExecutionTruthState(value["state"])
|
||||
value["evidence"] = dict(value.get("evidence") or {})
|
||||
value["error"] = dict(value.get("error") or {})
|
||||
return ExecutionTruthRecord(**value)
|
||||
|
||||
def _event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
task_id: str,
|
||||
event_type: str,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO execution_truth_events VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
f"truth_evt_{uuid.uuid4().hex}",
|
||||
task_id,
|
||||
event_type,
|
||||
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True, default=str),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
def declare(self, manifest: ExecutionTaskManifest) -> ExecutionTruthRecord:
|
||||
manifest_json = self._manifest_json(manifest)
|
||||
record = ExecutionTruthRecord(
|
||||
task_id=manifest.task_id,
|
||||
manifest_json=manifest_json,
|
||||
state=ExecutionTruthState.DECLARED,
|
||||
)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT record_json FROM execution_truth WHERE task_id = ?",
|
||||
(manifest.task_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
current = self._decode(existing)
|
||||
if current.manifest_json != manifest_json:
|
||||
previous_manifest = current.manifest.as_dict()
|
||||
replacement_manifest = manifest.as_dict()
|
||||
previous_oid = previous_manifest.pop("source_oid")
|
||||
replacement_oid = replacement_manifest.pop("source_oid")
|
||||
if (
|
||||
current.state != ExecutionTruthState.NOT_STARTED
|
||||
or previous_manifest != replacement_manifest
|
||||
):
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict(
|
||||
"task_id already declared with another manifest"
|
||||
)
|
||||
now = utc_now()
|
||||
updated = replace(
|
||||
current,
|
||||
manifest_json=manifest_json,
|
||||
revision=current.revision + 1,
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE execution_truth SET manifest_json = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
|
||||
(
|
||||
manifest_json,
|
||||
self._record_json(updated),
|
||||
updated.revision,
|
||||
now,
|
||||
updated.task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
updated.task_id,
|
||||
"MANIFEST_SUPERSEDED",
|
||||
{
|
||||
"previous_source_oid": previous_oid,
|
||||
"source_oid": replacement_oid,
|
||||
"state": updated.state.value,
|
||||
},
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
connection.rollback()
|
||||
return current
|
||||
connection.execute(
|
||||
"INSERT INTO execution_truth VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
record.task_id,
|
||||
manifest_json,
|
||||
record.state.value,
|
||||
self._record_json(record),
|
||||
record.revision,
|
||||
record.declared_at,
|
||||
),
|
||||
)
|
||||
self._event(connection, record.task_id, "DECLARED", {"source_oid": manifest.source_oid})
|
||||
connection.commit()
|
||||
return record
|
||||
|
||||
def get(self, task_id: str) -> ExecutionTruthRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT record_json FROM execution_truth WHERE task_id = ?",
|
||||
(required(task_id, "task_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("execution task not declared")
|
||||
return self._decode(row)
|
||||
|
||||
def _transition(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
allowed: Sequence[ExecutionTruthState],
|
||||
target: ExecutionTruthState,
|
||||
executor_run_id: str | None = None,
|
||||
executor_id: str | None = None,
|
||||
current_step: int | None = None,
|
||||
evidence: Mapping[str, str] | None = None,
|
||||
error: Mapping[str, Any] | None = None,
|
||||
) -> ExecutionTruthRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT record_json FROM execution_truth WHERE task_id = ?",
|
||||
(required(task_id, "task_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.rollback()
|
||||
raise KeyError("execution task not declared")
|
||||
current = self._decode(row)
|
||||
if current.state not in set(allowed):
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict(
|
||||
f"cannot transition {current.state.value} to {target.value}"
|
||||
)
|
||||
now = utc_now()
|
||||
updates: dict[str, Any] = {
|
||||
"state": target,
|
||||
"revision": current.revision + 1,
|
||||
}
|
||||
if executor_run_id is not None:
|
||||
if current.executor_run_id and current.executor_run_id != executor_run_id:
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict("executor_run_id mismatch")
|
||||
updates["executor_run_id"] = required(executor_run_id, "executor_run_id")
|
||||
if executor_id is not None:
|
||||
if current.executor_id and current.executor_id != executor_id:
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict("executor_id mismatch")
|
||||
updates["executor_id"] = required(executor_id, "executor_id")
|
||||
if target == ExecutionTruthState.REGISTERED:
|
||||
updates["registered_at"] = now
|
||||
if target == ExecutionTruthState.CLAIMED:
|
||||
updates["claimed_at"] = now
|
||||
updates["heartbeat_at"] = now
|
||||
if target == ExecutionTruthState.RUNNING:
|
||||
updates["started_at"] = current.started_at or now
|
||||
updates["heartbeat_at"] = now
|
||||
if current_step is not None:
|
||||
if current_step < current.current_step or current_step > len(current.manifest.steps):
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict("invalid task step progression")
|
||||
updates["current_step"] = current_step
|
||||
updates["heartbeat_at"] = now
|
||||
if evidence is not None:
|
||||
updates["evidence"] = {
|
||||
**dict(current.evidence),
|
||||
**{required(key, "evidence key"): required(value, "evidence hash") for key, value in evidence.items()},
|
||||
}
|
||||
if error is not None:
|
||||
updates["error"] = json.loads(json.dumps(dict(error), default=str))
|
||||
if target in TERMINAL_STATES:
|
||||
updates["completed_at"] = now
|
||||
updated = replace(current, **updates)
|
||||
connection.execute(
|
||||
"UPDATE execution_truth SET state = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
|
||||
(
|
||||
updated.state.value,
|
||||
self._record_json(updated),
|
||||
updated.revision,
|
||||
now,
|
||||
updated.task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
updated.task_id,
|
||||
target.value,
|
||||
{
|
||||
"executor_run_id": updated.executor_run_id,
|
||||
"executor_id": updated.executor_id,
|
||||
"current_step": updated.current_step,
|
||||
},
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
|
||||
def register(self, task_id: str, *, executor_run_id: str) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.DECLARED,),
|
||||
target=ExecutionTruthState.REGISTERED,
|
||||
executor_run_id=executor_run_id,
|
||||
)
|
||||
|
||||
def retry_not_started(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
) -> ExecutionTruthRecord:
|
||||
"""Start a new audited attempt after the declaration deadline expired."""
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT record_json FROM execution_truth WHERE task_id = ?",
|
||||
(required(task_id, "task_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.rollback()
|
||||
raise KeyError("execution task not declared")
|
||||
current = self._decode(row)
|
||||
if current.state != ExecutionTruthState.NOT_STARTED:
|
||||
connection.rollback()
|
||||
raise ExecutionTruthConflict(
|
||||
f"cannot retry task from {current.state.value}"
|
||||
)
|
||||
now = utc_now()
|
||||
updated = replace(
|
||||
current,
|
||||
state=ExecutionTruthState.REGISTERED,
|
||||
executor_run_id=required(executor_run_id, "executor_run_id"),
|
||||
executor_id=None,
|
||||
registered_at=now,
|
||||
claimed_at=None,
|
||||
started_at=None,
|
||||
heartbeat_at=None,
|
||||
completed_at=None,
|
||||
current_step=0,
|
||||
evidence={},
|
||||
error={},
|
||||
revision=current.revision + 1,
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE execution_truth SET state = ?, record_json = ?, revision = ?, updated_at = ? WHERE task_id = ?",
|
||||
(
|
||||
updated.state.value,
|
||||
self._record_json(updated),
|
||||
updated.revision,
|
||||
now,
|
||||
updated.task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
updated.task_id,
|
||||
"RETRIED",
|
||||
{
|
||||
"previous_state": current.state.value,
|
||||
"previous_executor_run_id": current.executor_run_id,
|
||||
"executor_run_id": updated.executor_run_id,
|
||||
"previous_error": dict(current.error),
|
||||
},
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
|
||||
def claim(self, task_id: str, *, executor_run_id: str, executor_id: str) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.REGISTERED,),
|
||||
target=ExecutionTruthState.CLAIMED,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
|
||||
def start(self, task_id: str, *, executor_run_id: str, executor_id: str) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.CLAIMED,),
|
||||
target=ExecutionTruthState.RUNNING,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
current_step=1,
|
||||
)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
current_step: int,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.RUNNING,),
|
||||
target=ExecutionTruthState.RUNNING,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
evidence: Mapping[str, str],
|
||||
) -> ExecutionTruthRecord:
|
||||
current = self.get(task_id)
|
||||
missing = sorted(set(current.manifest.required_evidence) - set(evidence))
|
||||
if missing:
|
||||
raise ExecutionTruthConflict(f"missing required evidence: {missing}")
|
||||
if current.current_step != len(current.manifest.steps):
|
||||
raise ExecutionTruthConflict("all declared steps must complete before success")
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.RUNNING,),
|
||||
target=ExecutionTruthState.SUCCEEDED,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
def fail(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
executor_run_id: str,
|
||||
executor_id: str,
|
||||
error: Mapping[str, Any],
|
||||
evidence: Mapping[str, str] | None = None,
|
||||
) -> ExecutionTruthRecord:
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(ExecutionTruthState.REGISTERED, ExecutionTruthState.CLAIMED, ExecutionTruthState.RUNNING),
|
||||
target=ExecutionTruthState.FAILED,
|
||||
executor_run_id=executor_run_id,
|
||||
executor_id=executor_id,
|
||||
evidence=evidence,
|
||||
error=error,
|
||||
)
|
||||
|
||||
def evaluate_deadlines(self, task_id: str, *, now: datetime | None = None) -> ExecutionTruthRecord:
|
||||
current = self.get(task_id)
|
||||
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
manifest = current.manifest
|
||||
if current.state in TERMINAL_STATES:
|
||||
return current
|
||||
declared = parse_time(current.declared_at)
|
||||
if current.state in {ExecutionTruthState.DECLARED, ExecutionTruthState.REGISTERED}:
|
||||
if current_time >= declared + timedelta(seconds=manifest.start_deadline_seconds):
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(current.state,),
|
||||
target=ExecutionTruthState.NOT_STARTED,
|
||||
error={"code": "start_deadline_exceeded"},
|
||||
)
|
||||
if current.state in {ExecutionTruthState.CLAIMED, ExecutionTruthState.RUNNING}:
|
||||
heartbeat = parse_time(current.heartbeat_at or current.claimed_at or current.declared_at)
|
||||
if current_time >= heartbeat + timedelta(seconds=manifest.heartbeat_timeout_seconds):
|
||||
return self._transition(
|
||||
task_id,
|
||||
allowed=(current.state,),
|
||||
target=ExecutionTruthState.STALLED,
|
||||
error={"code": "heartbeat_timeout"},
|
||||
)
|
||||
return current
|
||||
|
||||
def gate(self, task_id: str) -> Mapping[str, Any]:
|
||||
record = self.evaluate_deadlines(task_id)
|
||||
passed = record.state == ExecutionTruthState.SUCCEEDED
|
||||
return {
|
||||
"task_id": record.task_id,
|
||||
"state": record.state.value,
|
||||
"passed": passed,
|
||||
"release_blocking": not passed,
|
||||
"executor_run_id": record.executor_run_id,
|
||||
"executor_id": record.executor_id,
|
||||
"current_step": record.current_step,
|
||||
"total_steps": len(record.manifest.steps),
|
||||
"evidence": dict(record.evidence),
|
||||
"error": dict(record.error),
|
||||
}
|
||||
|
||||
def events(self, task_id: str) -> list[Mapping[str, Any]]:
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT event_id, event_type, details_json, created_at FROM execution_truth_events WHERE task_id = ? ORDER BY created_at, event_id",
|
||||
(required(task_id, "task_id"),),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"event_type": row["event_type"],
|
||||
"details": json.loads(row["details_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""AIOS unified, role-aware web portal."""
|
||||
|
||||
from .application import UnifiedPortalApplication, build_application
|
||||
from .capabilities import ROLE_CAPABILITIES, allows, require, resolve
|
||||
|
||||
__all__ = [
|
||||
"ROLE_CAPABILITIES",
|
||||
"UnifiedPortalApplication",
|
||||
"allows",
|
||||
"build_application",
|
||||
"require",
|
||||
"resolve",
|
||||
]
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Unified, role-aware AIOS web application.
|
||||
|
||||
The portal wraps the production CandidateApplication instead of replacing its
|
||||
runtime/control-plane routes. Portal endpoints aggregate existing authoritative
|
||||
stores and require server-side capabilities before delegating any state change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_v2.app import (
|
||||
CandidateApplication,
|
||||
CandidateHTTPServer,
|
||||
build_application as build_candidate_application,
|
||||
)
|
||||
from aios_v2.control_plane import RequestContext, TaskState
|
||||
from aios_v2.gitops_status import collect_gitops_status
|
||||
from aios_v2.provenance import SQLiteProvenanceStore
|
||||
from aios_v2.provenance_grants import SQLiteGrantStore
|
||||
|
||||
from .capabilities import allows, require, resolve
|
||||
|
||||
|
||||
def _json_value(value: Any) -> Any:
|
||||
if dataclasses.is_dataclass(value):
|
||||
return {
|
||||
field.name: _json_value(getattr(value, field.name))
|
||||
for field in dataclasses.fields(value)
|
||||
}
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _required(value: Any, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_count(items: list[Any]) -> int:
|
||||
return len(items)
|
||||
|
||||
|
||||
class AuthorizationCatalog:
|
||||
"""Tenant-filtered read/revoke access to existing Provenance stores."""
|
||||
|
||||
def __init__(self, store_file: str | Path) -> None:
|
||||
self.store_file = Path(store_file).resolve()
|
||||
|
||||
def _read_connection(self) -> sqlite3.Connection | None:
|
||||
if not self.store_file.exists():
|
||||
return None
|
||||
connection = sqlite3.connect(
|
||||
f"file:{self.store_file}?mode=ro",
|
||||
uri=True,
|
||||
timeout=10.0,
|
||||
)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def snapshot(self, *, tenant_id: str) -> Mapping[str, Any]:
|
||||
connection = self._read_connection()
|
||||
if connection is None:
|
||||
return {
|
||||
"configured": False,
|
||||
"workflows": [],
|
||||
"grants": [],
|
||||
"summary": {"workflows": 0, "grants": 0, "active_grants": 0},
|
||||
}
|
||||
try:
|
||||
workflows: list[dict[str, Any]] = []
|
||||
grants: list[dict[str, Any]] = []
|
||||
tables = {
|
||||
row["name"]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
if "provenance_records" in tables:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT r.payload_json, r.status, r.signature_verified,
|
||||
r.created_at, r.updated_at,
|
||||
COUNT(u.authorization_id) AS usage_count
|
||||
FROM provenance_records r
|
||||
LEFT JOIN provenance_usage u
|
||||
ON u.provenance_id = r.provenance_id
|
||||
GROUP BY r.provenance_id
|
||||
ORDER BY r.updated_at DESC, r.provenance_id
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload_json"])
|
||||
if payload.get("tenant_id") != tenant_id:
|
||||
continue
|
||||
workflows.append(
|
||||
{
|
||||
**payload,
|
||||
"status": row["status"],
|
||||
"signature_verified": bool(row["signature_verified"]),
|
||||
"usage_count": int(row["usage_count"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
)
|
||||
if "provenance_grants" in tables:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT g.payload_json, g.status, g.signature_verified,
|
||||
g.created_at, g.updated_at,
|
||||
COUNT(u.authorization_id) AS usage_count
|
||||
FROM provenance_grants g
|
||||
LEFT JOIN provenance_grant_usage u
|
||||
ON u.grant_id = g.grant_id
|
||||
GROUP BY g.grant_id
|
||||
ORDER BY g.updated_at DESC, g.grant_id
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload_json"])
|
||||
if payload.get("tenant_id") != tenant_id:
|
||||
continue
|
||||
grants.append(
|
||||
{
|
||||
**payload,
|
||||
"status": row["status"],
|
||||
"signature_verified": bool(row["signature_verified"]),
|
||||
"usage_count": int(row["usage_count"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
)
|
||||
active = sum(
|
||||
1
|
||||
for item in grants
|
||||
if item.get("status") not in {"revoked", "expired"}
|
||||
)
|
||||
return {
|
||||
"configured": True,
|
||||
"workflows": workflows,
|
||||
"grants": grants,
|
||||
"summary": {
|
||||
"workflows": len(workflows),
|
||||
"grants": len(grants),
|
||||
"active_grants": active,
|
||||
"revoked_grants": sum(
|
||||
1 for item in grants if item.get("status") == "revoked"
|
||||
),
|
||||
},
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def revoke_workflow(
|
||||
self,
|
||||
*,
|
||||
provenance_id: str,
|
||||
tenant_id: str,
|
||||
principal_id: str,
|
||||
reason: str,
|
||||
) -> Mapping[str, Any]:
|
||||
store = SQLiteProvenanceStore(self.store_file)
|
||||
try:
|
||||
record = store.get(provenance_id)
|
||||
if record.tenant_id != tenant_id:
|
||||
raise KeyError("workflow provenance not found")
|
||||
store.revoke(
|
||||
record.provenance_id,
|
||||
reason=reason,
|
||||
principal_id=principal_id,
|
||||
)
|
||||
return {
|
||||
"provenance_id": record.provenance_id,
|
||||
"status": store.status(record.provenance_id).value,
|
||||
}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def revoke_grant(
|
||||
self,
|
||||
*,
|
||||
grant_id: str,
|
||||
tenant_id: str,
|
||||
principal_id: str,
|
||||
reason: str,
|
||||
) -> Mapping[str, Any]:
|
||||
store = SQLiteGrantStore(self.store_file)
|
||||
try:
|
||||
grant = store.get(grant_id)
|
||||
if grant.tenant_id != tenant_id:
|
||||
raise KeyError("provenance grant not found")
|
||||
store.revoke(
|
||||
grant.grant_id,
|
||||
reason=reason,
|
||||
principal_id=principal_id,
|
||||
)
|
||||
return {
|
||||
"grant_id": grant.grant_id,
|
||||
"status": store.status(grant.grant_id).value,
|
||||
}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class UnifiedPortalApplication:
|
||||
"""Role-aware portal facade over the existing production application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base: CandidateApplication,
|
||||
*,
|
||||
provenance_store_file: str | Path,
|
||||
) -> None:
|
||||
self.base = base
|
||||
self.gateway = base.gateway
|
||||
self.orchestrator = base.orchestrator
|
||||
self.tracker = base.tracker
|
||||
self.hermes = base.hermes
|
||||
self.openclaw = base.openclaw
|
||||
self.authorization_catalog = AuthorizationCatalog(provenance_store_file)
|
||||
|
||||
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||
return self.base.context(headers)
|
||||
|
||||
def _runtime(self, headers: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
status, payload = self.base.handle_json("GET", "/api/v2/runtime", headers, {})
|
||||
if int(status) != HTTPStatus.OK:
|
||||
raise RuntimeError("runtime status is unavailable")
|
||||
return payload
|
||||
|
||||
def _gitops(self, context: RequestContext) -> Mapping[str, Any]:
|
||||
return {
|
||||
"tenant_id": context.tenant_id,
|
||||
"gitops": collect_gitops_status().as_dict(),
|
||||
}
|
||||
|
||||
def _tasks_view(self, context: RequestContext) -> list[Mapping[str, Any]]:
|
||||
service = self.orchestrator.control_plane
|
||||
tasks = service.list_tasks(context)
|
||||
dispatches = {
|
||||
item.task_id: item
|
||||
for item in self.orchestrator.dispatch_repository.list(
|
||||
tenant_id=context.tenant_id
|
||||
)
|
||||
}
|
||||
evidence_by_task: dict[str, list[Any]] = {}
|
||||
for evidence in self.tracker.list(tenant_id=context.tenant_id):
|
||||
evidence_by_task.setdefault(evidence.task_id, []).append(evidence)
|
||||
audit_by_task: dict[str, list[Any]] = {}
|
||||
for event in service.list_audit_events(context):
|
||||
if event.resource_type == "task":
|
||||
audit_by_task.setdefault(event.resource_id, []).append(event)
|
||||
capabilities = set(resolve(context).capabilities)
|
||||
result: list[Mapping[str, Any]] = []
|
||||
for task in tasks:
|
||||
actions: list[str] = []
|
||||
if task.state == TaskState.CREATED and "task.approve" in capabilities:
|
||||
actions.append("approve")
|
||||
if task.state in {TaskState.CREATED, TaskState.APPROVED, TaskState.RUNNING} and "task.cancel" in capabilities:
|
||||
actions.append("cancel")
|
||||
if task.state == TaskState.APPROVED and "task.dispatch" in capabilities:
|
||||
actions.append("dispatch")
|
||||
result.append(
|
||||
{
|
||||
"task": _json_value(task),
|
||||
"dispatch": _json_value(dispatches.get(task.task_id)),
|
||||
"evidence": _json_value(evidence_by_task.get(task.task_id, [])),
|
||||
"audit": _json_value(audit_by_task.get(task.task_id, [])[-12:]),
|
||||
"available_actions": actions,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def _overview(
|
||||
self,
|
||||
context: RequestContext,
|
||||
headers: Mapping[str, str],
|
||||
) -> Mapping[str, Any]:
|
||||
service = self.orchestrator.control_plane
|
||||
capabilities = resolve(context)
|
||||
sections: dict[str, Any] = {}
|
||||
tasks = service.list_tasks(context) if allows(context, "task.read") else []
|
||||
dispatches = (
|
||||
self.orchestrator.dispatch_repository.list(tenant_id=context.tenant_id)
|
||||
if allows(context, "dispatch.read")
|
||||
else []
|
||||
)
|
||||
evidence = (
|
||||
self.tracker.list(tenant_id=context.tenant_id)
|
||||
if allows(context, "evidence.read")
|
||||
else []
|
||||
)
|
||||
audit = (
|
||||
service.list_audit_events(context)
|
||||
if allows(context, "audit.read")
|
||||
else []
|
||||
)
|
||||
if allows(context, "runtime.read"):
|
||||
sections["runtime"] = self._runtime(headers)
|
||||
if allows(context, "release.read"):
|
||||
sections["release"] = {
|
||||
**self._gitops(context),
|
||||
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C1"),
|
||||
"release_oid": os.getenv("AIOS_RELEASE_OID", "unknown"),
|
||||
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
|
||||
"status": os.getenv("AIOS_RELEASE_STATUS", "PRODUCTION_DEPLOYED"),
|
||||
}
|
||||
if allows(context, "provenance.read"):
|
||||
sections["authorization"] = self.authorization_catalog.snapshot(
|
||||
tenant_id=context.tenant_id
|
||||
)["summary"]
|
||||
task_states: dict[str, int] = {}
|
||||
for task in tasks:
|
||||
task_states[task.state.value] = task_states.get(task.state.value, 0) + 1
|
||||
return {
|
||||
"identity": capabilities.as_dict(),
|
||||
"summary": {
|
||||
"tasks": _safe_count(tasks),
|
||||
"task_states": task_states,
|
||||
"dispatches": _safe_count(dispatches),
|
||||
"active_dispatches": sum(
|
||||
1
|
||||
for item in dispatches
|
||||
if item.state.value not in {"COMPLETED", "FAILED", "CANCELLED"}
|
||||
),
|
||||
"evidence": _safe_count(evidence),
|
||||
"audit_events": _safe_count(audit),
|
||||
},
|
||||
"sections": sections,
|
||||
"recent": {
|
||||
"tasks": _json_value(tasks[:8]),
|
||||
"dispatches": _json_value(dispatches[-8:]),
|
||||
"audit": _json_value(audit[-12:]),
|
||||
},
|
||||
}
|
||||
|
||||
def handle_json(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
|
||||
if not path.startswith("/api/v2/portal") and path != "/api/v2/me/capabilities":
|
||||
return self.base.handle_json(method, raw_path, headers, body)
|
||||
|
||||
context = self.context(headers)
|
||||
payload = _mapping(body or {})
|
||||
|
||||
if method == "GET" and path == "/api/v2/me/capabilities":
|
||||
require(context, "portal.access")
|
||||
return HTTPStatus.OK, resolve(context).as_dict()
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/overview":
|
||||
require(context, "dashboard.read")
|
||||
return HTTPStatus.OK, self._overview(context, headers)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/tasks":
|
||||
require(context, "task.read")
|
||||
return HTTPStatus.OK, {"items": self._tasks_view(context)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/authorizations":
|
||||
require(context, "provenance.read")
|
||||
return HTTPStatus.OK, self.authorization_catalog.snapshot(
|
||||
tenant_id=context.tenant_id
|
||||
)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/runtime":
|
||||
require(context, "runtime.read")
|
||||
return HTTPStatus.OK, {
|
||||
**self._runtime(headers),
|
||||
"dispatches": _json_value(
|
||||
self.orchestrator.dispatch_repository.list(
|
||||
tenant_id=context.tenant_id
|
||||
)
|
||||
),
|
||||
"hermes_jobs": _json_value(
|
||||
self.hermes.list(tenant_id=context.tenant_id)
|
||||
),
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/evidence":
|
||||
require(context, "evidence.read")
|
||||
return HTTPStatus.OK, {
|
||||
"executions": _json_value(
|
||||
self.tracker.list(tenant_id=context.tenant_id)
|
||||
)
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/audit":
|
||||
require(context, "audit.read")
|
||||
return HTTPStatus.OK, {
|
||||
"events": _json_value(
|
||||
self.orchestrator.control_plane.list_audit_events(context)
|
||||
)
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/release":
|
||||
require(context, "release.read")
|
||||
return HTTPStatus.OK, {
|
||||
**self._gitops(context),
|
||||
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C1"),
|
||||
"release_oid": os.getenv("AIOS_RELEASE_OID", "unknown"),
|
||||
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
|
||||
"status": os.getenv("AIOS_RELEASE_STATUS", "PRODUCTION_DEPLOYED"),
|
||||
"operations": (
|
||||
["review_release", "request_tag", "request_rollback"]
|
||||
if allows(context, "release.operate")
|
||||
else []
|
||||
),
|
||||
"note": "Release operations remain GitOps approval requests; the portal does not mutate tags or production directly.",
|
||||
}
|
||||
|
||||
if method == "POST" and path == "/api/v2/portal/tasks":
|
||||
require(context, "task.create")
|
||||
return self.base.handle_json("POST", "/api/v2/tasks", headers, payload)
|
||||
|
||||
task_action = re.fullmatch(
|
||||
r"/api/v2/portal/tasks/([^/]+)/(approve|cancel|dispatch)", path
|
||||
)
|
||||
if method == "POST" and task_action:
|
||||
action = task_action.group(2)
|
||||
require(context, f"task.{action}")
|
||||
task_id = urllib.parse.unquote(task_action.group(1))
|
||||
target = (
|
||||
f"/api/v2/tasks/{task_id}/dispatch"
|
||||
if action == "dispatch"
|
||||
else f"/api/v2/tasks/{task_id}/{action}"
|
||||
)
|
||||
return self.base.handle_json("POST", target, headers, payload)
|
||||
|
||||
workflow_revoke = re.fullmatch(
|
||||
r"/api/v2/portal/provenance/([^/]+)/revoke", path
|
||||
)
|
||||
if method == "POST" and workflow_revoke:
|
||||
require(context, "provenance.revoke")
|
||||
reason = _required(payload.get("reason"), "reason")
|
||||
result = self.authorization_catalog.revoke_workflow(
|
||||
provenance_id=urllib.parse.unquote(workflow_revoke.group(1)),
|
||||
tenant_id=context.tenant_id,
|
||||
principal_id=context.principal_id,
|
||||
reason=reason,
|
||||
)
|
||||
return HTTPStatus.OK, {"workflow": result}
|
||||
|
||||
grant_revoke = re.fullmatch(r"/api/v2/portal/grants/([^/]+)/revoke", path)
|
||||
if method == "POST" and grant_revoke:
|
||||
require(context, "provenance.revoke")
|
||||
reason = _required(payload.get("reason"), "reason")
|
||||
result = self.authorization_catalog.revoke_grant(
|
||||
grant_id=urllib.parse.unquote(grant_revoke.group(1)),
|
||||
tenant_id=context.tenant_id,
|
||||
principal_id=context.principal_id,
|
||||
reason=reason,
|
||||
)
|
||||
return HTTPStatus.OK, {"grant": result}
|
||||
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "portal route not found",
|
||||
}
|
||||
|
||||
|
||||
def build_application() -> UnifiedPortalApplication:
|
||||
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||
provenance_file = Path(
|
||||
os.getenv(
|
||||
"AIOS_PROVENANCE_STORE_FILE",
|
||||
str(state_dir / "run-provenance.sqlite3"),
|
||||
)
|
||||
)
|
||||
return UnifiedPortalApplication(
|
||||
build_candidate_application(),
|
||||
provenance_store_file=provenance_file,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("AIOS_V2_PORT", "18920"))
|
||||
server = CandidateHTTPServer((host, port), build_application())
|
||||
print(f"AIOS V2 unified portal listening on {host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Server-side role and capability resolution for the unified AIOS portal."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from aios_v2.control_plane import RequestContext, Unauthorized
|
||||
|
||||
|
||||
ROLE_CAPABILITIES: Mapping[str, frozenset[str]] = {
|
||||
"platform_admin": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "tenant.manage",
|
||||
"agent.read", "agent.manage", "task.read", "task.create", "task.approve",
|
||||
"task.cancel", "task.dispatch", "policy.read", "audit.read", "evidence.read",
|
||||
"runtime.read", "runtime.operate", "dispatch.read", "provenance.read",
|
||||
"provenance.revoke", "external_task.read", "gitops.read", "release.read",
|
||||
"release.operate", "worktree.read", "system.admin",
|
||||
}),
|
||||
"tenant_admin": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "agent.read", "agent.manage",
|
||||
"task.read", "task.create", "task.approve", "task.cancel", "task.dispatch",
|
||||
"policy.read", "audit.read", "evidence.read", "runtime.read", "dispatch.read",
|
||||
"provenance.read", "provenance.revoke", "external_task.read", "gitops.read",
|
||||
"release.read",
|
||||
}),
|
||||
"operator": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "agent.read", "task.read",
|
||||
"task.create", "task.cancel", "task.dispatch", "runtime.read", "dispatch.read",
|
||||
"evidence.read", "provenance.read", "external_task.read", "gitops.read",
|
||||
"release.read",
|
||||
}),
|
||||
"approver": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "task.read", "task.approve",
|
||||
"task.cancel", "policy.read", "audit.read", "provenance.read", "evidence.read",
|
||||
"external_task.read", "release.read",
|
||||
}),
|
||||
"auditor": frozenset({
|
||||
"portal.access", "dashboard.read", "tenant.read", "agent.read", "task.read",
|
||||
"audit.read", "evidence.read", "runtime.read", "dispatch.read",
|
||||
"provenance.read", "external_task.read", "gitops.read", "release.read",
|
||||
"worktree.read",
|
||||
}),
|
||||
"runtime": frozenset({
|
||||
"portal.access", "dashboard.read", "task.read", "runtime.read", "runtime.operate",
|
||||
"dispatch.read", "evidence.read", "provenance.read", "external_task.read",
|
||||
}),
|
||||
"release_manager": frozenset({
|
||||
"portal.access", "dashboard.read", "audit.read", "runtime.read", "dispatch.read",
|
||||
"provenance.read", "external_task.read", "gitops.read", "release.read",
|
||||
"release.operate", "worktree.read",
|
||||
}),
|
||||
"viewer": frozenset({
|
||||
"portal.access", "dashboard.read", "task.read", "runtime.read",
|
||||
"external_task.read",
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
NAVIGATION: Sequence[Mapping[str, str]] = (
|
||||
{"id": "overview", "label": "总览", "capability": "dashboard.read"},
|
||||
{"id": "resources", "label": "租户与 Agent", "capability": "tenant.read"},
|
||||
{"id": "tasks", "label": "任务中心", "capability": "task.read"},
|
||||
{"id": "execution", "label": "执行中心", "capability": "external_task.read"},
|
||||
{"id": "authorization", "label": "授权中心", "capability": "provenance.read"},
|
||||
{"id": "runtime", "label": "运行与运维", "capability": "runtime.read"},
|
||||
{"id": "evidence", "label": "执行证据", "capability": "evidence.read"},
|
||||
{"id": "audit", "label": "审计中心", "capability": "audit.read"},
|
||||
{"id": "release", "label": "GitOps 与发布", "capability": "release.read"},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedCapabilities:
|
||||
tenant_id: str
|
||||
principal_id: str
|
||||
roles: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
navigation: tuple[Mapping[str, str], ...]
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"principal_id": self.principal_id,
|
||||
"roles": list(self.roles),
|
||||
"capabilities": list(self.capabilities),
|
||||
"navigation": [dict(item) for item in self.navigation],
|
||||
}
|
||||
|
||||
|
||||
def resolve(context: RequestContext) -> ResolvedCapabilities:
|
||||
capabilities: set[str] = set()
|
||||
for role in context.roles:
|
||||
capabilities.update(ROLE_CAPABILITIES.get(role, ()))
|
||||
ordered = tuple(sorted(capabilities))
|
||||
navigation = tuple(item for item in NAVIGATION if item["capability"] in capabilities)
|
||||
return ResolvedCapabilities(
|
||||
tenant_id=context.tenant_id,
|
||||
principal_id=context.principal_id,
|
||||
roles=tuple(sorted(context.roles)),
|
||||
capabilities=ordered,
|
||||
navigation=navigation,
|
||||
)
|
||||
|
||||
|
||||
def require(context: RequestContext, capability: str) -> None:
|
||||
if capability not in resolve(context).capabilities:
|
||||
raise Unauthorized(f"portal capability is required: {capability}")
|
||||
|
||||
|
||||
def allows(context: RequestContext, capability: str) -> bool:
|
||||
return capability in resolve(context).capabilities
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Unified portal HTML export with V2R1C2 resource and execution centers.
|
||||
|
||||
The canonical dependency-free portal remains in ``aios_v2.console``. This
|
||||
module applies deterministic, fail-fast extensions at import time so the
|
||||
V2R1C1 production entrypoint remains unchanged while the V2R1C2 candidate gets
|
||||
resource and portable-executor views.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from aios_v2.console import CONSOLE_HTML as _BASE_PORTAL_HTML
|
||||
|
||||
|
||||
def _extend_portal(source: str) -> str:
|
||||
replacements = (
|
||||
(
|
||||
'<section id="overview" class="view active"></section>\n <section id="tasks" class="view"></section>\n <section id="authorization" class="view"></section>',
|
||||
'<section id="overview" class="view active"></section>\n <section id="resources" class="view"></section>\n <section id="tasks" class="view"></section>\n <section id="execution" class="view"></section>\n <section id="authorization" class="view"></section>',
|
||||
),
|
||||
(
|
||||
"const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心'",
|
||||
"const labels={overview:'系统总览',resources:'租户与 Agent',tasks:'任务中心',execution:'执行中心',authorization:'授权中心'",
|
||||
),
|
||||
(
|
||||
"if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();",
|
||||
"if(view==='overview')await overview();if(view==='resources')await resources();if(view==='tasks')await tasks();if(view==='execution')await execution();if(view==='authorization')await authorizations();",
|
||||
),
|
||||
(
|
||||
"async function tasks(){const d=await api('/api/v2/portal/tasks');",
|
||||
"""async function resources(){const d=await api('/api/v2/portal/resources');const t=d.tenant||{},agents=d.agents||[],ops=d.operations||{};const agentButton=ops.agent_create?'<button class=\"btn\" id=\"newAgent\">注册 Agent</button>':'';$('#resources').innerHTML=`<div class=\"cards\">${card('租户',t.tenant_id||'—',t.status||'')}${card('Agent',d.summary?.agents||0,`启用 ${d.summary?.enabled_agents||0}`)}${card('任务',d.summary?.tasks||0)}${card('Runtime 类型',(d.summary?.runtimes||[]).length,(d.summary?.runtimes||[]).join(', '))}</div><div class=\"grid\"><div class=\"panel\"><div class=\"panel-head\"><h2>当前租户</h2></div><div class=\"kv\"><div>Tenant ID</div><div>${esc(t.tenant_id||'—')}</div><div>名称</div><div>${esc(t.display_name||'—')}</div><div>状态</div><div>${pill(t.status||'unknown')}</div><div>创建时间</div><div>${esc(t.created_at||'—')}</div><div>更新时间</div><div>${esc(t.updated_at||'—')}</div></div></div><div class=\"panel\"><div class=\"panel-head\"><h2>Agent Registry</h2><div>${agentButton}</div></div>${table([['Agent',x=>esc(x.agent_id)],['名称',x=>esc(x.display_name)],['Runtime',x=>pill(x.runtime)],['能力',x=>(x.capabilities||[]).map(pill).join('')],['启用',x=>pill(x.enabled?'enabled':'disabled')],['任务数',x=>esc(x.task_count)]],agents)}</div></div>`;$('#newAgent')?.addEventListener('click',()=>{const agent_id=prompt('Agent ID');const display_name=prompt('显示名称',agent_id||'');const runtime=prompt('Runtime Provider','openclaw');const capabilities=prompt('能力,逗号分隔','');if(!agent_id||!display_name||!runtime)return;api('/api/v2/portal/agents',{method:'POST',body:JSON.stringify({agent_id,display_name,runtime,capabilities:String(capabilities||'').split(',').map(x=>x.trim()).filter(Boolean),metadata:{source:'unified-portal'}})}).then(resources).catch(err)})}
|
||||
function executionStage(x){const s=x.stage||{},status=x.truth||{},record=status.record||{},gate=status.gate||{},d=x.dependency_gate||{},notifications=gate.notifications||[],evidence=record.evidence||{};const stateName=record.state||'WAITING_DEPENDENCY';const deps=(s.depends_on||[]).join(', ')||'根阶段';return `<div class=\"panel\" style=\"background:#fbfcfe\"><div class=\"panel-head\"><h2>${esc(s.stage_id)} · ${esc(s.role)}</h2><div>${pill(stateName)} ${pill(s.executor_type)}</div></div><div class=\"chain\"><div class=\"chain-item\"><b>Provider</b><div>${esc(s.executor_type)}</div><small>${esc(deps)}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Run</b><div>${esc(record.executor_run_id||'未注册')}</div><small>${esc(record.executor_id||'')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Progress</b><div>${esc(record.current_step||0)}/${esc((record.manifest?.steps||s.steps||[]).length)}</div><small>${esc(record.heartbeat_at||'无心跳')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Evidence</b><div>${Object.keys(evidence).length} 项</div><small>${Object.keys(evidence).slice(0,2).map(esc).join(', ')}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Notification</b><div>${notifications.length} 条</div><small>${notifications.map(n=>n.status).join(', ')||'无'}</small></div><span class=\"chain-arrow\">→</span><div class=\"chain-item\"><b>Gate</b><div>${pill(gate.passed?'PASS':'BLOCKED')}</div><small>${d.passed?'依赖满足':'等待依赖'}</small></div></div>${Object.keys(record.error||{}).length?json(record.error):''}</div>`}
|
||||
async function execution(){const d=await api('/api/v2/portal/execution');const providers=d.providers||[],plans=d.plans||[];$('#execution').innerHTML=`<div class=\"cards\">${card('Provider',providers.length)}${card('Execution Plan',plans.length)}${card('协议',d.protocol_version||'—')}${card('Plan Schema',d.plan_schema_version||'—')}</div><div class=\"panel\"><div class=\"panel-head\"><h2>Executor Provider Catalog</h2></div>${table([['Provider',x=>esc(x.executor_type)],['Adapter',x=>esc(x.adapter_version)],['协议',x=>esc(x.protocol_version)],['Trust Roles',x=>(x.trust_roles||[]).map(pill).join('')],['Capabilities',x=>(x.capabilities||[]).map(pill).join('')]],providers)}</div>${plans.map(p=>`<div class=\"panel\"><div class=\"panel-head\"><h2>${esc(p.plan_id)} · ${esc(p.target_version)}</h2><div>${pill(p.gate?.passed?'PASS':'BLOCKED')}</div></div><div class=\"kv\"><div>Source Branch</div><div>${esc(p.source_branch)}</div><div>Source OID</div><div>${esc(p.source_oid)}</div><div>Release Gate</div><div>${pill(p.gate?.passed?'PASS':'BLOCKED')}</div></div></div>${(p.snapshot?.stages||[]).map(executionStage).join('')}`).join('')||'<div class=\"empty\">尚未声明执行计划</div>'}`}
|
||||
async function tasks(){const d=await api('/api/v2/portal/tasks');""",
|
||||
),
|
||||
)
|
||||
result = source
|
||||
for old, new in replacements:
|
||||
if old not in result:
|
||||
raise RuntimeError(f"unified portal extension anchor not found: {old[:100]}")
|
||||
result = result.replace(old, new, 1)
|
||||
return result
|
||||
|
||||
|
||||
PORTAL_HTML = _extend_portal(_BASE_PORTAL_HTML)
|
||||
|
||||
if "\n+function executionStage" in PORTAL_HTML or "\n+async function execution" in PORTAL_HTML:
|
||||
raise RuntimeError("execution center contains an invalid diff marker")
|
||||
|
||||
__all__ = ["PORTAL_HTML"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""HTTP facade for portable multi-provider execution plans."""
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_v2.control_plane import RequestContext, Unauthorized
|
||||
from aios_v2.execution_plan import ExecutionPlanCoordinator
|
||||
from aios_v2.executor_protocol import ExecutorProviderRegistry, normalize_executor
|
||||
|
||||
from .capabilities import require
|
||||
|
||||
|
||||
class ExecutionPlanAPI:
|
||||
"""Portal read model and provider-authenticated plan discovery API."""
|
||||
|
||||
INTERNAL_PREFIX = "/api/v2/internal/execution-plans"
|
||||
PORTAL_PATH = "/api/v2/portal/execution"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ExecutionPlanCoordinator,
|
||||
*,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.providers = providers
|
||||
|
||||
@classmethod
|
||||
def is_route(cls, raw_path: str) -> bool:
|
||||
path = urllib.parse.urlparse(raw_path).path.rstrip("/") or "/"
|
||||
return path == cls.PORTAL_PATH or path.startswith(cls.INTERNAL_PREFIX)
|
||||
|
||||
def _require_provider(
|
||||
self,
|
||||
context: RequestContext,
|
||||
executor_type: str,
|
||||
) -> str:
|
||||
normalized = normalize_executor(executor_type)
|
||||
if not self.providers.trusted(
|
||||
executor_type=normalized,
|
||||
roles=tuple(context.roles),
|
||||
platform_admin="platform_admin" in context.roles,
|
||||
):
|
||||
raise Unauthorized(
|
||||
f"trusted role for executor Provider is required: {normalized}"
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _portal_snapshot(self) -> Mapping[str, Any]:
|
||||
plans = []
|
||||
for plan in self.coordinator.plan_store.list():
|
||||
plans.append(
|
||||
{
|
||||
"plan_id": plan.plan_id,
|
||||
"target_version": plan.target_version,
|
||||
"source_branch": plan.source_branch,
|
||||
"source_oid": plan.source_oid,
|
||||
"snapshot": self.coordinator.snapshot(plan.plan_id),
|
||||
"gate": self.coordinator.gate(plan.plan_id),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"protocol_version": "aios.executor.v1",
|
||||
"plan_schema_version": "aios.execution-plan.v2",
|
||||
"providers": [
|
||||
item.as_dict() for item in self.providers.list_descriptors()
|
||||
],
|
||||
"plans": plans,
|
||||
}
|
||||
|
||||
def handle(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
context: RequestContext,
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
del body
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
|
||||
if method == "GET" and path == self.PORTAL_PATH:
|
||||
require(context, "external_task.read")
|
||||
return HTTPStatus.OK, self._portal_snapshot()
|
||||
|
||||
if not path.startswith(f"{self.INTERNAL_PREFIX}/"):
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "execution plan route not found",
|
||||
}
|
||||
|
||||
remainder = urllib.parse.unquote(path[len(self.INTERNAL_PREFIX) + 1 :])
|
||||
parts = tuple(item for item in remainder.split("/") if item)
|
||||
if len(parts) != 2:
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "execution plan route not found",
|
||||
}
|
||||
plan_id, action = parts
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
executor_type = self._require_provider(
|
||||
context,
|
||||
str(query.get("executor", [""])[0]),
|
||||
)
|
||||
|
||||
if method == "GET" and action == "discover":
|
||||
items = self.coordinator.discover_ready(
|
||||
plan_id=plan_id,
|
||||
executor_type=executor_type,
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"plan_id": plan_id,
|
||||
"executor_type": executor_type,
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
if method == "GET" and action == "snapshot":
|
||||
return HTTPStatus.OK, self.coordinator.snapshot(plan_id)
|
||||
|
||||
if method == "GET" and action == "gate":
|
||||
return HTTPStatus.OK, self.coordinator.gate(plan_id)
|
||||
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "execution plan route not found",
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Protected HTTP facade for portable executor truth and notification receipts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_v2.control_plane import RequestContext, Unauthorized
|
||||
from aios_v2.executor_protocol import ExecutorProviderRegistry, normalize_executor
|
||||
from aios_v2.external_task_control import ExternalTaskCoordinator
|
||||
from aios_v2.external_task_query import list_records
|
||||
from aios_v2.task_execution_registry import ExecutionTruthState
|
||||
|
||||
from .capabilities import require
|
||||
|
||||
|
||||
def _body(value: Any) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _required(value: Any, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _mapping_strings(value: Any, label: str) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(f"{label} must be an object")
|
||||
return {
|
||||
_required(key, f"{label} key"): _required(item, f"{label}[{key}]")
|
||||
for key, item in value.items()
|
||||
}
|
||||
|
||||
|
||||
class ExternalTaskAPI:
|
||||
"""Fail-closed external task API shared by every registered Provider."""
|
||||
|
||||
INTERNAL_PREFIX = "/api/v2/internal/external-tasks"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ExternalTaskCoordinator,
|
||||
*,
|
||||
store_file: str | Path,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.store_file = Path(store_file).resolve()
|
||||
self.providers = providers
|
||||
|
||||
@staticmethod
|
||||
def is_route(path: str) -> bool:
|
||||
normalized = path.rstrip("/") or "/"
|
||||
return normalized.startswith(ExternalTaskAPI.INTERNAL_PREFIX) or normalized == "/api/v2/portal/external-tasks"
|
||||
|
||||
def _require_executor(self, context: RequestContext, executor_type: str) -> str:
|
||||
normalized = normalize_executor(executor_type)
|
||||
if not self.providers.trusted(
|
||||
executor_type=normalized,
|
||||
roles=tuple(context.roles),
|
||||
platform_admin="platform_admin" in context.roles,
|
||||
):
|
||||
raise Unauthorized(
|
||||
f"trusted role for executor Provider is required: {normalized}"
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _task_executor(self, task_id: str) -> str:
|
||||
return normalize_executor(
|
||||
self.coordinator.registry.get(task_id).manifest.executor
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _executor_identity(
|
||||
context: RequestContext,
|
||||
payload: Mapping[str, Any],
|
||||
) -> str:
|
||||
executor_id = _required(payload.get("executor_id"), "executor_id")
|
||||
if executor_id != context.principal_id and "platform_admin" not in context.roles:
|
||||
raise Unauthorized("executor_id must match trusted principal")
|
||||
return executor_id
|
||||
|
||||
def declare_directory(self, directory: str | Path) -> list[Mapping[str, Any]]:
|
||||
root = Path(directory).resolve()
|
||||
if not root.exists():
|
||||
return []
|
||||
declared: list[Mapping[str, Any]] = []
|
||||
for manifest in sorted(root.glob("*.json")):
|
||||
record = self.coordinator.declare_file(manifest)
|
||||
self.providers.validate_manifest(record.manifest)
|
||||
declared.append(
|
||||
{
|
||||
"manifest": str(manifest),
|
||||
"task_id": record.task_id,
|
||||
"executor_type": record.manifest.executor,
|
||||
"state": record.state.value,
|
||||
}
|
||||
)
|
||||
return declared
|
||||
|
||||
def handle(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
context: RequestContext,
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
payload = _body(body or {})
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/external-tasks":
|
||||
require(context, "external_task.read")
|
||||
records = list_records(self.store_file)
|
||||
return HTTPStatus.OK, {
|
||||
"count": len(records),
|
||||
"items": [self.coordinator.status(item.task_id) for item in records],
|
||||
}
|
||||
|
||||
if method == "GET" and path == f"{self.INTERNAL_PREFIX}/discover":
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
states = tuple(query.get("state", ()))
|
||||
executor = self._require_executor(
|
||||
context,
|
||||
str(query.get("executor", [""])[0]),
|
||||
)
|
||||
records = list_records(
|
||||
self.store_file,
|
||||
executor=executor,
|
||||
states=states,
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"count": len(records),
|
||||
"executor_type": executor,
|
||||
"items": [item.as_dict() for item in records],
|
||||
}
|
||||
|
||||
if method == "GET" and path == f"{self.INTERNAL_PREFIX}/notifications":
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
executor = self._require_executor(
|
||||
context,
|
||||
str(query.get("executor", [""])[0]),
|
||||
)
|
||||
task_id = query.get("task_id", [None])[0]
|
||||
status = query.get("status", [None])[0]
|
||||
items = self.coordinator.notifications.list(
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
)
|
||||
filtered = [
|
||||
item
|
||||
for item in items
|
||||
if self._task_executor(item.task_id) == executor
|
||||
]
|
||||
return HTTPStatus.OK, {
|
||||
"count": len(filtered),
|
||||
"executor_type": executor,
|
||||
"items": [item.as_dict() for item in filtered],
|
||||
}
|
||||
|
||||
notification_ack = self._match(
|
||||
path,
|
||||
f"{self.INTERNAL_PREFIX}/notifications/",
|
||||
"/ack",
|
||||
)
|
||||
if method == "POST" and notification_ack:
|
||||
notification = self.coordinator.notifications.get(notification_ack)
|
||||
executor = self._task_executor(notification.task_id)
|
||||
self._require_executor(context, executor)
|
||||
self._executor_identity(context, payload)
|
||||
record = self.coordinator.notifications.acknowledge(
|
||||
notification_ack,
|
||||
receipt_sha256=_required(
|
||||
payload.get("receipt_sha256"),
|
||||
"receipt_sha256",
|
||||
),
|
||||
)
|
||||
return HTTPStatus.OK, {"notification": record.as_dict()}
|
||||
|
||||
truth_task = self._match(path, f"{self.INTERNAL_PREFIX}/", "/truth")
|
||||
if method == "GET" and truth_task:
|
||||
self._require_executor(context, self._task_executor(truth_task))
|
||||
return HTTPStatus.OK, self.coordinator.status(truth_task)
|
||||
|
||||
for action in ("register", "retry", "claim", "start", "heartbeat", "succeed", "fail"):
|
||||
task_id = self._match(path, f"{self.INTERNAL_PREFIX}/", f"/{action}")
|
||||
if method != "POST" or not task_id:
|
||||
continue
|
||||
executor_type = self._task_executor(task_id)
|
||||
self._require_executor(context, executor_type)
|
||||
run_id = _required(payload.get("executor_run_id"), "executor_run_id")
|
||||
if action == "register":
|
||||
return HTTPStatus.OK, {
|
||||
"record": self.coordinator.register(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
).as_dict()
|
||||
}
|
||||
if action == "retry":
|
||||
return HTTPStatus.OK, {
|
||||
"record": self.coordinator.retry_not_started(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
).as_dict()
|
||||
}
|
||||
executor_id = self._executor_identity(context, payload)
|
||||
if action == "claim":
|
||||
record = self.coordinator.claim(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
elif action == "start":
|
||||
record = self.coordinator.start(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
)
|
||||
elif action == "heartbeat":
|
||||
record = self.coordinator.heartbeat(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
current_step=int(payload.get("current_step")),
|
||||
)
|
||||
elif action == "succeed":
|
||||
record = self.coordinator.succeed(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
evidence=_mapping_strings(payload.get("evidence"), "evidence"),
|
||||
)
|
||||
else:
|
||||
error = payload.get("error")
|
||||
if not isinstance(error, Mapping):
|
||||
raise ValueError("error must be an object")
|
||||
evidence_value = payload.get("evidence")
|
||||
evidence = (
|
||||
_mapping_strings(evidence_value, "evidence")
|
||||
if evidence_value is not None
|
||||
else None
|
||||
)
|
||||
record = self.coordinator.fail(
|
||||
task_id,
|
||||
executor_run_id=run_id,
|
||||
executor_id=executor_id,
|
||||
error=dict(error),
|
||||
evidence=evidence,
|
||||
)
|
||||
return HTTPStatus.OK, {"record": record.as_dict()}
|
||||
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "external task route not found",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _match(path: str, prefix: str, suffix: str) -> str | None:
|
||||
if not path.startswith(prefix) or not path.endswith(suffix):
|
||||
return None
|
||||
middle = path[len(prefix) : len(path) - len(suffix)]
|
||||
if not middle or "/" in middle:
|
||||
return None
|
||||
return urllib.parse.unquote(middle)
|
||||
|
||||
|
||||
DISCOVERABLE_STATES: Sequence[str] = (
|
||||
ExecutionTruthState.DECLARED.value,
|
||||
ExecutionTruthState.REGISTERED.value,
|
||||
)
|
||||
@@ -0,0 +1,376 @@
|
||||
"""V2R1C2 unified portal and portable executor release entrypoint.
|
||||
|
||||
The V2R1C1 production entrypoint remains unchanged. This candidate entrypoint
|
||||
uses an isolated state directory, loads a versioned executor catalog, declares
|
||||
portable multi-provider execution plans and exposes both portal read models and
|
||||
provider-authenticated lifecycle APIs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from dataclasses import replace
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import aios_v2.app as candidate_app_module
|
||||
from aios_v2.app import (
|
||||
CandidateApplication,
|
||||
CandidateHTTPServer,
|
||||
build_application as build_candidate_application,
|
||||
)
|
||||
from aios_v2.control_plane import RequestContext
|
||||
from aios_v2.execution_plan import ExecutionPlanCoordinator, ExecutionPlanManifest
|
||||
from aios_v2.executor_catalog import FileExecutorCatalogStore
|
||||
from aios_v2.official_executor_providers import official_provider_factories
|
||||
|
||||
from .application import UnifiedPortalApplication, _json_value, _mapping
|
||||
from .capabilities import allows, require
|
||||
from .console import PORTAL_HTML
|
||||
from .execution_plan_api import ExecutionPlanAPI
|
||||
from .external_task_api import ExternalTaskAPI
|
||||
|
||||
|
||||
_READ_BRIDGE_ROLES = frozenset({"approver", "runtime", "viewer", "release_manager"})
|
||||
_GIT_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def _resolve_environment_value(value: Any, *, label: str) -> Any:
|
||||
if not isinstance(value, str) or not value.startswith("ENV:"):
|
||||
return value
|
||||
variable = value[4:].strip()
|
||||
if not variable:
|
||||
raise ValueError(f"{label} environment variable name is empty")
|
||||
resolved = str(os.getenv(variable, "")).strip()
|
||||
if not resolved:
|
||||
raise RuntimeError(f"required environment variable is not set: {variable}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _load_execution_plan(path: str | Path) -> ExecutionPlanManifest:
|
||||
source = Path(path).resolve()
|
||||
value = json.loads(source.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(f"execution plan must contain a JSON object: {source}")
|
||||
payload = dict(value)
|
||||
for label in ("source_branch", "source_oid", "target_version"):
|
||||
payload[label] = _resolve_environment_value(payload.get(label), label=label)
|
||||
source_oid = str(payload.get("source_oid") or "").strip().lower()
|
||||
if not _GIT_OID.fullmatch(source_oid):
|
||||
raise ValueError(
|
||||
"execution plan source_oid must resolve to an exact 40-character Git OID"
|
||||
)
|
||||
payload["source_oid"] = source_oid
|
||||
return ExecutionPlanManifest.from_dict(payload)
|
||||
|
||||
|
||||
def _path_list(value: str) -> tuple[Path, ...]:
|
||||
return tuple(
|
||||
Path(item).expanduser().resolve()
|
||||
for item in value.split(os.pathsep)
|
||||
if item.strip()
|
||||
)
|
||||
|
||||
|
||||
class V2R1C2PortalApplication(UnifiedPortalApplication):
|
||||
"""Unified portal candidate with provider-neutral execution governance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base: CandidateApplication,
|
||||
*,
|
||||
provenance_store_file: str | Path,
|
||||
external_task_api: ExternalTaskAPI,
|
||||
execution_plan_api: ExecutionPlanAPI,
|
||||
plan_coordinator: ExecutionPlanCoordinator,
|
||||
catalog_snapshot: Mapping[str, Any],
|
||||
declared_plans: Sequence[str],
|
||||
) -> None:
|
||||
super().__init__(base, provenance_store_file=provenance_store_file)
|
||||
self.external_task_api = external_task_api
|
||||
self.execution_plan_api = execution_plan_api
|
||||
self.plan_coordinator = plan_coordinator
|
||||
self.catalog_snapshot = dict(catalog_snapshot)
|
||||
self.declared_plans = tuple(declared_plans)
|
||||
|
||||
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||
original = self.base.context(headers)
|
||||
if original.roles.intersection(_READ_BRIDGE_ROLES):
|
||||
return replace(original, roles=frozenset((*original.roles, "auditor")))
|
||||
return original
|
||||
|
||||
def _resource_snapshot(self, context: RequestContext) -> Mapping[str, Any]:
|
||||
service = self.orchestrator.control_plane
|
||||
tenant = service.get_tenant(context)
|
||||
agents = service.list_agents(context) if allows(context, "agent.read") else []
|
||||
tasks = service.list_tasks(context) if allows(context, "task.read") else []
|
||||
tasks_by_agent: dict[str, int] = {}
|
||||
for task in tasks:
|
||||
tasks_by_agent[task.agent_id] = tasks_by_agent.get(task.agent_id, 0) + 1
|
||||
return {
|
||||
"tenant": _json_value(tenant),
|
||||
"agents": [
|
||||
{
|
||||
**_json_value(agent),
|
||||
"task_count": tasks_by_agent.get(agent.agent_id, 0),
|
||||
}
|
||||
for agent in agents
|
||||
],
|
||||
"summary": {
|
||||
"agents": len(agents),
|
||||
"enabled_agents": sum(1 for agent in agents if agent.enabled),
|
||||
"tasks": len(tasks),
|
||||
"runtimes": sorted({agent.runtime for agent in agents}),
|
||||
},
|
||||
"operations": {
|
||||
"tenant_create": allows(context, "tenant.manage"),
|
||||
"agent_create": allows(context, "agent.manage"),
|
||||
},
|
||||
}
|
||||
|
||||
def _runtime_snapshot(
|
||||
self,
|
||||
context: RequestContext,
|
||||
headers: Mapping[str, str],
|
||||
) -> Mapping[str, Any]:
|
||||
return {
|
||||
**self._runtime(headers),
|
||||
"dispatches": _json_value(
|
||||
self.orchestrator.dispatch_repository.list(
|
||||
tenant_id=context.tenant_id
|
||||
)
|
||||
),
|
||||
"hermes_jobs": _json_value(
|
||||
self.hermes.list_jobs(tenant_id=context.tenant_id)
|
||||
),
|
||||
"contract": {
|
||||
"openclaw": os.getenv(
|
||||
"AIOS_OPENCLAW_CONTRACT_VERSION",
|
||||
"aios.openclaw.command.v2",
|
||||
),
|
||||
"signature_algorithm": os.getenv(
|
||||
"AIOS_OPENCLAW_SIGNATURE_ALGORITHM",
|
||||
"Ed25519",
|
||||
),
|
||||
"plugin_version": os.getenv(
|
||||
"AIOS_OPENCLAW_PLUGIN_VERSION",
|
||||
"2.1.0-rc.4",
|
||||
),
|
||||
"key_id": os.getenv(
|
||||
"AIOS_OPENCLAW_SIGNING_KEY_ID",
|
||||
"openclaw-v2r1-candidate-ed25519",
|
||||
),
|
||||
"executor_protocol": "aios.executor.v1",
|
||||
"execution_plan": "aios.execution-plan.v2",
|
||||
},
|
||||
}
|
||||
|
||||
def _release_snapshot(self, context: RequestContext) -> Mapping[str, Any]:
|
||||
plans = []
|
||||
for plan_id in self.declared_plans:
|
||||
plans.append(
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"gate": self.plan_coordinator.gate(plan_id),
|
||||
}
|
||||
)
|
||||
aggregate_passed = bool(plans) and all(
|
||||
item["gate"]["passed"] for item in plans
|
||||
)
|
||||
return {
|
||||
**self._gitops(context),
|
||||
"version": os.getenv("AIOS_RELEASE_VERSION", "V2R1C2-CANDIDATE"),
|
||||
"target_version": os.getenv("AIOS_TARGET_VERSION", "V2R1C2"),
|
||||
"release_oid": os.getenv("AIOS_RELEASE_OID", "candidate-not-tagged"),
|
||||
"source_oid": os.getenv("AIOS_RELEASE_SOURCE_OID", "missing"),
|
||||
"tag": os.getenv("AIOS_RELEASE_TAG", "NOT_CREATED"),
|
||||
"baseline": os.getenv("AIOS_BASELINE_VERSION", "V2R1C1"),
|
||||
"previous_version": os.getenv("AIOS_PREVIOUS_VERSION", "V2R1C1"),
|
||||
"status": os.getenv("AIOS_RELEASE_STATUS", "CANDIDATE_VALIDATION"),
|
||||
"evidence_manifest_sha256": os.getenv(
|
||||
"AIOS_RELEASE_EVIDENCE_SHA256",
|
||||
"pending",
|
||||
),
|
||||
"executor_catalog": {
|
||||
"path": self.catalog_snapshot.get("path"),
|
||||
"sha256": self.catalog_snapshot.get("catalog_sha256"),
|
||||
"protocol_version": "aios.executor.v1",
|
||||
},
|
||||
"execution_plans": plans,
|
||||
"execution_gate": {
|
||||
"passed": aggregate_passed,
|
||||
"release_blocking": not aggregate_passed,
|
||||
},
|
||||
"operations": (
|
||||
["review_release", "request_tag", "request_rollback"]
|
||||
if allows(context, "release.operate")
|
||||
else []
|
||||
),
|
||||
"note": (
|
||||
"Release operations remain GitOps approval requests. The portal "
|
||||
"never mutates tags, main, or production directly. Tag approval "
|
||||
"must remain blocked until every required execution-plan gate passes."
|
||||
),
|
||||
}
|
||||
|
||||
def handle_json(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
|
||||
if self.execution_plan_api.is_route(raw_path):
|
||||
context = (
|
||||
self.context(headers)
|
||||
if path == ExecutionPlanAPI.PORTAL_PATH
|
||||
else self.base.context(headers)
|
||||
)
|
||||
return self.execution_plan_api.handle(
|
||||
method,
|
||||
raw_path,
|
||||
context,
|
||||
body,
|
||||
)
|
||||
|
||||
if self.external_task_api.is_route(path):
|
||||
context = (
|
||||
self.context(headers)
|
||||
if path == "/api/v2/portal/external-tasks"
|
||||
else self.base.context(headers)
|
||||
)
|
||||
return self.external_task_api.handle(
|
||||
method,
|
||||
raw_path,
|
||||
context,
|
||||
body,
|
||||
)
|
||||
|
||||
context: RequestContext | None = None
|
||||
if path in {
|
||||
"/api/v2/portal/resources",
|
||||
"/api/v2/portal/runtime",
|
||||
"/api/v2/portal/release",
|
||||
"/api/v2/portal/tenants",
|
||||
"/api/v2/portal/agents",
|
||||
}:
|
||||
context = self.context(headers)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/resources":
|
||||
assert context is not None
|
||||
require(context, "tenant.read")
|
||||
return HTTPStatus.OK, self._resource_snapshot(context)
|
||||
|
||||
if method == "POST" and path == "/api/v2/portal/tenants":
|
||||
assert context is not None
|
||||
require(context, "tenant.manage")
|
||||
return self.base.handle_json(
|
||||
"POST",
|
||||
"/api/v2/tenants",
|
||||
headers,
|
||||
_mapping(body),
|
||||
)
|
||||
|
||||
if method == "POST" and path == "/api/v2/portal/agents":
|
||||
assert context is not None
|
||||
require(context, "agent.manage")
|
||||
return self.base.handle_json(
|
||||
"POST",
|
||||
"/api/v2/agents",
|
||||
headers,
|
||||
_mapping(body),
|
||||
)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/runtime":
|
||||
assert context is not None
|
||||
require(context, "runtime.read")
|
||||
return HTTPStatus.OK, self._runtime_snapshot(context, headers)
|
||||
|
||||
if method == "GET" and path == "/api/v2/portal/release":
|
||||
assert context is not None
|
||||
require(context, "release.read")
|
||||
return HTTPStatus.OK, self._release_snapshot(context)
|
||||
|
||||
return super().handle_json(method, raw_path, headers, body)
|
||||
|
||||
|
||||
def build_application() -> V2R1C2PortalApplication:
|
||||
state_dir = Path(
|
||||
os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2r1c2-portal")
|
||||
).resolve()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
provenance_file = Path(
|
||||
os.getenv(
|
||||
"AIOS_PROVENANCE_STORE_FILE",
|
||||
str(state_dir / "run-provenance.sqlite3"),
|
||||
)
|
||||
).resolve()
|
||||
truth_file = Path(
|
||||
os.getenv(
|
||||
"AIOS_EXTERNAL_TASK_STORE_FILE",
|
||||
str(state_dir / "external-task-truth.sqlite3"),
|
||||
)
|
||||
).resolve()
|
||||
catalog_file = Path(
|
||||
os.getenv(
|
||||
"AIOS_EXECUTOR_CATALOG_FILE",
|
||||
str(state_dir / "executor-catalog.json"),
|
||||
)
|
||||
).resolve()
|
||||
|
||||
catalog_store = FileExecutorCatalogStore(catalog_file)
|
||||
catalog_snapshot = catalog_store.initialize_defaults()
|
||||
providers = catalog_store.registry(
|
||||
provider_factories=official_provider_factories()
|
||||
)
|
||||
plan_coordinator = ExecutionPlanCoordinator.from_file(
|
||||
truth_file,
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
declared_plans: list[str] = []
|
||||
plan_files = _path_list(os.getenv("AIOS_EXECUTION_PLAN_FILES", ""))
|
||||
for plan_file in plan_files:
|
||||
plan = _load_execution_plan(plan_file)
|
||||
plan_coordinator.declare(plan)
|
||||
declared_plans.append(plan.plan_id)
|
||||
|
||||
base: CandidateApplication = build_candidate_application()
|
||||
external_task_api = ExternalTaskAPI(
|
||||
plan_coordinator.task_coordinator,
|
||||
store_file=truth_file,
|
||||
providers=providers,
|
||||
)
|
||||
execution_plan_api = ExecutionPlanAPI(
|
||||
plan_coordinator,
|
||||
providers=providers,
|
||||
)
|
||||
return V2R1C2PortalApplication(
|
||||
base,
|
||||
provenance_store_file=provenance_file,
|
||||
external_task_api=external_task_api,
|
||||
execution_plan_api=execution_plan_api,
|
||||
plan_coordinator=plan_coordinator,
|
||||
catalog_snapshot=catalog_snapshot.as_dict(),
|
||||
declared_plans=declared_plans,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
candidate_app_module.CONSOLE_HTML = PORTAL_HTML
|
||||
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("AIOS_V2_PORT", "18921"))
|
||||
server = CandidateHTTPServer((host, port), build_application())
|
||||
print(f"AIOS V2R1C2 unified portal listening on {host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,7 +9,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
RUN groupadd --system --gid 12001 aios \
|
||||
&& useradd --system --uid 12001 --gid aios --home-dir /nonexistent --shell /usr/sbin/nologin aios \
|
||||
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2
|
||||
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2 \
|
||||
&& python3 -m pip install --no-cache-dir 'cryptography>=42'
|
||||
|
||||
WORKDIR /opt/aios/app
|
||||
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
|
||||
|
||||
+35
-11
@@ -1,21 +1,45 @@
|
||||
# AIOS V2 candidate runtime configuration.
|
||||
# Copy to /etc/aios-v2/aios-v2.env and keep ownership root:aios, mode 0640.
|
||||
# AIOS V2R1 Candidate 环境模板
|
||||
# 正式环境禁止保存明文 Secret。
|
||||
# Provenance/OpenClaw 私钥只用于签发端,不进入应用容器或 Git。
|
||||
|
||||
# Runtime
|
||||
AIOS_V2_HOST=127.0.0.1
|
||||
AIOS_V2_PORT=18920
|
||||
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||
|
||||
# Optional OpenClaw runtime. Leave blank to run with OpenClaw disabled.
|
||||
# OpenClaw Ed25519 Command v2
|
||||
AIOS_OPENCLAW_URL=
|
||||
AIOS_OPENCLAW_CREDENTIAL_REF=
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=/run/credentials/aios-v2.service/openclaw-command-private-key
|
||||
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||
|
||||
# File path only. The referenced file must be mode 0600 or stricter and contain
|
||||
# at least 32 bytes. Never place signing material in this env file or Git.
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=
|
||||
# Parent Workflow Provenance
|
||||
AIOS_REQUIRE_RUN_PROVENANCE=true
|
||||
AIOS_PROVENANCE_PUBLIC_KEY_FILE=/etc/aios-v2/provenance/public-key.pem
|
||||
AIOS_PROVENANCE_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2/run-provenance.sqlite3
|
||||
AIOS_PROVENANCE_RECEIPT_DIR=/var/lib/aios-v2/provenance-receipts
|
||||
|
||||
# Reverse proxy must authenticate users and inject X-AIOS-* identity headers.
|
||||
AIOS_TRUST_PROXY_HEADERS=true
|
||||
# Least-privilege child Grants: production requires all seven IDs.
|
||||
AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID=
|
||||
AIOS_PROVENANCE_GRANT_GIT_ID=
|
||||
AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID=
|
||||
AIOS_PROVENANCE_GRANT_RUNTIME_ID=
|
||||
AIOS_PROVENANCE_GRANT_EVIDENCE_ID=
|
||||
AIOS_PROVENANCE_GRANT_ROLLBACK_ID=
|
||||
AIOS_PROVENANCE_GRANT_AUDIT_ID=
|
||||
|
||||
# Candidate deployment metadata exposed through health tooling.
|
||||
AIOS_RELEASE_CHANNEL=candidate
|
||||
AIOS_RELEASE_VERSION=V2R1C0-candidate
|
||||
# Per-operation context. Deployment/rollback wrappers populate these from the
|
||||
# approved task execution context; do not hard-code shared production values.
|
||||
AIOS_PROVENANCE_ID=
|
||||
AIOS_RUN_ID=
|
||||
AIOS_TENANT_ID=
|
||||
AIOS_TASK_ID=
|
||||
AIOS_AGENT_ID=
|
||||
AIOS_PRINCIPAL_ID=
|
||||
|
||||
# 禁止:
|
||||
# AIOS_PROVENANCE_PRIVATE_KEY_FILE
|
||||
# AIOS_OPENCLAW_TOKEN
|
||||
# API_KEY=value
|
||||
# PASSWORD=value
|
||||
|
||||
Regular → Executable
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
OPERATION_PATH="${1:-}"
|
||||
OPERATION_NAME="${2:-}"
|
||||
REQUEST_FILE="${3:-}"
|
||||
|
||||
fail() {
|
||||
echo "PROVENANCE_DENIED: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -n "$OPERATION_PATH" ]] || fail "usage: $0 <deployment|rollback|audit|code_change|runtime_execute|evidence_write> <operation> <request-json-file>"
|
||||
[[ -n "$OPERATION_NAME" ]] || fail "operation name is required"
|
||||
[[ -n "$REQUEST_FILE" && -f "$REQUEST_FILE" ]] || fail "request JSON file is required"
|
||||
|
||||
: "${AIOS_PROVENANCE_PUBLIC_KEY_FILE:?missing AIOS_PROVENANCE_PUBLIC_KEY_FILE}"
|
||||
: "${AIOS_PROVENANCE_KEY_ID:?missing AIOS_PROVENANCE_KEY_ID}"
|
||||
: "${AIOS_PROVENANCE_STORE_FILE:?missing AIOS_PROVENANCE_STORE_FILE}"
|
||||
: "${AIOS_PROVENANCE_RECEIPT_DIR:?missing AIOS_PROVENANCE_RECEIPT_DIR}"
|
||||
: "${AIOS_PROVENANCE_ID:?missing AIOS_PROVENANCE_ID}"
|
||||
: "${AIOS_RUN_ID:?missing AIOS_RUN_ID}"
|
||||
: "${AIOS_TENANT_ID:?missing AIOS_TENANT_ID}"
|
||||
: "${AIOS_TASK_ID:?missing AIOS_TASK_ID}"
|
||||
: "${AIOS_AGENT_ID:?missing AIOS_AGENT_ID}"
|
||||
: "${AIOS_PRINCIPAL_ID:?missing AIOS_PRINCIPAL_ID}"
|
||||
|
||||
PYTHONPATH_VALUE="${AIOS_V2_PYTHONPATH:-/opt/aios/current/active-runtime/app}"
|
||||
|
||||
PYTHONPATH="$PYTHONPATH_VALUE${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
python3 -m aios_v2.provenance_cli authorize \
|
||||
--context-from-env \
|
||||
--store-file "$AIOS_PROVENANCE_STORE_FILE" \
|
||||
--public-key-file "$AIOS_PROVENANCE_PUBLIC_KEY_FILE" \
|
||||
--key-id "$AIOS_PROVENANCE_KEY_ID" \
|
||||
--receipt-dir "$AIOS_PROVENANCE_RECEIPT_DIR" \
|
||||
--path "$OPERATION_PATH" \
|
||||
--operation "$OPERATION_NAME" \
|
||||
--request-file "$REQUEST_FILE"
|
||||
Regular → Executable
+18
-6
@@ -8,12 +8,28 @@ STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
|
||||
BACKUP_ROOT="${AIOS_V2_BACKUP_ROOT:-/var/backups/aios-v2}"
|
||||
TARGET_RELEASE="${1:-}"
|
||||
RESTORE_STATE="${AIOS_V2_RESTORE_STATE:-0}"
|
||||
HEALTH_RETRIES="${AIOS_V2_ROLLBACK_HEALTH_RETRIES:-30}"
|
||||
HEALTH_RETRY_DELAY="${AIOS_V2_ROLLBACK_HEALTH_RETRY_DELAY:-1}"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_healthcheck() {
|
||||
local release="$1"
|
||||
local attempt
|
||||
for ((attempt = 1; attempt <= HEALTH_RETRIES; attempt++)); do
|
||||
if AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$release/deploy/aios-v2/healthcheck/check.sh"; then
|
||||
return 0
|
||||
fi
|
||||
[[ "$attempt" -lt "$HEALTH_RETRIES" ]] && sleep "$HEALTH_RETRY_DELAY"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
|
||||
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
|
||||
|
||||
@@ -53,9 +69,7 @@ mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
|
||||
systemctl daemon-reload
|
||||
systemctl start "$SERVICE"
|
||||
|
||||
if AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh"; then
|
||||
if run_healthcheck "$TARGET_PATH"; then
|
||||
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
|
||||
exit 0
|
||||
fi
|
||||
@@ -78,9 +92,7 @@ systemctl daemon-reload
|
||||
systemctl start "$SERVICE" || true
|
||||
|
||||
if [[ -n "$PREVIOUS_PATH" && -x "$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" ]]; then
|
||||
AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" || true
|
||||
run_healthcheck "$PREVIOUS_PATH" || true
|
||||
fi
|
||||
|
||||
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# AIOS V2R1C2 Unified Portal Candidate
|
||||
# Install as /etc/aios-v2/aios-v2r1c2-portal.env with mode 0640.
|
||||
# Do not place secret values in this file. Use secret references only.
|
||||
|
||||
AIOS_V2_HOST=127.0.0.1
|
||||
AIOS_V2_PORT=18921
|
||||
AIOS_V2_STATE_DIR=/var/lib/aios-v2r1c2-portal
|
||||
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2r1c2-portal/run-provenance.sqlite3
|
||||
AIOS_EXTERNAL_TASK_STORE_FILE=/var/lib/aios-v2r1c2-portal/external-task-truth.sqlite3
|
||||
AIOS_EXECUTOR_CATALOG_FILE=/var/lib/aios-v2r1c2-portal/executor-catalog.json
|
||||
AIOS_EXECUTION_PLAN_FILES=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json:/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
||||
AIOS_EXECUTION_PLAN_FILE=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
||||
|
||||
AIOS_RELEASE_VERSION=V2R1C2-CANDIDATE
|
||||
AIOS_TARGET_VERSION=V2R1C2
|
||||
AIOS_BASELINE_VERSION=V2R1C1
|
||||
AIOS_PREVIOUS_VERSION=V2R1C1
|
||||
AIOS_RELEASE_STATUS=CANDIDATE_VALIDATION
|
||||
AIOS_RELEASE_SOURCE_OID=REPLACE_WITH_EXACT_40_CHARACTER_GIT_OID
|
||||
AIOS_RELEASE_OID=candidate-not-tagged
|
||||
AIOS_RELEASE_TAG=NOT_CREATED
|
||||
AIOS_RELEASE_EVIDENCE_SHA256=pending
|
||||
|
||||
AIOS_OPENCLAW_CONTRACT_VERSION=aios.openclaw.command.v2
|
||||
AIOS_OPENCLAW_SIGNATURE_ALGORITHM=Ed25519
|
||||
AIOS_OPENCLAW_PLUGIN_VERSION=2.1.0-rc.4
|
||||
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||
|
||||
# Runtime resolves this reference through its secret store.
|
||||
AIOS_OWNER_WECHAT_TARGET_REF=AIOS_OWNER_WECHAT_TARGET_REF
|
||||
@@ -0,0 +1,44 @@
|
||||
[Unit]
|
||||
Description=AIOS V2R1C2 Unified Portal Candidate
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/opt/aios-v2r1c2/active-runtime/app/aios_web/release_application.py
|
||||
ConditionPathExists=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json
|
||||
ConditionPathExists=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios
|
||||
Group=aios
|
||||
WorkingDirectory=/opt/aios-v2r1c2/active-runtime/app
|
||||
EnvironmentFile=/etc/aios-v2/aios-v2r1c2-portal.env
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStartPre=/usr/bin/test "$AIOS_V2_PORT" = "18921"
|
||||
ExecStartPre=/usr/bin/test -n "$AIOS_RELEASE_SOURCE_OID"
|
||||
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_SOURCE_OID" != "candidate-not-tagged"
|
||||
ExecStart=/usr/bin/python3 -m aios_web.release_application
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=30
|
||||
KillSignal=SIGTERM
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
ReadWritePaths=/var/lib/aios-v2r1c2-portal
|
||||
StateDirectory=aios-v2r1c2-portal
|
||||
StateDirectoryMode=0750
|
||||
UMask=0027
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,45 @@
|
||||
[Unit]
|
||||
Description=AIOS Candidate OpenClaw Session Broker
|
||||
After=aios-session-broker-bootstrap-writer-candidate.service
|
||||
Requires=aios-session-broker-bootstrap-writer-candidate.service
|
||||
ConditionPathIsDirectory=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
|
||||
ConditionPathExists=/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/broker-policy.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
RuntimeDirectory=aios-openclaw-session-broker-candidate
|
||||
RuntimeDirectoryMode=0700
|
||||
RuntimeDirectoryPreserve=restart
|
||||
LoadCredential=broker_control_token:/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/secrets/broker-control-token.source
|
||||
ExecStart=/usr/bin/python3 /data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725/services/aios-session-broker-candidate/candidate_session_broker.py --policy /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/broker-policy.json --database /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/evidence/broker-state.sqlite3 --projection-root /run/aios-openclaw-session-broker-candidate/sessions --writer-endpoint http://127.0.0.1:19932 --host 127.0.0.1 --port 19931
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
UMask=0077
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
MemoryDenyWriteExecute=true
|
||||
SystemCallArchitectures=native
|
||||
ReadWritePaths=/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725
|
||||
ReadWritePaths=/run/aios-openclaw-session-broker-candidate
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,46 @@
|
||||
[Unit]
|
||||
Description=AIOS task-scoped Candidate Bootstrap Writer
|
||||
After=network.target
|
||||
ConditionPathIsDirectory=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
|
||||
ConditionPathExists=/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/writer-policy.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
RuntimeDirectory=aios-session-broker-bootstrap-writer-candidate
|
||||
RuntimeDirectoryMode=0700
|
||||
RuntimeDirectoryPreserve=restart
|
||||
LoadCredential=writer_token:/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/secrets/writer-token.source
|
||||
LoadCredential=gitea_git_http:/etc/aios-gitops/credentials/gitea-git-http
|
||||
ExecStart=/usr/bin/python3 /data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725/services/aios-session-broker-candidate/candidate_writer.py --policy /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/writer-policy.json --worktree /data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725 --git-dir /run/aios-session-broker-bootstrap-writer-candidate/candidate.git --state /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/evidence/writer-state.json --audit /data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725/evidence/writer-audit.jsonl --host 127.0.0.1 --port 19932
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
UMask=0077
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
MemoryDenyWriteExecute=true
|
||||
SystemCallArchitectures=native
|
||||
ReadWritePaths=/data/aios-gitops/worktrees/openclaw-real-dispatch-session-broker-20260725
|
||||
ReadWritePaths=/data/aios-gitops/runtime/tasks/AIOS-SESSION-BROKER-BOOTSTRAP-20260725
|
||||
ReadWritePaths=/run/aios-session-broker-bootstrap-writer-candidate
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,14 +1,14 @@
|
||||
# AIOS V2 用户需求规格
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 状态:候选版本需求基线
|
||||
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、审计与部署
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:加固候选版本需求基线
|
||||
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、Run Provenance、Dispatch、审计、部署与回滚
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现有系统已经具备 OpenClaw、Hermes、Agent Router、GitOps、局部 WebUI、审计和多租户候选能力。AIOS V2 的目标不是推倒重写,而是将这些分散能力收敛到统一、可审计、可 Web 化、可多租户隔离的控制面。
|
||||
|
||||
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录和执行证据。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
|
||||
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录、执行证据、Run Provenance 和 Dispatch。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
|
||||
|
||||
## 2. 用户角色
|
||||
|
||||
@@ -95,6 +95,7 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
- 禁止越级变化,例如 `CREATED -> COMPLETED`。
|
||||
- 每次变化包含 revision、principal、trace、时间和审计事件。
|
||||
- 公共 API 不允许直接调用 `start`、`complete` 或 `fail` 推进权威状态。
|
||||
|
||||
### UR-007 策略与审批
|
||||
|
||||
@@ -138,12 +139,12 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
### UR-011 持久化与恢复
|
||||
|
||||
租户、Agent、Task、Audit、Idempotency 和 Execution Evidence 必须在服务重启后恢复。
|
||||
租户、Agent、Task、Audit、Idempotency、Dispatch、Run Provenance、Grant 使用记录和 Execution Evidence 必须在服务重启后恢复。
|
||||
|
||||
验收:
|
||||
|
||||
- 候选版支持 SQLite WAL 原子快照。
|
||||
- 有 restart recovery 测试。
|
||||
- 候选版支持 SQLite WAL 或等价原子持久化。
|
||||
- 有 Control Plane 与 Dispatch restart recovery 测试。
|
||||
- 生产版需进一步提供迁移、备份、恢复、HA 和容量证据。
|
||||
|
||||
### UR-012 审计与合规
|
||||
@@ -155,6 +156,7 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
- tenant、principal、trace、action、resource、outcome、policy version、timestamp。
|
||||
- OpenClaw/Hermes Runtime 生命周期。
|
||||
- GitOps、部署和回滚动作。
|
||||
- Provenance Grant、Scope、Authorization Receipt 和 Dispatch 结果摘要。
|
||||
|
||||
### UR-013 密钥安全
|
||||
|
||||
@@ -165,6 +167,7 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
- Git 只存 `secret_ref`、`credential_ref`、`key_id` 或受保护文件路径。
|
||||
- Secret 文件权限为 `0600` 或更严格。
|
||||
- Secret Scanner 默认拒绝可疑提交。
|
||||
- OpenClaw Command 和 Run Provenance 使用 Ed25519;生产应用只读取公钥。
|
||||
|
||||
### UR-014 部署与回滚
|
||||
|
||||
@@ -187,15 +190,102 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
- Traceability Matrix 必须指向当前代码和测试。
|
||||
- 新架构决策必须增加或更新 ADR。
|
||||
|
||||
### UR-016 Workflow Run Provenance
|
||||
|
||||
每次生产 Run 必须具有一个可验证、可撤销、可过期的 Workflow Provenance,用于绑定 Run、Tenant、Task、Agent 与 Principal,但父 Workflow 本身不得授予操作权限。
|
||||
|
||||
验收:
|
||||
|
||||
- Workflow Provenance 使用 Ed25519 签名。
|
||||
- `allowed_paths` 与 `allowed_git_operations` 为空。
|
||||
- 上下文、签名、TTL、Nonce、Key ID 不合法时默认拒绝。
|
||||
- 撤销父 Workflow 后全部子 Grant 失效。
|
||||
|
||||
### UR-017 最小权限子 Grant
|
||||
|
||||
所有生产副作用必须通过与单一 ExecutionPath 对应的子 Grant 授权。
|
||||
|
||||
验收:
|
||||
|
||||
- Runtime、Evidence、Git、Deployment、Rollback、Audit 和 Code Change 使用独立 Grant。
|
||||
- 一个 Grant 只能包含一个路径。
|
||||
- Git Grant 必须限定具体 GitOperation。
|
||||
- 子 Grant 具有独立 TTL、撤销、状态和重放防护。
|
||||
|
||||
### UR-018 Runtime Result 信任绑定
|
||||
|
||||
OpenClaw/Hermes 结果只有在与持久 Dispatch 的身份和执行上下文完全匹配时,才能更新权威 Task。
|
||||
|
||||
验收:
|
||||
|
||||
- 至少校验 tenant、task、agent、principal、trace、runtime、runtime execution、provenance 和 run。
|
||||
- 不同终态结果摘要不得覆盖已有终态。
|
||||
- Hermes 终态结果必须来自持有 Job Lease 的 Worker。
|
||||
- Hermes Worker 生命周期验收入口必须是默认关闭的内部接口,仅接受可信
|
||||
`runtime` 或 `platform_admin` 身份;普通 Operator、浏览器和公共 API 不得直接调用。
|
||||
- 每次 Claim/Start/Heartbeat/Complete/Fail/Cancel 必须绑定
|
||||
tenant、task、trace、principal、provenance、run、dispatch、job、cell 和 profile。
|
||||
- Complete/Fail/Cancel 必须通过 `ExecutionOrchestrator.reconcile_hermes` 更新权威
|
||||
Task/Dispatch,并同步生成 Audit/Event 与 Execution Evidence。
|
||||
- Lease 到期必须持久化清除旧 Owner、递增 revision 并允许安全重领;旧 Worker
|
||||
后续 Result 必须拒绝。
|
||||
- Runtime callback 审计不得绕过 Orchestrator Reconcile。
|
||||
|
||||
### UR-019 Dispatch 持久化与恢复
|
||||
|
||||
Runtime 副作用之前必须持久化唯一 Dispatch,重启后能够恢复并继续严格 Reconcile。
|
||||
|
||||
验收:
|
||||
|
||||
- 同租户 Task 和 Runtime Execution 各自唯一绑定一个 Dispatch。
|
||||
- Dispatch 支持按 ID、Task 与 Runtime Execution 查询。
|
||||
- 终态 Dispatch 不得迁移到不同终态。
|
||||
- Hermes Job、Result、Lease、幂等键和 Worker heartbeat 必须与 Dispatch 一样在
|
||||
服务重启后恢复;不得以只存在于进程内的 Map 作为在线验收依据。
|
||||
- 有 SQLite Repository 重启恢复测试。
|
||||
|
||||
### UR-020 OpenClaw Ed25519 命令合同
|
||||
|
||||
OpenClaw 生产命令必须使用版本化 Ed25519 合同,不得继续使用 HMAC 作为新命令信任根。
|
||||
|
||||
验收:
|
||||
|
||||
- 合同版本为 `aios.openclaw.command.v2`。
|
||||
- 签名覆盖 Key ID、算法、公钥 Fingerprint、Tenant、Task、Trace、Payload、Idempotency 与时间字段。
|
||||
- Key ID/Fingerprint/签名/过期或时钟偏差不合法时拒绝。
|
||||
- Fingerprint 使用恒定时间比较。
|
||||
|
||||
### UR-021 服务级权限隔离
|
||||
|
||||
每个生产服务或 Worker 只能加载其职责所需的 Grant,不得加载全部 Grant 备用。
|
||||
|
||||
验收:
|
||||
|
||||
- 默认 AIOS Application 仅加载 Runtime、Evidence 与 Audit Grant。
|
||||
- Git、Deployment、Rollback 与 Code Change 由独立 Scope 加载。
|
||||
- 空 Scope、多余 Grant 映射和未声明路径调用全部拒绝。
|
||||
- Authorization Receipt 记录 Grant ID、Writer ID 和 Scope。
|
||||
|
||||
### UR-022 CLI 单 Grant 授权
|
||||
|
||||
运维 CLI 的一次授权操作只能加载一个与请求路径一致的 Grant。
|
||||
|
||||
验收:
|
||||
|
||||
- `authorize` 使用一个 `--path` 和一个 `--grant-id`。
|
||||
- Grant 类型与 Path 不一致时拒绝。
|
||||
- CLI 不提供一次加载七个生产 Grant 的授权入口。
|
||||
- 旧全权限 `issue` 仅允许候选兼容,不得进入生产流程。
|
||||
|
||||
## 4. 非功能需求
|
||||
|
||||
### NFR-001 安全
|
||||
|
||||
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计。
|
||||
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计、可撤销 Provenance 和 Runtime 结果绑定。
|
||||
|
||||
### NFR-002 可靠性
|
||||
|
||||
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、健康检查和回滚。
|
||||
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、Dispatch 恢复、健康检查和回滚。
|
||||
|
||||
### NFR-003 可维护性
|
||||
|
||||
@@ -203,28 +293,36 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
|
||||
### NFR-004 可扩展性
|
||||
|
||||
- Store、Transport、Policy、EventPublisher、Runtime Adapter 均通过接口扩展。
|
||||
- Store、Transport、Policy、EventPublisher、Runtime Adapter、Provenance Verifier 与 Writer 均通过接口扩展。
|
||||
|
||||
### NFR-005 可运维性
|
||||
|
||||
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告。
|
||||
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告、Grant 状态和 Receipt。
|
||||
|
||||
### NFR-006 性能候选指标
|
||||
|
||||
候选版上线前至少验证:
|
||||
|
||||
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间。
|
||||
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间、Grant 授权延迟和 Dispatch 恢复时间。
|
||||
- 具体 SLO 在预发布基准测试后固化,禁止无证据承诺。
|
||||
|
||||
## 5. 本候选版本范围
|
||||
|
||||
### 已纳入代码
|
||||
|
||||
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、部署和回滚。
|
||||
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、Run Provenance、Least-Privilege Grant、Scoped Writer、Dispatch Repository、部署和回滚资产。
|
||||
|
||||
### 已纳入测试代码
|
||||
|
||||
- Control Plane/Runtime 候选流程。
|
||||
- OpenClaw Ed25519 签名与验证。
|
||||
- Hermes Worker Lease 与严格 Reconcile。
|
||||
- Dispatch SQLite 重启恢复。
|
||||
- Workflow/Grant 撤销、过期、重放和服务 Scope 隔离。
|
||||
|
||||
### 尚需环境验证
|
||||
|
||||
- 精确测试执行证据。
|
||||
- 完整 Acceptance 实际执行证据。
|
||||
- OpenClaw 真实 Runtime 联调。
|
||||
- 生产数据库/队列替换或容量验证。
|
||||
- 预发布性能、安全、重启和回滚演练。
|
||||
@@ -234,10 +332,11 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
|
||||
1. 平台管理员创建租户并注册 OpenClaw/Hermes Agent。
|
||||
2. 操作员创建任务,审批人批准。
|
||||
3. AIOS Orchestrator 选择 Runtime,并创建签名命令或租户分区 Job。
|
||||
4. Runtime 执行并返回结果。
|
||||
5. AIOS 更新 Task、Audit 和 Execution Evidence。
|
||||
6. 租户用户在 Web Console 查看完整链路。
|
||||
7. 跨租户访问被拒绝。
|
||||
8. 服务重启后状态恢复。
|
||||
9. 候选部署健康检查通过,回滚演练成功。
|
||||
3. Owner 或受控签发端签发 Workflow Provenance 与所需最小权限 Grant。
|
||||
4. AIOS Orchestrator 持久化 Dispatch,并创建 Ed25519 命令或租户分区 Job。
|
||||
5. Runtime 执行并返回与 Dispatch 完全匹配的结果。
|
||||
6. AIOS 更新 Task、Audit、Authorization Receipt 和 Execution Evidence。
|
||||
7. 租户用户在 Web Console 查看完整链路。
|
||||
8. 跨租户、跨 Principal、跨 Grant Path 和重放请求被拒绝。
|
||||
9. 服务重启后 Control Plane、Dispatch 和 Provenance 状态恢复。
|
||||
10. 候选部署健康检查、Acceptance 和回滚演练实际执行并保存证据。
|
||||
|
||||
+286
-203
@@ -1,29 +1,37 @@
|
||||
# AIOS V2 模块契约
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 状态:候选实现契约基线
|
||||
- 原则:Control Plane 权威、Runtime 执行、全链路租户隔离、所有状态可审计
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:加固候选契约基线
|
||||
- 原则:Control Plane 权威、Runtime 仅执行、默认拒绝、服务级最小权限、全链路租户隔离、所有权威状态可审计和恢复
|
||||
|
||||
## 1. 通用上下文契约
|
||||
核心模型名称与实现保持逐字同步:`RequestContext` 承载可信租户、主体、角色、trace、
|
||||
session 与 policy version;`TaskState` 是 AIOS 权威 Task 生命周期枚举,Runtime Worker
|
||||
不得直接写入或复制该状态机。
|
||||
|
||||
## 1. 通用可信上下文契约
|
||||
|
||||
所有控制与执行请求必须携带以下上下文:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---:|---|
|
||||
| `tenant_id` | 是 | 权威租户标识 |
|
||||
| `principal_id` | 控制请求是 | 已认证主体 |
|
||||
| `principal_id` | 控制和 Reconcile 是 | 已认证主体 |
|
||||
| `trace_id` | 是 | 全链路追踪 |
|
||||
| `task_id` | 执行阶段是 | AIOS 权威任务 |
|
||||
| `session_id` | Web/交互请求建议 | 用户会话 |
|
||||
| `agent_id` | 执行和 Provenance 是 | 任务绑定 Agent |
|
||||
| `provenance_id` | 生产副作用操作是 | 父 Workflow Provenance |
|
||||
| `run_id` | 生产副作用操作是 | 本次执行 Run |
|
||||
| `dispatch_id` | Runtime Result 是 | 持久化 Dispatch 记录 |
|
||||
| `policy_version` | 控制请求是 | 策略版本 |
|
||||
| `idempotency_key` | 创建/执行是 | 幂等键 |
|
||||
|
||||
规则:
|
||||
|
||||
- 缺少必填字段时 fail closed。
|
||||
- 客户端不得覆盖由可信代理或 AIOS 生成的 `tenant_id`。
|
||||
- Runtime 返回的 tenant/task/trace 必须与请求完全匹配。
|
||||
- 所有持久化键、缓存键和队列分区必须包含租户命名空间。
|
||||
- 客户端不得覆盖可信代理或 AIOS 生成的租户、身份和追踪字段。
|
||||
- Runtime 返回的 tenant/task/trace/runtime execution 必须与持久 Dispatch 完全匹配。
|
||||
- Runtime Result 还必须匹配 Principal、Parent Provenance 和 Run。
|
||||
- 所有持久化键、缓存键、队列分区、Receipt 和 Evidence 路径必须包含租户命名空间。
|
||||
|
||||
## 2. Tenant Contract
|
||||
|
||||
@@ -41,9 +49,9 @@ updated_at
|
||||
约束:
|
||||
|
||||
- `tenant_id` 不可为空且不可变。
|
||||
- `status` 至少支持 `active` 和受控停用状态。
|
||||
- Tenant 只由 AIOS Control Plane 创建和维护。
|
||||
- Runtime 不得创建租户。
|
||||
- Runtime 不得创建或修改租户。
|
||||
- 跨租户读取默认返回拒绝或资源不存在,不得泄露存在性。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/control_plane.py`
|
||||
|
||||
@@ -68,8 +76,7 @@ updated_at
|
||||
- Agent 属于唯一租户。
|
||||
- `runtime` 由 Registry 决定,可取 `openclaw`、`hermes` 或后续注册 Runtime。
|
||||
- `capabilities` 是允许调度的动作集合。
|
||||
- `metadata` 可存 `cell_id`、`profile_id`、priority 等非敏感引用。
|
||||
- 不得在 Agent metadata 中保存明文凭据。
|
||||
- `metadata` 仅保存非敏感引用,不得保存明文凭据。
|
||||
|
||||
## 4. Task Contract
|
||||
|
||||
@@ -93,7 +100,7 @@ created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
### 4.2 状态机
|
||||
### 4.2 权威状态机
|
||||
|
||||
```text
|
||||
CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
@@ -104,22 +111,22 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
允许转换:
|
||||
|
||||
| 当前 | 允许目标 |
|
||||
|---|---|
|
||||
| CREATED | APPROVED, CANCELLED |
|
||||
| APPROVED | RUNNING, CANCELLED |
|
||||
| RUNNING | COMPLETED, FAILED, CANCELLED |
|
||||
| COMPLETED | 无 |
|
||||
| FAILED | 无 |
|
||||
| CANCELLED | 无 |
|
||||
| 当前 | 允许目标 | 权威入口 |
|
||||
|---|---|---|
|
||||
| CREATED | APPROVED, CANCELLED | Control Plane Approval/Cancel |
|
||||
| APPROVED | RUNNING, CANCELLED | Orchestrator Dispatch / Control Plane Cancel |
|
||||
| RUNNING | COMPLETED, FAILED, CANCELLED | Orchestrator Reconcile / Control Plane Cancel |
|
||||
| COMPLETED | 无 | 终态 |
|
||||
| FAILED | 无 | 终态 |
|
||||
| CANCELLED | 无 | 终态 |
|
||||
|
||||
约束:
|
||||
|
||||
- 每次转换必须校验 `revision`。
|
||||
- COMPLETED 必须带 `result`。
|
||||
- FAILED 必须带 `error`。
|
||||
- COMPLETED 必须带 `result`;FAILED 必须带 `error`。
|
||||
- 每次转换生成 AuditEvent。
|
||||
- Runtime 不得直接写 Task Store,只能通过 Orchestrator reconcile。
|
||||
- Runtime 不得直接写 Task Store。
|
||||
- 公共 API 不得直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`。
|
||||
|
||||
## 5. Policy Contract
|
||||
|
||||
@@ -158,17 +165,166 @@ created_at
|
||||
|
||||
约束:
|
||||
|
||||
- 按租户查询。
|
||||
- `details` 不得包含明文凭据。
|
||||
- OpenClaw/Hermes 生命周期事件必须映射到统一 AuditEvent。
|
||||
- 按租户写入和查询。
|
||||
- `details` 不得包含明文凭据、私钥或完整敏感 Payload。
|
||||
- Runtime callback 可以生成非权威审计事件,但不得单独触发 Task 状态变化。
|
||||
- Task 状态变化必须经过 Orchestrator 严格 Reconcile。
|
||||
- 生产版需增加不可变外部固化和完整性验证。
|
||||
|
||||
## 7. OpenClaw Contract
|
||||
## 7. Workflow Provenance Contract
|
||||
|
||||
### 7.1 OpenClawCommand
|
||||
### 7.1 RunProvenance
|
||||
|
||||
父 Workflow Provenance 只绑定身份与 Run,不授予操作权限:
|
||||
|
||||
```text
|
||||
contract_version
|
||||
provenance_id
|
||||
run_id
|
||||
issuer
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
allowed_paths = []
|
||||
allowed_git_operations = []
|
||||
issued_at
|
||||
expires_at
|
||||
policy_version
|
||||
key_id
|
||||
nonce
|
||||
signature
|
||||
schema_version
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 使用 Ed25519 签名。
|
||||
- `allowed_paths` 和 `allowed_git_operations` 必须为空。
|
||||
- 撤销或过期父 Workflow 后,全部子 Grant 自动失效。
|
||||
- 私钥只存在签发端,生产应用只读取公钥。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/provenance.py`
|
||||
|
||||
## 8. Provenance Grant Contract
|
||||
|
||||
### 8.1 ProvenanceGrant
|
||||
|
||||
```text
|
||||
grant_id
|
||||
parent_provenance_id
|
||||
grant_type
|
||||
execution_path
|
||||
run_id
|
||||
issuer
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
allowed_git_operations
|
||||
issued_at
|
||||
expires_at
|
||||
policy_version
|
||||
key_id
|
||||
nonce
|
||||
signature
|
||||
schema_version
|
||||
```
|
||||
|
||||
Grant 类型与唯一允许路径:
|
||||
|
||||
| GrantType | ExecutionPath |
|
||||
|---|---|
|
||||
| `code_change` | `code_change` |
|
||||
| `git` | `git_operation` |
|
||||
| `deployment` | `deployment` |
|
||||
| `runtime` | `runtime_execute` |
|
||||
| `evidence` | `evidence_write` |
|
||||
| `rollback` | `rollback` |
|
||||
| `audit` | `audit` |
|
||||
|
||||
约束:
|
||||
|
||||
- 一个 Grant 只允许一个路径。
|
||||
- Git Grant 必须显式声明具体 GitOperation;非 Git Grant 不得包含 Git 操作。
|
||||
- 子 Grant 身份字段必须与父 Workflow 完全一致。
|
||||
- 子 Grant TTL 不得超过父 Workflow。
|
||||
- `(grant_id, path, git_operation, request_digest)` 重复使用时拒绝。
|
||||
- Grant 支持独立 `issued/verified/used/expired/revoked` 生命周期。
|
||||
|
||||
实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/provenance_grants.py`
|
||||
- `active-runtime/app/aios_v2/provenance_scoped.py`
|
||||
|
||||
## 9. Service Scoped Writer Contract
|
||||
|
||||
生产进程只加载职责所需 Grant:
|
||||
|
||||
- AIOS Application:`runtime_execute`、`evidence_write`、`audit`。
|
||||
- Git Worker:`git_operation`,代码提交场景另需独立 `code_change` Grant。
|
||||
- Deployment Worker:`deployment`。
|
||||
- Rollback Worker:`rollback`。
|
||||
- CLI 单次授权:一个 `--path` + 一个 `--grant-id`。
|
||||
|
||||
约束:
|
||||
|
||||
- 空 Scope 拒绝。
|
||||
- 多余 Grant 映射拒绝。
|
||||
- 未配置路径的方法调用拒绝。
|
||||
- Receipt 必须记录 `grant_id`、`writer_id` 和服务 Scope。
|
||||
|
||||
## 10. Dispatch Contract
|
||||
|
||||
### 10.1 DispatchRecord
|
||||
|
||||
```text
|
||||
dispatch_id
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
runtime
|
||||
runtime_execution_id
|
||||
trace_id
|
||||
provenance_id
|
||||
run_id
|
||||
authorization_id
|
||||
state
|
||||
expected_worker_id
|
||||
result_digest
|
||||
created_at
|
||||
updated_at
|
||||
completed_at
|
||||
```
|
||||
|
||||
状态:
|
||||
|
||||
```text
|
||||
PREPARED
|
||||
RUNNING
|
||||
QUEUED
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- Dispatch 必须在 Runtime 副作用之前持久化。
|
||||
- 同一租户的 Task 和 Runtime Execution 各只能绑定一个 Dispatch。
|
||||
- 终态 Dispatch 不得跳转到不同终态。
|
||||
- 相同终态结果允许幂等读取,不同结果摘要拒绝。
|
||||
- SQLite Repository 必须支持按 Task 和 Runtime Execution 恢复。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/dispatch_store.py`
|
||||
|
||||
## 11. OpenClaw Contract
|
||||
|
||||
### 11.1 OpenClawCommand v2
|
||||
|
||||
```text
|
||||
contract_version = aios.openclaw.command.v2
|
||||
signature_algorithm = Ed25519
|
||||
command_id
|
||||
tenant_id
|
||||
task_id
|
||||
@@ -179,29 +335,24 @@ idempotency_key
|
||||
created_at
|
||||
expires_at
|
||||
key_id
|
||||
public_key_fingerprint
|
||||
signature
|
||||
```
|
||||
|
||||
签名覆盖:
|
||||
|
||||
- command_id
|
||||
- tenant_id
|
||||
- task_id
|
||||
- trace_id
|
||||
- action
|
||||
- payload
|
||||
- idempotency_key
|
||||
- created_at / expires_at
|
||||
- contract_version / key_id
|
||||
签名覆盖全部未签名字段,包括算法、Key ID 和公钥 Fingerprint。
|
||||
|
||||
约束:
|
||||
|
||||
- 使用规范化 JSON 和 HMAC-SHA256。
|
||||
- 只接受已批准任务生成的命令。
|
||||
- 过期、未来时间超差、签名错误命令必须拒绝。
|
||||
- `key_id` 只引用运行时材料,不包含材料本身。
|
||||
- 使用规范化 JSON 与 Ed25519。
|
||||
- HMAC-SHA256 不再是生产命令合同。
|
||||
- 只接受由已批准任务和已授权 Dispatch 生成的命令。
|
||||
- 过期、未来时间超差、签名错误、Key ID 或 Fingerprint 不匹配时拒绝。
|
||||
- Fingerprint 使用恒定时间比较。
|
||||
- `key_id` 只引用密钥,不包含密钥材料。
|
||||
- Candidate 组合层必须把 `AIOS_OPENCLAW_SIGNING_KEY_ID` 原样传入 Orchestrator,
|
||||
不得在已有批准 Key ID 时回落为默认值。
|
||||
|
||||
### 7.2 OpenClawResult
|
||||
### 11.2 OpenClawResult
|
||||
|
||||
```text
|
||||
command_id
|
||||
@@ -217,49 +368,30 @@ runtime_id
|
||||
attempts
|
||||
```
|
||||
|
||||
状态:
|
||||
|
||||
```text
|
||||
ACCEPTED
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
TIMED_OUT
|
||||
REJECTED
|
||||
```
|
||||
终态:`COMPLETED`、`FAILED`、`CANCELLED`、`TIMED_OUT`、`REJECTED`。
|
||||
|
||||
约束:
|
||||
|
||||
- Adapter 对外只接受终态结果用于 reconcile。
|
||||
- 身份字段不匹配时拒绝回写。
|
||||
- 结果通过 Orchestrator 更新 AIOS Task。
|
||||
|
||||
### 7.3 RuntimeHealth
|
||||
|
||||
```text
|
||||
runtime
|
||||
runtime_id
|
||||
status
|
||||
version
|
||||
heartbeat_at
|
||||
active_tasks
|
||||
workspace_isolated
|
||||
credential_ref_configured
|
||||
details
|
||||
```
|
||||
|
||||
健康策略:
|
||||
|
||||
- heartbeat 不得超时。
|
||||
- workspace isolation 与 credential reference 必须符合策略。
|
||||
- 不安全 Runtime 应 quarantine,不得继续接收新任务。
|
||||
- Result 必须匹配持久 Dispatch 的 command、tenant、task、trace、principal、provenance 和 run。
|
||||
- 只有 Orchestrator 可以更新 AIOS Task。
|
||||
- callback 审计不是权威 Reconcile。
|
||||
|
||||
实现:`active-runtime/app/aios_openclaw/`
|
||||
|
||||
## 8. Hermes Contract
|
||||
## 12. Hermes Contract
|
||||
|
||||
### 8.1 HermesJob
|
||||
### 12.0 内部生命周期入口
|
||||
|
||||
- 路径:`/api/v2/internal/runtime/hermes/lifecycle/{claim,start,heartbeat,complete,fail,cancel,status}`。
|
||||
- `AIOS_HERMES_LIFECYCLE_API_ENABLED` 默认关闭;关闭时按不存在的路由 fail closed。
|
||||
- 仅可信反向代理注入且具备 `runtime` 或 `platform_admin` 角色的身份可调用。
|
||||
- 所有写请求必须携带并严格匹配 tenant、task、trace、principal、provenance、run、
|
||||
dispatch、job、cell、profile;Claim 还必须指定精确 job,不能从分区中误领其他 Job。
|
||||
- Worker 不得直接修改 Task Store。Complete、Fail 和 Cancel 产生的 HermesResult
|
||||
必须经持久 Dispatch 校验并由 `ExecutionOrchestrator.reconcile_hermes` 终态化。
|
||||
- 每次成功生命周期写入同时落盘 Hermes 状态、Audit/Event 和 Execution Evidence。
|
||||
|
||||
### 12.1 HermesJob
|
||||
|
||||
```text
|
||||
job_id
|
||||
@@ -280,24 +412,18 @@ created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
状态机:
|
||||
|
||||
```text
|
||||
QUEUED -> CLAIMED -> RUNNING -> COMPLETED
|
||||
| | |
|
||||
+---------+----------+-> CANCELLED
|
||||
+-> FAILED
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `tenant_id + cell_id + profile_id` 形成执行分区。
|
||||
- claim 只能领取同分区 Job。
|
||||
- Worker ID 不得跨分区复用。
|
||||
- Lease 到期 Job 回到 QUEUED。
|
||||
- Hermes Store 使用 `AIOS_V2_STATE_DIR/hermes.sqlite3` 保存 Job、Result、Lease、
|
||||
Idempotency 和 Worker heartbeat;Adapter 仍是唯一 Queue/Lease 状态机。
|
||||
- Lease 到期重排必须清空 `assigned_worker_id`、清空 `lease_expires_at`、递增 revision
|
||||
并持久化。重启后由显式 lifecycle status/claim 或健康检查安全触发重排。
|
||||
- revision 冲突不得覆盖。
|
||||
|
||||
### 8.2 HermesResult
|
||||
### 12.2 HermesResult
|
||||
|
||||
```text
|
||||
job_id
|
||||
@@ -314,30 +440,19 @@ finished_at
|
||||
|
||||
约束:
|
||||
|
||||
- COMPLETED 必须有 output。
|
||||
- FAILED 必须有 error。
|
||||
- 只允许 AIOS Orchestrator reconcile。
|
||||
|
||||
### 8.3 HermesHealth
|
||||
|
||||
```text
|
||||
runtime_id
|
||||
status
|
||||
version
|
||||
heartbeat_at
|
||||
active_workers
|
||||
active_jobs
|
||||
queued_jobs
|
||||
tenant_partitions
|
||||
profile_isolation_enforced
|
||||
details
|
||||
```
|
||||
- 终态 Result 必须携带 Worker ID。
|
||||
- Worker 必须持有 Job Lease。
|
||||
- Result 必须匹配 Dispatch、Principal、Parent Provenance 和 Run。
|
||||
- 相同 Worker、相同终态和相同 output/error 的重复提交返回原 Result;不同终态或
|
||||
不同结果内容返回 Conflict,不得覆盖已持久化终态。
|
||||
- 只允许内部入口 `POST /api/v2/internal/runtime/hermes/reconcile` 处理。
|
||||
- 旧入口 `POST /api/v2/hermes/results/reconcile` 返回 `410 Gone`。
|
||||
|
||||
实现:`active-runtime/app/aios_hermes/`
|
||||
|
||||
## 9. Execution Evidence Contract
|
||||
## 13. Execution Evidence Contract
|
||||
|
||||
### 9.1 ExecutionEvidence
|
||||
### 13.1 ExecutionEvidence
|
||||
|
||||
```text
|
||||
execution_id
|
||||
@@ -357,53 +472,37 @@ result_summary
|
||||
claimed_at
|
||||
heartbeat_at
|
||||
completed_at
|
||||
```
|
||||
|
||||
状态建议:
|
||||
|
||||
```text
|
||||
claimed
|
||||
running
|
||||
blocked
|
||||
complete
|
||||
failed
|
||||
cancelled
|
||||
provenance_id
|
||||
run_id
|
||||
last_authorization_id
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 按租户存储和查询。
|
||||
- 每个阻塞必须记录原因、责任方和解除条件。
|
||||
- 每次结果必须记录 branch、commit、测试命令和状态。
|
||||
- 不得包含 Secret 或完整敏感 payload。
|
||||
- 生产 `claim/heartbeat/complete` 落盘前必须通过 Evidence Grant。
|
||||
- Evidence ID 不得被不同 Task、Agent 或 Provenance Context 复用。
|
||||
- 不得包含 Secret 或完整敏感 Payload。
|
||||
|
||||
## 10. GitOps Status Contract
|
||||
实现:`active-runtime/app/aios_v2/execution_tracking.py`
|
||||
|
||||
```text
|
||||
available
|
||||
service_status
|
||||
workspace_mode
|
||||
workspace_branch
|
||||
workspace_ref
|
||||
workspace_dirty
|
||||
candidate_branch
|
||||
release_channel
|
||||
release_version
|
||||
sync_status
|
||||
details
|
||||
```
|
||||
## 14. Git Writer Contract
|
||||
|
||||
允许操作:`READ_REF`、`FETCH`、`CREATE_BRANCH`、`COMMIT`、`MERGE`、`PUSH`、`UPDATE_EVIDENCE`,具体能力由 Git Grant 限定。
|
||||
|
||||
约束:
|
||||
|
||||
- Web 响应不得包含 remote URL、凭据或 stderr。
|
||||
- Controller 必须 `detached + clean`。
|
||||
- `sync_status` 必须区分 local 和 Gitea 镜像状态。
|
||||
- 禁止直接操作 `main` 或 `master`。
|
||||
- 所有命令使用参数数组,禁止 Shell 字符串拼接。
|
||||
- Commit 和 Evidence Commit 必须先计算文件内容摘要并完成 Grant 授权,再执行 `git add`。
|
||||
- `UPDATE_EVIDENCE` 只允许 `.agent/` 路径。
|
||||
- 输出必须脱敏并截断。
|
||||
|
||||
## 11. Web/API Contract
|
||||
实现:`active-runtime/app/aios_v2/provenance_git.py`
|
||||
|
||||
### 11.1 身份头
|
||||
## 15. Web/API Contract
|
||||
|
||||
可信代理注入:
|
||||
### 15.1 可信身份头
|
||||
|
||||
```text
|
||||
X-AIOS-Authenticated
|
||||
@@ -413,16 +512,17 @@ X-AIOS-Roles
|
||||
X-AIOS-Trace-ID
|
||||
X-AIOS-Session-ID
|
||||
X-AIOS-Policy-Version
|
||||
X-AIOS-Provenance-ID
|
||||
X-AIOS-Run-ID
|
||||
```
|
||||
|
||||
生产约束:
|
||||
|
||||
- 代理必须删除客户端同名头后重新注入。
|
||||
- 可信代理必须删除客户端同名头后重新注入。
|
||||
- 应用不得直接暴露公网端口。
|
||||
- HTTP 字段名按协议不区分大小写;应用把 Header 集合转换为普通映射后,必须先统一字段名大小写再解析身份。`X-AIOS-*`、`X-Aios-*` 与全小写形式必须得到相同的 `RequestContext`,但字段值本身不得被改写。
|
||||
- 真实监听器回归测试必须覆盖 `BaseHTTPRequestHandler` 的 title-case 行为,避免纯函数测试使用固定大写键而漏掉在线 401。
|
||||
- Header 与 Body 中重复的 Provenance/Run 值必须一致。
|
||||
|
||||
### 11.2 核心 API
|
||||
### 15.2 核心 API
|
||||
|
||||
```text
|
||||
GET /api/v2/health
|
||||
@@ -434,92 +534,75 @@ POST /api/v2/tasks
|
||||
GET /api/v2/tasks
|
||||
GET /api/v2/tasks/{id}
|
||||
POST /api/v2/tasks/{id}/approve
|
||||
POST /api/v2/tasks/{id}/start
|
||||
POST /api/v2/tasks/{id}/complete
|
||||
POST /api/v2/tasks/{id}/fail
|
||||
POST /api/v2/tasks/{id}/cancel
|
||||
POST /api/v2/tasks/{id}/dispatch
|
||||
GET /api/v2/audit
|
||||
GET /api/v2/runtime
|
||||
GET /api/v2/executions
|
||||
GET /api/v2/gitops
|
||||
POST /api/v2/hermes/results/reconcile
|
||||
POST /api/v2/internal/runtime/hermes/reconcile
|
||||
```
|
||||
|
||||
## 12. Persistence Contract
|
||||
公共生命周期旁路:
|
||||
|
||||
```text
|
||||
POST /api/v2/tasks/{id}/start -> 403
|
||||
POST /api/v2/tasks/{id}/complete -> 403
|
||||
POST /api/v2/tasks/{id}/fail -> 403
|
||||
```
|
||||
|
||||
## 16. Persistence Contract
|
||||
|
||||
候选版:
|
||||
|
||||
- SQLite WAL
|
||||
- `BEGIN IMMEDIATE`
|
||||
- 原子 snapshot
|
||||
- schema version
|
||||
- restart restore
|
||||
|
||||
持久化范围:
|
||||
|
||||
- tenants
|
||||
- agents
|
||||
- tasks
|
||||
- audit
|
||||
- idempotency
|
||||
- Control Plane:SQLite Snapshot。
|
||||
- Dispatch:独立 SQLite Repository。
|
||||
- Provenance/Grant/Usage/Event:SQLite WAL。
|
||||
- Evidence/Receipt:租户隔离原子文件写入。
|
||||
- 写事务使用原子替换或 `BEGIN IMMEDIATE`。
|
||||
|
||||
生产替换要求:
|
||||
|
||||
- 不改变 Domain/API Contract。
|
||||
- 提供 migration、backup、restore、HA、RLS/隔离和容量测试。
|
||||
- 提供 migration、backup、restore、HA、隔离和容量测试。
|
||||
- 恢复时保留已使用 Grant 与终态 Dispatch,禁止旧授权重放。
|
||||
|
||||
## 13. Deployment Contract
|
||||
## 17. Deployment Contract
|
||||
|
||||
### systemd
|
||||
|
||||
- 非 root `aios` 用户。
|
||||
- 状态目录 `/var/lib/aios-v2`。
|
||||
- 最小权限和文件系统保护。
|
||||
- 私钥通过受保护 credential 注入,不写入 Git 或普通环境文件。
|
||||
- 私钥文件必须是 regular file、禁止 symlink、权限 `0600` 或更严格。
|
||||
|
||||
### Docker
|
||||
|
||||
- 非 root UID/GID。
|
||||
- read-only root filesystem。
|
||||
- drop all capabilities。
|
||||
- 独立状态 volume。
|
||||
- 独立状态 volume 和 Secret mount。
|
||||
|
||||
### Rollback
|
||||
|
||||
- 回滚前备份状态。
|
||||
- 默认只回滚代码。
|
||||
- 状态恢复需显式授权。
|
||||
- 回滚后必须执行 healthcheck。
|
||||
- 状态恢复需独立 Rollback Grant 和显式批准。
|
||||
- 回滚后必须执行 Healthcheck 与恢复验证。
|
||||
- Release Archive 必须保留 `rollback.sh` 与 `healthcheck/check.sh` 的 executable bit;
|
||||
验收测试必须阻止非执行模式进入候选包。
|
||||
- `systemctl start` 后的回滚健康检查必须执行有界 readiness 重试,默认最多 30 次、
|
||||
每次间隔 1 秒;不得因服务尚未监听的一次瞬时拒绝误判目标 Release 失败。
|
||||
|
||||
## 14. 契约变更规则
|
||||
## 18. 契约变更规则
|
||||
|
||||
任何以下变化必须在同一提交或同一分支更新本文档:
|
||||
以下变化必须在同一分支更新本文档、追踪矩阵和相关 ADR:
|
||||
|
||||
- 字段、状态、Header、API、错误语义;
|
||||
- 租户或安全约束;
|
||||
- 租户、安全和 Provenance 约束;
|
||||
- OpenClaw/Hermes 边界;
|
||||
- Persistence 或 Deployment Contract;
|
||||
- Evidence/Audit 格式。
|
||||
- Dispatch、Persistence、Deployment、Rollback Contract;
|
||||
- Evidence、Audit、Receipt 格式;
|
||||
- 服务 Scope 或 Grant 类型。
|
||||
|
||||
不兼容变化必须新增 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` 控制另一租户的命令。
|
||||
Orchestrator 对调用方请求的更长超时执行 300 秒上限收敛,使签名有效期与 Runtime
|
||||
硬上限保持一致。
|
||||
|
||||
当前 Runtime action allowlist 仅包含无副作用的 `status` 和正式验收专用
|
||||
`acceptance.delay`。其成功只代表 OpenClaw 插件 Runtime 已执行对应合同动作,不代表
|
||||
模型推理或任意 Agent 工具执行。命令身份、幂等键与终态追加持久化;Gateway 重启时
|
||||
未完成的 `RUNNING` 记录收敛为 `FAILED/restart_interrupted`,不得误报为真实超时。
|
||||
不兼容变化必须新增 ADR 和版本号,不得静默修改。正式 Gate A 结论必须基于实际执行 Acceptance 和保存的证据,不得仅凭测试文件存在宣布通过。
|
||||
|
||||
@@ -1,131 +1,79 @@
|
||||
# AIOS V2 需求—架构—代码—测试—部署追踪矩阵
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 目的:确保每项用户需求都能追踪到架构设计、代码实现、自动测试、部署资产和验收命令
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:实现与测试代码已提交;测试执行和 Acceptance 证据待完成
|
||||
- 基线需求:`UR-001`~`UR-022`
|
||||
- 判定规则:测试文件存在不等于测试通过;只有实际执行并保存证据后,状态才能改为“已验证”
|
||||
|
||||
## 1. 追踪规则
|
||||
## 1. 状态定义
|
||||
|
||||
每条需求至少具备:
|
||||
| 状态 | 含义 |
|
||||
|---|---|
|
||||
| 已实现 | 代码、配置或文档已经提交到当前加固分支 |
|
||||
| 测试已提交 | 自动测试代码已经提交,但本报告没有实际执行结果 |
|
||||
| 待验收 | 需要运行 `deploy/aios-v2/acceptance/run-all.sh` 或对应预发布检查并保存证据 |
|
||||
| 未批准 | 尚未获得 Owner 的 Merge、Tag 或生产部署批准 |
|
||||
|
||||
1. 需求编号;
|
||||
2. 架构章节;
|
||||
3. 实现路径;
|
||||
4. 测试路径;
|
||||
5. 部署/运维资产;
|
||||
6. 验收状态。
|
||||
## 2. 用户需求追踪
|
||||
|
||||
状态定义:
|
||||
|
||||
- `Implemented`:代码已进入 Candidate 分支;
|
||||
- `Test-defined`:测试代码已进入 Candidate 分支;
|
||||
- `Test-verified`:精确 commit 的测试已运行并保存证据;
|
||||
- `Pre-production verified`:预发布部署、健康检查和回滚演练已通过;
|
||||
- `Production released`:Owner 批准并完成 merge/tag/deploy。
|
||||
|
||||
## 2. 核心追踪矩阵
|
||||
|
||||
| 需求 | 设计依据 | 代码实现 | 测试 | 部署/运维 | 当前状态 |
|
||||
| 需求 | 架构/决策 | 主要实现 | 测试代码 | 部署/验收入口 | 当前状态 |
|
||||
|---|---|---|---|---|---|
|
||||
| UR-001 AIOS 唯一控制面 | `02-TARGET-ARCHITECTURE.md` §3.2/§3.3;ADR-001 | `aios_v2/control_plane.py`、`aios_v2/orchestrator.py`、`aios_v2/app.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/systemd/aios-v2.service` | Implemented / Test-defined |
|
||||
| UR-002 复用现有能力 | `02-TARGET-ARCHITECTURE.md` §2;`03-MIGRATION-AND-DELIVERY.md` §2 | `aios_openclaw/`、`aios_hermes/` | OpenClaw/Hermes flow tests | Candidate branch + adapter boundaries | Implemented / Test-defined |
|
||||
| UR-003 OpenClaw/Hermes 解耦 | `02-TARGET-ARCHITECTURE.md` §3.4/§3.5;ADR-002 | `aios_openclaw/adapter.py`、`aios_hermes/adapter.py` | runtime boundary tests in candidate flow suite | systemd/Docker runtime configuration | Implemented / Test-defined |
|
||||
| UR-004 全面 Web 化 | `02-TARGET-ARCHITECTURE.md` §3.1/§8 | `aios_v2/console.py`、`aios_v2/gateway.py`、`aios_v2/app.py` | API/console smoke test pending | Docker/systemd + reverse proxy requirement | Implemented / Test pending |
|
||||
| UR-005 多租户隔离 | `02-TARGET-ARCHITECTURE.md` §4;ADR-004 | `control_plane.py`、`orchestrator.py`、`aios_openclaw/*`、`aios_hermes/*`、`execution_tracking.py` | `test_cross_tenant_task_read_is_denied` | tenant-scoped state/evidence directories | Implemented / Test-defined |
|
||||
| UR-006 权威任务生命周期 | `04-MODULE-CONTRACTS.md` §4 | `control_plane.py` | OpenClaw/Hermes full-flow tests | persisted state + audit | Implemented / Test-defined |
|
||||
| UR-007 策略与审批 | `04-MODULE-CONTRACTS.md` §5 | `control_plane.py`、`gateway.py` | role/policy negative tests需补充 | reverse proxy identity + audit | Implemented / Partial test |
|
||||
| UR-008 幂等与并发安全 | `02-TARGET-ARCHITECTURE.md` §7 | `control_plane.py`、`aios_openclaw/adapter.py`、`aios_hermes/adapter.py`、`worker.py` | flow + restart tests;并发专项测试需补充 | GitOps detached guard + SQLite WAL | Implemented / Partial test |
|
||||
| UR-009 执行证据 | `04-MODULE-CONTRACTS.md` §9 | `execution_tracking.py` | `test_execution_tracking_persists_claim_heartbeat_and_result` | `/var/lib/aios-v2/executions` | Implemented / Test-defined |
|
||||
| UR-010 GitOps 唯一变更流程 | `03-MIGRATION-AND-DELIVERY.md` §4/§5;ADR-003 | GitOps Runtime、`gitops_status.py` | healthcheck 的 detached/clean 检查 | `deploy/aios-v2/healthcheck/check.sh` | Implemented / Runtime verified partially |
|
||||
| UR-011 持久化与恢复 | `02-TARGET-ARCHITECTURE.md` §3.6 | `persistence.py` | `test_durable_service_recovers_state_after_restart` | state dir + backup/rollback | Implemented / Test-defined |
|
||||
| UR-012 审计与合规 | `04-MODULE-CONTRACTS.md` §6 | `control_plane.py`、`aios_openclaw/callback.py`、`app.py` | flow test + audit assertions需补充 | Console audit view | Implemented / Partial test |
|
||||
| UR-013 密钥安全 | `02-TARGET-ARCHITECTURE.md` §6;ADR-006 | `FileSigningMaterialResolver`、credential refs、Secret Gate | permission/secret-scan test需补充 | env template + external protected file | Implemented / Partial test |
|
||||
| UR-014 部署与回滚 | `03-MIGRATION-AND-DELIVERY.md` §7 | deployment scripts/assets | acceptance runner + healthcheck | `deploy/aios-v2/` | Implemented / Pre-prod pending |
|
||||
| UR-015 文档与代码同步 | `03-MIGRATION-AND-DELIVERY.md` §9;ADR-005 | `tools/aios_v2_docs_code_check.py` | checker self-test / acceptance runner | CI/Gitea Actions 可调用 | Implemented after checker commit |
|
||||
| UR-001 唯一控制面 | ADR-001;模块契约第 2~6 节 | `active-runtime/app/aios_v2/control_plane.py`、`gateway.py`、`orchestrator.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/acceptance/run-all.sh`;公共生命周期旁路负向检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-002 复用现有能力 | ADR-002 | `active-runtime/app/aios_openclaw/`、`active-runtime/app/aios_hermes/`、`active-runtime/app/aios_v2/orchestrator.py` | `test_candidate_flow.py` | OpenClaw/Hermes 联调 | 已实现;真实 Runtime 联调待验收 |
|
||||
| UR-003 OpenClaw/Hermes 解耦 | ADR-002、ADR-010;模块契约第 11、12 节 | OpenClaw Adapter、Hermes Adapter/Worker、内部 Lifecycle/Reconcile | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | 默认关闭的内部 Runtime API;Runtime audit/evidence 检查 | 已实现;Hermes 专项 17/17 |
|
||||
| UR-004 全面 Web 化 | 目标架构;模块契约第 15 节 | `active-runtime/app/aios_v2/app.py`、`console.py`、`gateway.py` | `test_candidate_flow.py` | Web/API 冒烟、Healthcheck | 已实现;UI/浏览器验收待执行 |
|
||||
| UR-005 多租户隔离 | ADR-004 | `control_plane.py`、`persistence.py`、`execution_tracking.py`、`dispatch_store.py` | `test_candidate_flow.py::test_cross_tenant_task_read_is_denied` | 跨租户负向 Acceptance | 已实现;测试已提交;待验收 |
|
||||
| UR-006 权威任务生命周期 | ADR-001;模块契约第 4 节 | `control_plane.py`、`gateway.py`、`orchestrator.py` | `test_candidate_flow.py` | `/start`、`/complete`、`/fail` 应返回拒绝;Runtime 严格 Reconcile | 已实现;测试/接口验收待执行 |
|
||||
| UR-007 策略与审批 | ADR-001 | `control_plane.py`、`gateway.py` | `test_candidate_flow.py` | Approval、deny audit、policy version 检查 | 已实现;策略场景验收待执行 |
|
||||
| UR-008 幂等与并发安全 | 模块契约第 4、10、12 节 | Task idempotency/revision、Hermes lease/revision、Dispatch 唯一约束 | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | Lease 到期重排、换主、相同终态幂等、不同终态冲突 | 已实现;专项测试通过 |
|
||||
| UR-009 可观测与执行证据 | 模块契约第 6、13 节 | `execution_tracking.py`、`gitops_status.py`、`console.py` | `test_candidate_flow.py::test_execution_tracking_persists_claim_heartbeat_and_result` | `/api/v2/executions`、Evidence Receipt 检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-010 GitOps 唯一变更流程 | ADR-003 | `active-runtime/app/aios_v2/provenance_git.py`、GitOps 工具与任务资产 | `test_provenance.py`、Grant 隔离测试 | Branch/Commit/Push 门禁、Controller clean、OID 同步 | 已实现候选控制;环境 GitOps 验收待执行 |
|
||||
| UR-011 持久化与恢复 | ADR-006、ADR-010;模块契约第 10、12、16 节 | `persistence.py`、`dispatch_store.py`、`aios_hermes/adapter.py::SQLiteHermesStore`、Provenance/Grant Store | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | 运行中 Job 重启恢复、重启后 Reconcile、SQLite quick_check | Hermes/Dispatch/Task 恢复测试通过;生产 HA/容量待验收 |
|
||||
| UR-012 审计与合规 | ADR-001、ADR-004、ADR-009 | `control_plane.py` AuditEvent、`app.py` callback audit、Provenance/Grant Event/Receipt | `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` | `/api/v2/audit`、Receipt/Event 完整性检查 | 已实现候选审计;不可变外部固化待预发布 |
|
||||
| UR-013 密钥安全 | ADR-009;模块契约第 6、11、17 节 | Ed25519 Signer/Verifier、protected file reader、SecretRef 配置 | `test_provenance.py`、`test_candidate_flow.py` | Secret scanner、权限检查、容器/systemd credential 检查 | 已实现;环境密钥注入验收待执行 |
|
||||
| UR-014 部署与回滚 | 迁移与交付文档;模块契约第 17 节 | `deploy/aios-v2/systemd/`、`docker/`、`healthcheck/`、`rollback/` | Acceptance 脚本与候选测试 | systemd/Docker 安装、Healthcheck、备份与回滚演练 | 资产已提交;实际部署/回滚未执行 |
|
||||
| UR-015 文档与代码同步 | ADR-005 | `tools/aios_v2_docs_code_check.py`、`docs/aios-v2/` | 文档门禁脚本 | Acceptance Runner 调用文档门禁 | 已实现;门禁尚未实际执行 |
|
||||
| UR-016 Workflow Run Provenance | ADR-009;模块契约第 7 节 | `active-runtime/app/aios_v2/provenance.py`、`provenance_grants.py::WorkflowProvenanceIssuer` | `test_provenance.py`、`test_provenance_grant_isolation.py::test_parent_workflow_has_no_operational_rights` | CLI `issue-workflow`、状态/撤销检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-017 最小权限子 Grant | ADR-009;模块契约第 8 节 | `provenance_grants.py`、`provenance_scoped.py` | `test_provenance_grant_isolation.py` | `issue-grant`、Grant status/revoke、Receipt 检查 | 已实现;专项测试已提交;待验收 |
|
||||
| UR-018 Runtime Result 信任绑定 | ADR-009、ADR-010;模块契约第 10~12 节 | `orchestrator.py`、`dispatch_store.py`、内部 Hermes Lifecycle/Reconcile | `test_hermes_live_lifecycle.py` | tenant/task/trace/principal/provenance/run/dispatch/job/cell/profile/worker 负向矩阵 | 已实现;专项 17/17 |
|
||||
| UR-019 Dispatch 持久化与恢复 | ADR-009、ADR-010;模块契约第 10、12 节 | `dispatch_store.py`、`orchestrator.py`、`app.py`、`SQLiteHermesStore` | Dispatch recovery 与 Hermes restart-reconcile 测试 | 进程重启后读取 Dispatch/Job 并完成 Reconcile | 已实现;测试通过 |
|
||||
| UR-020 OpenClaw Ed25519 命令合同 | ADR-009;模块契约第 11 节 | `active-runtime/app/aios_openclaw/contract.py`、`adapter.py`、`app.py` | `test_candidate_flow.py`、`test_provenance.py` | 真实 OpenClaw v2 合同联调、Key rotation、clock skew/expiry 负向验收 | 已实现;测试代码已升级;真实联调待执行 |
|
||||
| UR-021 服务级权限隔离 | ADR-009;模块契约第 9 节 | `provenance_scoped.py`、`provenance_operations.py`、`app.py` | `test_provenance_grant_isolation.py` | Application 仅加载 Runtime/Evidence/Audit;Git/Deploy/Rollback 单独 Scope | 已实现;专项测试已提交;配置验收待执行 |
|
||||
| UR-022 CLI 单 Grant 授权 | ADR-009;模块契约第 9 节 | `provenance_cli.py` | `test_provenance_grant_isolation.py` 提供 Writer/Grant 基础验证;CLI 端到端待补执行证据 | `authorize --path ... --grant-id ...`;旧批量 Grant 参数应不存在 | 已实现;CLI 端到端验收待执行 |
|
||||
|
||||
## 3. API 追踪
|
||||
## 3. P0 加固项追踪
|
||||
|
||||
| API | 需求 | 实现 | 测试状态 |
|
||||
|---|---|---|---|
|
||||
| `GET /api/v2/health` | UR-004, UR-014 | `gateway.py` | healthcheck 定义完成 |
|
||||
| `POST /api/v2/tenants` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | 需增加 HTTP 集成测试 |
|
||||
| `POST /api/v2/agents` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks` | UR-006, UR-008 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks/{id}/approve` | UR-006, UR-007 | `gateway.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks/{id}/dispatch` | UR-001, UR-003 | `app.py` / `orchestrator.py` | OpenClaw/Hermes flow 已覆盖 |
|
||||
| `GET /api/v2/runtime` | UR-004, UR-009 | `app.py` | HTTP smoke test待执行 |
|
||||
| `GET /api/v2/executions` | UR-009 | `app.py` / `execution_tracking.py` | Evidence persistence test已定义 |
|
||||
| `GET /api/v2/gitops` | UR-010 | `app.py` / `gitops_status.py` | Runtime/healthcheck验证待执行 |
|
||||
| `POST /api/v2/hermes/results/reconcile` | UR-003, UR-006 | `app.py` / `orchestrator.py` | Hermes flow已覆盖 Domain 路径 |
|
||||
| P0 | 风险 | 实现 | 测试/证据 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| P0-1 公共生命周期旁路 | 客户端直接推进 RUNNING/COMPLETED/FAILED | `gateway.py` 公共入口拒绝;`orchestrator.py` 权威推进 | API 负向 Acceptance | 代码完成;待执行 |
|
||||
| P0-2 Dispatch 持久化 | 重启丢失 Runtime 绑定 | `dispatch_store.py` SQLite Repository;`app.py` 接线 | Dispatch restart recovery 测试 | 代码/测试已提交;待执行 |
|
||||
| P0-3 Result 严格绑定 | 伪造或错配 Runtime Result 更新 Task | `orchestrator.py` 校验 Runtime Execution、Tenant、Task、Trace、Principal、Provenance、Run | Runtime 正向恢复测试;负向 Acceptance | 代码完成;完整验收待执行 |
|
||||
| P0-4 OpenClaw Ed25519 | HMAC 共享密钥和合同不一致 | `aios_openclaw/contract.py` v2 Ed25519;安全文件读取 | 候选 Flow 测试;真实 Runtime 联调 | 代码/测试已提交;联调待执行 |
|
||||
| P0-5 最小权限 Grant | 单凭证拥有全部生产路径 | Workflow + Child Grant + Scoped Writer + 单 Grant CLI | Grant isolation 专项测试 | 代码/测试已提交;待执行 |
|
||||
|
||||
## 4. 运行时契约追踪
|
||||
## 4. Acceptance 证据要求
|
||||
|
||||
| 契约 | 设计 | 代码 | 测试 |
|
||||
|---|---|---|---|
|
||||
| OpenClawCommand | `04-MODULE-CONTRACTS.md` §7.1 | `aios_openclaw/contract.py` | OpenClaw full-flow;签名负向测试需补充 |
|
||||
| OpenClawResult | §7.2 | `aios_openclaw/contract.py` | OpenClaw full-flow |
|
||||
| RuntimeHealth | §7.3 | `aios_openclaw/contract.py`、`health.py` | health policy测试需补充 |
|
||||
| HermesJob | §8.1 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||
| HermesResult | §8.2 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||
| HermesHealth | §8.3 | `aios_hermes/contract.py`、`adapter.py` | healthcheck/worker test待执行 |
|
||||
| ExecutionEvidence | §9 | `execution_tracking.py` | evidence persistence test |
|
||||
| GitOpsStatus | §10 | `gitops_status.py` | healthcheck detached/clean检查 |
|
||||
完整 Acceptance 至少应保存:
|
||||
|
||||
### 4.1 核心控制面模型说明
|
||||
1. 测试命令、开始/结束时间、退出码和完整日志。
|
||||
2. `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` 的实际结果。
|
||||
3. 文档—代码同步门禁实际结果。
|
||||
4. Secret scanner、依赖和容器/systemd 安全检查结果。
|
||||
5. OpenClaw Ed25519 v2 真实联调结果。
|
||||
6. Hermes 内部 Reconcile 正向与伪造上下文负向结果。
|
||||
7. Control Plane、Dispatch、Provenance/Grant 的重启恢复结果。
|
||||
8. 部署、Healthcheck、备份和回滚演练结果。
|
||||
9. 分支、Commit、Controller clean、远端 OID 同步证据。
|
||||
10. Owner 对 Merge、Tag 和生产部署的独立批准。
|
||||
|
||||
`RequestContext` 是每次控制面请求的不可缺失上下文。它承载租户、调用主体、追踪标识和请求来源,所有读取、写入、审批与调度都必须先用该上下文完成租户边界和身份检查。适配器不得自行补造租户,也不得把缺失上下文解释为默认管理权限。上下文校验失败时,控制面必须拒绝请求并写入不含密钥值的审计记录。
|
||||
## 5. 当前发布门禁
|
||||
|
||||
`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. 部署追踪
|
||||
|
||||
| 能力 | 文件 | 验收命令 |
|
||||
| Gate | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| systemd | `deploy/aios-v2/systemd/aios-v2.service` | `systemctl status aios-v2` |
|
||||
| 环境模板 | `deploy/aios-v2/env/aios-v2.env.example` | `systemd-analyze verify` + 文件权限检查 |
|
||||
| Docker | `deploy/aios-v2/docker/Dockerfile` | `docker build` |
|
||||
| Compose | `deploy/aios-v2/docker/compose.yaml` | `docker compose config` / `up` |
|
||||
| 健康检查 | `deploy/aios-v2/healthcheck/check.sh` | 直接执行 |
|
||||
| 回滚 | `deploy/aios-v2/rollback/rollback.sh` | 预发布回滚演练 |
|
||||
| 全量验收 | `deploy/aios-v2/acceptance/run-all.sh` | 直接执行 |
|
||||
|
||||
## 6. 文档覆盖追踪
|
||||
|
||||
| 文档 | 覆盖内容 | 代码/变更触发条件 |
|
||||
|---|---|---|
|
||||
| `README.md` | 事实源索引、状态语言、实现地图 | 新模块/目录/版本 |
|
||||
| `01-USER-REQUIREMENTS.md` | 用户需求、NFR、验收场景 | 用户行为、功能范围、验收变化 |
|
||||
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户 | 架构边界、数据流、运行时变化 |
|
||||
| `03-MIGRATION-AND-DELIVERY.md` | 迁移、GitOps、Gate、回滚 | 发布流程、迁移阶段、门禁变化 |
|
||||
| `04-MODULE-CONTRACTS.md` | 字段、API、状态机、错误语义 | Contract/API/模型变化 |
|
||||
| `05-TRACEABILITY-MATRIX.md` | 需求到代码测试部署映射 | 代码、测试、部署路径变化 |
|
||||
| `adr/*` | 关键不可逆或跨模块决策 | 新架构决策或旧决策替换 |
|
||||
|
||||
## 7. 当前未完成验证项
|
||||
|
||||
以下项目代码或测试定义已存在,但尚不能标记为 `Test-verified` 或 `Pre-production verified`:
|
||||
|
||||
1. 精确 Candidate commit 的测试执行结果;
|
||||
2. HTTP API 与 Web Console 冒烟测试;
|
||||
3. 真实 OpenClaw Runtime 签名、取消、超时和健康联调;
|
||||
4. Hermes 长任务心跳、Worker 崩溃和 Lease 恢复演练;
|
||||
5. 多实例 SQLite/并发容量边界;
|
||||
6. systemd、Docker、Compose、restart 和 rollback 演练;
|
||||
7. local/Gitea Candidate OID 一致证明;
|
||||
8. 生产身份代理、防伪 Header、CSRF/CORS/Rate Limit 验证;
|
||||
9. 文档—代码同步检查器在 CI/Acceptance 中执行通过。
|
||||
|
||||
## 8. 更新要求
|
||||
|
||||
任何代码、API、部署或测试路径变化,必须同步更新本矩阵。验收脚本会检查本文档引用的关键路径存在,并检查架构/功能代码变化是否同时包含 `docs/aios-v2/` 变化。
|
||||
|
||||
### OpenClaw 命令签名材料
|
||||
|
||||
`openclaw-v2r1-candidate-ed25519` 使用 Ed25519 私钥对 canonical command bytes 签名,签名以 URL-safe Base64 编码。运行材料只允许是 32 字节 raw private key 或 Ed25519 PEM;`FileSigningMaterialResolver` 不得对 raw bytes 执行 `strip()`。签名、篡改拒绝、过期拒绝由候选测试与运行时 sign/verify 探针共同验证。
|
||||
| 实现 | 完成 | 五项 P0 代码与配置已提交当前加固分支 |
|
||||
| 测试代码 | 完成 | 回归、Dispatch recovery、Grant isolation 测试已提交 |
|
||||
| 测试执行 | 未完成 | 当前没有可验证的实际运行日志和退出码 |
|
||||
| 文档同步 | 已提交 | 需求、契约、ADR、追踪矩阵已更新;门禁尚未执行 |
|
||||
| Acceptance | 未完成 | `run-all.sh` 尚无本轮实际执行证据 |
|
||||
| Gate A | 未批准 | 只能在 Acceptance 通过并完成独立审查后批准 |
|
||||
| Merge/Tag/生产部署 | 未批准 | 需要 AIOS Owner 明确批准 |
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# AIOS V2R1C2 统一门户与可迁移执行器补充契约
|
||||
|
||||
- 文档版本:`V2R1C2-candidate`
|
||||
- 状态:实现与测试资产已提交;在线 Acceptance、Owner 审批与生产发布未完成
|
||||
- 适用范围:统一角色门户、External Task Truth、Executor Provider、Execution Plan、通知回执、候选部署与 Release Gate
|
||||
- 关联决策:`ADR-011`
|
||||
|
||||
## 1. 新增用户需求
|
||||
|
||||
### UR-023 统一角色感知门户
|
||||
|
||||
AIOS 必须使用同一 Web Portal 服务平台管理员、租户管理员、操作员、审批人、审计员、Runtime、发布经理和只读用户。服务端能力矩阵是唯一授权依据,前端隐藏按钮不得替代服务端授权。
|
||||
|
||||
验收:
|
||||
|
||||
- 门户至少包含总览、租户与 Agent、任务、执行中心、授权、Runtime、Evidence、Audit、GitOps/Release。
|
||||
- `GET /api/v2/me/capabilities` 返回可信身份解析后的角色、能力和导航。
|
||||
- 所有写操作必须再次进行服务端 Capability 检查。
|
||||
- 浏览器不得直接调用 OpenClaw、Hermes、Codex 或未来执行器。
|
||||
|
||||
### UR-024 外部执行任务真相
|
||||
|
||||
任何由 OpenClaw、Hermes、Codex 或未来 Provider 执行的任务,必须进入同一持久 Execution Truth Registry。任务文件、进程存在、日志字符串或退出码不能单独证明任务已经执行。
|
||||
|
||||
统一生命周期:
|
||||
|
||||
```text
|
||||
DECLARED -> REGISTERED -> CLAIMED -> RUNNING -> SUCCEEDED
|
||||
| -> FAILED
|
||||
+ timeout -> STALLED
|
||||
REGISTER deadline exceeded -> NOT_STARTED
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
- 每次执行绑定 `task_id`、`executor_type`、`executor_id` 和 `executor_run_id`。
|
||||
- Claim、Start、Heartbeat、Success、Fail 均持久化事件。
|
||||
- Success 只有在所有步骤和 Required Evidence SHA256 完整时才允许。
|
||||
- 启动超时和心跳超时必须产生可查询的阻塞状态。
|
||||
|
||||
### UR-025 可迁移 Executor Provider
|
||||
|
||||
执行器类型不得由核心枚举固定。OpenClaw、Hermes 和 Codex 只是内置 Provider;未来执行器必须通过版本化 Provider Descriptor、Catalog 和 Adapter 接入,复用相同生命周期、Evidence、Notification 与 Gate。
|
||||
|
||||
验收:
|
||||
|
||||
- Provider Descriptor 包含 `executor_type`、`adapter_version`、`protocol_version`、`trust_roles`、`capabilities` 和配置 Schema。
|
||||
- Catalog 支持原子持久化、SHA256、防篡改导入导出和无 Secret 迁移包。
|
||||
- 新增 Provider 不修改 Truth Registry、Execution Plan 状态机、Portal 或 Release Gate。
|
||||
- API 的可信角色由 Provider Catalog 动态解析,不硬编码执行器名称。
|
||||
|
||||
### UR-026 多 Provider Execution Plan
|
||||
|
||||
发布任务必须可表达为任意 Provider 组成的 DAG。下游 Stage 在全部依赖 Gate 通过前保持 `WAITING_DEPENDENCY`,不得提前声明并消耗启动时限。
|
||||
|
||||
验收:
|
||||
|
||||
- Plan 使用 `aios.execution-plan.v2`,执行协议使用 `aios.executor.v1`。
|
||||
- Stage 包含 Provider、角色、步骤、依赖、Required Evidence、通知事件和超时。
|
||||
- Plan 创建时只声明根 Stage;依赖通过后原子激活下游 Stage。
|
||||
- 聚合 Release Gate 只有在全部 Required Stage 的 Truth、Evidence、Dependency 和 Notification Gate 通过时才允许放行。
|
||||
|
||||
### UR-027 Owner 主动通知与发布门禁
|
||||
|
||||
候选部署开始、成功和失败必须生成持久 Notification Outbox 事件。投递目标使用 Secret Reference,真实微信投递必须回写 Receipt SHA256。通知失败可幂等重试,但不得生成重复事件。
|
||||
|
||||
验收:
|
||||
|
||||
- `(task_id, event_type)` 唯一,同一 STARTED/SUCCEEDED/FAILED 事件只有一个 Notification ID。
|
||||
- `PENDING` 和 `FAILED` 均可重试;成功后原地更新为 `DELIVERED`。
|
||||
- Release Gate 要求配置的 STARTED 和 SUCCEEDED 通知均有 Receipt。
|
||||
- Git、日志、Portal 和任务资产不得存储微信账号、Token 或 Secret。
|
||||
|
||||
## 2. 补充模块契约
|
||||
|
||||
### 2.1 Executor Provider Catalog
|
||||
|
||||
权威实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/executor_protocol.py`
|
||||
- `active-runtime/app/aios_v2/executor_catalog.py`
|
||||
- `active-runtime/app/aios_v2/official_executor_providers.py`
|
||||
|
||||
Catalog 只包含可迁移描述符,不包含 Endpoint Credential、Token、私钥或 Runtime Secret。导入迁移包必须同时校验 Package SHA256 与 Catalog SHA256。
|
||||
|
||||
### 2.2 External Execution Truth
|
||||
|
||||
权威实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/task_execution_registry.py`
|
||||
- `active-runtime/app/aios_v2/external_task_control.py`
|
||||
- `active-runtime/app/aios_v2/external_task_query.py`
|
||||
|
||||
Truth Record 是外部执行状态的唯一事实源。`SUCCEEDED` 不等于 Release 可放行;仍需 Required Notification Receipt 和 Plan Dependency Gate。
|
||||
|
||||
### 2.3 Execution Plan
|
||||
|
||||
权威实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/execution_plan.py`
|
||||
- `active-runtime/app/aios_v2/executor_worker.py`
|
||||
- `tools/aios_execution_plan_cli.py`
|
||||
|
||||
Plan 是 Stage 定义的唯一事实源。不得再复制维护三个独立 Task Manifest,否则会产生 Plan/Task 漂移并可能提前启动下游计时。
|
||||
|
||||
### 2.4 Portal/API
|
||||
|
||||
权威实现:
|
||||
|
||||
- `active-runtime/app/aios_web/capabilities.py`
|
||||
- `active-runtime/app/aios_web/application.py`
|
||||
- `active-runtime/app/aios_web/release_application.py`
|
||||
- `active-runtime/app/aios_web/external_task_api.py`
|
||||
- `active-runtime/app/aios_web/execution_plan_api.py`
|
||||
- `active-runtime/app/aios_web/console.py`
|
||||
|
||||
核心新增 API:
|
||||
|
||||
```text
|
||||
GET /api/v2/portal/execution
|
||||
GET /api/v2/portal/external-tasks
|
||||
GET /api/v2/internal/execution-plans/{plan_id}/discover?executor={provider}
|
||||
GET /api/v2/internal/execution-plans/{plan_id}/snapshot?executor={provider}
|
||||
GET /api/v2/internal/execution-plans/{plan_id}/gate?executor={provider}
|
||||
GET /api/v2/internal/external-tasks/discover?executor={provider}
|
||||
POST /api/v2/internal/external-tasks/{task_id}/{register|claim|start|heartbeat|succeed|fail}
|
||||
GET /api/v2/internal/external-tasks/notifications?executor={provider}
|
||||
POST /api/v2/internal/external-tasks/notifications/{notification_id}/ack
|
||||
```
|
||||
|
||||
内部 API 只接受可信代理注入身份。Provider Trust Role、Task Manifest Executor 与 Principal/Executor ID 必须一致。
|
||||
|
||||
### 2.5 Candidate Deployment
|
||||
|
||||
候选服务:
|
||||
|
||||
- `deploy/aios-v2/systemd/aios-v2r1c2-portal.service`
|
||||
- `deploy/aios-v2/systemd/aios-v2r1c2-portal.env.example`
|
||||
|
||||
约束:
|
||||
|
||||
- V2R1C1 生产继续监听 `127.0.0.1:18920`。
|
||||
- V2R1C2 Candidate 只监听 `127.0.0.1:18921`。
|
||||
- Candidate 使用 `/var/lib/aios-v2r1c2-portal`,不得复用生产状态目录。
|
||||
- `AIOS_RELEASE_SOURCE_OID` 必须解析为精确 40 位 Git OID,否则拒绝启动。
|
||||
- Portal 不直接创建 Tag、修改 `main` 或部署生产。
|
||||
|
||||
## 3. V2R1C2 发布计划
|
||||
|
||||
权威计划:
|
||||
|
||||
- `plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json`
|
||||
|
||||
DAG:
|
||||
|
||||
```text
|
||||
openclaw-candidate-deploy
|
||||
|
|
||||
v
|
||||
hermes-runtime-validation
|
||||
|
|
||||
v
|
||||
codex-release-review
|
||||
|
|
||||
v
|
||||
AIOS Owner approval -> tag/release request
|
||||
```
|
||||
|
||||
OpenClaw 负责隔离候选部署、离线/在线 Acceptance、Role/Tenant 负向测试、重启与回滚演练;Hermes 负责 Queue/Lease/Worker/恢复验证;Codex 负责源代码、证据、安全边界、文档追踪和发布建议审查。所有 Provider 使用同一 Truth Registry。
|
||||
|
||||
## 4. 需求追踪矩阵
|
||||
|
||||
| 需求 | 决策/契约 | 实现 | 测试/验收 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| UR-023 统一门户 | ADR-011;本文 2.4 | `aios_web/*` | `tests/web/test_portable_executor_release.py`;在线 Portal Acceptance | 已实现;测试资产已提交;在线未执行 |
|
||||
| UR-024 执行真相 | ADR-011;本文 2.2 | `task_execution_registry.py`、`external_task_control.py` | Truth/Evidence/Notification tests;CLI Gate | 已实现;实际执行证据待 OpenClaw/Hermes/Codex |
|
||||
| UR-025 Provider 可迁移 | ADR-011;本文 2.1 | `executor_protocol.py`、`executor_catalog.py`、`official_executor_providers.py` | Catalog migration/tamper/dynamic Provider tests | 已实现;测试资产已提交 |
|
||||
| UR-026 Execution Plan DAG | ADR-011;本文 2.3 | `execution_plan.py`、`executor_worker.py` | 下游延迟激活、聚合 Gate tests | 已实现;在线 DAG 未执行 |
|
||||
| UR-027 主动通知 | ADR-011;本文 2.2 | Notification Outbox、Worker retry | 首投失败、幂等重试、Receipt Gate test | 已实现;真实微信投递未执行 |
|
||||
|
||||
## 5. Acceptance 与证据
|
||||
|
||||
离线/在线验收入口:
|
||||
|
||||
```text
|
||||
python3 tools/aios_v2r1c2_acceptance.py \
|
||||
--source-oid <exact-40-character-oid> \
|
||||
[--online] \
|
||||
--evidence-output artifacts/v2r1c2/acceptance-evidence.json
|
||||
```
|
||||
|
||||
专项测试:
|
||||
|
||||
- `tests/web/test_portable_executor_release.py`
|
||||
- `tests/aios_v2_candidate/`
|
||||
|
||||
机器证据至少包含:命令、时间、退出码、输出 SHA256、HTTP 响应 SHA256、Source OID、Plan SHA256、Catalog SHA256、Service/Env SHA256、回滚结果和微信 Receipt SHA256。
|
||||
|
||||
## 6. 当前门禁状态
|
||||
|
||||
| Gate | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 代码与部署资产 | 已提交 | 当前候选分支包含实现、Plan、systemd、CLI 和测试资产 |
|
||||
| 测试执行 | 未验证 | 当前 GitOps 工具未执行 Python 测试,禁止声称通过 |
|
||||
| OpenClaw Candidate Stage | `NOT_STARTED` | 必须由目标环境实际 Discover/Register/Claim/Start |
|
||||
| Hermes Runtime Stage | `WAITING_DEPENDENCY` | OpenClaw Gate 通过后才激活 |
|
||||
| Codex Review Stage | `WAITING_DEPENDENCY` | OpenClaw 与 Hermes Gate 通过后才激活 |
|
||||
| 微信通知 | 未投递 | 需要目标环境 Sender 和 Receipt 回写 |
|
||||
| Tag/生产发布 | 未批准 | 需要 Plan Gate 通过和 AIOS Owner 明确批准 |
|
||||
|
||||
测试文件、Acceptance 脚本、Task YAML、systemd 文件或日志文本的存在都不等于实际 Gate 已通过。
|
||||
@@ -109,15 +109,21 @@ AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和
|
||||
### 4.4 BLK-OPENCLAW-RUN-PROVENANCE-MISSING
|
||||
|
||||
- 严重级别:高
|
||||
- 状态:已解决
|
||||
- 根因:旧插件只有进程内 `runId -> sender` Map,没有 Owner-signed Schema、持久 Store 和限定 Git Writer。
|
||||
- 修复:部署 `aios-tenant-boundary` rc2,增加 Ed25519 issuer/store、Run/Sender/OID/期限绑定、一次性 permit 与专用 `aios_evidence_write`。
|
||||
- 验证:Provenance `openclaw-v2r1-20260721T031523Z` 完成 Heartbeat 写入、Commit、普通 Push 和审计;`README.md` 越权请求被拒绝;Node 测试 9/9。
|
||||
- 状态:开放
|
||||
- 现象: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 和审计链验证通过。
|
||||
|
||||
### 4.5 BLK-CONTROLLER-LEGACY-GITEA-CHECK
|
||||
|
||||
- 严重级别:高
|
||||
- 状态:已解决
|
||||
- 状态:开放
|
||||
- 现象:Controller 健康检查仍硬编码旧 `aios-v23`,并返回 `gitea=false`、`branch_sync=false`。
|
||||
- 风险:健康状态与当前 AIOS V2R1 仓库不一致,可能错误阻止或错误放行发布。
|
||||
- 解除条件:
|
||||
@@ -146,9 +152,9 @@ AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和
|
||||
|
||||
| Gate | 当前状态 | 说明 |
|
||||
|---|---|---|
|
||||
| Gate A:文档与设计 | 允许启动 | 五项高风险 Blocker 已解除,等待正式执行 |
|
||||
| Gate B:代码与测试 | 等待 Gate A | 等待正式执行候选测试 |
|
||||
| Gate C:预发布 | 禁止 | 等待 Gate A、Gate B、离线全量验收和在线联调 |
|
||||
| Gate A:文档与设计 | 未运行 | Candidate 未合并到任务,阻塞未解除 |
|
||||
| Gate B:代码与测试 | 未运行 | 不能基于不完整任务分支测试 |
|
||||
| Gate C:预发布 | 禁止 | Secret、签名、Provenance、Controller Health 未通过 |
|
||||
| Gate D:发布审查 | `STOP_PREPRODUCTION` | Codex 明确停止预发布 |
|
||||
| Gate E:生产发布 | 禁止 | 未满足任何生产 Gate |
|
||||
|
||||
@@ -184,18 +190,29 @@ AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和
|
||||
|
||||
## 8. 外部受信输入
|
||||
|
||||
五项高风险 Blocker 当前均不再需要新的外部受信输入。签名材料继续留在受保护边界,Provenance 仍须按 Run 由 Owner 限时签发,不得转化为长期通用权限。
|
||||
目前仅有两项确实需要外部受信输入:
|
||||
|
||||
1. **批准的签名材料**
|
||||
- 不得通过聊天、Git 或普通日志传递;
|
||||
- 必须通过批准的 Secret/Key 交付链安装。
|
||||
|
||||
2. **OpenClaw Run Provenance 授权**
|
||||
- 仅允许当前任务所需的七个 `.agent` JSON 写入;
|
||||
- 必须绑定任务、执行器实例、Claim、Candidate 和分支;
|
||||
- 不得授予通用生产写权限。
|
||||
|
||||
其余阻塞均可由 OpenClaw、Codex、Windows ChatGPT Owner 和 GitOps 运维流程处理。
|
||||
|
||||
## 9. 监督结论
|
||||
|
||||
当前状态保持:
|
||||
|
||||
```text
|
||||
PARTIAL
|
||||
BLOCKED
|
||||
STOP_PREPRODUCTION
|
||||
```
|
||||
|
||||
三分支已同步 Gitea,五项高风险 Blocker 已解除,允许执行 Gate A、Gate B 和离线全量验收;在三项正式结果与后续在线验收形成证据前,不进入预发布。
|
||||
虽然三分支已经同步 Gitea、凭据已经最小权限轮换、执行服务已经恢复,但当前尚无足够证据允许执行 Gate A–D 或进入预发布。
|
||||
|
||||
任何后续状态更新必须同步:
|
||||
|
||||
@@ -205,14 +222,3 @@ 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,51 @@
|
||||
# V2R1C2 Owner Provenance 与微信接管迁移
|
||||
|
||||
## 边界
|
||||
|
||||
本迁移不关闭或降低 `aios-tenant-boundary`。普通、已识别的消息可以进入只读对话;
|
||||
只有服务端确认的 Owner 才能为 `aios_protected_dispatch` 自动签发
|
||||
`aios.owner-provenance/v2`。未知、阻止名单和受保护操作的非 Owner 请求继续 fail closed。
|
||||
|
||||
客户端不得提交可信身份 Header、私钥、签名或 Provenance Token。插件从 OpenClaw
|
||||
可信 hook 上下文取得 `principal_id`、`agent_id`、`run_id` 和 execution path,并对
|
||||
精确工具参数计算 SHA256。服务端签发记录绑定:
|
||||
|
||||
- `tenant_id`
|
||||
- `principal_id`
|
||||
- `task_id`
|
||||
- `agent_id`
|
||||
- `run_id`
|
||||
- `execution_path`
|
||||
- `request_digest`
|
||||
- `issued_at` / `expires_at`
|
||||
- `key_id` 与覆盖全部字段的 Ed25519 signature
|
||||
|
||||
参数在 hook 与 tool execute 之间改变、错误身份绑定、过期、撤销或单次 permit 重放
|
||||
都会被拒绝。
|
||||
|
||||
## 配置迁移
|
||||
|
||||
私钥仍位于仓库外。部署系统只写入路径引用,并验证目标文件存在、为普通文件且权限
|
||||
不向 group/other 开放。需要在 Candidate 专用配置中增加:
|
||||
|
||||
```text
|
||||
provenancePrivateKeyPath=<outside-git path>
|
||||
provenancePublicKeyPath=<outside-git path>
|
||||
provenanceStoreDir=/var/lib/aios-v2r1c2-portal/provenance
|
||||
provenanceKeyId=<non-secret key id>
|
||||
tenantId=<candidate tenant>
|
||||
protectedDispatchProvider=chatgpt-supervisor
|
||||
protectedDispatchTransport=mock|sandbox
|
||||
ownerProvenanceTtlMs<=300000
|
||||
```
|
||||
|
||||
`mockFailFirstDelivery` 只允许在明确的模拟验收配置中启用。模拟输出必须保留:
|
||||
`simulation=true`、`transport=mock|sandbox`、`real_user_impersonated=false`。
|
||||
|
||||
## 部署与回滚
|
||||
|
||||
1. 从精确 Candidate OID 构建插件与 Portal;不得从 live 目录回写源码。
|
||||
2. 备份 Candidate 插件、配置和 unit;不修改 18920 服务或状态目录。
|
||||
3. 在 18921 完成正向链、负向矩阵、mock/sandbox 微信、restart recovery。
|
||||
4. 回滚只停止 `aios-v2r1c2-portal.service` 并恢复 Candidate 备份;随后重新验证 18920。
|
||||
5. 未经 Owner 明确批准,不创建 Tag、不修改 `main`、不替换生产。
|
||||
@@ -0,0 +1,104 @@
|
||||
# AIOS V2R1 Hermes 生命周期最终验证报告
|
||||
|
||||
- 报告版本:`V2R1-hermes-lifecycle-20260721.3`
|
||||
- 日期:`2026-07-21`
|
||||
- 分支:`patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
|
||||
- 固定验证 OID:`95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
|
||||
- 总体状态:`PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
|
||||
- 发布决策:`STOP_PREPRODUCTION`
|
||||
- 生产发布:`NOT_APPROVED`
|
||||
|
||||
## 1. 执行结论
|
||||
|
||||
`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING` 的 Hermes 功能与证据子条件已在固定 OID 上全部通过。候选实现复用现有 Hermes Adapter、Queue、Lease、Task 与 Orchestrator 状态机,新增持久 SQLite Store 和默认关闭的受保护内部生命周期接口。Hermes 完整生命周期 `17/17`、候选测试 `49/49`、Gate A、Gate B `10/10`、离线 Acceptance `8/0/1`、真实 OpenClaw HTTP、重启恢复、双向回滚和最终健康检查均通过。
|
||||
|
||||
但 Owner 指令规定只有全部 Acceptance 条件同时满足时才允许正式解除该 Blocker。由于“所有 Worktree clean”失败,本报告不得把它标记为正式 `RESOLVED`;准确状态是 `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`。
|
||||
|
||||
最终发布门禁仍失败:权威 Bare Repo 登记的 88 个 worktree 中有 10 个脏 worktree。候选 worktree 和活动 Controller worktree 均 clean,且无 locked/prunable;但 Owner 的验收条件要求所有 worktree clean,因此不得准备 RC、不得发起 Tag Approval Request、不得进入预生产评审。
|
||||
|
||||
## 2. 关键实现
|
||||
|
||||
- `SQLiteHermesStore` 持久保存 Job、Result、Idempotency 与 Worker Heartbeat。
|
||||
- 现有 `HermesAdapter` 保持唯一 Queue/Lease 状态机,支持指定 Job claim、租约到期 requeue、重启恢复与严格终态冲突。
|
||||
- 生命周期身份绑定覆盖 tenant、task、trace、principal、provenance、run、dispatch、job、cell、profile。
|
||||
- start/heartbeat/complete/fail 强制 Lease Owner;complete/fail/cancel 通过 `ExecutionOrchestrator.reconcile_hermes` 更新 Task 与 Dispatch。
|
||||
- cancel 仅允许同时具备 `runtime` 与 `platform_admin` 的 AIOS Control Plane,要求 `cancellation_source=aios-control-plane`,并拒绝携带 `worker_id`,Worker 只能观察取消。
|
||||
- 所有成功生命周期操作写入 Audit/Event/Execution Evidence;接口由 `AIOS_HERMES_LIFECYCLE_API_ENABLED` 控制,默认关闭。
|
||||
|
||||
## 3. 固定 OID 验证
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| Python syntax/import | PASS |
|
||||
| 全部 candidate tests | `49/49` |
|
||||
| Grant isolation | `11/11` |
|
||||
| Dispatch recovery | `1/1` |
|
||||
| Hermes lifecycle | `17/17` |
|
||||
| Docs code gate / Gate A | `0 errors / 0 warnings` |
|
||||
| Secret scan | `547 tracked files / 0 violations` |
|
||||
| SQLite quick_check | `2/2 ok` |
|
||||
| Gate B | `10/10` |
|
||||
| Offline Acceptance | `8 passed / 0 failed / 1 expected warning` |
|
||||
| OpenClaw real HTTP | `COMPLETED` |
|
||||
| Hermes full live rerun | `17/17` |
|
||||
| Restart recovery | `2/2` |
|
||||
| Previous → Current rollback/forward | PASS |
|
||||
| State preservation | `STATE_PRESERVED_EXACTLY=PASS` |
|
||||
| Corrected final health (`16R`) | PASS,三服务 active |
|
||||
|
||||
原 `16.log` 由非服务用户执行健康脚本,打印 state-dir 权限失败后被外层脚本错误覆盖为退出码 0,因此不作为通过证据。纠错后的 `16R.log` 使用受控 `sudo env`,子 shell 保留失败码,所有健康项实际 PASS 且 `EXIT_CODE=0`。原文件保留用于审计。
|
||||
|
||||
回滚日志中的短暂 connection-refused 发生于 systemd 重启窗口内,受 `AIOS_V2_ROLLBACK_HEALTH_RETRIES=30` 与 1 秒间隔限制;前后两次均在界限内达到健康、输出 `ROLLBACK_COMPLETE`,并完成状态文件精确哈希对账。
|
||||
|
||||
## 4. Evidence
|
||||
|
||||
- 根目录:`/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
|
||||
- `steps.tsv` SHA256:`b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf`
|
||||
- `final-live-gate.log` SHA256:`82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa`
|
||||
- `16R.log` SHA256:`641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf`
|
||||
- `15.log` SHA256:`b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e`
|
||||
- `13.log` SHA256:`b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710`
|
||||
- `12R2.log` SHA256:`0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67`
|
||||
|
||||
证据只记录身份、状态、命令、时间、退出码与 Hash,不记录 Secret 值。
|
||||
|
||||
## 5. Git 与运行态门禁
|
||||
|
||||
| 门禁 | 当前值 | 结果 |
|
||||
|---|---|---|
|
||||
| Bare/Gitea/Candidate OID | 全部 `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4` | PASS |
|
||||
| Candidate worktree | clean | PASS |
|
||||
| Active Controller | `6188da26453505dacde9d6d298191437164c8d27`,detached、clean | PASS |
|
||||
| Locked worktree | `0` | PASS |
|
||||
| Prunable worktree | `0` | PASS |
|
||||
| 全局 worktree clean | `10 dirty / 88 total` | FAIL |
|
||||
| 当前隔离 Release | `b158041b8633e658c34ce182f3c8aa97b4bf5f9a` | 已恢复 |
|
||||
| AIOS/OpenClaw/Controller services | active | PASS |
|
||||
|
||||
10 个脏 worktree 均不属于本任务允许清理的范围:
|
||||
|
||||
1. `chatgpt/TASK-V2R1-PERSONAL-WEIXIN-GOLDEN-CONTRACT-R2-20260715`(36)
|
||||
2. `chatgpt/TASK-V2R1-PERSONAL-WEIXIN-PORTAL-ADAPTER-20260715`(2)
|
||||
3. `chatgpt/TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8`(2)
|
||||
4. `codex`(56)
|
||||
5. `codex/TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8`(6)
|
||||
6. `controller`(11)
|
||||
7. `openclaw`(5)
|
||||
8. `openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8`(3)
|
||||
9. `openclaw/TASK-V2R1-OPENCLAW-WEIXIN-WEB-LOGIN-CONTRACT-20260715`(20)
|
||||
10. `openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8`(3)
|
||||
|
||||
## 6. Reviewer
|
||||
|
||||
独立 Spark Reviewer 首先发现 cancel 权限边界不够明确;该问题已在 `95d2e52` 修复并新增三条 HTTP 断言。纠错后 Reviewer 确认代码安全边界、16 步证据与有界回滚均 PASS。对发布门禁的最终布尔复核为:`DIRTY_WORKTREE_COUNT=10` 且 `GLOBAL_WORKTREE_CLEAN=false`,所以全局门禁 FAIL,不能进入预生产评审。
|
||||
|
||||
## 7. Blocker 与发布决策
|
||||
|
||||
- 功能与证据子条件通过、正式关闭受阻:`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING`
|
||||
- 直接阻断条件:`BLK-GLOBAL-WORKTREE-CLEANLINESS`
|
||||
- 状态:`BLOCKED_EVIDENCE_REQUIRED`
|
||||
- 决策:`STOP_PREPRODUCTION`
|
||||
|
||||
本轮未修改 `main`、未创建 Tag、未 Force Push、未切换生产流量、未部署新 RC、未输出 Secret。
|
||||
|
||||
下一步唯一允许动作:由各 worktree Owner 审查并提交、归档或可信清理上述 10 个脏 worktree,然后重新执行全局 Git 门禁。只有全局 dirty 数量降为 0 后,才允许准备 RC 与 Tag Approval Request。
|
||||
@@ -0,0 +1,107 @@
|
||||
# ADR-007:AIOS V2 Run Provenance 可信执行来源控制
|
||||
|
||||
- 状态:已采纳
|
||||
- 日期:2026-07-20
|
||||
- 影响范围:Runtime 执行、Evidence、GitOps、部署、回滚、审计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
AIOS V2 在进入预生产阶段前,需要证明每一次高权限动作来源可信。仅依靠进程内 runId、sender 或任务字段无法证明:
|
||||
|
||||
- 谁授权了执行;
|
||||
- 执行是否属于当前任务;
|
||||
- Git、部署、回滚和 Evidence 写入是否经过批准;
|
||||
- 是否存在重放或伪造运行记录。
|
||||
|
||||
## 2. 决策
|
||||
|
||||
引入 Run Provenance:
|
||||
|
||||
```text
|
||||
Owner / Control Plane
|
||||
|
|
||||
v
|
||||
Ed25519 Signed Provenance
|
||||
|
|
||||
v
|
||||
Persistent Provenance Store
|
||||
|
|
||||
v
|
||||
Controlled Writer
|
||||
|
|
||||
v
|
||||
Runtime / GitOps / Deploy / Evidence / Rollback
|
||||
```
|
||||
|
||||
## 3. 七类受保护路径
|
||||
|
||||
生产 Provenance 必须覆盖:
|
||||
|
||||
1. code_change
|
||||
2. git_operation
|
||||
3. deployment
|
||||
4. runtime_execute
|
||||
5. evidence_write
|
||||
6. rollback
|
||||
7. audit
|
||||
|
||||
任何未授权路径默认拒绝。
|
||||
|
||||
## 4. 安全规则
|
||||
|
||||
- 私钥只用于签发端。
|
||||
- Runtime 只持有公钥验证能力。
|
||||
- Provenance 使用 Ed25519 签名。
|
||||
- 状态持久保存 issued、verified、used、expired、revoked。
|
||||
- 相同 provenance/path/request_digest 不允许重复授权。
|
||||
- Secret 不进入 Git、日志和 Evidence。
|
||||
|
||||
## 5. 实现映射
|
||||
|
||||
代码:
|
||||
|
||||
```text
|
||||
active-runtime/app/aios_v2/provenance.py
|
||||
active-runtime/app/aios_v2/provenance_git.py
|
||||
active-runtime/app/aios_v2/provenance_operations.py
|
||||
active-runtime/app/aios_v2/provenance_cli.py
|
||||
```
|
||||
|
||||
接入:
|
||||
|
||||
```text
|
||||
orchestrator.py
|
||||
execution_tracking.py
|
||||
app.py
|
||||
```
|
||||
|
||||
## 6. 验收要求
|
||||
|
||||
必须验证:
|
||||
|
||||
- 签名正确执行;
|
||||
- 篡改拒绝;
|
||||
- 过期拒绝;
|
||||
- revoke 拒绝;
|
||||
- replay 拒绝;
|
||||
- 无 Provenance Runtime Dispatch 拒绝;
|
||||
- 无 Evidence 授权写入拒绝;
|
||||
- 无 Git 授权操作拒绝;
|
||||
- 无 Deployment/Rollback 授权拒绝。
|
||||
|
||||
## 7. 回滚
|
||||
|
||||
若 Provenance 服务异常:
|
||||
|
||||
- 不允许降级绕过安全检查进入生产;
|
||||
- 候选环境可以在明确测试配置中关闭强制模式;
|
||||
- 生产模式必须保持 AIOS_REQUIRE_RUN_PROVENANCE=true。
|
||||
|
||||
## 8. 后续演进
|
||||
|
||||
生产阶段可进一步接入:
|
||||
|
||||
- HSM/KMS;
|
||||
- 外部不可变审计;
|
||||
- 多签审批;
|
||||
- 硬件密钥认证。
|
||||
@@ -0,0 +1,156 @@
|
||||
# ADR-009:Workflow Provenance 与最小权限子 Grant
|
||||
|
||||
- 状态:已采纳
|
||||
- 日期:2026-07-21
|
||||
- 决策范围:AIOS V2R1 生产授权、Runtime 调度、Evidence、Git、部署、回滚与审计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
早期候选实现使用一个 Run Provenance 同时承载身份绑定和七类生产操作权限。该模型可以证明一次执行来自哪个租户、任务、Agent 与 Principal,但同一凭证能够授权 Runtime、Evidence、Git、部署、回滚、审计和代码变更,违反最小权限原则。任何持有该凭证的进程都可能获得超出职责的能力。
|
||||
|
||||
同时,Runtime Result 回写、Git 写入和部署包装器需要可撤销、可过期、可重放防护且可持久恢复的独立授权。仅依赖进程角色或环境变量不足以形成可审计的生产信任链。
|
||||
|
||||
## 2. 决策
|
||||
|
||||
采用两层 Provenance 模型:
|
||||
|
||||
1. **Workflow Provenance** 只绑定 `run_id`、`tenant_id`、`task_id`、`agent_id`、`principal_id`、签发者、TTL、Key ID、Nonce 和签名,不授予任何操作路径。
|
||||
2. **Provenance Grant** 由 Workflow Provenance 派生,每个 Grant 只绑定一个 `ExecutionPath`,拥有独立的 TTL、Nonce、状态、撤销和重放记录。
|
||||
3. Git Grant 还必须显式限定允许的 `GitOperation`,例如只允许 `READ_REF` 而不允许 `PUSH`。
|
||||
4. 子 Grant 的身份字段必须与父 Workflow 完全一致,且子 Grant 的有效期不得超过父 Workflow。
|
||||
5. 父 Workflow 被撤销或过期后,其全部子 Grant 自动失效。
|
||||
6. 授权使用 `(grant_id, execution_path, git_operation, request_digest)` 建立唯一使用记录,重复请求默认拒绝。
|
||||
|
||||
## 3. Grant 类型
|
||||
|
||||
| GrantType | 唯一允许路径 |
|
||||
|---|---|
|
||||
| `code_change` | `code_change` |
|
||||
| `git` | `git_operation` |
|
||||
| `deployment` | `deployment` |
|
||||
| `runtime` | `runtime_execute` |
|
||||
| `evidence` | `evidence_write` |
|
||||
| `rollback` | `rollback` |
|
||||
| `audit` | `audit` |
|
||||
|
||||
Grant 类型与路径不一致时,构造阶段即拒绝。非 Git Grant 不得包含 Git 操作;Git Grant 未声明具体操作时不得签发。
|
||||
|
||||
## 4. 服务级 Scope
|
||||
|
||||
生产进程不得加载全部 Grant。`ScopedLeastPrivilegeProvenanceWriter` 只接受当前服务声明的路径和对应 Grant ID,多余 Grant 映射会导致启动失败。
|
||||
|
||||
默认 AIOS Application Scope 仅包含:
|
||||
|
||||
- `runtime_execute`
|
||||
- `evidence_write`
|
||||
- `audit`
|
||||
|
||||
Git、部署、回滚和代码变更必须由独立进程或包装器显式声明自己的单一路径 Scope。运维 CLI 的一次授权命令只接受一个 `--grant-id`,并要求它与 `--path` 一致。
|
||||
|
||||
## 5. Runtime 与 Dispatch 绑定
|
||||
|
||||
Runtime 执行还必须经过持久化 `DispatchRecord`。记录至少绑定:
|
||||
|
||||
- `dispatch_id`
|
||||
- `tenant_id`
|
||||
- `task_id`
|
||||
- `agent_id`
|
||||
- `principal_id`
|
||||
- `runtime`
|
||||
- `runtime_execution_id`
|
||||
- `trace_id`
|
||||
- `provenance_id`
|
||||
- `run_id`
|
||||
- `authorization_id`
|
||||
|
||||
OpenClaw/Hermes 结果只有在上述上下文完全匹配时才能由 Orchestrator 更新 Task。公开 Task API 不允许直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`;Hermes 结果只通过内部 Runtime Reconcile 入口处理。
|
||||
|
||||
## 6. 密钥与签名
|
||||
|
||||
Workflow Provenance、子 Grant 和 OpenClaw Command 均采用 Ed25519。生产应用只读取公钥;私钥仅存在于受保护的签发端或 systemd credential。Git、日志、环境模板和审计中不得出现私钥内容。
|
||||
|
||||
Key ID 用于选择受信任密钥,不包含密钥材料。公钥 Fingerprint 使用恒定时间比较。OpenClaw Command 合同版本为 `aios.openclaw.command.v2`。
|
||||
|
||||
## 7. 备选方案
|
||||
|
||||
### 7.1 单一全权限 Provenance
|
||||
|
||||
拒绝。实现简单,但任何服务泄漏都可能获得全部生产能力,无法独立撤销 Runtime、Git 或部署权限。
|
||||
|
||||
### 7.2 仅依赖 RBAC
|
||||
|
||||
拒绝。RBAC 可以限制 API 调用者,但不能把具体 Run、Task、Agent、请求摘要和 Runtime Result 绑定到一次可验证授权。
|
||||
|
||||
### 7.3 每个操作重新签发完整 Workflow
|
||||
|
||||
拒绝。会复制身份数据和生命周期状态,难以统一撤销整次工作流,也增加签发和审计复杂度。
|
||||
|
||||
## 8. 正面影响
|
||||
|
||||
- 服务与操作权限按职责隔离。
|
||||
- Runtime、Evidence、Git、部署、回滚和审计可以独立撤销。
|
||||
- 父 Workflow 可作为整次 Run 的紧急停止开关。
|
||||
- SQLite 持久化支持重启后的授权与 Dispatch 恢复。
|
||||
- Receipt 明确记录 Grant、Scope、请求摘要和授权时间。
|
||||
- Git Index 只有在 Grant 授权完成后才允许修改。
|
||||
|
||||
## 9. 负面影响与风险
|
||||
|
||||
- 签发和部署配置比单凭证模式复杂。
|
||||
- 每个生产服务必须配置正确的 Grant ID。
|
||||
- Grant 状态与 Dispatch 状态需要备份、迁移和容量治理。
|
||||
- 当前候选实现使用 SQLite;高可用生产部署仍需外部持久化和并发容量证据。
|
||||
- OpenClaw callback 目前只作为非权威审计事件;状态变化仍必须经过 Orchestrator 严格 Reconcile。
|
||||
|
||||
## 10. 迁移与回滚
|
||||
|
||||
迁移步骤:
|
||||
|
||||
1. 签发无操作权限的 Workflow Provenance。
|
||||
2. 按所需路径签发独立子 Grant。
|
||||
3. 为每个服务配置其最小 Grant ID。
|
||||
4. 启用 `AIOS_REQUIRE_RUN_PROVENANCE=true`。
|
||||
5. 验证 Scope 拒绝、撤销、过期、重放和重启恢复测试。
|
||||
6. 完成预发布 Acceptance 后,禁止旧全权限 `issue` 命令进入生产流程。
|
||||
|
||||
回滚仅允许回到前一个已验证候选版本,不得回滚为生产全权限 Provenance。状态恢复必须保留父 Workflow、Grant 使用记录、Dispatch 和 Receipt,防止旧授权被再次使用。
|
||||
|
||||
## 11. 合规检查
|
||||
|
||||
必须满足:
|
||||
|
||||
- 父 Workflow 无操作路径。
|
||||
- 子 Grant 只包含一个路径。
|
||||
- 生产服务只加载职责所需 Grant。
|
||||
- 上下文不匹配、签名错误、过期、撤销和重放全部 fail closed。
|
||||
- Runtime Result 必须匹配持久 Dispatch。
|
||||
- 测试与文档在同一分支提交。
|
||||
- 正式 Gate A 结论必须基于实际执行 Acceptance,不得仅凭测试文件存在声明通过。
|
||||
|
||||
## 12. 受影响资产
|
||||
|
||||
代码:
|
||||
|
||||
- `active-runtime/app/aios_v2/provenance.py`
|
||||
- `active-runtime/app/aios_v2/provenance_grants.py`
|
||||
- `active-runtime/app/aios_v2/provenance_scoped.py`
|
||||
- `active-runtime/app/aios_v2/provenance_operations.py`
|
||||
- `active-runtime/app/aios_v2/provenance_cli.py`
|
||||
- `active-runtime/app/aios_v2/dispatch_store.py`
|
||||
- `active-runtime/app/aios_v2/orchestrator.py`
|
||||
- `active-runtime/app/aios_v2/execution_tracking.py`
|
||||
- `active-runtime/app/aios_v2/provenance_git.py`
|
||||
- `active-runtime/app/aios_openclaw/contract.py`
|
||||
|
||||
测试:
|
||||
|
||||
- `tests/aios_v2_candidate/test_provenance.py`
|
||||
- `tests/aios_v2_candidate/test_provenance_grant_isolation.py`
|
||||
- `tests/aios_v2_candidate/test_candidate_flow.py`
|
||||
|
||||
部署与文档:
|
||||
|
||||
- `deploy/aios-v2/env/aios-v2.env.example`
|
||||
- `docs/aios-v2/04-MODULE-CONTRACTS.md`
|
||||
- `docs/aios-v2/05-TRACEABILITY-MATRIX.md`
|
||||
- `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`
|
||||
@@ -0,0 +1,80 @@
|
||||
# ADR-010:Hermes 在线生命周期使用默认关闭的内部 Runtime API
|
||||
|
||||
- 状态:Accepted for Release Candidate
|
||||
- 日期:2026-07-21
|
||||
- 范围:AIOS V2R1 Hermes Worker Lifecycle Acceptance
|
||||
|
||||
## 决策
|
||||
|
||||
使用现有 `HermesAdapter` Queue/Lease 状态机,增加 SQLite 持久 Store,并通过默认关闭的
|
||||
内部 Runtime API 执行 Claim、Start、Heartbeat、Complete、Fail、Cancel 与 Status。
|
||||
不建立第二套 Queue、Lease 或 Task 状态机。
|
||||
|
||||
内部 API 只接受可信代理注入且具有 `runtime`/`platform_admin` 角色的身份。请求必须绑定
|
||||
tenant、task、trace、principal、provenance、run、dispatch、job、cell 和 profile。
|
||||
Complete、Fail、Cancel 只能经 `ExecutionOrchestrator.reconcile_hermes` 更新权威 Task。
|
||||
|
||||
## 背景
|
||||
|
||||
原 Candidate 已具有进程内 Hermes Claim/Lease/Worker 能力及持久 Dispatch,但在线服务
|
||||
只暴露 enqueue/reconcile,且 Hermes Job/Lease 本身在重启后丢失,不能形成完整在线证据。
|
||||
|
||||
## 原因
|
||||
|
||||
直接验收驱动会形成 Adapter 之外的额外变更入口,难以证明 HTTP 身份、Dispatch 与
|
||||
Orchestrator 边界。受保护的内部 API 可以在真实 localhost 服务、真实 SQLite Store 和
|
||||
真实 HTTP 解析路径上验证完整生命周期,同时在生产通过配置保持关闭。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- 受信验收驱动直接导入 Adapter:改动较小,但不能证明内部 HTTP 身份与请求解析边界,拒绝。
|
||||
- 新建独立 Queue/Lease 服务:会产生第二状态机和双写一致性问题,禁止。
|
||||
- 开放公共 Worker API:扩大攻击面并允许浏览器/Operator 触发 Runtime 状态,禁止。
|
||||
|
||||
## 安全不变量
|
||||
|
||||
1. 只有 APPROVED Task 可由 Orchestrator 创建 Hermes Job。
|
||||
2. Dispatch 在 Runtime 副作用前持久化。
|
||||
3. Worker 只能操作精确绑定且由其持有 Lease 的 Job。
|
||||
4. Runtime 结果必须通过完整身份元组与持久 Dispatch 校验。
|
||||
5. 只有 Orchestrator 能把 Task/Dispatch 推进到 Runtime 终态。
|
||||
6. 生命周期写入同步生成 Audit/Event 与 Execution Evidence。
|
||||
7. 相同终态重放幂等,不同终态或不同内容 fail closed。
|
||||
|
||||
## 恢复语义
|
||||
|
||||
`SQLiteHermesStore` 持久保存 Job、Result、Lease、幂等键与 Worker heartbeat。服务重启
|
||||
加载同一状态;过期 Lease 被持久重排为 QUEUED,清理旧 Owner 并递增 revision。新 Worker
|
||||
可重领,旧 Worker 的 Start/Heartbeat/Result 被拒绝。
|
||||
|
||||
## 影响与风险
|
||||
|
||||
- 正面:同一 Adapter 状态机获得持久恢复、真实 HTTP 验收、严格身份绑定和可审计证据。
|
||||
- 负面:SQLite Snapshot 每次生命周期变更都执行同步事务,需在预生产观察锁等待与吞吐。
|
||||
- 风险:可信代理头是身份边界;因此接口必须保持 localhost/内部路由、默认关闭,并由部署
|
||||
配置保证外部请求不能自行注入身份头。
|
||||
|
||||
## 迁移与回滚
|
||||
|
||||
新 Store 文件为新增的 `hermes.sqlite3`,不修改既有 Control Plane/Dispatch schema。回滚到
|
||||
Previous OID 时保留该文件但旧版本不读取;不得用旧备份覆盖新业务状态。重新升级时可继续
|
||||
加载。接口回滚只需关闭环境开关并恢复 Previous Release OID。
|
||||
|
||||
## 合规检查
|
||||
|
||||
- 不修改 main,不创建 Tag,不绕过 Provenance/Grant。
|
||||
- 生产默认关闭生命周期验收入口。
|
||||
- Secret 不进入请求、Audit、Evidence 或测试数据。
|
||||
- Result 由 Orchestrator 与持久 Dispatch 校验,负向场景 fail closed。
|
||||
|
||||
## 受影响资产
|
||||
|
||||
- 代码:`aios_hermes/adapter.py`、`aios_v2/app.py`、`orchestrator.py`、`gateway.py`。
|
||||
- 测试:`test_hermes_live_lifecycle.py` 及 Candidate/Provenance 回归。
|
||||
- 文档:用户需求、模块合同、追踪矩阵、Gate A 报告与本 ADR。
|
||||
|
||||
## 验证
|
||||
|
||||
`tests/aios_v2_candidate/test_hermes_live_lifecycle.py` 通过真实 localhost HTTP Server 与
|
||||
临时 SQLite 状态目录覆盖 17 项正向、负向、Lease、幂等和重启场景。最终发布结论仍必须
|
||||
以提交后的 Gate、在线回归、Secret Audit、SQLite 检查、回滚与独立 Reviewer 证据为准。
|
||||
@@ -1,6 +1,6 @@
|
||||
# AIOS V2 架构决策记录索引
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 适用范围:影响多个模块、长期演进、兼容性、安全、数据、部署或交付流程的关键架构决策
|
||||
|
||||
## 1. 架构决策记录列表
|
||||
@@ -13,15 +13,27 @@
|
||||
| [ADR-004](ADR-004-multi-tenant-isolation.md) | 多租户隔离采用默认拒绝模型 | 已采纳 |
|
||||
| [ADR-005](ADR-005-document-code-sync-gate.md) | 文档与代码同步门禁 | 已采纳 |
|
||||
| [ADR-006](ADR-006-candidate-persistence-strategy.md) | 候选持久化采用可替换 SQLite Snapshot | 已采纳 |
|
||||
| ADR-007 | 当前仓库未分配;禁止补写不存在的历史决策 | 未分配 |
|
||||
| ADR-008 | 当前仓库未分配;禁止补写不存在的历史决策 | 未分配 |
|
||||
| [ADR-009](ADR-009-provenance-least-privilege.md) | Workflow Provenance、最小权限子 Grant 与服务级 Scope | 已采纳 |
|
||||
| [ADR-010](ADR-010-hermes-live-lifecycle.md) | Hermes 在线生命周期使用默认关闭的内部 Runtime API | RC 已采纳;待 Owner 发布批准 |
|
||||
|
||||
## 2. 状态定义
|
||||
## 2. 编号规则
|
||||
|
||||
- ADR 编号一经发布不得重用。
|
||||
- 编号缺口可以保留,不能通过伪造历史文件填补。
|
||||
- 新决策使用下一个未占用编号,并在同一分支更新本索引、模块契约和追踪矩阵。
|
||||
- ADR-007 与 ADR-008 在当前仓库没有可验证历史文件,因此明确标记为未分配。
|
||||
|
||||
## 3. 状态定义
|
||||
|
||||
- **已提出**:决策已提出,但尚未被正式采纳。
|
||||
- **已采纳**:当前有效,所有新实现必须遵守。
|
||||
- **已替代**:已被新的 ADR 替代,保留用于历史追踪。
|
||||
- **已废弃**:保留历史,但禁止新实现继续采用。
|
||||
- **未分配**:该编号当前没有 ADR 文件,不代表存在隐含历史决策。
|
||||
|
||||
## 3. 必须新增或更新 ADR 的场景
|
||||
## 4. 必须新增或更新 ADR 的场景
|
||||
|
||||
以下变化必须新增或更新 ADR:
|
||||
|
||||
@@ -32,9 +44,10 @@
|
||||
5. GitOps、发布、回滚或文档门禁变化;
|
||||
6. 替换 OpenClaw、Hermes、Agent Router 或核心基础设施;
|
||||
7. 引入新的权威事实源、任务生命周期或租户状态;
|
||||
8. 生产安全模型、身份代理或 Secret 管理方式变化。
|
||||
8. 生产安全模型、身份代理、Run Provenance、Grant 或 Secret 管理方式变化;
|
||||
9. Runtime Result、Dispatch 或服务 Scope 信任边界变化。
|
||||
|
||||
## 4. ADR 内容要求
|
||||
## 5. ADR 内容要求
|
||||
|
||||
每份 ADR 必须包含:
|
||||
|
||||
@@ -48,20 +61,22 @@
|
||||
- 合规检查;
|
||||
- 受影响的代码、测试和文档。
|
||||
|
||||
## 5. ADR 变更联动
|
||||
## 6. ADR 变更联动
|
||||
|
||||
ADR 新增、替代或废弃时,必须同步检查并更新:
|
||||
|
||||
- `01-USER-REQUIREMENTS.md`;
|
||||
- `02-TARGET-ARCHITECTURE.md`;
|
||||
- `03-MIGRATION-AND-DELIVERY.md`;
|
||||
- `04-MODULE-CONTRACTS.md`;
|
||||
- `05-TRACEABILITY-MATRIX.md`;
|
||||
- 相关代码、测试和部署资产;
|
||||
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md` 中的风险与发布结论。
|
||||
- 候选或 Gate 报告中的风险与发布结论。
|
||||
|
||||
## 6. 审查规则
|
||||
## 7. 审查规则
|
||||
|
||||
- OpenClaw 不得自行采纳改变控制面边界的 ADR;发现需求后必须升级给 Codex。
|
||||
- Codex 负责审查 ADR 与代码、测试和文档是否一致。
|
||||
- 涉及合并、Tag、生产部署或重大风险接受的 ADR,必须由 AIOS Owner 最终批准。
|
||||
- ADR 未完成审查前,相关变更不得标记为 `Test-verified` 或 `Production released`。
|
||||
- ADR、测试文件或 Acceptance 脚本存在,不等于 Gate 已通过;必须有实际执行日志、退出码和证据。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# AIOS V2R1C1 Release Notes
|
||||
|
||||
- VRC: `V2R1C1`
|
||||
- SemVer: `2.1.0-rc.1`
|
||||
- 固定产品基线: `fb739f6e8ff9c031d319221ace68dce157694086`
|
||||
- 当前状态: `RELEASE_VALIDATION_PASS / FINAL_METADATA_COMMIT_REVALIDATION_REQUIRED`
|
||||
- 发布决策: `STOP_PREPRODUCTION`
|
||||
|
||||
OpenClaw Command v2 Companion:
|
||||
|
||||
- 分支: `patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721`
|
||||
- 源 OID: `83f8f878c7da5dd8263f2af2efa80750492e5cb3`
|
||||
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`
|
||||
- 插件版本: `2.1.0-rc.4`
|
||||
- 合同: `aios.openclaw.command.v2` / `Ed25519`
|
||||
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`
|
||||
- 隔离验证: `AIOS -> OpenClaw V2 PASS`,正向与负向在线合同均通过。
|
||||
|
||||
旧 Release OID `80c4cbde03a82c30648a09b817bc2f03d5402936` 保留为原始 HTTP 401 失败诊断基线。Release OID `0778a31ec073e7c90003ef091f1d093c16bf22e5` 已实际完成 Gate A/B、文档同步、Secret Scan、Release Metadata、OpenClaw 第 13 步正负向、Hermes、重启恢复、隔离回滚/前滚和最终健康门禁,全部通过。
|
||||
|
||||
本次最终元数据闭环仍只修改五个允许的 Release 元数据文件;提交后形成的最终 OID 必须再次执行 Gate A/B、文档同步、Secret Scan、Release Metadata 和 OpenClaw 第 13 步正负向复验。复验通过前 Tag 保持不存在,生产流量保持未切换。
|
||||
|
||||
本分支只包含发布元数据。Personal Weixin Portal Adapter、Spark 专有 Provisioning、Future Incubator、Worktree 并发实验和长期 Agent 工作目录均未进入发布图。
|
||||
|
||||
只有最终 Release OID 的全部复验和步骤 14–17 通过后,才允许创建不可变 `V2R1C1` Tag。
|
||||
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Safely patch Tencent Weixin sender identity and transient session retries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
|
||||
PATCHES = {
|
||||
"src/messaging/inbound.ts": [
|
||||
(
|
||||
" From: from_user_id,\n To: from_user_id,",
|
||||
" From: from_user_id,\n SenderId: from_user_id,\n To: from_user_id,",
|
||||
),
|
||||
],
|
||||
"dist/src/messaging/inbound.js": [
|
||||
(
|
||||
" From: from_user_id,\n To: from_user_id,",
|
||||
" From: from_user_id,\n SenderId: from_user_id,\n To: from_user_id,",
|
||||
),
|
||||
],
|
||||
"src/monitor/monitor.ts": [
|
||||
(
|
||||
'import { processOneMessage } from "../messaging/process-message.js";',
|
||||
'import { processOneMessage } from "../messaging/process-message.js";\n'
|
||||
'import type { ProcessMessageDeps } from "../messaging/process-message.js";\n'
|
||||
'import type { WeixinMessage } from "../api/types.js";',
|
||||
),
|
||||
(
|
||||
"const RETRY_DELAY_MS = 2_000;\n",
|
||||
"const RETRY_DELAY_MS = 2_000;\n"
|
||||
"const REPLY_SESSION_INIT_CONFLICT_RE = /reply session initialization conflicted for \\S+/u;\n"
|
||||
"const REPLY_SESSION_INIT_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 40_000] as const;\n",
|
||||
),
|
||||
(
|
||||
"/**\n * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.\n",
|
||||
"export function resolveReplySessionInitRetryDelayMs(err: unknown, attempt: number): number | undefined {\n"
|
||||
" const delayMs = REPLY_SESSION_INIT_RETRY_DELAYS_MS[attempt];\n"
|
||||
" return delayMs != null && REPLY_SESSION_INIT_CONFLICT_RE.test(String(err)) ? delayMs : undefined;\n"
|
||||
"}\n\n"
|
||||
"export async function processOneMessageWithReplySessionRetry(\n"
|
||||
" full: WeixinMessage,\n"
|
||||
" deps: ProcessMessageDeps,\n"
|
||||
" abortSignal?: AbortSignal,\n"
|
||||
"): Promise<void> {\n"
|
||||
" for (let attempt = 0; ; attempt += 1) {\n"
|
||||
" try {\n"
|
||||
" await processOneMessage(full, deps);\n"
|
||||
" return;\n"
|
||||
" } catch (err) {\n"
|
||||
" const delayMs = resolveReplySessionInitRetryDelayMs(err, attempt);\n"
|
||||
" if (delayMs == null) throw err;\n"
|
||||
" deps.errLog(\n"
|
||||
" `weixin reply session conflict; retry=${attempt + 1}/${REPLY_SESSION_INIT_RETRY_DELAYS_MS.length} delayMs=${delayMs}`,\n"
|
||||
" );\n"
|
||||
" await sleep(delayMs, abortSignal);\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n\n"
|
||||
"/**\n * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.\n",
|
||||
),
|
||||
(
|
||||
" await processOneMessage(full, {\n"
|
||||
" accountId,\n"
|
||||
" config,\n"
|
||||
" channelRuntime,\n"
|
||||
" baseUrl,\n"
|
||||
" cdnBaseUrl,\n"
|
||||
" token,\n"
|
||||
" typingTicket: cachedConfig.typingTicket,\n"
|
||||
" log: opts.runtime?.log ?? (() => {}),\n"
|
||||
" errLog,\n"
|
||||
" });",
|
||||
" await processOneMessageWithReplySessionRetry(full, {\n"
|
||||
" accountId,\n"
|
||||
" config,\n"
|
||||
" channelRuntime,\n"
|
||||
" baseUrl,\n"
|
||||
" cdnBaseUrl,\n"
|
||||
" token,\n"
|
||||
" typingTicket: cachedConfig.typingTicket,\n"
|
||||
" log: opts.runtime?.log ?? (() => {}),\n"
|
||||
" errLog,\n"
|
||||
" }, abortSignal);",
|
||||
),
|
||||
],
|
||||
"dist/src/monitor/monitor.js": [
|
||||
(
|
||||
"const RETRY_DELAY_MS = 2_000;\n",
|
||||
"const RETRY_DELAY_MS = 2_000;\n"
|
||||
"const REPLY_SESSION_INIT_CONFLICT_RE = /reply session initialization conflicted for \\S+/u;\n"
|
||||
"const REPLY_SESSION_INIT_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 40_000];\n",
|
||||
),
|
||||
(
|
||||
"/**\n * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.\n",
|
||||
"export function resolveReplySessionInitRetryDelayMs(err, attempt) {\n"
|
||||
" const delayMs = REPLY_SESSION_INIT_RETRY_DELAYS_MS[attempt];\n"
|
||||
" return delayMs != null && REPLY_SESSION_INIT_CONFLICT_RE.test(String(err)) ? delayMs : undefined;\n"
|
||||
"}\n"
|
||||
"export async function processOneMessageWithReplySessionRetry(full, deps, abortSignal) {\n"
|
||||
" for (let attempt = 0;; attempt += 1) {\n"
|
||||
" try {\n"
|
||||
" await processOneMessage(full, deps);\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" catch (err) {\n"
|
||||
" const delayMs = resolveReplySessionInitRetryDelayMs(err, attempt);\n"
|
||||
" if (delayMs == null)\n"
|
||||
" throw err;\n"
|
||||
" deps.errLog(`weixin reply session conflict; retry=${attempt + 1}/${REPLY_SESSION_INIT_RETRY_DELAYS_MS.length} delayMs=${delayMs}`);\n"
|
||||
" await sleep(delayMs, abortSignal);\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"/**\n * Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.\n",
|
||||
),
|
||||
(
|
||||
" await processOneMessage(full, {\n"
|
||||
" accountId,\n"
|
||||
" config,\n"
|
||||
" channelRuntime,\n"
|
||||
" baseUrl,\n"
|
||||
" cdnBaseUrl,\n"
|
||||
" token,\n"
|
||||
" typingTicket: cachedConfig.typingTicket,\n"
|
||||
" log: opts.runtime?.log ?? (() => { }),\n"
|
||||
" errLog,\n"
|
||||
" });",
|
||||
" await processOneMessageWithReplySessionRetry(full, {\n"
|
||||
" accountId,\n"
|
||||
" config,\n"
|
||||
" channelRuntime,\n"
|
||||
" baseUrl,\n"
|
||||
" cdnBaseUrl,\n"
|
||||
" token,\n"
|
||||
" typingTicket: cachedConfig.typingTicket,\n"
|
||||
" log: opts.runtime?.log ?? (() => { }),\n"
|
||||
" errLog,\n"
|
||||
" }, abortSignal);",
|
||||
),
|
||||
],
|
||||
"src/messaging/process-message.ts": [
|
||||
(
|
||||
" await deps.channelRuntime.session.recordInboundSession({\n"
|
||||
" storePath,\n"
|
||||
" sessionKey: route.sessionKey,\n"
|
||||
" ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0][\"ctx\"],\n"
|
||||
" updateLastRoute: {\n"
|
||||
" sessionKey: route.mainSessionKey,\n"
|
||||
" channel: \"openclaw-weixin\",\n"
|
||||
" to: ctx.To,\n"
|
||||
" accountId: deps.accountId,\n"
|
||||
" },\n"
|
||||
" onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),\n"
|
||||
" });",
|
||||
" const sessionMetaTasks: Promise<unknown>[] = [];\n"
|
||||
" await deps.channelRuntime.session.recordInboundSession({\n"
|
||||
" storePath,\n"
|
||||
" sessionKey: route.sessionKey,\n"
|
||||
" ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0][\"ctx\"],\n"
|
||||
" updateLastRoute: {\n"
|
||||
" sessionKey: route.mainSessionKey,\n"
|
||||
" channel: \"openclaw-weixin\",\n"
|
||||
" to: ctx.To,\n"
|
||||
" accountId: deps.accountId,\n"
|
||||
" },\n"
|
||||
" onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),\n"
|
||||
" trackSessionMetaTask: (task) => sessionMetaTasks.push(task),\n"
|
||||
" });\n"
|
||||
" await Promise.all(sessionMetaTasks);",
|
||||
),
|
||||
(
|
||||
"// AIOS Hermes image follow-up helpers end\n\nexport async function processOneMessage(\n",
|
||||
"// AIOS Hermes image follow-up helpers end\n\n"
|
||||
"const DISPATCH_SESSION_INIT_CONFLICT_RE = /reply session initialization conflicted for \\S+/u;\n"
|
||||
"const DISPATCH_SESSION_INIT_RETRY_DELAYS_MS = [100, 250, 500, 1_000] as const;\n\n"
|
||||
"export function resolveDispatchSessionInitRetryDelayMs(err: unknown, attempt: number): number | undefined {\n"
|
||||
" const delayMs = DISPATCH_SESSION_INIT_RETRY_DELAYS_MS[attempt];\n"
|
||||
" return delayMs != null && DISPATCH_SESSION_INIT_CONFLICT_RE.test(String(err)) ? delayMs : undefined;\n"
|
||||
"}\n\n"
|
||||
"export async function dispatchWithSessionInitConflictRetry<T>(\n"
|
||||
" run: () => Promise<T>,\n"
|
||||
" onRetry: (attempt: number, delayMs: number) => void,\n"
|
||||
"): Promise<T> {\n"
|
||||
" for (let attempt = 0; ; attempt += 1) {\n"
|
||||
" try {\n"
|
||||
" return await run();\n"
|
||||
" } catch (err) {\n"
|
||||
" const delayMs = resolveDispatchSessionInitRetryDelayMs(err, attempt);\n"
|
||||
" if (delayMs == null) throw err;\n"
|
||||
" onRetry(attempt + 1, delayMs);\n"
|
||||
" await new Promise((resolve) => setTimeout(resolve, delayMs));\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n\n"
|
||||
"export async function processOneMessage(\n",
|
||||
),
|
||||
(
|
||||
" try {\n"
|
||||
" await deps.channelRuntime.reply.withReplyDispatcher({\n"
|
||||
" dispatcher,\n"
|
||||
" run: () =>\n"
|
||||
" deps.channelRuntime.reply.dispatchReplyFromConfig({\n"
|
||||
" ctx: finalized,\n"
|
||||
" cfg: deps.config,\n"
|
||||
" dispatcher,\n"
|
||||
" replyOptions: {\n"
|
||||
" ...replyOptions,\n"
|
||||
" ...(replyProgressSender?.replyOptions ?? {}),\n"
|
||||
" disableBlockStreaming: true,\n"
|
||||
" },\n"
|
||||
" }),\n"
|
||||
" });",
|
||||
" try {\n"
|
||||
" await dispatchWithSessionInitConflictRetry(\n"
|
||||
" () => deps.channelRuntime.reply.withReplyDispatcher({\n"
|
||||
" dispatcher,\n"
|
||||
" run: () =>\n"
|
||||
" deps.channelRuntime.reply.dispatchReplyFromConfig({\n"
|
||||
" ctx: finalized,\n"
|
||||
" cfg: deps.config,\n"
|
||||
" dispatcher,\n"
|
||||
" replyOptions: {\n"
|
||||
" ...replyOptions,\n"
|
||||
" ...(replyProgressSender?.replyOptions ?? {}),\n"
|
||||
" disableBlockStreaming: true,\n"
|
||||
" },\n"
|
||||
" }),\n"
|
||||
" }),\n"
|
||||
" (attempt, delayMs) => deps.errLog(\n"
|
||||
" `weixin dispatch session conflict; retry=${attempt}/${DISPATCH_SESSION_INIT_RETRY_DELAYS_MS.length} delayMs=${delayMs}`,\n"
|
||||
" ),\n"
|
||||
" );",
|
||||
),
|
||||
],
|
||||
"dist/src/messaging/process-message.js": [
|
||||
(
|
||||
" await deps.channelRuntime.session.recordInboundSession({\n"
|
||||
" storePath,\n"
|
||||
" sessionKey: route.sessionKey,\n"
|
||||
" ctx: finalized,\n"
|
||||
" updateLastRoute: {\n"
|
||||
" sessionKey: route.mainSessionKey,\n"
|
||||
" channel: \"openclaw-weixin\",\n"
|
||||
" to: ctx.To,\n"
|
||||
" accountId: deps.accountId,\n"
|
||||
" },\n"
|
||||
" onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),\n"
|
||||
" });",
|
||||
" const sessionMetaTasks = [];\n"
|
||||
" await deps.channelRuntime.session.recordInboundSession({\n"
|
||||
" storePath,\n"
|
||||
" sessionKey: route.sessionKey,\n"
|
||||
" ctx: finalized,\n"
|
||||
" updateLastRoute: {\n"
|
||||
" sessionKey: route.mainSessionKey,\n"
|
||||
" channel: \"openclaw-weixin\",\n"
|
||||
" to: ctx.To,\n"
|
||||
" accountId: deps.accountId,\n"
|
||||
" },\n"
|
||||
" onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),\n"
|
||||
" trackSessionMetaTask: (task) => sessionMetaTasks.push(task),\n"
|
||||
" });\n"
|
||||
" await Promise.all(sessionMetaTasks);",
|
||||
),
|
||||
(
|
||||
"// AIOS Hermes image follow-up helpers end\n\nexport async function processOneMessage(full, deps) {\n",
|
||||
"// AIOS Hermes image follow-up helpers end\n\n"
|
||||
"const DISPATCH_SESSION_INIT_CONFLICT_RE = /reply session initialization conflicted for \\S+/u;\n"
|
||||
"const DISPATCH_SESSION_INIT_RETRY_DELAYS_MS = [100, 250, 500, 1_000];\n"
|
||||
"export function resolveDispatchSessionInitRetryDelayMs(err, attempt) {\n"
|
||||
" const delayMs = DISPATCH_SESSION_INIT_RETRY_DELAYS_MS[attempt];\n"
|
||||
" return delayMs != null && DISPATCH_SESSION_INIT_CONFLICT_RE.test(String(err)) ? delayMs : undefined;\n"
|
||||
"}\n"
|
||||
"export async function dispatchWithSessionInitConflictRetry(run, onRetry) {\n"
|
||||
" for (let attempt = 0;; attempt += 1) {\n"
|
||||
" try {\n"
|
||||
" return await run();\n"
|
||||
" }\n"
|
||||
" catch (err) {\n"
|
||||
" const delayMs = resolveDispatchSessionInitRetryDelayMs(err, attempt);\n"
|
||||
" if (delayMs == null)\n"
|
||||
" throw err;\n"
|
||||
" onRetry(attempt + 1, delayMs);\n"
|
||||
" await new Promise((resolve) => setTimeout(resolve, delayMs));\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"export async function processOneMessage(full, deps) {\n",
|
||||
),
|
||||
(
|
||||
" try {\n"
|
||||
" await deps.channelRuntime.reply.withReplyDispatcher({\n"
|
||||
" dispatcher,\n"
|
||||
" run: () => deps.channelRuntime.reply.dispatchReplyFromConfig({\n"
|
||||
" ctx: finalized,\n"
|
||||
" cfg: deps.config,\n"
|
||||
" dispatcher,\n"
|
||||
" replyOptions: {\n"
|
||||
" ...replyOptions,\n"
|
||||
" ...(replyProgressSender?.replyOptions ?? {}),\n"
|
||||
" disableBlockStreaming: true,\n"
|
||||
" },\n"
|
||||
" }),\n"
|
||||
" });",
|
||||
" try {\n"
|
||||
" await dispatchWithSessionInitConflictRetry(\n"
|
||||
" () => deps.channelRuntime.reply.withReplyDispatcher({\n"
|
||||
" dispatcher,\n"
|
||||
" run: () => deps.channelRuntime.reply.dispatchReplyFromConfig({\n"
|
||||
" ctx: finalized,\n"
|
||||
" cfg: deps.config,\n"
|
||||
" dispatcher,\n"
|
||||
" replyOptions: {\n"
|
||||
" ...replyOptions,\n"
|
||||
" ...(replyProgressSender?.replyOptions ?? {}),\n"
|
||||
" disableBlockStreaming: true,\n"
|
||||
" },\n"
|
||||
" }),\n"
|
||||
" }),\n"
|
||||
" (attempt, delayMs) => deps.errLog(`weixin dispatch session conflict; retry=${attempt}/${DISPATCH_SESSION_INIT_RETRY_DELAYS_MS.length} delayMs=${delayMs}`),\n"
|
||||
" );",
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_root(root: Path) -> Path:
|
||||
root = root.resolve(strict=True)
|
||||
manifest = root / "package.json"
|
||||
if not manifest.is_file() or manifest.is_symlink():
|
||||
raise RuntimeError("invalid_plugin_manifest")
|
||||
package = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if package.get("name") != "@tencent-weixin/openclaw-weixin":
|
||||
raise RuntimeError("unexpected_plugin_package")
|
||||
return root
|
||||
|
||||
|
||||
def render_patch(path: Path, patches: list[tuple[str, str]]) -> tuple[str, bool]:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise RuntimeError(f"unsafe_or_missing_target:{path.name}")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
changed = False
|
||||
for old, new in patches:
|
||||
if new in text:
|
||||
continue
|
||||
if text.count(old) != 1:
|
||||
raise RuntimeError(f"target_shape_mismatch:{path.name}")
|
||||
text = text.replace(old, new, 1)
|
||||
changed = True
|
||||
return text, changed
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: str) -> None:
|
||||
mode = path.stat().st_mode & 0o777
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def patch_plugin(root: Path, *, apply: bool, backup_dir: Path | None = None) -> dict:
|
||||
root = validate_root(root)
|
||||
if apply and backup_dir is None:
|
||||
raise RuntimeError("backup_dir_required")
|
||||
rendered = {}
|
||||
result = {"status": "CHECKED", "changed": False, "files": {}}
|
||||
for relative, patches in PATCHES.items():
|
||||
path = root / relative
|
||||
content, changed = render_patch(path, patches)
|
||||
rendered[path] = (relative, content, changed)
|
||||
result["changed"] = result["changed"] or changed
|
||||
result["files"][relative] = {"before_sha256": sha256(path), "changed": changed}
|
||||
if apply:
|
||||
backup_dir = backup_dir.resolve()
|
||||
backup_dir.mkdir(parents=True, exist_ok=False)
|
||||
for path, (relative, content, changed) in rendered.items():
|
||||
backup = backup_dir / relative
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, backup, follow_symlinks=False)
|
||||
if changed:
|
||||
atomic_write(path, content)
|
||||
result["files"][relative]["after_sha256"] = sha256(path)
|
||||
result["files"][relative]["backup_sha256"] = sha256(backup)
|
||||
result["status"] = "APPLIED"
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--plugin-root", type=Path, required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--backup-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
result = patch_plugin(args.plugin_root, apply=args.apply, backup_dir=args.backup_dir)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/chatgpt/AIOS-V2R1-HARDENING-20260721",
|
||||
"base": "patch/codex/AIOS-V2R1-RUN-PROVENANCE-CONTROL-20260720",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-21T02:46:59Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/chatgpt/AIOS-V2R1C1-UNIFIED-WEB-ROLE-PORTAL-20260721",
|
||||
"base": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-21T15:17:54Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"branch": "patch/codex/AIOS-V2R1-RUN-PROVENANCE-CONTROL-20260720",
|
||||
"base": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-20T03:49:09Z",
|
||||
"created_at": "2026-07-20T12:11:59Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"schema_version": "aios.execution-plan.v2",
|
||||
"plan_id": "AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE-20260721",
|
||||
"source_branch": "patch/chatgpt/AIOS-V2R1C1-UNIFIED-WEB-ROLE-PORTAL-20260721",
|
||||
"source_oid": "ENV:AIOS_RELEASE_SOURCE_OID",
|
||||
"target_version": "V2R1C2",
|
||||
"release_gate_policy": "all_required_stages",
|
||||
"metadata": {
|
||||
"owner": "AIOS-Control-Plane",
|
||||
"purpose": "Unified role-aware portal and portable executor rollout",
|
||||
"executor_protocol": "aios.executor.v1",
|
||||
"owner_notification_channel": "wechat",
|
||||
"owner_notification_target_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"production_baseline": "V2R1C1",
|
||||
"candidate_port": 18921,
|
||||
"production_port": 18920,
|
||||
"production_mutation_allowed": false
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "openclaw-candidate-deploy",
|
||||
"task_id": "AIOS-V2R1C2-OPENCLAW-CANDIDATE-DEPLOY-20260721",
|
||||
"executor_type": "openclaw",
|
||||
"role": "candidate_deployment_operator",
|
||||
"depends_on": [],
|
||||
"steps": [
|
||||
"verify_exact_source_oid_and_clean_worktree",
|
||||
"bootstrap_isolated_candidate_state_and_executor_catalog",
|
||||
"run_static_compile_and_candidate_unit_tests",
|
||||
"install_and_start_v2r1c2_candidate_service_on_18921",
|
||||
"run_health_api_ui_and_role_matrix_smoke",
|
||||
"run_tenant_isolation_and_protected_action_negative_tests",
|
||||
"run_restart_recovery_and_rollback_drill",
|
||||
"publish_candidate_evidence_manifest"
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_oid_attestation",
|
||||
"bootstrap_log",
|
||||
"compile_report",
|
||||
"unit_test_report",
|
||||
"service_status",
|
||||
"health_check",
|
||||
"api_smoke",
|
||||
"ui_smoke",
|
||||
"role_matrix",
|
||||
"tenant_isolation",
|
||||
"protected_action_negative",
|
||||
"restart_recovery",
|
||||
"rollback_drill",
|
||||
"candidate_evidence_manifest"
|
||||
],
|
||||
"notification_events": [
|
||||
"STARTED",
|
||||
"SUCCEEDED",
|
||||
"FAILED"
|
||||
],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "openclaw",
|
||||
"candidate_service": "aios-v2r1c2-portal.service",
|
||||
"candidate_url": "http://127.0.0.1:18921",
|
||||
"production_service_must_remain_untouched": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage_id": "hermes-runtime-validation",
|
||||
"task_id": "AIOS-V2R1C2-HERMES-RUNTIME-VALIDATION-20260721",
|
||||
"executor_type": "hermes",
|
||||
"role": "runtime_validation_operator",
|
||||
"depends_on": [
|
||||
"openclaw-candidate-deploy"
|
||||
],
|
||||
"steps": [
|
||||
"validate_hermes_health_and_worker_identity",
|
||||
"validate_queue_claim_and_completion",
|
||||
"validate_lease_heartbeat_and_timeout_recovery",
|
||||
"validate_restart_recovery_and_duplicate_delivery_safety",
|
||||
"reconcile_runtime_evidence_with_candidate_audit"
|
||||
],
|
||||
"required_evidence": [
|
||||
"hermes_health",
|
||||
"queue_claim_completion",
|
||||
"lease_heartbeat",
|
||||
"timeout_recovery",
|
||||
"restart_recovery",
|
||||
"duplicate_delivery_safety",
|
||||
"runtime_evidence_reconciliation"
|
||||
],
|
||||
"notification_events": [
|
||||
"STARTED",
|
||||
"SUCCEEDED",
|
||||
"FAILED"
|
||||
],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "hermes",
|
||||
"candidate_url": "http://127.0.0.1:18921"
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage_id": "codex-release-review",
|
||||
"task_id": "AIOS-V2R1C2-CODEX-RELEASE-REVIEW-20260721",
|
||||
"executor_type": "codex",
|
||||
"role": "release_evidence_reviewer",
|
||||
"depends_on": [
|
||||
"openclaw-candidate-deploy",
|
||||
"hermes-runtime-validation"
|
||||
],
|
||||
"steps": [
|
||||
"review_source_diff_and_architecture_invariants",
|
||||
"review_test_and_runtime_evidence_hashes",
|
||||
"review_security_tenant_and_role_boundaries",
|
||||
"review_documentation_traceability_and_rollback_readiness",
|
||||
"produce_release_recommendation_without_creating_tag"
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_diff_review",
|
||||
"test_evidence_review",
|
||||
"runtime_evidence_review",
|
||||
"security_boundary_review",
|
||||
"documentation_traceability_review",
|
||||
"rollback_readiness_review",
|
||||
"release_recommendation"
|
||||
],
|
||||
"notification_events": [
|
||||
"STARTED",
|
||||
"SUCCEEDED",
|
||||
"FAILED"
|
||||
],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "codex",
|
||||
"tag_creation_allowed": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"schema_version": "aios.execution-plan.v2",
|
||||
"plan_id": "AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722",
|
||||
"source_branch": "patch/codex/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722",
|
||||
"source_oid": "ENV:AIOS_RELEASE_SOURCE_OID",
|
||||
"target_version": "V2R1C2",
|
||||
"release_gate_policy": "all_required_stages",
|
||||
"metadata": {
|
||||
"owner": "AIOS-Control-Plane",
|
||||
"purpose": "Legal ChatGPT supervisor takeover for the isolated Unified Portal candidate",
|
||||
"executor_protocol": "aios.executor.v1",
|
||||
"owner_notification_channel": "wechat-simulation",
|
||||
"owner_notification_target_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"production_baseline": "V2R1C1",
|
||||
"candidate_port": 18921,
|
||||
"production_port": 18920,
|
||||
"production_mutation_allowed": false,
|
||||
"release_tag_creation_allowed": false,
|
||||
"simulation_required": true
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "chatgpt-supervisor-candidate-deploy",
|
||||
"task_id": "OPENCLAW-TASK-V2R1C2-UNIFIED-PORTAL-DEPLOY",
|
||||
"executor_type": "chatgpt-supervisor",
|
||||
"role": "candidate_deployment_supervisor",
|
||||
"depends_on": [],
|
||||
"steps": [
|
||||
"verify_exact_source_oid_and_clean_worktree",
|
||||
"verify_owner_provenance_and_tenant_boundary",
|
||||
"compile_and_test_candidate",
|
||||
"deploy_isolated_candidate_on_18921",
|
||||
"run_offline_and_online_acceptance",
|
||||
"run_simulated_wechat_acceptance",
|
||||
"run_restart_recovery_and_rollback_drill",
|
||||
"publish_hashed_evidence_and_gate_report"
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_oid_attestation",
|
||||
"provenance_positive_path",
|
||||
"provenance_negative_matrix",
|
||||
"wechat_simulated_transcript",
|
||||
"wechat_simulated_receipts",
|
||||
"provenance_replay_test",
|
||||
"tenant_isolation",
|
||||
"provider_identity_test",
|
||||
"compile_report",
|
||||
"unit_test_report",
|
||||
"candidate_regression_report",
|
||||
"acceptance_offline",
|
||||
"acceptance_online",
|
||||
"service_status",
|
||||
"health_check",
|
||||
"api_smoke",
|
||||
"ui_smoke",
|
||||
"role_matrix",
|
||||
"restart_recovery",
|
||||
"rollback_drill",
|
||||
"production_health_before",
|
||||
"production_health_after",
|
||||
"final_execution_snapshot",
|
||||
"final_gate_report"
|
||||
],
|
||||
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "chatgpt-supervisor",
|
||||
"candidate_service": "aios-v2r1c2-portal.service",
|
||||
"candidate_url": "http://127.0.0.1:18921",
|
||||
"production_url": "http://127.0.0.1:18920",
|
||||
"production_service_must_remain_untouched": true,
|
||||
"wechat_transport": "mock-or-sandbox",
|
||||
"real_user_impersonated": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage_id": "hermes-runtime-validation",
|
||||
"task_id": "AIOS-V2R1C2-HERMES-RUNTIME-VALIDATION-20260722",
|
||||
"executor_type": "hermes",
|
||||
"role": "runtime_validation_operator",
|
||||
"depends_on": ["chatgpt-supervisor-candidate-deploy"],
|
||||
"steps": [
|
||||
"validate_hermes_health_and_worker_identity",
|
||||
"validate_queue_claim_and_completion",
|
||||
"validate_lease_heartbeat_and_timeout_recovery",
|
||||
"validate_restart_recovery_and_duplicate_delivery_safety",
|
||||
"reconcile_runtime_evidence_with_candidate_audit"
|
||||
],
|
||||
"required_evidence": [
|
||||
"hermes_health",
|
||||
"queue_claim_completion",
|
||||
"lease_heartbeat",
|
||||
"timeout_recovery",
|
||||
"restart_recovery",
|
||||
"duplicate_delivery_safety",
|
||||
"runtime_evidence_reconciliation"
|
||||
],
|
||||
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "hermes",
|
||||
"candidate_url": "http://127.0.0.1:18921"
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage_id": "codex-release-review",
|
||||
"task_id": "AIOS-V2R1C2-CODEX-RELEASE-REVIEW-20260722",
|
||||
"executor_type": "codex",
|
||||
"role": "release_evidence_reviewer",
|
||||
"depends_on": [
|
||||
"chatgpt-supervisor-candidate-deploy",
|
||||
"hermes-runtime-validation"
|
||||
],
|
||||
"steps": [
|
||||
"review_source_diff_and_architecture_invariants",
|
||||
"review_test_and_runtime_evidence_hashes",
|
||||
"review_security_tenant_and_role_boundaries",
|
||||
"review_documentation_traceability_and_rollback_readiness",
|
||||
"produce_release_recommendation_without_creating_tag"
|
||||
],
|
||||
"required_evidence": [
|
||||
"source_diff_review",
|
||||
"test_evidence_review",
|
||||
"runtime_evidence_review",
|
||||
"security_boundary_review",
|
||||
"documentation_traceability_review",
|
||||
"rollback_readiness_review",
|
||||
"release_recommendation"
|
||||
],
|
||||
"notification_events": ["STARTED", "SUCCEEDED", "FAILED"],
|
||||
"owner_notification_ref": "AIOS_OWNER_WECHAT_TARGET_REF",
|
||||
"start_deadline_seconds": 900,
|
||||
"heartbeat_timeout_seconds": 360,
|
||||
"required": true,
|
||||
"metadata": {
|
||||
"runtime": "codex",
|
||||
"tag_creation_allowed": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -49,3 +49,16 @@ node --test test/*.test.js
|
||||
```
|
||||
|
||||
验收还必须包含受保护签发、Heartbeat 实际 commit/push、审计链验证,以及 `README.md` 等未授权路径的拒绝测试。
|
||||
|
||||
## Candidate task-plane transport
|
||||
|
||||
`protectedDispatchTransport=task-plane` 仅用于隔离 Candidate 验证。它要求:
|
||||
|
||||
- TaskPlan 与服务器配置的 `task_id`、repository、patch branch、完整 base OID、允许路径和 capability 完全一致;
|
||||
- Owner provenance 已通过现有签名校验,Policy approval 与 TaskPlan 摘要、Owner principal、有效期绑定;
|
||||
- Broker 端点只能是 loopback HTTP,Bearer 凭据只能从绝对路径的受控凭据投影读取;
|
||||
- Broker 返回 `simulation=false`、真实 provisioning 元数据和 `secret-ref://` 引用,禁止返回 Token 或 Authorization 内容;
|
||||
- Broker 不可达时直接失败,不回退到 `mock` 或 `sandbox`;
|
||||
- 配置 `protectedDispatchAuditPath` 时仅写入脱敏 JSONL 审计记录。
|
||||
|
||||
该 Candidate 不修改生产插件或 Gateway;OpenClaw/Codex 原生子会话未完成前必须分别报告为未验证。
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function redacted(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const scrubbed = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === "token" || key === "authorization" || key === "secret" || key === "credential") {
|
||||
continue;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
scrubbed[key] = redacted(item);
|
||||
} else if (typeof item === "string" && ["x-aios-token", "x-api-token", "authorization"].includes(key.toLowerCase())) {
|
||||
scrubbed[key] = "[redacted]";
|
||||
} else {
|
||||
scrubbed[key] = item;
|
||||
}
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function appendRecord(auditPath, record) {
|
||||
const parent = path.dirname(auditPath);
|
||||
fs.mkdirSync(parent, { recursive: true, mode: 0o750 });
|
||||
const fd = fs.openSync(auditPath, "a", 0o640);
|
||||
try {
|
||||
fs.writeSync(fd, `${JSON.stringify(record)}\n`, null, "utf8");
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.chmodSync(auditPath, 0o640);
|
||||
}
|
||||
|
||||
export function appendDispatchAudit(auditPath, record) {
|
||||
if (!auditPath) return;
|
||||
const absolute = path.isAbsolute(auditPath);
|
||||
if (!absolute) throw new Error("invalid_audit_path");
|
||||
const payload = {
|
||||
schema_version: "aios.protected-dispatch-audit/v1",
|
||||
recorded_at: Date.now(),
|
||||
...(redacted(record) || {}),
|
||||
};
|
||||
appendRecord(auditPath, payload);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { canonicalize } from "./provenance.js";
|
||||
|
||||
const FORBIDDEN_BRANCHES = new Set(["main", "master", "trunk"]);
|
||||
const OID_RE = /^[0-9a-f]{40}$/;
|
||||
const BRANCH_RE = /^(?!\/)(?!.*(?:\.\.|@\{|\/\/|[~^:?*[\]\\\s]))(?!.*\/$)[A-Za-z0-9._/-]{1,240}$/;
|
||||
const REPOSITORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RECIPIENT_BINDING_RE = /^principal:sha256:[0-9a-f]{64}$/;
|
||||
const SECRET_KEYS = /^(?:token|access_token|authorization|authorization_header|credential|credential_value|secret|secret_value)$/i;
|
||||
const DEFAULT_CAPABILITIES = Object.freeze([
|
||||
"file.read.allowlisted",
|
||||
"commit_file.atomic",
|
||||
"git.diff.read",
|
||||
"git.push.patch_branch",
|
||||
"test.execute",
|
||||
"artifact.build",
|
||||
]);
|
||||
|
||||
function object(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function string(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function exactStringArray(value, field, { max = 64 } = {}) {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > max) {
|
||||
return { ok: false, code: `${field}_invalid` };
|
||||
}
|
||||
const values = value.map(string);
|
||||
if (values.some((item) => !item) || new Set(values).size !== values.length) {
|
||||
return { ok: false, code: `${field}_invalid` };
|
||||
}
|
||||
return { ok: true, values };
|
||||
}
|
||||
|
||||
function sameSet(left, right) {
|
||||
return left.length === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
|
||||
}
|
||||
|
||||
function parseIso(value, field) {
|
||||
const timestamp = Date.parse(string(value));
|
||||
return Number.isFinite(timestamp) ? { ok: true, timestamp } : { ok: false, code: `${field}_invalid` };
|
||||
}
|
||||
|
||||
function containsSecretField(value) {
|
||||
if (Array.isArray(value)) return value.some(containsSecretField);
|
||||
if (!object(value)) return false;
|
||||
return Object.entries(value).some(([key, item]) => SECRET_KEYS.test(key) || containsSecretField(item));
|
||||
}
|
||||
|
||||
export function deriveRequiredCapabilities(action) {
|
||||
if (!new Set(["candidate.session.provision", "candidate.repository.change"]).has(string(action))) {
|
||||
return { ok: false, code: "task_plane_action_not_allowed" };
|
||||
}
|
||||
return { ok: true, capabilities: [...DEFAULT_CAPABILITIES] };
|
||||
}
|
||||
|
||||
export function taskPlanDigest(taskPlan) {
|
||||
return crypto.createHash("sha256").update(canonicalize(taskPlan)).digest("hex");
|
||||
}
|
||||
|
||||
export function validateTaskPlaneRequest({ request, permit, config = {}, now = Date.now() }) {
|
||||
const payload = object(request?.payload);
|
||||
const plan = object(payload?.task_plan);
|
||||
const approval = object(payload?.approval_record);
|
||||
if (!plan) return { ok: false, code: "protected_dispatch_task_plan_missing" };
|
||||
if (!approval) return { ok: false, code: "task_plan_approval_missing" };
|
||||
|
||||
const expectedPlanKeys = [
|
||||
"task_id", "parent_session_id", "requested_role", "required_capabilities",
|
||||
"repository", "branch", "approved_base_oid", "allowed_paths", "credential_policy",
|
||||
"result_delivery",
|
||||
].sort();
|
||||
const actualPlanKeys = Object.keys(plan).sort();
|
||||
if (actualPlanKeys.length !== expectedPlanKeys.length || expectedPlanKeys.some((key) => !actualPlanKeys.includes(key))) {
|
||||
return { ok: false, code: "task_plan_schema_mismatch" };
|
||||
}
|
||||
|
||||
const taskId = string(plan.task_id);
|
||||
if (!TASK_ID_RE.test(taskId) || taskId !== string(request.task_id) || taskId !== string(permit?.record?.payload?.task_id)) {
|
||||
return { ok: false, code: "task_plan_task_mismatch" };
|
||||
}
|
||||
const parentSessionId = string(plan.parent_session_id);
|
||||
if (!parentSessionId) return { ok: false, code: "parent_session_id_required" };
|
||||
if (string(plan.requested_role) !== "dedicated_platform_writer") {
|
||||
return { ok: false, code: "requested_role_rejected" };
|
||||
}
|
||||
|
||||
const repository = string(plan.repository);
|
||||
const branch = string(plan.branch);
|
||||
const baseOid = string(plan.approved_base_oid).toLowerCase();
|
||||
if (!REPOSITORY_RE.test(repository)) return { ok: false, code: "repository_invalid" };
|
||||
if (!BRANCH_RE.test(branch) || FORBIDDEN_BRANCHES.has(branch.toLowerCase())) {
|
||||
return { ok: false, code: "task_plan_branch_rejected" };
|
||||
}
|
||||
if (!OID_RE.test(baseOid)) return { ok: false, code: "task_plan_base_invalid" };
|
||||
|
||||
for (const [actual, expected, code] of [
|
||||
[taskId, string(config.taskPlaneTaskId), "task_plan_task_not_configured"],
|
||||
[repository, string(config.taskPlaneRepository), "task_plan_repository_mismatch"],
|
||||
[branch, string(config.taskPlaneBranch), "task_plan_branch_mismatch"],
|
||||
[baseOid, string(config.taskPlaneApprovedBaseOid).toLowerCase(), "task_plan_base_mismatch"],
|
||||
]) {
|
||||
if (!expected || actual !== expected) return { ok: false, code };
|
||||
}
|
||||
|
||||
const pathCheck = exactStringArray(plan.allowed_paths, "task_plan_allowed_paths");
|
||||
if (!pathCheck.ok) return pathCheck;
|
||||
const configuredPaths = Array.isArray(config.taskPlaneAllowedPaths) ? config.taskPlaneAllowedPaths.map(string) : [];
|
||||
if (!sameSet(pathCheck.values, configuredPaths)) return { ok: false, code: "task_plan_path_rejected" };
|
||||
|
||||
const derived = deriveRequiredCapabilities(request.action);
|
||||
if (!derived.ok) return derived;
|
||||
const capabilityCheck = exactStringArray(plan.required_capabilities, "task_plan_required_capabilities");
|
||||
if (!capabilityCheck.ok) return capabilityCheck;
|
||||
const configuredCapabilities = Array.isArray(config.taskPlaneAllowedCapabilities)
|
||||
? config.taskPlaneAllowedCapabilities.map(string)
|
||||
: [...DEFAULT_CAPABILITIES];
|
||||
if (!sameSet(derived.capabilities, configuredCapabilities) || !sameSet(capabilityCheck.values, derived.capabilities)) {
|
||||
return { ok: false, code: "task_plan_capability_mismatch" };
|
||||
}
|
||||
|
||||
const credentialPolicy = object(plan.credential_policy);
|
||||
if (!credentialPolicy || Object.keys(credentialPolicy).sort().join(",") !== "max_uses,ttl_seconds") {
|
||||
return { ok: false, code: "credential_policy_invalid" };
|
||||
}
|
||||
const ttlSeconds = Number(credentialPolicy.ttl_seconds);
|
||||
const maxUses = Number(credentialPolicy.max_uses);
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 1800 ||
|
||||
!Number.isInteger(maxUses) || maxUses < 1 || maxUses > 100) {
|
||||
return { ok: false, code: "credential_policy_invalid" };
|
||||
}
|
||||
|
||||
const resultDelivery = object(plan.result_delivery);
|
||||
const expectedResultDeliveryKeys = [
|
||||
"channel", "recipient_binding", "content_type", "encoding", "image_only",
|
||||
].sort();
|
||||
if (!resultDelivery ||
|
||||
Object.keys(resultDelivery).sort().join(",") !== expectedResultDeliveryKeys.join(",") ||
|
||||
string(resultDelivery.channel) !== "supervisor" ||
|
||||
string(resultDelivery.content_type) !== "text/plain" ||
|
||||
string(resultDelivery.encoding).toLowerCase() !== "utf-8" ||
|
||||
resultDelivery.image_only !== false ||
|
||||
!RECIPIENT_BINDING_RE.test(string(resultDelivery.recipient_binding)) ||
|
||||
string(resultDelivery.recipient_binding) !== string(config.taskPlaneResultRecipientBinding)) {
|
||||
return { ok: false, code: "task_plan_result_delivery_rejected" };
|
||||
}
|
||||
|
||||
const expectedApprovalKeys = [
|
||||
"approval_id", "task_id", "decision", "policy_id", "owner_principal_id",
|
||||
"task_plan_sha256", "approved_capabilities", "not_before", "expires_at",
|
||||
].sort();
|
||||
const actualApprovalKeys = Object.keys(approval).sort();
|
||||
if (actualApprovalKeys.length !== expectedApprovalKeys.length ||
|
||||
expectedApprovalKeys.some((key) => !actualApprovalKeys.includes(key))) {
|
||||
return { ok: false, code: "approval_record_schema_mismatch" };
|
||||
}
|
||||
if (!string(approval.approval_id) || string(approval.task_id) !== taskId ||
|
||||
string(approval.decision) !== "approved" ||
|
||||
string(approval.policy_id) !== string(config.taskPlanePolicyId) ||
|
||||
string(approval.owner_principal_id) !== string(permit.record.payload.principal_id) ||
|
||||
string(approval.task_plan_sha256) !== taskPlanDigest(plan)) {
|
||||
return { ok: false, code: "approval_record_mismatch" };
|
||||
}
|
||||
const approvalCapabilities = exactStringArray(approval.approved_capabilities, "approval_capabilities");
|
||||
if (!approvalCapabilities.ok || !sameSet(approvalCapabilities.values, derived.capabilities)) {
|
||||
return { ok: false, code: "task_plan_capability_mismatch" };
|
||||
}
|
||||
const notBefore = parseIso(approval.not_before, "approval_not_before");
|
||||
const expiresAt = parseIso(approval.expires_at, "approval_expires_at");
|
||||
if (!notBefore.ok) return notBefore;
|
||||
if (!expiresAt.ok) return expiresAt;
|
||||
if (notBefore.timestamp > now || expiresAt.timestamp <= now || expiresAt.timestamp - notBefore.timestamp > 1_800_000) {
|
||||
return { ok: false, code: "task_plan_approval_expired" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
task_id: taskId,
|
||||
parent_session_id: parentSessionId,
|
||||
requested_role: "dedicated_platform_writer",
|
||||
required_capabilities: derived.capabilities,
|
||||
repository,
|
||||
branch,
|
||||
approved_base_oid: baseOid,
|
||||
allowed_paths: pathCheck.values,
|
||||
credential_policy: { ttl_seconds: ttlSeconds, max_uses: maxUses },
|
||||
result_delivery: {
|
||||
channel: "supervisor",
|
||||
recipient_binding: string(resultDelivery.recipient_binding),
|
||||
content_type: "text/plain",
|
||||
encoding: "utf-8",
|
||||
image_only: false,
|
||||
},
|
||||
},
|
||||
approval: {
|
||||
approval_plan_id: string(approval.approval_id),
|
||||
policy_id: string(approval.policy_id),
|
||||
owner_provenance_id: string(permit.record.payload.provenance_id),
|
||||
task_plan_sha256: string(approval.task_plan_sha256),
|
||||
not_before: string(approval.not_before),
|
||||
expires_at: string(approval.expires_at),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApprovalPayload(
|
||||
validated,
|
||||
{ idempotencyKey, riskClass, signingKeyId },
|
||||
) {
|
||||
const approvalPlanId = validated.approval.approval_plan_id;
|
||||
const nonceSeed = canonicalize({
|
||||
approval_plan_id: approvalPlanId,
|
||||
task_id: validated.plan.task_id,
|
||||
idempotency_key: string(idempotencyKey),
|
||||
});
|
||||
return {
|
||||
approval_plan_id: approvalPlanId,
|
||||
task_id: validated.plan.task_id,
|
||||
task_plan_sha256: validated.approval.task_plan_sha256,
|
||||
repository: validated.plan.repository,
|
||||
branch: validated.plan.branch,
|
||||
approved_base_oid: validated.plan.approved_base_oid,
|
||||
allowed_paths: [...validated.plan.allowed_paths],
|
||||
approved_capabilities: [...validated.plan.required_capabilities],
|
||||
risk_class: string(riskClass),
|
||||
not_before: validated.approval.not_before,
|
||||
expires_at: validated.approval.expires_at,
|
||||
nonce: `approval-nonce-v1:${crypto.createHash("sha256").update(nonceSeed).digest("hex")}`,
|
||||
signing_key_id: string(signingKeyId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProvisionRequest(validated, idempotencyKey, approvalEnvelope) {
|
||||
if (!object(approvalEnvelope)) throw new Error("approval_envelope_missing");
|
||||
return {
|
||||
...validated.plan,
|
||||
idempotency_key: string(idempotencyKey),
|
||||
approval_envelope: approvalEnvelope,
|
||||
owner_provenance_id: validated.approval.owner_provenance_id,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateProvisionResponse(response, expected, now = Date.now()) {
|
||||
const value = object(response);
|
||||
if (!value || containsSecretField(value)) return { ok: false, code: "broker_response_contains_secret_field" };
|
||||
if (value.simulation !== false || value.status !== "provisioned") {
|
||||
return { ok: false, code: "broker_response_invalid" };
|
||||
}
|
||||
if (value.broker_session_verified !== true ||
|
||||
value.openclaw_native_child_session_verified !== false ||
|
||||
value.codex_child_session_verified !== false) {
|
||||
return { ok: false, code: "broker_response_session_type_invalid" };
|
||||
}
|
||||
for (const field of ["child_session_id", "worker_id", "execution_endpoint_id", "worktree_id"]) {
|
||||
if (!string(value[field])) return { ok: false, code: "broker_response_invalid" };
|
||||
}
|
||||
if (string(value.task_id) !== expected.task_id ||
|
||||
string(value.bound_branch) !== expected.branch ||
|
||||
string(value.bound_base_oid).toLowerCase() !== expected.approved_base_oid) {
|
||||
return { ok: false, code: "broker_response_mismatch" };
|
||||
}
|
||||
const capabilities = exactStringArray(value.capabilities, "broker_response_capabilities");
|
||||
if (!capabilities.ok || !sameSet(capabilities.values, expected.required_capabilities)) {
|
||||
return { ok: false, code: "broker_response_capability_mismatch" };
|
||||
}
|
||||
const credentialReference = string(value.credential_reference);
|
||||
if (!credentialReference.startsWith("secret-ref://")) {
|
||||
return { ok: false, code: "broker_response_credential_reference_invalid" };
|
||||
}
|
||||
const expiry = parseIso(value.expires_at, "broker_response_expires_at");
|
||||
if (!expiry.ok || expiry.timestamp <= now) return { ok: false, code: "broker_response_expired" };
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
simulation: false,
|
||||
status: "provisioned",
|
||||
broker_session_verified: true,
|
||||
openclaw_native_child_session_verified: false,
|
||||
codex_child_session_verified: false,
|
||||
child_session_id: string(value.child_session_id),
|
||||
worker_id: string(value.worker_id),
|
||||
task_id: string(value.task_id),
|
||||
execution_endpoint_id: string(value.execution_endpoint_id),
|
||||
credential_reference: credentialReference,
|
||||
bound_branch: string(value.bound_branch),
|
||||
bound_base_oid: string(value.bound_base_oid).toLowerCase(),
|
||||
worktree_id: string(value.worktree_id),
|
||||
expires_at: string(value.expires_at),
|
||||
capabilities: capabilities.values,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __test = { containsSecretField, sameSet };
|
||||
@@ -0,0 +1,32 @@
|
||||
export const DISPATCH_ERROR_CODES = {
|
||||
protectedDispatchTransportNotAuthorized: "protected_dispatch_transport_not_authorized",
|
||||
taskPlanMissing: "protected_dispatch_task_plan_missing",
|
||||
taskPlanInvalid: "protected_dispatch_task_plan_invalid",
|
||||
taskPlanBranchRejected: "task_plan_branch_rejected",
|
||||
taskPlanBaseInvalid: "task_plan_base_invalid",
|
||||
taskPlanPathRejected: "task_plan_path_rejected",
|
||||
taskPlanCapabilityMismatch: "task_plan_capability_mismatch",
|
||||
taskPlanApprovalMissing: "task_plan_approval_missing",
|
||||
taskPlanApprovalExpired: "task_plan_approval_expired",
|
||||
taskPlanApprovalTaskMismatch: "task_plan_approval_task_mismatch",
|
||||
brokerConfigInvalid: "broker_config_invalid",
|
||||
brokerCredentialReadFailed: "broker_credential_read_failed",
|
||||
brokerCredentialInvalid: "broker_credential_invalid",
|
||||
brokerRequestFailed: "broker_request_failed",
|
||||
brokerUnavailable: "broker_unreachable",
|
||||
brokerResponseInvalid: "broker_response_invalid",
|
||||
brokerResponseMismatch: "broker_response_mismatch",
|
||||
brokerResponseContainsSecret: "broker_response_contains_secret_field",
|
||||
transportExecutionFailed: "protected_dispatch_transport_failed",
|
||||
};
|
||||
|
||||
export function dispatchError(code) {
|
||||
const error = new Error(code);
|
||||
error.code = code;
|
||||
error.aiosDispatchError = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function isDispatchError(error) {
|
||||
return error instanceof Error && error.aiosDispatchError === true && typeof error.code === "string";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function redacted(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const scrubbed = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === "token" || key === "authorization" || key === "secret" || key === "credential") {
|
||||
continue;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
scrubbed[key] = redacted(item);
|
||||
} else if (typeof item === "string" && ["x-aios-token", "x-api-token", "authorization"].includes(key.toLowerCase())) {
|
||||
scrubbed[key] = "[redacted]";
|
||||
} else {
|
||||
scrubbed[key] = item;
|
||||
}
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function appendRecord(auditPath, record) {
|
||||
const parent = path.dirname(auditPath);
|
||||
fs.mkdirSync(parent, { recursive: true, mode: 0o750 });
|
||||
const fd = fs.openSync(auditPath, "a", 0o640);
|
||||
try {
|
||||
fs.writeSync(fd, `${JSON.stringify(record)}\n`, null, "utf8");
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.chmodSync(auditPath, 0o640);
|
||||
}
|
||||
|
||||
export function appendDispatchAudit(auditPath, record) {
|
||||
if (!auditPath) return;
|
||||
const absolute = path.isAbsolute(auditPath);
|
||||
if (!absolute) throw new Error("invalid_audit_path");
|
||||
const payload = {
|
||||
schema_version: "aios.protected-dispatch-audit/v1",
|
||||
recorded_at: Date.now(),
|
||||
...(redacted(record) || {}),
|
||||
};
|
||||
appendRecord(auditPath, payload);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { canonicalize } from "./provenance.js";
|
||||
|
||||
const FORBIDDEN_BRANCHES = new Set(["main", "master", "trunk"]);
|
||||
const OID_RE = /^[0-9a-f]{40}$/;
|
||||
const BRANCH_RE = /^(?!\/)(?!.*(?:\.\.|@\{|\/\/|[~^:?*[\]\\\s]))(?!.*\/$)[A-Za-z0-9._/-]{1,240}$/;
|
||||
const REPOSITORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RECIPIENT_BINDING_RE = /^principal:sha256:[0-9a-f]{64}$/;
|
||||
const SECRET_KEYS = /^(?:token|access_token|authorization|authorization_header|credential|credential_value|secret|secret_value)$/i;
|
||||
const DEFAULT_CAPABILITIES = Object.freeze([
|
||||
"file.read.allowlisted",
|
||||
"commit_file.atomic",
|
||||
"git.diff.read",
|
||||
"git.push.patch_branch",
|
||||
"test.execute",
|
||||
"artifact.build",
|
||||
]);
|
||||
|
||||
function object(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function string(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function exactStringArray(value, field, { max = 64 } = {}) {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > max) {
|
||||
return { ok: false, code: `${field}_invalid` };
|
||||
}
|
||||
const values = value.map(string);
|
||||
if (values.some((item) => !item) || new Set(values).size !== values.length) {
|
||||
return { ok: false, code: `${field}_invalid` };
|
||||
}
|
||||
return { ok: true, values };
|
||||
}
|
||||
|
||||
function sameSet(left, right) {
|
||||
return left.length === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
|
||||
}
|
||||
|
||||
function parseIso(value, field) {
|
||||
const timestamp = Date.parse(string(value));
|
||||
return Number.isFinite(timestamp) ? { ok: true, timestamp } : { ok: false, code: `${field}_invalid` };
|
||||
}
|
||||
|
||||
function containsSecretField(value) {
|
||||
if (Array.isArray(value)) return value.some(containsSecretField);
|
||||
if (!object(value)) return false;
|
||||
return Object.entries(value).some(([key, item]) => SECRET_KEYS.test(key) || containsSecretField(item));
|
||||
}
|
||||
|
||||
export function deriveRequiredCapabilities(action) {
|
||||
if (!new Set(["candidate.session.provision", "candidate.repository.change"]).has(string(action))) {
|
||||
return { ok: false, code: "task_plane_action_not_allowed" };
|
||||
}
|
||||
return { ok: true, capabilities: [...DEFAULT_CAPABILITIES] };
|
||||
}
|
||||
|
||||
export function taskPlanDigest(taskPlan) {
|
||||
return crypto.createHash("sha256").update(canonicalize(taskPlan)).digest("hex");
|
||||
}
|
||||
|
||||
export function validateTaskPlaneRequest({ request, permit, config = {}, now = Date.now() }) {
|
||||
const payload = object(request?.payload);
|
||||
const plan = object(payload?.task_plan);
|
||||
const approval = object(payload?.approval_record);
|
||||
if (!plan) return { ok: false, code: "protected_dispatch_task_plan_missing" };
|
||||
if (!approval) return { ok: false, code: "task_plan_approval_missing" };
|
||||
|
||||
const expectedPlanKeys = [
|
||||
"task_id", "parent_session_id", "requested_role", "required_capabilities",
|
||||
"repository", "branch", "approved_base_oid", "allowed_paths", "credential_policy",
|
||||
"result_delivery",
|
||||
].sort();
|
||||
const actualPlanKeys = Object.keys(plan).sort();
|
||||
if (actualPlanKeys.length !== expectedPlanKeys.length || expectedPlanKeys.some((key) => !actualPlanKeys.includes(key))) {
|
||||
return { ok: false, code: "task_plan_schema_mismatch" };
|
||||
}
|
||||
|
||||
const taskId = string(plan.task_id);
|
||||
if (!TASK_ID_RE.test(taskId) || taskId !== string(request.task_id) || taskId !== string(permit?.record?.payload?.task_id)) {
|
||||
return { ok: false, code: "task_plan_task_mismatch" };
|
||||
}
|
||||
const parentSessionId = string(plan.parent_session_id);
|
||||
if (!parentSessionId) return { ok: false, code: "parent_session_id_required" };
|
||||
if (string(plan.requested_role) !== "dedicated_platform_writer") {
|
||||
return { ok: false, code: "requested_role_rejected" };
|
||||
}
|
||||
|
||||
const repository = string(plan.repository);
|
||||
const branch = string(plan.branch);
|
||||
const baseOid = string(plan.approved_base_oid).toLowerCase();
|
||||
if (!REPOSITORY_RE.test(repository)) return { ok: false, code: "repository_invalid" };
|
||||
if (!BRANCH_RE.test(branch) || FORBIDDEN_BRANCHES.has(branch.toLowerCase())) {
|
||||
return { ok: false, code: "task_plan_branch_rejected" };
|
||||
}
|
||||
if (!OID_RE.test(baseOid)) return { ok: false, code: "task_plan_base_invalid" };
|
||||
|
||||
for (const [actual, expected, code] of [
|
||||
[taskId, string(config.taskPlaneTaskId), "task_plan_task_not_configured"],
|
||||
[repository, string(config.taskPlaneRepository), "task_plan_repository_mismatch"],
|
||||
[branch, string(config.taskPlaneBranch), "task_plan_branch_mismatch"],
|
||||
[baseOid, string(config.taskPlaneApprovedBaseOid).toLowerCase(), "task_plan_base_mismatch"],
|
||||
]) {
|
||||
if (!expected || actual !== expected) return { ok: false, code };
|
||||
}
|
||||
|
||||
const pathCheck = exactStringArray(plan.allowed_paths, "task_plan_allowed_paths");
|
||||
if (!pathCheck.ok) return pathCheck;
|
||||
const configuredPaths = Array.isArray(config.taskPlaneAllowedPaths) ? config.taskPlaneAllowedPaths.map(string) : [];
|
||||
if (!sameSet(pathCheck.values, configuredPaths)) return { ok: false, code: "task_plan_path_rejected" };
|
||||
|
||||
const derived = deriveRequiredCapabilities(request.action);
|
||||
if (!derived.ok) return derived;
|
||||
const capabilityCheck = exactStringArray(plan.required_capabilities, "task_plan_required_capabilities");
|
||||
if (!capabilityCheck.ok) return capabilityCheck;
|
||||
const configuredCapabilities = Array.isArray(config.taskPlaneAllowedCapabilities)
|
||||
? config.taskPlaneAllowedCapabilities.map(string)
|
||||
: [...DEFAULT_CAPABILITIES];
|
||||
if (!sameSet(derived.capabilities, configuredCapabilities) || !sameSet(capabilityCheck.values, derived.capabilities)) {
|
||||
return { ok: false, code: "task_plan_capability_mismatch" };
|
||||
}
|
||||
|
||||
const credentialPolicy = object(plan.credential_policy);
|
||||
if (!credentialPolicy || Object.keys(credentialPolicy).sort().join(",") !== "max_uses,ttl_seconds") {
|
||||
return { ok: false, code: "credential_policy_invalid" };
|
||||
}
|
||||
const ttlSeconds = Number(credentialPolicy.ttl_seconds);
|
||||
const maxUses = Number(credentialPolicy.max_uses);
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 60 || ttlSeconds > 1800 ||
|
||||
!Number.isInteger(maxUses) || maxUses < 1 || maxUses > 100) {
|
||||
return { ok: false, code: "credential_policy_invalid" };
|
||||
}
|
||||
|
||||
const resultDelivery = object(plan.result_delivery);
|
||||
const expectedResultDeliveryKeys = [
|
||||
"channel", "recipient_binding", "content_type", "encoding", "image_only",
|
||||
].sort();
|
||||
if (!resultDelivery ||
|
||||
Object.keys(resultDelivery).sort().join(",") !== expectedResultDeliveryKeys.join(",") ||
|
||||
string(resultDelivery.channel) !== "supervisor" ||
|
||||
string(resultDelivery.content_type) !== "text/plain" ||
|
||||
string(resultDelivery.encoding).toLowerCase() !== "utf-8" ||
|
||||
resultDelivery.image_only !== false ||
|
||||
!RECIPIENT_BINDING_RE.test(string(resultDelivery.recipient_binding)) ||
|
||||
string(resultDelivery.recipient_binding) !== string(config.taskPlaneResultRecipientBinding)) {
|
||||
return { ok: false, code: "task_plan_result_delivery_rejected" };
|
||||
}
|
||||
|
||||
const expectedApprovalKeys = [
|
||||
"approval_id", "task_id", "decision", "policy_id", "owner_principal_id",
|
||||
"task_plan_sha256", "approved_capabilities", "not_before", "expires_at",
|
||||
].sort();
|
||||
const actualApprovalKeys = Object.keys(approval).sort();
|
||||
if (actualApprovalKeys.length !== expectedApprovalKeys.length ||
|
||||
expectedApprovalKeys.some((key) => !actualApprovalKeys.includes(key))) {
|
||||
return { ok: false, code: "approval_record_schema_mismatch" };
|
||||
}
|
||||
if (!string(approval.approval_id) || string(approval.task_id) !== taskId ||
|
||||
string(approval.decision) !== "approved" ||
|
||||
string(approval.policy_id) !== string(config.taskPlanePolicyId) ||
|
||||
string(approval.owner_principal_id) !== string(permit.record.payload.principal_id) ||
|
||||
string(approval.task_plan_sha256) !== taskPlanDigest(plan)) {
|
||||
return { ok: false, code: "approval_record_mismatch" };
|
||||
}
|
||||
const approvalCapabilities = exactStringArray(approval.approved_capabilities, "approval_capabilities");
|
||||
if (!approvalCapabilities.ok || !sameSet(approvalCapabilities.values, derived.capabilities)) {
|
||||
return { ok: false, code: "task_plan_capability_mismatch" };
|
||||
}
|
||||
const notBefore = parseIso(approval.not_before, "approval_not_before");
|
||||
const expiresAt = parseIso(approval.expires_at, "approval_expires_at");
|
||||
if (!notBefore.ok) return notBefore;
|
||||
if (!expiresAt.ok) return expiresAt;
|
||||
if (notBefore.timestamp > now || expiresAt.timestamp <= now || expiresAt.timestamp - notBefore.timestamp > 1_800_000) {
|
||||
return { ok: false, code: "task_plan_approval_expired" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
task_id: taskId,
|
||||
parent_session_id: parentSessionId,
|
||||
requested_role: "dedicated_platform_writer",
|
||||
required_capabilities: derived.capabilities,
|
||||
repository,
|
||||
branch,
|
||||
approved_base_oid: baseOid,
|
||||
allowed_paths: pathCheck.values,
|
||||
credential_policy: { ttl_seconds: ttlSeconds, max_uses: maxUses },
|
||||
result_delivery: {
|
||||
channel: "supervisor",
|
||||
recipient_binding: string(resultDelivery.recipient_binding),
|
||||
content_type: "text/plain",
|
||||
encoding: "utf-8",
|
||||
image_only: false,
|
||||
},
|
||||
},
|
||||
approval: {
|
||||
approval_plan_id: string(approval.approval_id),
|
||||
policy_id: string(approval.policy_id),
|
||||
owner_provenance_id: string(permit.record.payload.provenance_id),
|
||||
task_plan_sha256: string(approval.task_plan_sha256),
|
||||
not_before: string(approval.not_before),
|
||||
expires_at: string(approval.expires_at),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApprovalPayload(
|
||||
validated,
|
||||
{ idempotencyKey, riskClass, signingKeyId },
|
||||
) {
|
||||
const approvalPlanId = validated.approval.approval_plan_id;
|
||||
const nonceSeed = canonicalize({
|
||||
approval_plan_id: approvalPlanId,
|
||||
task_id: validated.plan.task_id,
|
||||
idempotency_key: string(idempotencyKey),
|
||||
});
|
||||
return {
|
||||
approval_plan_id: approvalPlanId,
|
||||
task_id: validated.plan.task_id,
|
||||
task_plan_sha256: validated.approval.task_plan_sha256,
|
||||
repository: validated.plan.repository,
|
||||
branch: validated.plan.branch,
|
||||
approved_base_oid: validated.plan.approved_base_oid,
|
||||
allowed_paths: [...validated.plan.allowed_paths],
|
||||
approved_capabilities: [...validated.plan.required_capabilities],
|
||||
risk_class: string(riskClass),
|
||||
not_before: validated.approval.not_before,
|
||||
expires_at: validated.approval.expires_at,
|
||||
nonce: `approval-nonce-v1:${crypto.createHash("sha256").update(nonceSeed).digest("hex")}`,
|
||||
signing_key_id: string(signingKeyId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProvisionRequest(validated, idempotencyKey, approvalEnvelope) {
|
||||
if (!object(approvalEnvelope)) throw new Error("approval_envelope_missing");
|
||||
return {
|
||||
...validated.plan,
|
||||
idempotency_key: string(idempotencyKey),
|
||||
approval_envelope: approvalEnvelope,
|
||||
owner_provenance_id: validated.approval.owner_provenance_id,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateProvisionResponse(response, expected, now = Date.now()) {
|
||||
const value = object(response);
|
||||
if (!value || containsSecretField(value)) return { ok: false, code: "broker_response_contains_secret_field" };
|
||||
if (value.simulation !== false || value.status !== "provisioned") {
|
||||
return { ok: false, code: "broker_response_invalid" };
|
||||
}
|
||||
if (value.broker_session_verified !== true ||
|
||||
value.openclaw_native_child_session_verified !== false ||
|
||||
value.codex_child_session_verified !== false) {
|
||||
return { ok: false, code: "broker_response_session_type_invalid" };
|
||||
}
|
||||
for (const field of ["child_session_id", "worker_id", "execution_endpoint_id", "worktree_id"]) {
|
||||
if (!string(value[field])) return { ok: false, code: "broker_response_invalid" };
|
||||
}
|
||||
if (string(value.task_id) !== expected.task_id ||
|
||||
string(value.bound_branch) !== expected.branch ||
|
||||
string(value.bound_base_oid).toLowerCase() !== expected.approved_base_oid) {
|
||||
return { ok: false, code: "broker_response_mismatch" };
|
||||
}
|
||||
const capabilities = exactStringArray(value.capabilities, "broker_response_capabilities");
|
||||
if (!capabilities.ok || !sameSet(capabilities.values, expected.required_capabilities)) {
|
||||
return { ok: false, code: "broker_response_capability_mismatch" };
|
||||
}
|
||||
const credentialReference = string(value.credential_reference);
|
||||
if (!credentialReference.startsWith("secret-ref://")) {
|
||||
return { ok: false, code: "broker_response_credential_reference_invalid" };
|
||||
}
|
||||
const expiry = parseIso(value.expires_at, "broker_response_expires_at");
|
||||
if (!expiry.ok || expiry.timestamp <= now) return { ok: false, code: "broker_response_expired" };
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
simulation: false,
|
||||
status: "provisioned",
|
||||
broker_session_verified: true,
|
||||
openclaw_native_child_session_verified: false,
|
||||
codex_child_session_verified: false,
|
||||
child_session_id: string(value.child_session_id),
|
||||
worker_id: string(value.worker_id),
|
||||
task_id: string(value.task_id),
|
||||
execution_endpoint_id: string(value.execution_endpoint_id),
|
||||
credential_reference: credentialReference,
|
||||
bound_branch: string(value.bound_branch),
|
||||
bound_base_oid: string(value.bound_base_oid).toLowerCase(),
|
||||
worktree_id: string(value.worktree_id),
|
||||
expires_at: string(value.expires_at),
|
||||
capabilities: capabilities.values,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __test = { containsSecretField, sameSet };
|
||||
@@ -0,0 +1,32 @@
|
||||
export const DISPATCH_ERROR_CODES = {
|
||||
protectedDispatchTransportNotAuthorized: "protected_dispatch_transport_not_authorized",
|
||||
taskPlanMissing: "protected_dispatch_task_plan_missing",
|
||||
taskPlanInvalid: "protected_dispatch_task_plan_invalid",
|
||||
taskPlanBranchRejected: "task_plan_branch_rejected",
|
||||
taskPlanBaseInvalid: "task_plan_base_invalid",
|
||||
taskPlanPathRejected: "task_plan_path_rejected",
|
||||
taskPlanCapabilityMismatch: "task_plan_capability_mismatch",
|
||||
taskPlanApprovalMissing: "task_plan_approval_missing",
|
||||
taskPlanApprovalExpired: "task_plan_approval_expired",
|
||||
taskPlanApprovalTaskMismatch: "task_plan_approval_task_mismatch",
|
||||
brokerConfigInvalid: "broker_config_invalid",
|
||||
brokerCredentialReadFailed: "broker_credential_read_failed",
|
||||
brokerCredentialInvalid: "broker_credential_invalid",
|
||||
brokerRequestFailed: "broker_request_failed",
|
||||
brokerUnavailable: "broker_unreachable",
|
||||
brokerResponseInvalid: "broker_response_invalid",
|
||||
brokerResponseMismatch: "broker_response_mismatch",
|
||||
brokerResponseContainsSecret: "broker_response_contains_secret_field",
|
||||
transportExecutionFailed: "protected_dispatch_transport_failed",
|
||||
};
|
||||
|
||||
export function dispatchError(code) {
|
||||
const error = new Error(code);
|
||||
error.code = code;
|
||||
error.aiosDispatchError = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function isDispatchError(error) {
|
||||
return error instanceof Error && error.aiosDispatchError === true && typeof error.code === "string";
|
||||
}
|
||||
+480
-1
@@ -1 +1,480 @@
|
||||
export { default } from "../index.js";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {
|
||||
canonicalize,
|
||||
isOwnerProvenanceRevoked,
|
||||
loadStoredRecord,
|
||||
readPublicKey,
|
||||
validatePayload,
|
||||
verifyOwnerProvenanceRecord,
|
||||
verifyRecord,
|
||||
} from "./provenance.js";
|
||||
import { issueOwnerProvenance } from "./issuer.mjs";
|
||||
import { classifyRun, decideConversationRun, decideToolCall, runKey, validateWriterParams } from "./policy.js";
|
||||
import { executeEvidenceWrite } from "./writer.js";
|
||||
import { createRuntimeApi } from "./runtime.js";
|
||||
import { appendDispatchAudit } from "./dispatch-audit.js";
|
||||
import { dispatchError, isDispatchError } from "./dispatch-errors.js";
|
||||
import { dispatchMockTransport } from "./transports/mock.js";
|
||||
import { dispatchSandboxTransport } from "./transports/sandbox.js";
|
||||
import { dispatchTaskPlaneTransport } from "./transports/task-plane.js";
|
||||
|
||||
const activeRuns = new Map();
|
||||
const permits = new Map();
|
||||
const ownerPermits = new Map();
|
||||
const ownerUses = new Map();
|
||||
const dispatchReceipts = new Map();
|
||||
const dispatchAttempts = new Map();
|
||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
||||
const MAX_DISPATCH_CACHE_AGE_MS = 60 * 60 * 1000;
|
||||
const MAX_SECURITY_CACHE_ENTRIES = 4096;
|
||||
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_PROTECTED_DISPATCH_PROVIDER = "chatgpt-supervisor";
|
||||
const PROTECTED_DISPATCH_TRANSPORTS = Object.freeze({
|
||||
mock: dispatchMockTransport,
|
||||
sandbox: dispatchSandboxTransport,
|
||||
"task-plane": dispatchTaskPlaneTransport,
|
||||
});
|
||||
|
||||
function pluginConfig(event, api) {
|
||||
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||
}
|
||||
|
||||
function protectedDispatchProvider(config = {}) {
|
||||
return String(config.protectedDispatchProvider || DEFAULT_PROTECTED_DISPATCH_PROVIDER);
|
||||
}
|
||||
|
||||
function protectedDispatchTransport(config = {}) {
|
||||
return String(config.protectedDispatchTransport || "disabled");
|
||||
}
|
||||
|
||||
function protectedDispatchTransportConfig(config = {}) {
|
||||
return PROTECTED_DISPATCH_TRANSPORTS[protectedDispatchTransport(config)];
|
||||
}
|
||||
|
||||
function ownerPrincipalId(senderId) {
|
||||
const digest = crypto.createHash("sha256").update(String(senderId)).digest("hex");
|
||||
return `principal:sha256:${digest}`;
|
||||
}
|
||||
|
||||
function trimToLimit(map, limit = MAX_SECURITY_CACHE_ENTRIES) {
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
}
|
||||
|
||||
function setBounded(map, key, value) {
|
||||
if (map.has(key)) map.delete(key);
|
||||
map.set(key, value);
|
||||
trimToLimit(map);
|
||||
}
|
||||
|
||||
function appendProtectedDispatchAudit(config, details) {
|
||||
const auditPath = String(config.protectedDispatchAuditPath || "");
|
||||
if (!auditPath) return;
|
||||
if (!path.isAbsolute(auditPath)) return;
|
||||
appendDispatchAudit(auditPath, details);
|
||||
}
|
||||
|
||||
export 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);
|
||||
for (const [key, value] of ownerPermits) if (now - value.recordedAt > value.ttlMs) ownerPermits.delete(key);
|
||||
for (const [key, expiresAt] of ownerUses) if (now >= expiresAt) ownerUses.delete(key);
|
||||
for (const [key, value] of dispatchReceipts) {
|
||||
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchReceipts.delete(key);
|
||||
}
|
||||
for (const [key, value] of dispatchAttempts) {
|
||||
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchAttempts.delete(key);
|
||||
}
|
||||
for (const map of [activeRuns, permits, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts]) trimToLimit(map);
|
||||
}
|
||||
|
||||
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 protectedDispatchParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "protected_params_missing" };
|
||||
const expected = ["action", "idempotency_key", "payload", "provider", "task_id"];
|
||||
const keys = Object.keys(params).sort();
|
||||
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) {
|
||||
return { ok: false, code: "protected_params_schema_mismatch" };
|
||||
}
|
||||
for (const key of ["action", "idempotency_key", "provider", "task_id"]) {
|
||||
if (typeof params[key] !== "string" || !params[key].trim()) return { ok: false, code: `invalid_${key}` };
|
||||
}
|
||||
if (!params.payload || typeof params.payload !== "object" || Array.isArray(params.payload)) return { ok: false, code: "invalid_payload" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function verifyOwnerBinding({ record, publicKeyPem, expectedKeyId, expected, storeDir, now = Date.now() }) {
|
||||
const verified = verifyOwnerProvenanceRecord(record, publicKeyPem, expectedKeyId, now);
|
||||
if (!verified.ok) return verified;
|
||||
if (isOwnerProvenanceRevoked(storeDir, record.payload.provenance_id)) return { ok: false, code: "owner_revoked" };
|
||||
for (const key of ["tenant_id", "principal_id", "task_id", "agent_id", "run_id", "execution_path", "request_digest"]) {
|
||||
if (record.payload[key] !== expected[key]) return { ok: false, code: `${key}_mismatch` };
|
||||
}
|
||||
return { ok: true, payload: record.payload };
|
||||
}
|
||||
|
||||
export function authorizeProtectedDispatchCall({ event, ctx = {}, trust, config, now = Date.now() }) {
|
||||
const callId = toolCallId(event);
|
||||
if (!callId) return { ok: false, code: "tool_call_id_missing" };
|
||||
if (!trust?.trusted || trust.origin !== "authenticated_owner") return { ok: false, code: trust?.reason || "owner_not_authenticated" };
|
||||
const params = eventParams(event);
|
||||
const paramsCheck = protectedDispatchParams(params);
|
||||
if (!paramsCheck.ok) return paramsCheck;
|
||||
const expectedProvider = protectedDispatchProvider(config);
|
||||
const request = { ...params, provider: expectedProvider };
|
||||
const principalId = ownerPrincipalId(trust.senderId);
|
||||
const runId = runKey(event, ctx) || trust.runId;
|
||||
const agentId = String(ctx.agentId || trust.agentId || "");
|
||||
const tenantId = String(config.tenantId || "");
|
||||
const executionPath = String(trust.executionPath || "");
|
||||
if (!runId || !agentId || !tenantId || !executionPath) return { ok: false, code: "server_identity_incomplete" };
|
||||
const callerParamsDigest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
const requestDigest = crypto.createHash("sha256").update(canonicalize(request)).digest("hex");
|
||||
let issued;
|
||||
try {
|
||||
issued = issueOwnerProvenance({
|
||||
identity: {
|
||||
tenant_id: tenantId,
|
||||
principal_id: principalId,
|
||||
task_id: params.task_id,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
execution_path: executionPath,
|
||||
},
|
||||
request,
|
||||
privateKeyPath: requireAbsoluteConfig(config, "provenancePrivateKeyPath"),
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
keyId: String(config.provenanceKeyId || ""),
|
||||
ttlMs: Math.min(Number(config.ownerProvenanceTtlMs || 120_000), 300_000),
|
||||
now,
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, code: error instanceof Error ? error.message : "owner_issue_failed" };
|
||||
}
|
||||
const expected = {
|
||||
tenant_id: tenantId,
|
||||
principal_id: principalId,
|
||||
task_id: params.task_id,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
execution_path: executionPath,
|
||||
request_digest: requestDigest,
|
||||
};
|
||||
const binding = verifyOwnerBinding({
|
||||
record: issued.record,
|
||||
publicKeyPem: readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")),
|
||||
expectedKeyId: String(config.provenanceKeyId || ""),
|
||||
expected,
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
now,
|
||||
});
|
||||
if (!binding.ok) return binding;
|
||||
if (ownerPermits.has(callId)) return { ok: false, code: "owner_permit_replay" };
|
||||
const ttlMs = Math.min(Number(config.permitTtlMs || DEFAULT_PERMIT_TTL_MS), DEFAULT_PERMIT_TTL_MS);
|
||||
ownerPermits.set(callId, {
|
||||
record: issued.record,
|
||||
expected,
|
||||
request,
|
||||
callerParamsDigest,
|
||||
paramsDigest: requestDigest,
|
||||
recordedAt: now,
|
||||
ttlMs,
|
||||
publicKeyPath: requireAbsoluteConfig(config, "provenancePublicKeyPath"),
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
keyId: String(config.provenanceKeyId || ""),
|
||||
});
|
||||
return { ok: true, provenance_id: issued.provenanceId };
|
||||
}
|
||||
|
||||
export function consumeProtectedPermit(toolCallIdValue, params, now = Date.now()) {
|
||||
const permit = ownerPermits.get(toolCallIdValue);
|
||||
ownerPermits.delete(toolCallIdValue);
|
||||
if (!permit) throw new Error("owner_permit_missing");
|
||||
if (now - permit.recordedAt > permit.ttlMs) throw new Error("owner_permit_expired");
|
||||
const callerParamsDigest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(callerParamsDigest), Buffer.from(permit.callerParamsDigest))) {
|
||||
throw new Error("owner_permit_params_mismatch");
|
||||
}
|
||||
const requestDigest = crypto.createHash("sha256").update(canonicalize(permit.request)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(requestDigest), Buffer.from(permit.paramsDigest))) {
|
||||
throw new Error("owner_permit_request_mismatch");
|
||||
}
|
||||
const binding = verifyOwnerBinding({
|
||||
record: permit.record,
|
||||
publicKeyPem: readPublicKey(permit.publicKeyPath),
|
||||
expectedKeyId: permit.keyId,
|
||||
expected: permit.expected,
|
||||
storeDir: permit.storeDir,
|
||||
now,
|
||||
});
|
||||
if (!binding.ok) throw new Error(binding.code);
|
||||
const provenanceId = permit.record.payload.provenance_id;
|
||||
if (ownerUses.has(provenanceId)) throw new Error("owner_provenance_replay");
|
||||
setBounded(ownerUses, provenanceId, permit.record.payload.expires_at);
|
||||
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" });
|
||||
}
|
||||
|
||||
function registerProtectedDispatchTool(api) {
|
||||
const expectedProvider = protectedDispatchProvider(api.pluginConfig);
|
||||
const expected = new Set(["mock", "sandbox", "task-plane"]);
|
||||
api.registerTool(() => ({
|
||||
name: "aios_protected_dispatch",
|
||||
label: "AIOS Protected Dispatch",
|
||||
description: "Dispatch one Owner-authenticated request with server-issued provenance.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["task_id", "provider", "action", "payload", "idempotency_key"],
|
||||
properties: {
|
||||
task_id: { type: "string" },
|
||||
provider: { type: "string", enum: [expectedProvider] },
|
||||
action: { type: "string" },
|
||||
payload: { type: "object" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
},
|
||||
executionMode: "sequential",
|
||||
async execute(callId, params) {
|
||||
const permit = consumeProtectedPermit(callId, params);
|
||||
const request = permit.request;
|
||||
const config = api.pluginConfig ?? {};
|
||||
const transport = protectedDispatchTransportConfig(config);
|
||||
const mode = protectedDispatchTransport(config);
|
||||
if (!expected.has(mode)) {
|
||||
throw dispatchError("protected_dispatch_transport_not_authorized");
|
||||
}
|
||||
const dedupeKey = `${permit.record.payload.tenant_id}\0${permit.record.payload.principal_id}\0${request.idempotency_key}`;
|
||||
const prior = dispatchReceipts.get(dedupeKey);
|
||||
if (prior) {
|
||||
if (prior.paramsDigest !== permit.paramsDigest) throw new Error("protected_dispatch_idempotency_conflict");
|
||||
return toolResult({ ...prior.result, deduplicated: true });
|
||||
}
|
||||
const attempts = (dispatchAttempts.get(dedupeKey)?.attempts || 0) + 1;
|
||||
setBounded(dispatchAttempts, dedupeKey, { attempts, recordedAt: Date.now() });
|
||||
if (config.mockFailFirstDelivery === true && mode === "mock" && attempts === 1) {
|
||||
throw new Error("simulated_delivery_failure");
|
||||
}
|
||||
try {
|
||||
const result = await transport({
|
||||
permit,
|
||||
params,
|
||||
request,
|
||||
config,
|
||||
attempts,
|
||||
mode,
|
||||
});
|
||||
if (!result.receipt_sha256) {
|
||||
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
|
||||
}
|
||||
setBounded(dispatchReceipts, dedupeKey, { paramsDigest: permit.paramsDigest, result, recordedAt: Date.now() });
|
||||
appendProtectedDispatchAudit(config, {
|
||||
transport: mode,
|
||||
task_id: result.task_id,
|
||||
tenant_id: permit.record.payload.tenant_id,
|
||||
principal_id: permit.record.payload.principal_id,
|
||||
provenance_id: permit.record.payload.provenance_id,
|
||||
simulation: result.simulation,
|
||||
status: result.status || "accepted",
|
||||
branch: result.bound_branch,
|
||||
base: result.bound_base_oid,
|
||||
paths: permit.request.payload?.task_plan?.allowed_paths,
|
||||
capabilities: result.capabilities,
|
||||
request_idempotency_key: request.idempotency_key,
|
||||
});
|
||||
return toolResult(result);
|
||||
} catch (error) {
|
||||
appendProtectedDispatchAudit(config, {
|
||||
transport: mode,
|
||||
task_id: request.task_id,
|
||||
tenantId: permit.record.payload.tenant_id,
|
||||
principal_id: permit.record.payload.principal_id,
|
||||
provenance_id: permit.record.payload.provenance_id,
|
||||
simulation: false,
|
||||
request_idempotency_key: request.idempotency_key,
|
||||
transport_error: isDispatchError(error)
|
||||
? error.code
|
||||
: error instanceof Error ? error.message : "transport_error",
|
||||
status_code: isDispatchError(error) ? error.code : "transport_error",
|
||||
});
|
||||
if (isDispatchError(error)) throw error;
|
||||
const thrown = error instanceof Error ? error : dispatchError("protected_dispatch_transport_failed");
|
||||
throw thrown;
|
||||
}
|
||||
},
|
||||
}), { name: "aios_protected_dispatch" });
|
||||
}
|
||||
|
||||
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);
|
||||
registerProtectedDispatchTool(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) {
|
||||
const channel = String(event.channelId || ctx.channelId || "unknown");
|
||||
const session = String(ctx.sessionId || ctx.sessionKey || "unknown");
|
||||
activeRuns.set(key, {
|
||||
...trust,
|
||||
agentId: String(ctx.agentId || ""),
|
||||
runId: key,
|
||||
executionPath: `channel:${channel}/session:${session}`,
|
||||
recordedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
const conversationDecision = decideConversationRun(trust, config);
|
||||
if (conversationDecision.action === "block" &&
|
||||
(config.blockUnknownAgentRuns === true || trust.reason === "sender_blocked")) {
|
||||
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 === "verify_owner_provenance") {
|
||||
const authorized = authorizeProtectedDispatchCall({ event, ctx, trust, config });
|
||||
if (!authorized.ok) return { block: true, blockReason: `AIOS Owner 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, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts };
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ALLOWED_BROKER_HOSTS = new Set(["127.0.0.1", "localhost"]);
|
||||
const DEFAULT_TIMEOUT_MS = 3000;
|
||||
|
||||
export function brokerCredentialFromPath(credentialPath) {
|
||||
if (!path.isAbsolute(credentialPath)) throw new Error("broker_credential_path_invalid");
|
||||
const stat = fs.lstatSync(credentialPath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("broker_credential_path_invalid");
|
||||
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
|
||||
throw new Error("broker_credential_permissions_invalid");
|
||||
}
|
||||
const token = fs.readFileSync(credentialPath, "utf8").trim();
|
||||
if (!token) throw new Error("broker_credential_empty");
|
||||
return token;
|
||||
}
|
||||
|
||||
export function assertLocalBrokerEndpoint(endpoint) {
|
||||
const safe = new URL(endpoint);
|
||||
if (safe.protocol !== "http:") throw new Error("broker_endpoint_protocol_invalid");
|
||||
if (!ALLOWED_BROKER_HOSTS.has(safe.hostname)) throw new Error("broker_endpoint_host_invalid");
|
||||
if (safe.username || safe.password || safe.search || safe.hash) throw new Error("broker_endpoint_invalid");
|
||||
if (safe.pathname === "/") safe.pathname = "/v1/sessions/provision";
|
||||
if (safe.pathname !== "/v1/sessions/provision") throw new Error("broker_endpoint_path_invalid");
|
||||
return safe;
|
||||
}
|
||||
|
||||
export async function callBrokerProvision({
|
||||
endpoint,
|
||||
credentialPath,
|
||||
payload,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
}) {
|
||||
const brokerUrl = assertLocalBrokerEndpoint(endpoint);
|
||||
const credential = brokerCredentialFromPath(credentialPath);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.min(Math.max(Number(timeoutMs) || DEFAULT_TIMEOUT_MS, 500), 15_000));
|
||||
try {
|
||||
const response = await fetch(brokerUrl, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${credential}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseBody = await response.text();
|
||||
let body;
|
||||
try {
|
||||
body = responseBody ? JSON.parse(responseBody) : {};
|
||||
} catch {
|
||||
throw new Error("broker_response_not_json");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const reason = typeof body?.error === "string" ? body.error : `http_${response.status}`;
|
||||
const error = new Error(reason);
|
||||
error.code = "broker_http_error";
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") {
|
||||
const timeout = new Error("broker_timeout");
|
||||
timeout.code = "broker_timeout";
|
||||
throw timeout;
|
||||
}
|
||||
if (error.code) throw error;
|
||||
const transport = new Error("broker_unreachable");
|
||||
transport.code = "broker_unreachable";
|
||||
throw transport;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { canonicalize } from "../provenance.js";
|
||||
|
||||
export function dispatchMockTransport({ permit, params, attempts, mode }) {
|
||||
const result = {
|
||||
accepted: true,
|
||||
task_id: permit.request.task_id,
|
||||
provider: permit.request.provider,
|
||||
provider_source: "server_config",
|
||||
provider_hint_overridden: params.provider !== permit.request.provider,
|
||||
provenance_id: permit.record.payload.provenance_id,
|
||||
simulation: true,
|
||||
transport: mode,
|
||||
real_user_impersonated: false,
|
||||
attempts,
|
||||
};
|
||||
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { dispatchMockTransport } from "./mock.js";
|
||||
|
||||
export function dispatchSandboxTransport(context) {
|
||||
return dispatchMockTransport({ ...context, mode: "sandbox" });
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import crypto from "node:crypto";
|
||||
import net from "node:net";
|
||||
|
||||
import {
|
||||
buildApprovalPayload,
|
||||
buildProvisionRequest,
|
||||
validateProvisionResponse,
|
||||
validateTaskPlaneRequest,
|
||||
} from "../dispatch-contract.js";
|
||||
import { callBrokerProvision } from "../session-broker-client.js";
|
||||
import { dispatchError } from "../dispatch-errors.js";
|
||||
import { canonicalize } from "../provenance.js";
|
||||
|
||||
const DEFAULT_SIGNER_SOCKET = "/run/aios-owner-approval-signer-candidate/signer.sock";
|
||||
const MAX_SIGNER_RESPONSE_BYTES = 256 * 1024;
|
||||
|
||||
function exactObject(value, keys) {
|
||||
return value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).sort().join(",") === [...keys].sort().join(",");
|
||||
}
|
||||
|
||||
function callOwnerApprovalSigner({ socketPath, request, timeoutMs }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ path: socketPath });
|
||||
let settled = false;
|
||||
let response = "";
|
||||
const finish = (error, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
if (error) reject(error);
|
||||
else resolve(value);
|
||||
};
|
||||
socket.setEncoding("utf8");
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.on("connect", () => socket.end(`${canonicalize(request)}\n`));
|
||||
socket.on("data", (chunk) => {
|
||||
response += chunk;
|
||||
if (Buffer.byteLength(response, "utf8") > MAX_SIGNER_RESPONSE_BYTES) {
|
||||
finish(new Error("owner_signer_response_too_large"));
|
||||
}
|
||||
});
|
||||
socket.on("timeout", () => finish(new Error("owner_signer_timeout")));
|
||||
socket.on("error", (error) => finish(error));
|
||||
socket.on("end", () => {
|
||||
try {
|
||||
const line = response.trim();
|
||||
finish(null, JSON.parse(line));
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildApprovalEnvelope(validated, config, idempotencyKey) {
|
||||
const socketPath = config.taskPlaneOwnerApprovalSignerSocketPath || DEFAULT_SIGNER_SOCKET;
|
||||
const keyId = config.taskPlaneApprovalSigningKeyId;
|
||||
const riskClass = config.taskPlaneApprovalRiskClass;
|
||||
if (typeof socketPath !== "string" || !socketPath.startsWith("/") ||
|
||||
typeof keyId !== "string" || !keyId ||
|
||||
typeof riskClass !== "string" || !riskClass) {
|
||||
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
|
||||
}
|
||||
const payload = buildApprovalPayload(validated, {
|
||||
idempotencyKey,
|
||||
riskClass,
|
||||
signingKeyId: keyId,
|
||||
});
|
||||
const signerRequest = {
|
||||
schema_version: "aios.owner-approval-sign-request.v1",
|
||||
operation: "sign_owner_approval",
|
||||
approval_plan_id: validated.approval.approval_plan_id,
|
||||
signing_key_id: keyId,
|
||||
canonical_payload: canonicalize(payload),
|
||||
approval_payload: payload,
|
||||
};
|
||||
let envelope;
|
||||
try {
|
||||
envelope = await callOwnerApprovalSigner({
|
||||
socketPath,
|
||||
request: signerRequest,
|
||||
timeoutMs: config.protectedDispatchBrokerTimeoutMs || 5_000,
|
||||
});
|
||||
} catch {
|
||||
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
|
||||
}
|
||||
if (!exactObject(envelope, [
|
||||
"approval_plan_id", "approval_key_id", "approval_signature", "approval_payload",
|
||||
]) ||
|
||||
envelope.approval_plan_id !== validated.approval.approval_plan_id ||
|
||||
envelope.approval_key_id !== keyId ||
|
||||
typeof envelope.approval_signature !== "string" ||
|
||||
!envelope.approval_signature ||
|
||||
canonicalize(envelope.approval_payload) !== signerRequest.canonical_payload) {
|
||||
throw dispatchError("BLOCKED_OWNER_APPROVAL_VERIFIER_UNAVAILABLE");
|
||||
}
|
||||
return envelope;
|
||||
}
|
||||
|
||||
function buildSupervisorTextResult({ request, validated, checked }) {
|
||||
const content = [
|
||||
"AIOS Candidate Session Broker result",
|
||||
`task_id=${request.task_id}`,
|
||||
`broker_session_id=${checked.value.child_session_id}`,
|
||||
"broker_session_verified=true",
|
||||
"openclaw_native_child_session_verified=false",
|
||||
"codex_child_session_verified=false",
|
||||
].join("\n");
|
||||
const messageSeed = canonicalize({
|
||||
task_id: request.task_id,
|
||||
idempotency_key: request.idempotency_key,
|
||||
child_session_id: checked.value.child_session_id,
|
||||
recipient_binding: validated.plan.result_delivery.recipient_binding,
|
||||
});
|
||||
const messageId = `message-${crypto.createHash("sha256").update(messageSeed).digest("hex").slice(0, 32)}`;
|
||||
const result = {
|
||||
content_type: "text/plain",
|
||||
encoding: "utf-8",
|
||||
image_only: false,
|
||||
task_id: request.task_id,
|
||||
message_id: messageId,
|
||||
recipient_binding: validated.plan.result_delivery.recipient_binding,
|
||||
content,
|
||||
secrets_included: false,
|
||||
};
|
||||
result.receipt = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function dispatchTaskPlaneTransport({ permit, request, params, config, attempts }) {
|
||||
const validated = validateTaskPlaneRequest({ request, permit, config });
|
||||
if (!validated.ok) throw dispatchError(validated.code);
|
||||
if (typeof config.protectedDispatchBrokerEndpoint !== "string" ||
|
||||
typeof config.protectedDispatchBrokerCredentialPath !== "string") {
|
||||
throw dispatchError("broker_config_invalid");
|
||||
}
|
||||
const approvalEnvelope = await buildApprovalEnvelope(validated, config, request.idempotency_key);
|
||||
const provisionRequest = buildProvisionRequest(validated, request.idempotency_key, approvalEnvelope);
|
||||
let response;
|
||||
try {
|
||||
response = await callBrokerProvision({
|
||||
endpoint: config.protectedDispatchBrokerEndpoint,
|
||||
credentialPath: config.protectedDispatchBrokerCredentialPath,
|
||||
payload: provisionRequest,
|
||||
timeoutMs: config.protectedDispatchBrokerTimeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
throw dispatchError(error instanceof Error && error.code ? error.code : "broker_unreachable");
|
||||
}
|
||||
const checked = validateProvisionResponse(response, validated.plan);
|
||||
if (!checked.ok) throw dispatchError(checked.code);
|
||||
const result = {
|
||||
accepted: true,
|
||||
task_id: request.task_id,
|
||||
provider: request.provider,
|
||||
provider_source: "server_config",
|
||||
provider_hint_overridden: params.provider !== request.provider,
|
||||
provenance_id: permit.record.payload.provenance_id,
|
||||
simulation: false,
|
||||
transport: "task-plane",
|
||||
real_user_impersonated: false,
|
||||
attempts,
|
||||
...checked.value,
|
||||
};
|
||||
result.supervisor_text_result = buildSupervisorTextResult({ request, validated, checked });
|
||||
result.plain_text_result_delivery = true;
|
||||
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
|
||||
return result;
|
||||
}
|
||||
@@ -1,22 +1,91 @@
|
||||
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 {
|
||||
canonicalize,
|
||||
isOwnerProvenanceRevoked,
|
||||
loadStoredRecord,
|
||||
readPublicKey,
|
||||
validatePayload,
|
||||
verifyOwnerProvenanceRecord,
|
||||
verifyRecord,
|
||||
} from "./provenance.js";
|
||||
import { issueOwnerProvenance } from "./issuer.mjs";
|
||||
import { classifyRun, decideConversationRun, decideToolCall, runKey, validateWriterParams } from "./policy.js";
|
||||
import { executeEvidenceWrite } from "./writer.js";
|
||||
import { createRuntimeApi } from "./runtime.js";
|
||||
import { appendDispatchAudit } from "./dispatch-audit.js";
|
||||
import { dispatchError, isDispatchError } from "./dispatch-errors.js";
|
||||
import { dispatchMockTransport } from "./transports/mock.js";
|
||||
import { dispatchSandboxTransport } from "./transports/sandbox.js";
|
||||
import { dispatchTaskPlaneTransport } from "./transports/task-plane.js";
|
||||
|
||||
const activeRuns = new Map();
|
||||
const permits = new Map();
|
||||
const ownerPermits = new Map();
|
||||
const ownerUses = new Map();
|
||||
const dispatchReceipts = new Map();
|
||||
const dispatchAttempts = new Map();
|
||||
const MAX_RUN_AGE_MS = 60 * 60 * 1000;
|
||||
const MAX_DISPATCH_CACHE_AGE_MS = 60 * 60 * 1000;
|
||||
const MAX_SECURITY_CACHE_ENTRIES = 4096;
|
||||
const DEFAULT_PERMIT_TTL_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_PROTECTED_DISPATCH_PROVIDER = "chatgpt-supervisor";
|
||||
const PROTECTED_DISPATCH_TRANSPORTS = Object.freeze({
|
||||
mock: dispatchMockTransport,
|
||||
sandbox: dispatchSandboxTransport,
|
||||
"task-plane": dispatchTaskPlaneTransport,
|
||||
});
|
||||
|
||||
function pluginConfig(event, api) {
|
||||
return event?.context?.pluginConfig ?? api.pluginConfig ?? {};
|
||||
}
|
||||
|
||||
function prune(now = Date.now()) {
|
||||
function protectedDispatchProvider(config = {}) {
|
||||
return String(config.protectedDispatchProvider || DEFAULT_PROTECTED_DISPATCH_PROVIDER);
|
||||
}
|
||||
|
||||
function protectedDispatchTransport(config = {}) {
|
||||
return String(config.protectedDispatchTransport || "disabled");
|
||||
}
|
||||
|
||||
function protectedDispatchTransportConfig(config = {}) {
|
||||
return PROTECTED_DISPATCH_TRANSPORTS[protectedDispatchTransport(config)];
|
||||
}
|
||||
|
||||
function ownerPrincipalId(senderId) {
|
||||
const digest = crypto.createHash("sha256").update(String(senderId)).digest("hex");
|
||||
return `principal:sha256:${digest}`;
|
||||
}
|
||||
|
||||
function trimToLimit(map, limit = MAX_SECURITY_CACHE_ENTRIES) {
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
}
|
||||
|
||||
function setBounded(map, key, value) {
|
||||
if (map.has(key)) map.delete(key);
|
||||
map.set(key, value);
|
||||
trimToLimit(map);
|
||||
}
|
||||
|
||||
function appendProtectedDispatchAudit(config, details) {
|
||||
const auditPath = String(config.protectedDispatchAuditPath || "");
|
||||
if (!auditPath) return;
|
||||
if (!path.isAbsolute(auditPath)) return;
|
||||
appendDispatchAudit(auditPath, details);
|
||||
}
|
||||
|
||||
export 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);
|
||||
for (const [key, value] of ownerPermits) if (now - value.recordedAt > value.ttlMs) ownerPermits.delete(key);
|
||||
for (const [key, expiresAt] of ownerUses) if (now >= expiresAt) ownerUses.delete(key);
|
||||
for (const [key, value] of dispatchReceipts) {
|
||||
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchReceipts.delete(key);
|
||||
}
|
||||
for (const [key, value] of dispatchAttempts) {
|
||||
if (now - value.recordedAt > MAX_DISPATCH_CACHE_AGE_MS) dispatchAttempts.delete(key);
|
||||
}
|
||||
for (const map of [activeRuns, permits, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts]) trimToLimit(map);
|
||||
}
|
||||
|
||||
function toolCallId(event) {
|
||||
@@ -83,6 +152,131 @@ export function consumePermit(toolCallIdValue, params, now = Date.now()) {
|
||||
return permit;
|
||||
}
|
||||
|
||||
function protectedDispatchParams(params) {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) return { ok: false, code: "protected_params_missing" };
|
||||
const expected = ["action", "idempotency_key", "payload", "provider", "task_id"];
|
||||
const keys = Object.keys(params).sort();
|
||||
if (keys.length !== expected.length || expected.some((key) => !keys.includes(key))) {
|
||||
return { ok: false, code: "protected_params_schema_mismatch" };
|
||||
}
|
||||
for (const key of ["action", "idempotency_key", "provider", "task_id"]) {
|
||||
if (typeof params[key] !== "string" || !params[key].trim()) return { ok: false, code: `invalid_${key}` };
|
||||
}
|
||||
if (!params.payload || typeof params.payload !== "object" || Array.isArray(params.payload)) return { ok: false, code: "invalid_payload" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function verifyOwnerBinding({ record, publicKeyPem, expectedKeyId, expected, storeDir, now = Date.now() }) {
|
||||
const verified = verifyOwnerProvenanceRecord(record, publicKeyPem, expectedKeyId, now);
|
||||
if (!verified.ok) return verified;
|
||||
if (isOwnerProvenanceRevoked(storeDir, record.payload.provenance_id)) return { ok: false, code: "owner_revoked" };
|
||||
for (const key of ["tenant_id", "principal_id", "task_id", "agent_id", "run_id", "execution_path", "request_digest"]) {
|
||||
if (record.payload[key] !== expected[key]) return { ok: false, code: `${key}_mismatch` };
|
||||
}
|
||||
return { ok: true, payload: record.payload };
|
||||
}
|
||||
|
||||
export function authorizeProtectedDispatchCall({ event, ctx = {}, trust, config, now = Date.now() }) {
|
||||
const callId = toolCallId(event);
|
||||
if (!callId) return { ok: false, code: "tool_call_id_missing" };
|
||||
if (!trust?.trusted || trust.origin !== "authenticated_owner") return { ok: false, code: trust?.reason || "owner_not_authenticated" };
|
||||
const params = eventParams(event);
|
||||
const paramsCheck = protectedDispatchParams(params);
|
||||
if (!paramsCheck.ok) return paramsCheck;
|
||||
const expectedProvider = protectedDispatchProvider(config);
|
||||
const request = { ...params, provider: expectedProvider };
|
||||
const principalId = ownerPrincipalId(trust.senderId);
|
||||
const runId = runKey(event, ctx) || trust.runId;
|
||||
const agentId = String(ctx.agentId || trust.agentId || "");
|
||||
const tenantId = String(config.tenantId || "");
|
||||
const executionPath = String(trust.executionPath || "");
|
||||
if (!runId || !agentId || !tenantId || !executionPath) return { ok: false, code: "server_identity_incomplete" };
|
||||
const callerParamsDigest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
const requestDigest = crypto.createHash("sha256").update(canonicalize(request)).digest("hex");
|
||||
let issued;
|
||||
try {
|
||||
issued = issueOwnerProvenance({
|
||||
identity: {
|
||||
tenant_id: tenantId,
|
||||
principal_id: principalId,
|
||||
task_id: params.task_id,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
execution_path: executionPath,
|
||||
},
|
||||
request,
|
||||
privateKeyPath: requireAbsoluteConfig(config, "provenancePrivateKeyPath"),
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
keyId: String(config.provenanceKeyId || ""),
|
||||
ttlMs: Math.min(Number(config.ownerProvenanceTtlMs || 120_000), 300_000),
|
||||
now,
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, code: error instanceof Error ? error.message : "owner_issue_failed" };
|
||||
}
|
||||
const expected = {
|
||||
tenant_id: tenantId,
|
||||
principal_id: principalId,
|
||||
task_id: params.task_id,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
execution_path: executionPath,
|
||||
request_digest: requestDigest,
|
||||
};
|
||||
const binding = verifyOwnerBinding({
|
||||
record: issued.record,
|
||||
publicKeyPem: readPublicKey(requireAbsoluteConfig(config, "provenancePublicKeyPath")),
|
||||
expectedKeyId: String(config.provenanceKeyId || ""),
|
||||
expected,
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
now,
|
||||
});
|
||||
if (!binding.ok) return binding;
|
||||
if (ownerPermits.has(callId)) return { ok: false, code: "owner_permit_replay" };
|
||||
const ttlMs = Math.min(Number(config.permitTtlMs || DEFAULT_PERMIT_TTL_MS), DEFAULT_PERMIT_TTL_MS);
|
||||
ownerPermits.set(callId, {
|
||||
record: issued.record,
|
||||
expected,
|
||||
request,
|
||||
callerParamsDigest,
|
||||
paramsDigest: requestDigest,
|
||||
recordedAt: now,
|
||||
ttlMs,
|
||||
publicKeyPath: requireAbsoluteConfig(config, "provenancePublicKeyPath"),
|
||||
storeDir: requireAbsoluteConfig(config, "provenanceStoreDir"),
|
||||
keyId: String(config.provenanceKeyId || ""),
|
||||
});
|
||||
return { ok: true, provenance_id: issued.provenanceId };
|
||||
}
|
||||
|
||||
export function consumeProtectedPermit(toolCallIdValue, params, now = Date.now()) {
|
||||
const permit = ownerPermits.get(toolCallIdValue);
|
||||
ownerPermits.delete(toolCallIdValue);
|
||||
if (!permit) throw new Error("owner_permit_missing");
|
||||
if (now - permit.recordedAt > permit.ttlMs) throw new Error("owner_permit_expired");
|
||||
const callerParamsDigest = crypto.createHash("sha256").update(canonicalize(params)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(callerParamsDigest), Buffer.from(permit.callerParamsDigest))) {
|
||||
throw new Error("owner_permit_params_mismatch");
|
||||
}
|
||||
const requestDigest = crypto.createHash("sha256").update(canonicalize(permit.request)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(requestDigest), Buffer.from(permit.paramsDigest))) {
|
||||
throw new Error("owner_permit_request_mismatch");
|
||||
}
|
||||
const binding = verifyOwnerBinding({
|
||||
record: permit.record,
|
||||
publicKeyPem: readPublicKey(permit.publicKeyPath),
|
||||
expectedKeyId: permit.keyId,
|
||||
expected: permit.expected,
|
||||
storeDir: permit.storeDir,
|
||||
now,
|
||||
});
|
||||
if (!binding.ok) throw new Error(binding.code);
|
||||
const provenanceId = permit.record.payload.provenance_id;
|
||||
if (ownerUses.has(provenanceId)) throw new Error("owner_provenance_replay");
|
||||
setBounded(ownerUses, provenanceId, permit.record.payload.expires_at);
|
||||
return permit;
|
||||
}
|
||||
|
||||
function toolResult(details) {
|
||||
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
||||
}
|
||||
@@ -124,6 +318,96 @@ function registerWriterTool(api) {
|
||||
}), { name: "aios_evidence_write" });
|
||||
}
|
||||
|
||||
function registerProtectedDispatchTool(api) {
|
||||
const expectedProvider = protectedDispatchProvider(api.pluginConfig);
|
||||
const expected = new Set(["mock", "sandbox", "task-plane"]);
|
||||
api.registerTool(() => ({
|
||||
name: "aios_protected_dispatch",
|
||||
label: "AIOS Protected Dispatch",
|
||||
description: "Dispatch one Owner-authenticated request with server-issued provenance.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["task_id", "provider", "action", "payload", "idempotency_key"],
|
||||
properties: {
|
||||
task_id: { type: "string" },
|
||||
provider: { type: "string", enum: [expectedProvider] },
|
||||
action: { type: "string" },
|
||||
payload: { type: "object" },
|
||||
idempotency_key: { type: "string" },
|
||||
},
|
||||
},
|
||||
executionMode: "sequential",
|
||||
async execute(callId, params) {
|
||||
const permit = consumeProtectedPermit(callId, params);
|
||||
const request = permit.request;
|
||||
const config = api.pluginConfig ?? {};
|
||||
const transport = protectedDispatchTransportConfig(config);
|
||||
const mode = protectedDispatchTransport(config);
|
||||
if (!expected.has(mode)) {
|
||||
throw dispatchError("protected_dispatch_transport_not_authorized");
|
||||
}
|
||||
const dedupeKey = `${permit.record.payload.tenant_id}\0${permit.record.payload.principal_id}\0${request.idempotency_key}`;
|
||||
const prior = dispatchReceipts.get(dedupeKey);
|
||||
if (prior) {
|
||||
if (prior.paramsDigest !== permit.paramsDigest) throw new Error("protected_dispatch_idempotency_conflict");
|
||||
return toolResult({ ...prior.result, deduplicated: true });
|
||||
}
|
||||
const attempts = (dispatchAttempts.get(dedupeKey)?.attempts || 0) + 1;
|
||||
setBounded(dispatchAttempts, dedupeKey, { attempts, recordedAt: Date.now() });
|
||||
if (config.mockFailFirstDelivery === true && mode === "mock" && attempts === 1) {
|
||||
throw new Error("simulated_delivery_failure");
|
||||
}
|
||||
try {
|
||||
const result = await transport({
|
||||
permit,
|
||||
params,
|
||||
request,
|
||||
config,
|
||||
attempts,
|
||||
mode,
|
||||
});
|
||||
if (!result.receipt_sha256) {
|
||||
result.receipt_sha256 = crypto.createHash("sha256").update(canonicalize(result)).digest("hex");
|
||||
}
|
||||
setBounded(dispatchReceipts, dedupeKey, { paramsDigest: permit.paramsDigest, result, recordedAt: Date.now() });
|
||||
appendProtectedDispatchAudit(config, {
|
||||
transport: mode,
|
||||
task_id: result.task_id,
|
||||
tenant_id: permit.record.payload.tenant_id,
|
||||
principal_id: permit.record.payload.principal_id,
|
||||
provenance_id: permit.record.payload.provenance_id,
|
||||
simulation: result.simulation,
|
||||
status: result.status || "accepted",
|
||||
branch: result.bound_branch,
|
||||
base: result.bound_base_oid,
|
||||
paths: permit.request.payload?.task_plan?.allowed_paths,
|
||||
capabilities: result.capabilities,
|
||||
request_idempotency_key: request.idempotency_key,
|
||||
});
|
||||
return toolResult(result);
|
||||
} catch (error) {
|
||||
appendProtectedDispatchAudit(config, {
|
||||
transport: mode,
|
||||
task_id: request.task_id,
|
||||
tenantId: permit.record.payload.tenant_id,
|
||||
principal_id: permit.record.payload.principal_id,
|
||||
provenance_id: permit.record.payload.provenance_id,
|
||||
simulation: false,
|
||||
request_idempotency_key: request.idempotency_key,
|
||||
transport_error: isDispatchError(error)
|
||||
? error.code
|
||||
: error instanceof Error ? error.message : "transport_error",
|
||||
status_code: isDispatchError(error) ? error.code : "transport_error",
|
||||
});
|
||||
if (isDispatchError(error)) throw error;
|
||||
const thrown = error instanceof Error ? error : dispatchError("protected_dispatch_transport_failed");
|
||||
throw thrown;
|
||||
}
|
||||
},
|
||||
}), { name: "aios_protected_dispatch" });
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "aios-tenant-boundary",
|
||||
name: "AIOS Tenant Boundary",
|
||||
@@ -134,13 +418,26 @@ export default {
|
||||
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);
|
||||
registerProtectedDispatchTool(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") {
|
||||
if (key) {
|
||||
const channel = String(event.channelId || ctx.channelId || "unknown");
|
||||
const session = String(ctx.sessionId || ctx.sessionKey || "unknown");
|
||||
activeRuns.set(key, {
|
||||
...trust,
|
||||
agentId: String(ctx.agentId || ""),
|
||||
runId: key,
|
||||
executionPath: `channel:${channel}/session:${session}`,
|
||||
recordedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
const conversationDecision = decideConversationRun(trust, config);
|
||||
if (conversationDecision.action === "block" &&
|
||||
(config.blockUnknownAgentRuns === true || trust.reason === "sender_blocked")) {
|
||||
return { outcome: "block", reason: trust.reason, message: "Authenticated Owner provenance is required." };
|
||||
}
|
||||
return { outcome: "pass" };
|
||||
@@ -157,6 +454,11 @@ export default {
|
||||
if (!authorized.ok) return { block: true, blockReason: `AIOS V2R1 provenance denied: ${authorized.code}` };
|
||||
return undefined;
|
||||
}
|
||||
if (decision.action === "verify_owner_provenance") {
|
||||
const authorized = authorizeProtectedDispatchCall({ event, ctx, trust, config });
|
||||
if (!authorized.ok) return { block: true, blockReason: `AIOS Owner provenance denied: ${authorized.code}` };
|
||||
return undefined;
|
||||
}
|
||||
if (decision.action === "approval") {
|
||||
return { requireApproval: {
|
||||
title: `Approve ${String(event.toolName)}`,
|
||||
@@ -175,4 +477,4 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
export const __state = { activeRuns, permits };
|
||||
export const __state = { activeRuns, permits, ownerPermits, ownerUses, dispatchReceipts, dispatchAttempts };
|
||||
|
||||
@@ -3,7 +3,14 @@ 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";
|
||||
import {
|
||||
canonicalize,
|
||||
privateKeyFromMaterial,
|
||||
readPrivateKeyMaterial,
|
||||
signRecord,
|
||||
validateOwnerProvenancePayload,
|
||||
validatePayload,
|
||||
} from "./provenance.js";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
@@ -24,10 +31,21 @@ function atomicWrite(filePath, contents, mode) {
|
||||
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");
|
||||
function atomicCreate(filePath, contents, mode) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o750 });
|
||||
const parent = fs.realpathSync(path.dirname(filePath));
|
||||
const target = path.join(parent, path.basename(filePath));
|
||||
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, contents, { mode, flag: "wx" });
|
||||
fs.linkSync(temporary, target);
|
||||
fs.chmodSync(target, mode);
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") throw new Error("provenance_id_already_issued");
|
||||
throw error;
|
||||
} finally {
|
||||
try { fs.unlinkSync(temporary); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, storeDir, keyId, now = Date.now() }) {
|
||||
@@ -37,18 +55,56 @@ export function issueFromFiles({ payloadPath, privateKeyPath, publicKeyPath, sto
|
||||
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 privateMaterial = readPrivateKeyMaterial(privateKeyPath);
|
||||
fs.mkdirSync(storeDir, { recursive: true, mode: 0o750 });
|
||||
const recordPath = path.join(fs.realpathSync(storeDir), `${payload.provenance_id}.json`);
|
||||
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);
|
||||
atomicCreate(recordPath, `${canonicalize(record)}\n`, 0o640);
|
||||
return payload.provenance_id;
|
||||
}
|
||||
|
||||
export function issueOwnerProvenance({
|
||||
identity,
|
||||
request,
|
||||
privateKeyPath,
|
||||
storeDir,
|
||||
keyId,
|
||||
ttlMs = 120_000,
|
||||
now = Date.now(),
|
||||
provenanceId = `owner-${crypto.randomUUID()}`,
|
||||
}) {
|
||||
if (!identity || typeof identity !== "object" || Array.isArray(identity)) throw new Error("owner_identity_missing");
|
||||
for (const value of [privateKeyPath, storeDir, keyId]) {
|
||||
if (typeof value !== "string" || !value) throw new Error("missing_owner_issuer_argument");
|
||||
}
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1000 || ttlMs > 300_000) throw new Error("invalid_owner_ttl");
|
||||
const privateMaterial = readPrivateKeyMaterial(privateKeyPath);
|
||||
const payload = {
|
||||
schema_version: "aios.owner-provenance/v2",
|
||||
provenance_id: provenanceId,
|
||||
tenant_id: identity.tenant_id,
|
||||
principal_id: identity.principal_id,
|
||||
task_id: identity.task_id,
|
||||
agent_id: identity.agent_id,
|
||||
run_id: identity.run_id,
|
||||
execution_path: identity.execution_path,
|
||||
request_digest: crypto.createHash("sha256").update(canonicalize(request)).digest("hex"),
|
||||
issued_at: now,
|
||||
expires_at: now + ttlMs,
|
||||
key_id: keyId,
|
||||
};
|
||||
const validated = validateOwnerProvenancePayload(payload, now);
|
||||
if (!validated.ok) throw new Error(validated.code);
|
||||
fs.mkdirSync(storeDir, { recursive: true, mode: 0o750 });
|
||||
const recordPath = path.join(fs.realpathSync(storeDir), `${provenanceId}.json`);
|
||||
const record = signRecord(payload, privateMaterial, keyId);
|
||||
atomicCreate(recordPath, `${canonicalize(record)}\n`, 0o640);
|
||||
return { provenanceId, recordPath, record };
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
try {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"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-rc.4",
|
||||
"version": "2.1.0-rc4",
|
||||
"entry": "dist/index.js",
|
||||
"contracts": {
|
||||
"tools": ["aios_evidence_write"]
|
||||
"tools": ["aios_evidence_write", "aios_protected_dispatch"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
@@ -13,6 +13,7 @@
|
||||
"properties": {
|
||||
"requireExplicitTrustedSenders": { "type": "boolean", "default": true },
|
||||
"requireSenderIsOwner": { "type": "boolean", "default": true },
|
||||
"allowUntrustedConversations": { "type": "boolean", "default": true },
|
||||
"trustedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"blockedSenderIds": { "type": "array", "items": { "type": "string" }, "default": [] },
|
||||
"readOnlyTools": { "type": "array", "items": { "type": "string" } },
|
||||
@@ -21,9 +22,30 @@
|
||||
"approvalTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
|
||||
"permitTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||
"provenanceStoreDir": { "type": "string" },
|
||||
"provenancePrivateKeyPath": { "type": "string" },
|
||||
"provenancePublicKeyPath": { "type": "string" },
|
||||
"provenanceKeyId": { "type": "string" },
|
||||
"provenanceIssuer": { "type": "string" },
|
||||
"tenantId": { "type": "string" },
|
||||
"ownerProvenanceTtlMs": { "type": "integer", "minimum": 1000, "maximum": 300000 },
|
||||
"protectedDispatchProvider": { "type": "string", "default": "chatgpt-supervisor" },
|
||||
"protectedDispatchTransport": { "type": "string", "enum": ["disabled", "mock", "sandbox", "task-plane"], "default": "disabled" },
|
||||
"protectedDispatchBrokerEndpoint": { "type": "string" },
|
||||
"protectedDispatchBrokerCredentialPath": { "type": "string" },
|
||||
"protectedDispatchBrokerTimeoutMs": { "type": "integer", "minimum": 250, "maximum": 15000 },
|
||||
"protectedDispatchAuditPath": { "type": "string" },
|
||||
"taskPlaneTaskId": { "type": "string" },
|
||||
"taskPlanePolicyId": { "type": "string" },
|
||||
"taskPlaneRepository": { "type": "string" },
|
||||
"taskPlaneBranch": { "type": "string" },
|
||||
"taskPlaneApprovedBaseOid": { "type": "string" },
|
||||
"taskPlaneAllowedPaths": { "type": "array", "items": { "type": "string" } },
|
||||
"taskPlaneAllowedCapabilities": { "type": "array", "items": { "type": "string" } },
|
||||
"taskPlaneOwnerApprovalSignerSocketPath": { "type": "string", "default": "/run/aios-owner-approval-signer-candidate/signer.sock" },
|
||||
"taskPlaneApprovalSigningKeyId": { "type": "string" },
|
||||
"taskPlaneApprovalRiskClass": { "type": "string", "default": "high" },
|
||||
"taskPlaneResultRecipientBinding": { "type": "string" },
|
||||
"mockFailFirstDelivery": { "type": "boolean", "default": false },
|
||||
"executor": { "type": "string" },
|
||||
"executorInstance": { "type": "string" },
|
||||
"writerRepoRoot": { "type": "string" },
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"writer.js",
|
||||
"runtime.js",
|
||||
"issuer.mjs",
|
||||
"dispatch-contract.js",
|
||||
"dispatch-audit.js",
|
||||
"dispatch-errors.js",
|
||||
"session-broker-client.js",
|
||||
"transports",
|
||||
"dist",
|
||||
"openclaw.plugin.json"
|
||||
],
|
||||
|
||||
@@ -15,26 +15,103 @@ function strings(value) {
|
||||
: [];
|
||||
}
|
||||
|
||||
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;
|
||||
function firstString(values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasConversationInput(event = {}, ctx = {}) {
|
||||
return Boolean(firstString([
|
||||
event.prompt,
|
||||
ctx.prompt,
|
||||
event.input,
|
||||
ctx.input,
|
||||
event.text,
|
||||
ctx.text,
|
||||
event.message?.text,
|
||||
ctx.message?.text,
|
||||
event.message?.content,
|
||||
ctx.message?.content,
|
||||
event.channelContext?.text,
|
||||
ctx.channelContext?.text,
|
||||
event.channelContext?.message?.text,
|
||||
ctx.channelContext?.message?.text,
|
||||
]));
|
||||
}
|
||||
|
||||
/** Resolve only identities populated by the trusted OpenClaw hook boundary. */
|
||||
export function resolveSenderId(ctx = {}, event = {}) {
|
||||
return firstString([
|
||||
event.senderId,
|
||||
ctx.senderId,
|
||||
event.sender?.id,
|
||||
ctx.sender?.id,
|
||||
event.channelContext?.sender?.id,
|
||||
ctx.channelContext?.sender?.id,
|
||||
]);
|
||||
}
|
||||
|
||||
export function classifyRun(event = {}, ctx = {}, config = {}) {
|
||||
const senderId = resolveSenderId({ ...ctx, senderId: event.senderId ?? ctx.senderId });
|
||||
const senderId = resolveSenderId(ctx, event);
|
||||
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 (typeof ctx.jobId === "string" && ctx.jobId) {
|
||||
return { trusted: false, origin: "automation", reason: "job_id_present" };
|
||||
}
|
||||
if (config.requireSenderIsOwner !== false && event.senderIsOwner !== true) {
|
||||
if (!senderId) {
|
||||
return {
|
||||
trusted: false,
|
||||
origin: "system_or_unknown",
|
||||
reason: "sender_id_missing",
|
||||
conversationOnly: hasConversationInput(event, ctx),
|
||||
};
|
||||
}
|
||||
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,
|
||||
conversationOnly: true,
|
||||
};
|
||||
}
|
||||
const singletonTrustedOwner = config.requireExplicitTrustedSenders !== false &&
|
||||
trusted.size === 1 && trusted.has(senderId);
|
||||
const ownerVerified = event.senderIsOwner === true || singletonTrustedOwner;
|
||||
if (config.requireSenderIsOwner !== false && !ownerVerified) {
|
||||
return { trusted: false, origin: "non_owner", reason: "sender_not_owner", senderId };
|
||||
}
|
||||
return { trusted: true, origin: "authenticated_owner", reason: "owner_verified", senderId };
|
||||
return {
|
||||
trusted: true,
|
||||
origin: "authenticated_owner",
|
||||
reason: event.senderIsOwner === true ? "owner_verified" : "owner_verified_by_singleton_trusted_binding",
|
||||
senderId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow identified/message-bearing runs to enter a conversation-only agent
|
||||
* session. Missing non-message system runs and explicitly blocked senders stay
|
||||
* fail-closed. Tool access is enforced separately by decideToolCall().
|
||||
*/
|
||||
export function decideConversationRun(trust, config = {}) {
|
||||
if (trust?.reason === "sender_blocked") {
|
||||
return { action: "block", code: "sender_blocked" };
|
||||
}
|
||||
if (trust?.trusted || trust?.reason === "sender_not_owner" || trust?.reason === "job_id_present") {
|
||||
return { action: "pass", code: trust?.reason || "trusted" };
|
||||
}
|
||||
if (trust?.conversationOnly === true) {
|
||||
return config.allowUntrustedConversations === false
|
||||
? { action: "block", code: trust?.reason || "untrusted_conversation" }
|
||||
: { action: "pass", code: "conversation_only" };
|
||||
}
|
||||
return { action: "block", code: trust?.reason || "run_provenance_missing" };
|
||||
}
|
||||
|
||||
export function runKey(event = {}, ctx = {}) {
|
||||
@@ -46,11 +123,15 @@ 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" };
|
||||
if (runTrust?.conversationOnly === true) {
|
||||
return { action: "block", code: "conversation_only_no_tools" };
|
||||
}
|
||||
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 (name === "aios_protected_dispatch") return { action: "verify_owner_provenance", code: "protected_dispatch" };
|
||||
if (config.requireApprovalForHumanSideEffects === false) return { action: "allow", code: "trusted_side_effect" };
|
||||
return { action: "approval", code: "human_side_effect" };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user