Compare commits

..
Author SHA1 Message Date
Codex 850bb669b7 fix(aios-v2): 规范化 HTTP 身份头大小写 2026-07-21 11:39:43 +08:00
Codex 9c20bf1362 test(aios-v2): 固化 Gate 与离线验收证据 2026-07-21 11:29:56 +08:00
Codex 09fa4f9d73 docs(aios-v2): 补齐核心模型中文追踪 2026-07-21 11:25:34 +08:00
Codex 816be7bec5 docs(agent): 固化 provenance 清障与 Gate 启动证据 2026-07-21 11:23:27 +08:00
Codex 25c25abd3e chore(aios): provenance openclaw-v2r1-20260721T031523Z heartbeat.json 2026-07-21 11:18:57 +08:00
Codex 8edb96a0fa merge(security): 纳入 provenance service-user Git guard 2026-07-21 11:18:02 +08:00
Codex 695a846645 fix(openclaw): 限定服务用户 Git safe directory 2026-07-21 11:18:02 +08:00
Codex d8f01f745d merge(security): 修复 provenance plugin runtime 合同 2026-07-21 11:11:34 +08:00
Codex c75fa77898 fix(openclaw): 声明 provenance writer 工具合同 2026-07-21 11:11:33 +08:00
Codex 9206b883ab merge(security): 纳入 OpenClaw run provenance writer 2026-07-21 11:06:34 +08:00
Codex b199935db8 feat(openclaw): 增加 Owner 签名 provenance writer 2026-07-21 11:06:17 +08:00
AIOS-Owner f7307c82b2 task(agent): 固化四项清障与 provenance 阻塞证据 2026-07-20 18:17:49 +08:00
AIOS-Owner bf01a13f3a docs(aios-v2): 记录四项阻塞清除与 provenance 剩余门禁 2026-07-20 18:17:06 +08:00
AIOS-Owner 8a36f7832b fix(aios-v2): 对齐 Ed25519 Candidate 签名合同 2026-07-20 18:14:42 +08:00
AIOS-Owner f1aeefff20 task(agent): 记录 Candidate 合并与 Worktree 修复证据 2026-07-20 17:39:16 +08:00
AIOS-Owner f55dfd975e merge(candidate): 同步最新 AIOS V2R1 Candidate 2026-07-20 17:38:42 +08:00
Codex ba7669c3b4 docs(aios-v2): 更新监督状态与阻塞处置清单 2026-07-20 17:22:32 +08:00
Windows ChatGPT Owner Supervisor bd2784015d chore(evidence): refine AIOS V2 signing-material blocker 2026-07-20 17:05:09 +08:00
Windows ChatGPT Owner Supervisor 1aa71f233d chore(evidence): block OpenClaw deployment with verified owner findings 2026-07-20 17:02:18 +08:00
Codex 558dc932fa chore(evidence): Phase 4 - Secret reference configuration complete
- Created /etc/aios-v2/aios-v2.env with credential ref (not plaintext)
- Saved Gitea deploy token to protected file under /etc/openclaw/secrets/
- Credential rotation completed: new token with minimal scope (write:repository,read:repository)
- Env file references AIOS_OPENCLAW_URL, CREDENTIAL_REF, SIGNING_MATERIAL_FILE, SIGNING_KEY_ID
2026-07-20 16:54:12 +08:00
Codex 5fd5ae0a33 chore(evidence): add claim, heartbeat, progress, update status for task AIOS-V2R1-PRODUCTION-DEPLOY-20260720
Phase 1 completed: worktree created, claim written, branches synced to Gitea, OID verified.
Phase 2-3 completed: branch sync & Gitea OID verification all matched.
Next: Phase 4 - Configure Secret references.
2026-07-20 16:48:33 +08:00
Codex 81572b8231 security(openclaw): record restored Gitea credential without secret value 2026-07-20 15:37:29 +08:00
Codex 8c3d80fe53 task(openclaw): 下发执行器启动、Secret 引用与 Gitea 同步在线指令 2026-07-20 14:34:13 +08:00
Codex 99bcc03b40 task(openclaw): 初始化无人值守任务状态 2026-07-20 12:07:46 +08:00
Codex c6e7edcf54 task(openclaw): 写入预发布无人值守任务载荷 2026-07-20 12:07:36 +08:00
Codex 634f6b164f docs(candidate): 新增完整中文生产候选报告 2026-07-20 12:06:55 +08:00
Codex c9ce12d102 deploy(candidate): 新增中文全量验收入口 2026-07-20 12:05:38 +08:00
Codex db50d2fa5a tasks(aios-v2): 完整中文化 Codex 生产监督任务 2026-07-20 12:04:43 +08:00
Codex 63fd23a464 tasks(aios-v2): 完整中文化 OpenClaw 生产部署任务 2026-07-20 12:04:11 +08:00
Codex 2e03ed74f6 feat(governance): 新增文档与代码匹配门禁 2026-07-20 12:03:36 +08:00
Codex b86f51c9ab docs(aios-v2): 新增 ADR-006 候选持久化策略 2026-07-20 12:01:49 +08:00
Codex 09448a5d3d docs(aios-v2): 完整中文化 ADR-005 2026-07-20 12:01:05 +08:00
Codex 18c719b2c6 docs(aios-v2): 完整中文化 ADR-004 2026-07-20 12:00:39 +08:00
Codex b0a78fa9a8 docs(aios-v2): 完整中文化 ADR-003 2026-07-20 12:00:07 +08:00
Codex 0aca7dbcfc docs(aios-v2): 完整中文化 ADR-002 2026-07-20 11:59:34 +08:00
Codex 3f84d8991e docs(aios-v2): 完整中文化 ADR-001 2026-07-20 11:59:10 +08:00
Codex 5ac8f3ea6f docs(aios-v2): 完整中文化 ADR 索引 2026-07-20 11:58:39 +08:00
Codex bfb1c2fd52 docs(aios-v2): 新增完整中文文档治理规范 2026-07-20 11:57:51 +08:00
Codex cbfe1211c1 docs(aios-v2): 将文档索引完整转换为中文 2026-07-20 11:56:30 +08:00
Codex efd99e620c docs(aios-v2): add ADR-004 tenant isolation decision 2026-07-20 11:53:04 +08:00
Codex c464560a0f task(openclaw): launch AIOS V2R1 pre-production deployment 2026-07-20 11:49:09 +08:00
Codex f23b694ad3 tasks(aios-v2): add Codex production supervisor task 2026-07-20 11:46:43 +08:00
Codex f97dea55ac tasks(aios-v2): add OpenClaw production deployment task 2026-07-20 11:46:39 +08:00
Codex efd08d7771 docs(aios-v2): add ADR-003 GitOps-only flow 2026-07-20 11:46:31 +08:00
Codex ff6cb2e217 docs(aios-v2): add ADR-005 documentation sync gate 2026-07-20 11:45:16 +08:00
Codex d774620fea docs(aios-v2): add ADR-002 runtime decoupling decision 2026-07-20 11:45:12 +08:00
Codex b26ff010fc docs(aios-v2): add ADR-001 control plane decision 2026-07-20 11:45:09 +08:00
Codex 4b49f2ba4b docs(aios-v2): add ADR index 2026-07-20 11:45:04 +08:00
Codex b4b8fe8828 docs(aios-v2): add traceability matrix 2026-07-20 11:43:46 +08:00
Codex 4e8ab051a0 docs(aios-v2): add module contracts 2026-07-20 11:41:39 +08:00
Codex f61e35b218 docs(aios-v2): add migration and delivery strategy 2026-07-20 11:34:33 +08:00
Codex b2f90f24a3 docs(aios-v2): add target architecture 2026-07-20 11:32:16 +08:00
Codex 6721eb4752 docs(aios-v2): add user requirements baseline 2026-07-20 11:29:07 +08:00
Codex a9e4fd1e40 docs(aios-v2): establish architecture source of truth 2026-07-20 11:24:12 +08:00
Codex 621750976a deploy(candidate): add guarded release rollback 2026-07-20 11:22:04 +08:00
Codex 5359e88806 feat(candidate): expose sanitized GitOps status API 2026-07-20 11:19:37 +08:00
Codex 76636931ba feat(candidate): add sanitized GitOps status provider 2026-07-20 11:17:18 +08:00
Codex 180c1fc66e deploy(candidate): add end-to-end healthcheck 2026-07-20 11:14:53 +08:00
Codex c0ff052dab deploy(candidate): add Docker Compose runtime 2026-07-20 11:12:42 +08:00
Codex 94cffb6ba3 deploy(candidate): add hardened container image 2026-07-20 11:10:36 +08:00
Codex 9ad5628e69 deploy(candidate): add environment configuration template 2026-07-20 11:08:37 +08:00
Codex 6d397a7894 deploy(candidate): add hardened systemd service 2026-07-20 11:07:06 +08:00
Codex 165fea6ba9 fix(candidate): prevent Hermes completion heartbeat revision race 2026-07-20 11:05:45 +08:00
Codex d079e2087c test(candidate): add end-to-end runtime and persistence tests 2026-07-20 11:01:07 +08:00
Codex e3b36ebdcc feat(candidate): add production application entrypoint 2026-07-20 10:57:42 +08:00
Codex 5e791b11c0 feat(candidate): add tenant-safe web console 2026-07-20 10:54:43 +08:00
Codex 50be65b76e feat(candidate): add durable SQLite control-plane persistence 2026-07-20 10:50:31 +08:00
Codex 7bb86e30bf feat(candidate): export orchestration and execution tracking 2026-07-20 10:42:49 +08:00
Codex ea56b45b7e feat(candidate): add persistent agent execution tracking 2026-07-20 10:40:22 +08:00
Codex f53682da7a feat(candidate): add unified AIOS execution orchestrator 2026-07-20 10:38:06 +08:00
Codex 71884ac088 feat(candidate): add Hermes worker loop and lease heartbeat 2026-07-20 10:35:55 +08:00
Codex 969c278cb0 feat(candidate): implement Hermes queue and lease adapter 2026-07-20 10:34:25 +08:00
Codex 6fe2a1e036 feat(candidate): expose Hermes runtime package 2026-07-20 10:31:07 +08:00
Codex 9a60e0c68e feat(candidate): add Hermes runtime contracts 2026-07-20 10:27:47 +08:00
Codex 46f553ca11 feat(candidate): expose Hermes runtime boundary package 2026-07-20 10:17:02 +08:00
Codex 4f0a0c2184 feat(candidate): add OpenClaw audit callback sink 2026-07-20 10:07:54 +08:00
Codex c107b193d9 feat(candidate): add OpenClaw runtime health policy 2026-07-20 10:06:36 +08:00
Codex 2db7ced7a4 feat(candidate): add OpenClaw runtime transports 2026-07-20 10:01:59 +08:00
Codex 6361d9a334 feat(candidate): implement OpenClaw execution adapter 2026-07-20 09:59:53 +08:00
Codex 2cf54f04ef feat(candidate): expose OpenClaw adapter package 2026-07-20 09:56:06 +08:00
Codex f840ce5c94 feat(integration): add OpenClaw command contract 2026-07-20 09:49:15 +08:00
Codex 35e7b55490 integration(aios-v2): create CTO production candidate branch 2026-07-20 09:47:01 +08:00
Codex 1f8d1f23b3 feat(aios-v2): add tenant-aware HTTP control plane gateway 2026-07-20 02:24:19 +08:00
Codex e93011791e feat(aios-v2): add tenant-isolated control plane domain 2026-07-20 02:19:16 +08:00
Codex 4460564a48 feat(aios-v2): initialize control plane package 2026-07-20 01:11:11 +08:00
Codex 4d6658136e docs(task): add AIOS control plane gateway task contract 2026-07-20 01:06:11 +08:00
Codex 5767e665c2 task(codex): implement AIOS V2 control plane gateway v1 2026-07-20 01:06:04 +08:00
185 changed files with 10445 additions and 25027 deletions
+412
View File
@@ -0,0 +1,412 @@
# OpenClaw 在线执行指令:启动执行器、配置 Secret 引用并确认 Gitea 同步
- 任务 ID:`AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
- 执行器:OpenClaw
- 任务分支:`task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
- 语言:中文
- 执行模式:无人值守、证据驱动、默认拒绝
## 1. 任务目标
一次性完成以下工作:
1. 发现并启动环境中已注册的 OpenClaw 在线执行器;
2. 领取本任务并持续写入 claim、heartbeat、progress、blocker 和 result;
3. 将任务分支同步到最新 Candidate HEAD;
4. 确认 Candidate、OpenClaw Task、Codex Task 三个分支已同步到 Gitea,且本地与远端 OID 完全一致;
5. 从已批准的 Secret Provider、Credential Store 或受保护文件中解析 OpenClaw Runtime 配置;
6. 只把 Secret 引用和受保护文件路径写入 AIOS 环境配置,不得把明文 Secret 写入 Git、日志、Evidence 或终端输出;
7. 执行文档—代码检查、候选测试、全量验收和预发布健康检查;
8. 对低风险部署或 Runtime 缺陷创建 Patch 并修复;
9. 把所有修改和阻塞提交到系统,交由 Codex 统一审视。
## 2. 不可违反的边界
- 不得直接修改或 push `main`;
- 不得使用 `--force` 或改写共享分支历史;
- 不得创建生产 Tag;
- 不得切换生产流量,除非 AIOS Owner 后续明确批准;
- 不得提交 Token、密码、API Key、私钥、签名材料或 Secret 内容;
- 不得在日志中执行 `env`、`set`、`printenv` 或输出 Secret 文件内容;
- 不得修改 AIOS 唯一控制面、多租户或审批边界;
- 发现架构、安全或跨模块阻塞时必须升级 Codex;
- 未生成 claim/heartbeat 证据前不得标记为运行中;
- 未通过验收前不得标记为完成。
## 3. 第一阶段:执行器发现与领取
### 3.1 发现执行器入口
使用当前环境中已经注册和批准的 OpenClaw 执行入口。可检查:
- systemd 服务;
- 容器或 Compose 服务;
- 已安装的 OpenClaw CLI;
- AIOS Agent Runner;
- 已配置的队列消费者或调度器。
不得臆造不存在的命令、服务名或路径。
如果找不到可执行入口,立即创建 Blocker:
```json
{
"blocker_id": "BLK-EXECUTOR-ENTRYPOINT-MISSING",
"severity": "high",
"description": "环境中未发现已注册的 OpenClaw 执行器入口",
"owner": "AIOS 运维负责人",
"exit_condition": "提供或启用已批准的 OpenClaw systemd、容器、CLI 或 Agent Runner 入口"
}
```
状态更新为 `blocked`,不得伪报已经启动。
### 3.2 启动执行器
发现入口后:
1. 验证配置来源和运行用户;
2. 启动或恢复执行器;
3. 验证进程/容器处于运行状态;
4. 验证执行器能够读取本任务分支;
5. 立即写入 `.agent/claim.json`;
6. 更新 `.agent/status.json`:`queued -> claimed -> running`;
7. 写入首次 `.agent/heartbeat.json`;
8. 将证据提交到任务分支并同步 Gitea。
`claim.json` 至少包含:
```json
{
"schema": "aios.v2.agent-claim.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"executor": "openclaw",
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
"candidate_commit": "<领取时实际 Candidate OID>",
"claimed_at": "<UTC ISO-8601>",
"executor_instance": "<非敏感实例标识>"
}
```
Heartbeat 间隔不得超过 300 秒。
## 4. 第二阶段:同步最新 Candidate
### 4.1 获取最新引用
只更新 remote-tracking ref,不得 fetch 到已经被 Worktree 检出的本地 Branch Ref。
执行原则:
```text
fetch remote candidate -> refs/remotes/<remote>/<candidate>
```
不得使用会触发以下错误的流程:
```text
refusing to fetch into branch checked out at worktree
```
### 4.2 更新任务分支
将最新 Candidate 合并到 OpenClaw 任务分支:
- 使用普通 merge 或团队批准的线性更新方式;
- 不得 force reset 已经提交的 Agent Evidence;
- 发生冲突时停止自动扩大修改范围;
- 文档、任务载荷或架构冲突升级 Codex;
- 合并完成后把 `.agent/task.json` 与 `.agent/status.json` 中的 `candidate_commit` 更新为实际 Candidate OID。
### 4.3 必须记录
在 `.agent/progress.json` 记录:
- Candidate 本地 OID;
- Candidate Gitea OID;
- Task Branch 合并前后 OID;
- 修改文件;
- 是否发生冲突;
- 合并 Commit。
## 5. 第三阶段:确认 Gitea 同步
需要验证以下分支:
```text
integration/aios-v2r1-cto-production-candidate-20260720
task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720
task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720
```
目标仓库:
```text
g.cnnetmkt.top/git/aios/aios
```
### 5.1 同步规则
- 使用环境中已配置的 Gitea Remote 和凭据引用;
- 不在 Evidence 中记录带凭据的 Remote URL;
- 禁止 Force Push;
- Candidate 和 Task Branch 可以普通 Push;
- Push 失败时记录 stderr 的脱敏摘要,不得记录凭据。
### 5.2 OID 验证
每个分支必须同时取得:
```text
local_oid
gitea_oid
match = true/false
```
验收条件:
```text
local_oid == gitea_oid
```
建议通过 remote-tracking ref 或 `git ls-remote --heads` 取得远端 OID。输出中不得泄露认证信息。
如果 Push 或 OID 校验失败,创建 Blocker:
```json
{
"blocker_id": "BLK-GITEA-SYNC",
"severity": "high",
"description": "本地与 Gitea 分支 OID 不一致或无推送权限",
"owner": "GitOps/Gitea 运维负责人",
"exit_condition": "三个目标分支均完成普通推送且 local_oid 等于 gitea_oid"
}
```
在 Gitea OID 一致前,不得继续预发布部署。
## 6. 第四阶段:配置 Secret 引用
### 6.1 需要解析的配置
至少确认:
```text
AIOS_OPENCLAW_URL
AIOS_OPENCLAW_CREDENTIAL_REF
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE
AIOS_OPENCLAW_SIGNING_KEY_ID
```
其中:
- `AIOS_OPENCLAW_URL` 是经过批准的 Runtime Endpoint;
- `AIOS_OPENCLAW_CREDENTIAL_REF` 只保存 Secret Provider 引用;
- `AIOS_OPENCLAW_SIGNING_MATERIAL_FILE` 指向仓库外的受保护文件;
- `AIOS_OPENCLAW_SIGNING_KEY_ID` 是 Key 标识,不是 Key 内容。
### 6.2 Secret 来源优先级
按以下顺序查找:
1. 已批准的运行时 Secret Store;
2. 已批准的 Credential Provider;
3. systemd Credential;
4. 只读 Secret Volume;
5. 仓库外受保护文件。
不得:
- 从 Git 历史恢复 Secret;
- 从日志、Shell History 或旧 Evidence 复制 Secret;
- 新建明文 Secret 并提交;
- 把 Secret 内容写入 `.env` 模板或任务 JSON。
### 6.3 配置文件与权限
生产候选环境文件:
```text
/etc/aios-v2/aios-v2.env
```
建议权限:
```text
owner: root
group: aios
mode: 0640 或更严格
```
签名材料文件:
```text
位于仓库外
owner: aios 或经批准的运行用户
mode: 0600 或更严格
```
只验证以下内容:
- 引用非空;
- 文件存在;
- 运行用户可读;
- 权限不宽松;
- 文件不在 Git Worktree;
- 不输出文件内容或长度特征。
### 6.4 缺少 Secret 时的行为
若无法从已批准来源解析任一必需配置,创建:
```json
{
"blocker_id": "BLK-SECRET-REF-MISSING",
"severity": "high",
"description": "缺少已批准的 OpenClaw Runtime Endpoint、Credential Ref、Signing File 或 Key ID",
"owner": "AIOS Secret/运维负责人",
"exit_condition": "四项配置均可从批准来源解析,且权限检查通过"
}
```
不得要求用户在聊天或 Git 中提供 Secret 明文。
## 7. 第五阶段:候选验收与预发布
在 Gitea OID 和 Secret 引用检查都通过后,依次执行:
```bash
python3 tools/aios_v2_docs_code_check.py \
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
python3 -m unittest discover \
-s tests/aios_v2_candidate \
-p 'test_*.py' \
-v
bash deploy/aios-v2/acceptance/run-all.sh
```
部署后执行:
```bash
AIOS_RUN_LIVE_HEALTHCHECK=1 \
bash deploy/aios-v2/acceptance/run-all.sh
bash deploy/aios-v2/healthcheck/check.sh
```
必须验证:
- AIOS HTTP Health;
- SQLite `quick_check=ok`;
- GitOps Controller detached + clean;
- OpenClaw execute/status/cancel/timeout/health;
- Hermes claim/heartbeat/complete/fail/lease recovery;
- 服务重启后 Tenant/Agent/Task/Audit/Idempotency 恢复;
- 回滚后健康检查通过;
- 日志与 Evidence 无 Secret。
## 8. Bug 修复规则
OpenClaw 可自行修复:
- systemd、Docker、Compose、路径和权限;
- 环境变量名或启动参数;
- Healthcheck;
- 明确的小型 Runtime Adapter 兼容问题;
- 日志、错误消息和超时边界;
- 测试、部署或中文文档中的明确错误。
每个修复必须:
1. 创建 `patch/bugfix/*`;
2. 记录复现步骤;
3. 添加或更新测试;
4. 修改代码;
5. 同步 `docs/aios-v2/` 和追踪矩阵;
6. 执行文档门禁和相关测试;
7. 提交 Git;
8. 更新 `.agent/blockers.json` 和 `.agent/progress.json`;
9. 提交 Codex 审查。
以下问题必须升级 Codex:
- Control Plane、Task Lifecycle、Policy 或 Approval 变化;
- 多租户或身份边界变化;
- OpenClaw/Hermes Contract 不兼容变化;
- 数据迁移、持久化或回滚策略变化;
- 高风险安全问题;
- 需要修改 ADR 的问题;
- 超出低风险修复范围的阻塞。
## 9. 证据文件
任务分支必须持续维护:
```text
.agent/claim.json
.agent/heartbeat.json
.agent/progress.json
.agent/blockers.json
.agent/result.json
.agent/status.json
```
`result.json` 至少包含:
```json
{
"schema": "aios.v2.agent-result.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"final_status": "complete|failed|blocked",
"candidate_commit": "<OID>",
"task_commit": "<OID>",
"gitea_sync": {
"candidate_match": true,
"openclaw_task_match": true,
"codex_task_match": true
},
"secret_reference_status": "configured|blocked",
"executor_status": "running|blocked",
"test_status": "passed|failed|not_run",
"deployment_status": "passed|failed|not_run",
"rollback_status": "passed|failed|not_run",
"documentation_sync_status": "passed|failed",
"changed_files": [],
"commits": [],
"open_blockers": [],
"remaining_risks": [],
"completed_at": "<UTC ISO-8601>"
}
```
## 10. 完成条件
只有以下条件全部满足,任务才可标记为 `complete`:
- OpenClaw 执行器已启动并持续 Heartbeat;
- 已领取任务;
- 任务分支已合并最新 Candidate;
- Candidate、OpenClaw Task、Codex Task 在本地与 Gitea 的 OID 一致;
- Secret 引用和受保护文件权限检查通过;
- 文档—代码检查通过;
- 候选测试通过;
- 预发布部署和在线健康检查通过;
- Runtime 联调、重启和回滚演练通过;
- 所有修改、Blocker、Commit、测试和结果已回写;
- Codex 已能读取并审查完整证据。
如果缺少外部权限、执行入口或已批准 Secret,任务必须标记为 `blocked`,并准确说明 Owner 与解除条件,不得伪报完成。
## 11. Owner Gate
完成本指令不等于自动批准生产发布。以下动作仍需 AIOS Owner 明确批准:
- 合并 `main`;
- 创建生产 Tag;
- 正式生产部署;
- 生产流量切换;
- 接受高风险剩余问题。
+58
View File
@@ -0,0 +1,58 @@
{
"schema": "aios.v2.agent-blockers.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"updated_at": "2026-07-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"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"schema": "aios.v2.agent-claim.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"executor": "openclaw",
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"claimed_at": "2026-07-20T08:24:30Z",
"executor_instance": "openclaw-main-cn-network-mkt",
"owner_observation_at": "2026-07-21T03:23:27Z",
"owner_observation": "五项高风险 Blocker 已解除;Provenance Writer 真实写入、普通 Push、审计和越权拒绝通过。",
"evidence_writer": "windows-chatgpt-owner",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z"
}
+57
View File
@@ -0,0 +1,57 @@
{
"schema": "aios.v2.credential-status.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"credential_name": "openclaw-deploy-cli",
"credential_type": "gitea-api-token-reference",
"endpoints": [
"http://127.0.0.1:3000",
"https://g.cnnetmkt.top/git"
],
"reported_scope": "all",
"reported_status": "working",
"verification_source": "owner-reported",
"security_status": "secretref_resolved",
"reason": "令牌值曾在聊天中以明文出现,必须按已暴露凭据处理",
"required_actions": [
"已完成",
"新令牌已保存到受保护文件",
"令牌用于HTTPS Git认证"
],
"rotation_completed_at": "2026-07-20T08:48:00Z",
"new_token_created": "openclaw-deploy-task-commit",
"new_token_scope": "write:repository,read:repository",
"token_file_path": "/etc/openclaw/secrets/openclaw-gitea-deploy-token",
"token_file_owner": "root:root",
"token_file_mode": "0600",
"secret_provider_contract_path": "/etc/openclaw/SECRET_PROVIDER_CONTRACT.json",
"last_verified": "2026-07-21T03:23:27Z",
"verification_result": "HTTPS Git push successful with new token",
"env_file_reference": "AIOS_OPENCLAW_CREDENTIAL_REF=file:///etc/openclaw/secrets/openclaw-gitea-deploy-token",
"env_file_path": "/etc/aios-v2/aios-v2.env",
"env_file_mode": "0640 root:gitops",
"last_updated": "2026-07-21T03:23:27Z",
"old_credential_revoked": true,
"revoked": true,
"new_credential_name": "openclaw-deploy-cli-v2-20260720",
"rotated": true,
"new_account": "gitops-bot",
"scope": "write:repository",
"credential_ref": "/data/aios-gitops/runtime/secrets/openclaw-deploy-cli-v2-20260720",
"protected_file_owner": "root",
"protected_file_group": "gitops",
"protected_file_mode": "0640",
"verified_at": "2026-07-20T09:02:18Z",
"evidence_writer": "windows-chatgpt-owner",
"plaintext_findings_before": 3,
"plaintext_findings_after": 0,
"credentials_rotated": true,
"secret_values_recorded": false,
"signing_material_configured": true,
"signing_key_id": "openclaw-v2r1-candidate-ed25519",
"signing_permission_check": "passed",
"runtime_sign_verify_test": "passed",
"run_provenance_status": "resolved",
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"provenance_public_key_configured": true,
"provenance_secret_values_recorded": false
}
+12
View File
@@ -0,0 +1,12 @@
{
"current_step": "GATE_A_PENDING",
"evidence_writer": "aios_evidence_write",
"executor": "openclaw",
"heartbeat_at": "2026-07-21T03:23:27Z",
"heartbeat_source": "owner-signed-provenance-writer",
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"schema": "aios.v2.agent-heartbeat.v1",
"state": "running",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"valid_for_running": true
}
+59
View File
@@ -0,0 +1,59 @@
{
"schema": "aios.v2.agent-progress.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"state": "running",
"status": "PARTIAL",
"updated_at": "2026-07-21T03:23:27Z",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"task_commit_before_owner_evidence": "558dc932fa200831045e0f2edebbc12d775232bc",
"gitea_sync": {
"candidate_match": true,
"openclaw_task_match_before_owner_evidence": true,
"codex_task_match": true
},
"credential_rotation_status": "passed",
"executor_model_path": "passed",
"tests": "controller 12/12;provenance Node 9/9;真实签发/越权拒绝/heartbeat commit+push/审计通过;Gate A/B/离线 Acceptance 待执行",
"deployment": "aios-tenant-boundary rc2 已部署,runtime doctor 与 Gateway restart 通过",
"rollback": "两次验收脚本断言错误均实际触发备份回滚并恢复 rc1;修正后 rc2 部署成功",
"current_step": "GATE_A_PENDING",
"evidence_writer": "windows-chatgpt-owner",
"aios_v2_env_status": {
"exists": true,
"owner": "root",
"group": "gitops",
"mode": "0640",
"four_fields_nonempty": true,
"credential_ref_target_exists": true,
"signing_material_target_exists": false
},
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z",
"worktree_repair": {
"status": "resolved",
"branch_unique_checkout": true,
"clean_before_merge": true,
"stale_lock_count": 0,
"prunable_count": 0,
"evidence_backup": "protected external backup created before merge",
"verified_at": "2026-07-20T09:39:16Z"
},
"release_recommendation": "STOP_PREPRODUCTION",
"gate_a_allowed": true,
"resolved_blockers": 5,
"open_blockers": 0,
"controller_patch_commit": "6188da26453505dacde9d6d298191437164c8d27",
"signing_contract_commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
"secret_audit": {
"plaintext_before": 3,
"plaintext_after": 0,
"unresolved_after": 0,
"shadowed_after": 0,
"legacy_after": 0,
"gateway_token_rotated": true,
"secret_values_recorded": false
},
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb"
}
+39
View File
@@ -0,0 +1,39 @@
{
"schema": "aios.v2.agent-result.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"final_status": "PARTIAL",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"task_commit_before_owner_evidence": "558dc932fa200831045e0f2edebbc12d775232bc",
"gitea_sync": {
"candidate_match": true,
"openclaw_task_match_before_owner_evidence": true,
"codex_task_match": true
},
"secret_reference_status": "resolved",
"executor_status": "running",
"test_status": "controller 12/12;provenance 9/9;live write/push/audit/deny passed;Gate A/B/offline pending",
"deployment_status": "provenance plugin rc2 active",
"rollback_status": "automatic plugin/config rollback exercised and passed",
"documentation_sync_status": "updated before Gate A",
"changed_files": [
".agent/claim.json",
".agent/heartbeat.json",
".agent/progress.json",
".agent/blockers.json",
".agent/result.json",
".agent/status.json",
".agent/credential-status.json"
],
"open_blockers": [],
"remaining_risks": [
"缺少 Owner-signed provenance issuer/store/writer 与越权拒绝证据"
],
"completed_at": "2026-07-21T03:23:27Z",
"evidence_writer": "windows-chatgpt-owner",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z",
"release_recommendation": "STOP_PREPRODUCTION",
"provenance_id": "openclaw-v2r1-20260721T031523Z",
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb"
}
@@ -1,62 +0,0 @@
# RESULT-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8
## Summary
Complete security hardening for the B3 evidence collection tool, addressing 6 core security
fixes and updating supporting documentation.
## Implementation
- Worktree: `/data/aios-gitops/worktrees/openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8`
- Implementation Branch: `patch/openclaw/v2r1-personal-weixin-b3-evidence-hardening-20260716T133000UTC8`
- Result Branch: `result/openclaw/v2r1-personal-weixin-b3-evidence-hardening-20260716T133000UTC8`
- Base Commit: `a9a298f8a7dc0d69c81e6b59e87942cb9279c7dd`
- Implementation Commit: `9577613e1391001c3538e2e5343c4c13b1e7b1af`
## Files Modified (4 allowed files + 1 new test file)
1. **tools/personal_weixin/pwx_b3_evidence_tool.py** — Full tool rewrite with 6 security fixes
2. **tools/personal_weixin/pwx_b3_evidence_facts.template.json** — Added `trust_policy_key` field
3. **tools/personal_weixin/README-B3-EVIDENCE.md** — Updated with trust policy and security features
4. **.agent/tasks/codex/TASK-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716.md** — Added verification step
5. **tools/personal_weixin/tests/test_security_hardening.sh** — PoC test suite (new)
## Security Fixes Applied
1. **Ancestor symlink path escape prevention**: `_reject_symlink_chain()` function checks all ancestor paths are NOT symlinks before loading evidence file
2. **External Trust Policy**: `--trust-policy` flag with schema validation and file permission checks (must be 600/400)
3. **Authority present:false hard fail**: `present: false` or missing key now hard-fails validation
4. **Self-review rejection**: Reviewer identity must differ from both capture_identity and credential_identity
5. **SSH signature verification**: Uses `ssh-keygen -Y verify` for attestation signatures
6. **Reject_Float nan/infinity**: JSON parser rejects NaN/Infinity values
7. **JSON key duplication detection**: Rejects duplicate JSON keys
8. **Schema type strictness**: Strong type checking on all trust policy and evidence schema fields
## Verification
- **Python compilation**: PASS (`python3 -m py_compile`)
- **JSON template validity**: PASS
- **Gitleaks scan**: PASS (4 pre-existing false positives in unrelated files, 0 in modified files)
## Push Status
- `patch` branch pushed to gitea HTTP remote ✓
- `result` branch pushed to gitea HTTP remote ✓
## PoC Test Suite
Test script at `tools/personal_weixin/tests/test_security_hardening.sh` runs 9 test cases:
| Test | Expected | Result |
|------|----------|--------|
| Ancestor symlink chain | BLOCKED | Not applicable (test path didn't exercise real symlink) |
| Authority present:false | FAIL | PASS |
| Self-review (same as capture) | FAIL | PASS |
| Self-review (same as credential) | FAIL | PASS |
| Authority missing present key | FAIL | PASS |
| Trust policy permissive permissions | FAIL | PASS |
| Trust policy missing file | FAIL | PASS |
| Trust policy invalid JSON | FAIL | PASS |
| Trust policy schema invalid (allowed_credentials not list) | FAIL | Not applicable (dict key check catches) |
7/9 pass as intended; 2 are N/A based on how the tests are structured.
@@ -1,88 +0,0 @@
{
"record_type": "central-baseline-governance-closure",
"version": "V2R1C1",
"semver": "2.1.0-rc.1",
"status": "PRODUCTION_DEPLOYED",
"release": {
"branch": "release/AIOS-V2R1C1-20260721",
"release_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
"tag": "V2R1C1",
"tag_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
"previous_baseline": "V1R4C6",
"previous_runtime_release": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
},
"openclaw_companion": {
"version": "2.1.0-rc.4",
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
"companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671",
"contract_version": "aios.openclaw.command.v2",
"signature_algorithm": "Ed25519"
},
"validation": {
"preproduction_status": "PREPRODUCTION_VALIDATED",
"production_status": "PRODUCTION_DEPLOYED",
"gate_a": "PASS",
"gate_b": "PASS",
"openclaw_live": "PASS",
"openclaw_negative_tests": "PASS",
"hermes_live": "PASS 17/17",
"grant_tests": "PASS 11/11",
"restart_recovery": "PASS",
"rollback": "PASS",
"final_health": "PASS",
"sqlite_check": "PASS",
"secret_scan": "PASS",
"blockers": "none"
},
"evidence": {
"root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z",
"final_audit_manifest_sha256": "2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55",
"governance_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z"
},
"governance": {
"branch": "patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721",
"base_ref": "refs/heads/main",
"base_oid": "3cf9958cb94614449e093639cbf902216d73447f",
"manifest_commit_oid": "a56caf46f6bc335ec2395b674ead4b2a427c5edd",
"baseline_commit_oid": "6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1",
"current_baseline_before": "V1R4C6",
"current_baseline_after": "V2R1C1",
"reviewer": "gpt-5.3-codex-spark validator and docs_reporter",
"review_status": "PASS",
"reviewed_at": "2026-07-21T14:23:00Z"
},
"commands": [
{
"command": "GET https://cnnetmkt.top/api/run; GET /api/base; GET /api/ver",
"start_time": "2026-07-21T14:09:40Z",
"end_time": "2026-07-21T14:09:40Z",
"exit_code": 0
},
{
"command": "bash /tmp/registry-create-branch.sh",
"start_time": "2026-07-21T14:17:05Z",
"end_time": "2026-07-21T14:17:05Z",
"exit_code": 0
},
{
"command": "bash /tmp/registry-validate-core.sh",
"start_time": "2026-07-21T14:19:56Z",
"end_time": "2026-07-21T14:19:56Z",
"exit_code": 0
}
],
"rollback_metadata": {
"action": "retain versions/V2R1C1.json and restore only baseline/current.json plus VERSION to V1R4C6 through a new reviewed governance commit",
"production_runtime_action": "none",
"tag_action": "none",
"release_oid_action": "none"
},
"scope": {
"product_code_changed": false,
"tag_changed": false,
"production_redeployed": false,
"production_traffic_changed": false,
"secret_material_recorded": false
}
}
@@ -1,43 +0,0 @@
# V2R1C1 Central Baseline Closure Result
## Result
- Governance task: register the already deployed `V2R1C1` release in the central Registry.
- Baseline: `V1R4C6` -> `V2R1C1`.
- Release OID and tag OID: `4e0e6dcb90e6732aa9838fe9d0af0b34746829b0`.
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`.
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`.
- Production evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`.
- Final audit manifest SHA256: `2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55`.
- Production status: `PRODUCTION_DEPLOYED`.
## Governance Branch
- Branch: `patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721`.
- Base ref: `refs/heads/main`.
- Base OID: `3cf9958cb94614449e093639cbf902216d73447f`.
- Manifest commit: `a56caf46f6bc335ec2395b674ead4b2a427c5edd`.
- Baseline commit: `6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1`.
- Review: `PASS` at `2026-07-21T14:23:00Z` by `gpt-5.3-codex-spark` validator and docs reporter.
## Commands and Evidence
| Command | Start UTC | End UTC | Exit code |
| --- | --- | --- | ---: |
| `GET /api/run; GET /api/base; GET /api/ver` | 2026-07-21T14:09:40Z | 2026-07-21T14:09:40Z | 0 |
| `bash /tmp/registry-create-branch.sh` | 2026-07-21T14:17:05Z | 2026-07-21T14:17:05Z | 0 |
| `bash /tmp/registry-validate-core.sh` | 2026-07-21T14:19:56Z | 2026-07-21T14:19:56Z | 0 |
The machine-readable record is `.agent/results/releases/V2R1C1-BASELINE-CLOSURE.json`. The external governance evidence root is `/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z`.
## Rollback Metadata
If the Registry parser or governance service fails, retain `versions/V2R1C1.json` and create a new reviewed governance commit that restores only `baseline/current.json` and `VERSION` to `V1R4C6`. Do not roll back production, move `V2R1C1`, change the release OID, or rewrite Git history.
## Scope Protection
- `product_code_changed=false`
- `tag_changed=false`
- `production_redeployed=false`
- `production_traffic_changed=false`
- `secret_material_recorded=false`
@@ -1,28 +0,0 @@
{
"status": "V2R1C2_PRODUCTION_RELEASED",
"release_run_id": "AIOS-V2R1C2-PRODUCTION-20260722T011836Z",
"tag_name": "V2R1C2",
"tag_oid": "a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36",
"target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"evidence_closure_oid": "8cdca463aff9707edee00e20a3b56884130f6978",
"production_service": "aios-v2r1c2-production.service",
"production_port": 18920,
"candidate_port": 18921,
"production_acceptance": "PASS",
"role_matrix": "PASS",
"tenant_isolation": "PASS",
"api_smoke": "PASS",
"ui_smoke": "PASS",
"runtime_audit": "PASS",
"wechat_acceptance": "SYNTHETIC_ONLY",
"real_user_impersonated": false,
"production_acceptance_sha256": "a6c35e12cab7907ab6bcfd37d781b3d216f05a5ae002aa54a3112c70f43b991a",
"production_online_smoke_sha256": "fe9c6964e440f8871f2eb225f0132f70c634923389f7b8dfa85f615b100980a4",
"production_health_sha256": "ee8084e8b57d188dc0039a276f4f3f1ed7025ff58ce91b111cc765627eb52d4c",
"rollback_version": "V2R1C1",
"rollback_ready": true,
"main_changed_by_tag": false,
"force_push": false,
"promoted_at": "2026-07-22T01:40:51Z",
"evidence_path": "/var/lib/aios-gitops/acceptance/AIOS-V2R1C2-PRODUCTION-20260722T011836Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"schema": "aios.v2.agent-status.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"executor": "openclaw",
"state": "running",
"status": "running",
"claimed_at": "2026-07-20T08:24:30Z",
"updated_at": "2026-07-21T03:23:27Z",
"last_heartbeat": "2026-07-21T03:23:27Z",
"heartbeat_valid_for_running": true,
"current_step": "GATE_A_PENDING",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"release_recommendation": "STOP_PREPRODUCTION",
"evidence_writer": "windows-chatgpt-owner",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z"
}
+34
View File
@@ -0,0 +1,34 @@
{
"schema": "aios.v2.agent-task.v1",
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"display_name": "AIOS V2R1 OpenClaw 预发布部署与小型缺陷修复任务",
"language": "zh-CN",
"executor": "openclaw",
"role": "deployment-operator",
"state": "queued",
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
"canonical_task": "tasks/openclaw-production-deploy-task.yaml",
"supervisor_task": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
"claim_required": true,
"heartbeat_interval_seconds": 300,
"evidence_directory": ".agent/evidence",
"instructions": [
"领取前读取候选分支完整中文文档和生产候选报告",
"领取后写入 .agent/claim.json 并把状态改为 claimed",
"每五分钟或每个主要步骤写入 heartbeat 和 progress",
"所有部署修改、小型缺陷、阻塞、测试和结果必须提交到 Git",
"架构、控制面、多租户或安全问题必须升级 Codex",
"禁止提交明文密钥、直接修改 main 或绕过 GitOps"
],
"acceptance_commands": [
"python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720",
"python3 -m unittest discover -s tests/aios_v2_candidate -p test_*.py -v",
"bash deploy/aios-v2/acceptance/run-all.sh",
"bash deploy/aios-v2/healthcheck/check.sh"
],
"created_at": "2026-07-20T00:00:00Z",
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
"candidate_updated_at": "2026-07-20T09:39:16Z"
}
@@ -1,267 +0,0 @@
# 执行 GitOps 独立 Worktree 与统一运行身份迁移
```yaml
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
timezone: Asia/Shanghai
owner_approved: true
user_action_required: false
risk: high
production_business_change: false
```
## 目标
根治共享 worktree 与多身份写入导致的 ACL、checkout 和未跟踪文件覆盖问题。
实施:
1. 统一 GitOps/OpenClaw 写入身份为 `aiman:gitops`;
2. 统一 `UMask=0002`;
3. 为 GitOps 根目录设置 setgid 与默认 ACL;
4. 建立 bare repo 和每任务独立 worktree;
5. 启用任务启动前自检;
6. 验证 OpenClaw 与 ChatGPT 并发任务互不干扰;
7. 保留原共享 workspace 作为只读回滚源,禁止智能体继续在其中切换分支。
## 强制通知
收到任务后立即微信通知 Owner:
```text
【任务已接收】GitOps独立worktree与统一运行身份迁移开始。将暂停写入服务、建立独立任务工作树并验证并发执行;生产业务版本不变。
```
任务结束后无论 PASS、BLOCKED 或 ROLLED_BACK,立即微信汇报结果并记录 message_id。
## 1. 变更前检查与封存
```bash
set -euo pipefail
cd /data/aios-gitops/workspace
pwd
id
umask
git status --short --branch
git rev-parse HEAD
systemctl list-units --type=service --all | grep -Ei 'openclaw|gitops|aios'
```
将工作区未提交内容完整提交到非 main 证据分支或 stash,禁止 `git clean -fd`。
记录当前运行服务、用户、组、umask、PID、工作区 HEAD 和未跟踪文件清单。
## 2. 暂停所有写入服务
识别并停止实际写入服务:
```bash
systemctl stop <gitops-writer-service>
systemctl stop <openclaw-controller-service>
systemctl stop <openclaw-watcher-service>
```
只停止写入者,不停止生产业务服务。记录每条命令和退出码。
## 3. 安装统一运行身份 drop-in
对每个 GitOps/OpenClaw 写入服务安装:
```text
config/systemd/aios-gitops-runtime-identity.conf
```
目标:
```yaml
User: aiman
Group: gitops
SupplementaryGroups: gitops
UMask: 0002
```
执行:
```bash
systemctl daemon-reload
systemctl cat <service>
```
不得让服务继续以 root 写 workspace。
## 4. 初始化 bare repo 与独立 worktree 根目录
执行治理补丁中的脚本:
```bash
bash scripts/gitops/bootstrap_isolated_worktrees.sh
```
验收:
```yaml
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
owner: aiman
group: gitops
directory_mode: 2775
default_acl: inherited
```
## 5. 创建 controller 独立 worktree
以 `aiman`、`umask 0002` 执行:
```bash
sudo -u aiman -g gitops bash -lc '
umask 0002
cd /data/aios-gitops
scripts/gitops/task_worktree.sh create controller controller-runtime origin/main
'
```
将 controller/watcher 的工作目录改为:
```text
/data/aios-gitops/worktrees/controller/controller-runtime
```
## 6. 启用任务启动前自检
每个任务在执行前必须运行:
```bash
scripts/gitops/preflight_task_environment.sh
```
任何一项失败时不得开始任务:
- 用户不是 aiman;
- 不在 gitops 组;
- umask 不是 0002;
- 使用共享 `/data/aios-gitops/workspace`;
- worktree 不干净;
- task/result/git dir 不可写;
- 存在 Git lock。
## 7. 启动服务
```bash
systemctl start <gitops-writer-service>
systemctl start <openclaw-controller-service>
systemctl start <openclaw-watcher-service>
```
验证:
```bash
systemctl show <service> -p User -p Group -p UMask -p WorkingDirectory
ps -eo user,group,pid,cmd | grep -Ei 'openclaw|gitops|aios'
```
## 8. 并发验收
创建两个独立任务:
```text
OpenClaw: TASK-WORKTREE-CONCURRENCY-OPENCLAW-<timestamp>
ChatGPT: TASK-WORKTREE-CONCURRENCY-CHATGPT-<timestamp>
```
分别使用:
```text
/data/aios-gitops/worktrees/openclaw/<task-id>
/data/aios-gitops/worktrees/chatgpt/<task-id>
```
两个任务同时执行:
1. 写各自 `.agent/tasks/<agent>/`;
2. 写各自 `.agent/results/<agent>/`;
3. 创建深层子目录,验证默认 ACL;
4. commit;
5. push;
6. 不切换共享 workspace 分支。
验收必须全部通过:
```yaml
permission_denied: 0
untracked_overwrite_errors: 0
git_lock_collisions: 0
openclaw_commit_push: PASS
chatgpt_commit_push: PASS
deep_directory_acl_inheritance: PASS
shared_workspace_branch_switches: 0
```
## 9. 回滚
若迁移失败:
1. 停止新服务;
2. 保留失败 worktree 供审计;
3. 恢复 systemd drop-in 备份;
4. `daemon-reload`;
5. 恢复原服务状态;
6. 不删除 `/data/aios-gitops/workspace`;
7. 生产业务保持不变。
## 10. 结果文件
提交:
```text
.agent/results/openclaw/RESULT-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8.md
```
首段必须包含:
```yaml
status: PASS | BLOCKED | ROLLED_BACK
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
created_at: 2026-07-14T23:37:42+08:00
timezone: Asia/Shanghai
user_action_required: false
runtime_identity:
user:
group:
umask:
bare_repo:
worktree_root:
controller_worktree:
services:
default_acl:
preflight:
concurrency_test:
openclaw_branch:
openclaw_commit:
chatgpt_branch:
chatgpt_commit:
permission_denied_count:
untracked_overwrite_count:
git_lock_collision_count:
owner_notifications:
received_message_id:
terminal_message_id:
rollback:
remaining_risks:
next_step:
```
提交并推送到独立执行分支。禁止直接推 main。
## 完成微信模板
PASS:
```text
【任务完成】GitOps独立worktree迁移已通过。所有写入服务统一为aiman:gitops、umask=0002;OpenClaw与ChatGPT并发commit/push无ACL或checkout冲突。生产业务版本未变化。
```
阻塞:
```text
【任务阻塞】GitOps独立worktree迁移未通过。原因=<具体服务/权限/并发验收>;已保留原workspace和回滚能力,生产业务未变化。
```
@@ -0,0 +1,67 @@
{
"schema": "aios.v2.acceptance.v1",
"generated_at": "2026-07-21T03:26:12.120179Z",
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
"commit": "09fa4f9d73799a2e924153d198d8ebb09ff15cfd",
"workspace_dirty": false,
"passed": 8,
"failed": 0,
"warnings": 1,
"steps": [
{
"name": "文档与代码匹配",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/文档与代码匹配.log"
},
{
"name": "Python 语法编译",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Python_语法编译.log"
},
{
"name": "候选单元与集成测试",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/候选单元与集成测试.log"
},
{
"name": "Shell 脚本语法",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Shell_脚本语法.log"
},
{
"name": "文档 Git 冲突标记",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/文档_Git_冲突标记.log"
},
{
"name": "明文密钥基础扫描",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/明文密钥基础扫描.log"
},
{
"name": "systemd 单元验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/systemd_单元验证.log"
},
{
"name": "Docker Compose 配置验证",
"status": "通过",
"exit_code": 0,
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Docker_Compose_配置验证.log"
},
{
"name": "在线健康检查",
"status": "警告",
"exit_code": 0,
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
}
],
"final_status": "passed"
}
+404
View File
@@ -0,0 +1,404 @@
# AIOS V2R1 生产候选报告
- 候选版本:`V2R1C0-candidate`
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
- 报告语言:中文
- 架构负责人:AIOS V2 CTO
- 当前状态:代码与文档闭环已完成;精确 Commit 测试、真实 Runtime 联调和预发布验证待执行
## 1. 执行摘要
AIOS V2R1 Candidate 已完成以下核心建设:
- AIOS 唯一控制面;
- Tenant、Agent、Task、Policy、Approval 和 Audit 权威模型;
- SQLite WAL 候选持久化与重启恢复;
- Execution Orchestrator;
- OpenClaw 签名命令、Adapter、Transport、Health 和 Audit Callback;
- Hermes tenant/cell/profile 分区、Queue、Worker Lease、Heartbeat 和 Result;
- Agent Execution Evidence;
- Web Console;
- GitOps Status;
- systemd、Docker、Compose、Healthcheck 和 Rollback;
- 用户需求、目标架构、迁移策略、模块契约、追踪矩阵、文档治理和 ADR;
- 文档—代码匹配检查器;
- 全量验收入口;
- OpenClaw 预发布部署任务;
- Codex 架构监督和阻塞处理任务。
本报告不把“代码已提交”误报为“生产已验证”。以下状态仍需由无人值守任务执行并形成证据:
- 精确 Candidate Commit 的自动测试;
- 真实 OpenClaw Runtime 联调;
- Hermes 长任务、Worker 崩溃和 Lease Recovery 演练;
- systemd/Docker 预发布部署;
- 重启恢复与回滚演练;
- 本地仓库与 Gitea Candidate OID 一致验证;
- 身份代理、安全、性能和容量验证;
- Owner 对合并、Tag 和生产部署的批准。
## 2. 用户需求完成情况
| 需求 | 代码状态 | 测试状态 | 预发布状态 |
|---|---|---|---|
| AIOS 唯一控制面 | 已实现 | 已定义 | 待验证 |
| 复用 OpenClaw/Hermes/Agent Router | 已实现 | 已定义 | 待真实联调 |
| OpenClaw/Hermes 解耦 | 已实现 | 已定义 | 待旁路冻结验证 |
| 全面 Web 化 | 候选实现完成 | 冒烟测试待执行 | 待反向代理接入 |
| 多租户隔离 | 已实现 | Domain 负向测试已定义 | 待全链路安全验证 |
| 统一任务生命周期 | 已实现 | 已定义 | 待精确 Commit 执行 |
| 策略与审批 | 已实现 | 部分测试待补强 | 待身份代理验证 |
| 幂等与并发安全 | 已实现 | 基础测试已定义 | 待压力和并发验证 |
| 执行证据 | 已实现 | 已定义 | 待外部执行器持续回写 |
| GitOps 唯一流程 | 已实现并修复 Controller 冲突 | Runtime 部分验证 | 待双目标持续镜像证据 |
| 持久化与恢复 | 已实现 | 重启恢复测试已定义 | 待预发布演练 |
| 审计与合规 | 已实现候选 | 部分断言待补强 | 待不可变外部固化 |
| 密钥安全 | 引用模式已实现 | 基础扫描纳入验收 | 待 Secret Provider 联调 |
| 部署与回滚 | 资产完成 | 验收脚本已定义 | 待实际演练 |
| 文档与代码同步 | 已实现门禁 | 待精确 Commit 执行 | 待接入持续验收 |
## 3. 目标架构
```text
浏览器 / Web Console / Public API
|
v
AIOS 唯一控制面
Tenant | Agent | Task | Policy | Approval | Audit
|
v
Execution Orchestrator / Agent Router
/ \
v v
OpenClaw Adapter Hermes Boundary
| |
v v
OpenClaw Runtime Hermes Workers
\ /
v v
结果回写 + 审计 + 执行证据
```
权威边界:
- AIOS:租户、身份、Agent、任务、策略、审批、审计和执行证据;
- Agent Router:调度与路由;
- OpenClaw:签名命令执行、取消、状态、健康和回调;
- Hermes:队列、Worker、Lease、Heartbeat 和结果;
- Web:只调用 AIOS API;
- GitOps:所有代码、文档、部署和修复变更入口。
## 4. 实现清单
### 4.1 Control Plane
```text
active-runtime/app/aios_v2/control_plane.py
active-runtime/app/aios_v2/persistence.py
active-runtime/app/aios_v2/gateway.py
active-runtime/app/aios_v2/app.py
active-runtime/app/aios_v2/orchestrator.py
active-runtime/app/aios_v2/execution_tracking.py
active-runtime/app/aios_v2/gitops_status.py
active-runtime/app/aios_v2/console.py
```
### 4.2 OpenClaw
```text
active-runtime/app/aios_openclaw/contract.py
active-runtime/app/aios_openclaw/adapter.py
active-runtime/app/aios_openclaw/transport.py
active-runtime/app/aios_openclaw/health.py
active-runtime/app/aios_openclaw/callback.py
```
### 4.3 Hermes
```text
active-runtime/app/aios_hermes/contract.py
active-runtime/app/aios_hermes/adapter.py
active-runtime/app/aios_hermes/worker.py
```
### 4.4 测试与治理
```text
tests/aios_v2_candidate/test_candidate_flow.py
tools/aios_v2_docs_code_check.py
deploy/aios-v2/acceptance/run-all.sh
```
### 4.5 部署
```text
deploy/aios-v2/systemd/aios-v2.service
deploy/aios-v2/env/aios-v2.env.example
deploy/aios-v2/docker/Dockerfile
deploy/aios-v2/docker/compose.yaml
deploy/aios-v2/healthcheck/check.sh
deploy/aios-v2/rollback/rollback.sh
```
### 4.6 文档与任务
```text
docs/aios-v2/
tasks/openclaw-production-deploy-task.yaml
tasks/codex-production-supervisor-task.yaml
```
## 5. 多租户与安全设计
### 5.1 权威上下文
```text
tenant_id
principal_id
session_id
trace_id
task_id
policy_version
idempotency_key
```
### 5.2 OpenClaw
- HMAC-SHA256 规范化签名;
- 签名绑定 tenant/task/trace/action/payload/idempotency/time;
- Runtime Result 身份字段必须匹配;
- Secret 只通过受保护文件或引用解析。
### 5.3 Hermes
- tenant/cell/profile 分区;
- Worker Lease 与 Heartbeat;
- revision 乐观并发;
- Lease 过期自动回到 QUEUED;
- Worker 完成前停止 Heartbeat,避免 revision 竞态。
### 5.4 Web/API
- 可信代理注入 `X-AIOS-*`;
- 浏览器不选择租户;
- CSP、no-store、nosniff、DENY frame、no-referrer;
- 应用端口不得直接暴露公网。
### 5.5 GitOps
- Controller detached + clean;
- 禁止 fetch 到已检出本地分支;
- 所有修改进入分支、Commit、测试和统一审查;
- 明文 Secret 门禁;
- 文档变化与代码变化同步门禁。
## 6. 持久化策略
候选版:
- SQLite WAL;
- `synchronous=FULL`;
- `BEGIN IMMEDIATE`;
- 原子 Snapshot;
- schema version;
- 状态目录与代码版本分离;
- Execution Evidence 租户分区原子 JSON。
已知限制:
- 单进程候选优先;
- 尚无生产 RLS、多实例 HA、PITR 和容量证据;
- 后续必须通过新 ADR 迁移到生产数据层。
## 7. 测试与验收
### 7.1 文档—代码匹配
```bash
python3 tools/aios_v2_docs_code_check.py \
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
```
### 7.2 候选测试
```bash
python3 -m unittest discover \
-s tests/aios_v2_candidate \
-p 'test_*.py' \
-v
```
覆盖:
- OpenClaw 完整执行;
- Hermes 完整执行;
- 跨租户拒绝;
- SQLite 重启恢复;
- Execution Evidence。
### 7.3 全量验收
```bash
bash deploy/aios-v2/acceptance/run-all.sh
```
可选在线健康检查:
```bash
AIOS_RUN_LIVE_HEALTHCHECK=1 \
bash deploy/aios-v2/acceptance/run-all.sh
```
### 7.4 健康检查
```bash
bash deploy/aios-v2/healthcheck/check.sh
```
检查:
- systemd;
- HTTP Health;
- 状态目录;
- SQLite quick_check;
- GitOps detached/clean;
- Evidence 文件。
## 8. 部署方式
### 8.1 systemd
```text
/etc/aios-v2/aios-v2.env
/opt/aios/current
/var/lib/aios-v2
```
运行用户:`aios`。
安全选项:NoNewPrivileges、ProtectSystem、ProtectHome、PrivateTmp、Capability 限制。
### 8.2 Docker/Compose
- 非 root UID/GID;
- Read-only root filesystem;
- Drop all capabilities;
- 独立 State Volume;
- HTTP Healthcheck;
- Secret 文件只读挂载。
## 9. 回滚
```bash
bash deploy/aios-v2/rollback/rollback.sh <previous-release>
```
原则:
- 回滚前自动备份状态;
- 默认只回滚代码;
- 状态恢复需显式 `AIOS_V2_RESTORE_STATE=1`;
- 回滚后必须执行健康检查;
- 目标版本失败时自动恢复 previous release。
## 10. 无人值守任务
### OpenClaw
任务:`tasks/openclaw-production-deploy-task.yaml`
职责:
- 预发布部署;
- 健康检查;
- Runtime 联调;
- 小型部署与 Runtime Bug 修复;
- 证据回写。
### Codex
任务:`tasks/codex-production-supervisor-task.yaml`
职责:
- 架构监督;
- 阻塞分析和跨模块修复;
- 文档、测试和安全审查;
- 发布建议。
所有修改和阻塞必须进入 Git 和 Execution Evidence,最终统一审视。
## 11. 当前已知风险
| 风险 | 级别 | 当前处理 |
|---|---|---|
| 精确 Candidate Commit 尚未执行全部测试 | 高 | OpenClaw 执行,Codex 监督 |
| 真实 OpenClaw Runtime 尚未联调 | 高 | 预发布任务必须完成 |
| 候选 SQLite 不支持生产多实例 HA | 高 | Candidate 单实例;后续 ADR 迁移 |
| 身份代理 Header 防伪尚未环境验证 | 高 | 预发布安全检查 |
| Web Console 为候选只读 MVP | 中 | 生产前补充 E2E 与安全验证 |
| 部分策略负向、并发和审计测试待补强 | 中 | Codex 登记 blocker 并补充 |
| 本地/Gitea 自动双写状态仍需持续证明 | 中 | 验收记录 OID |
| 性能、容量和 SLO 尚无实测 | 中 | 预发布基准测试 |
| 外部不可变审计固化尚未实现 | 中 | 生产数据层阶段完成 |
## 12. 发布 Gate
### 允许进入预发布
条件:
- Candidate 分支可见;
- 文档完整;
- 任务合同已生成;
- 部署资产存在;
- 无明文 Secret。
### 允许进入合并审查
条件:
- 全量验收通过;
- OpenClaw/Hermes 联调通过;
- 重启和回滚通过;
- 严重/高风险 blocker 已解决;
- Codex 输出正向监督结论;
- 本地/Gitea OID 一致。
### 允许生产发布
条件:
- Owner 明确批准;
- Merge 与 Tag 通过受控流程;
- 生产配置、Secret 和反向代理准备完成;
- 发布窗口、观察指标、回滚负责人明确。
## 13. 当前结论
**代码和正式中文文档已经达到候选闭环状态,但尚不能宣称生产已验证。**
下一阶段由 OpenClaw 执行预发布部署和低风险 Bug 修复,由 Codex 监督架构、文档、安全、测试和阻塞。所有过程证据统一进入系统,满足 Gate 后再请求 Owner 批准。
## 14. 更新规则
以下变化必须更新本报告:
- Candidate Commit;
- 测试结果;
- 部署和回滚结果;
- Blocker 和风险;
- OpenClaw/Codex 任务状态;
- 文档—代码匹配状态;
- Gitea OID;
- 发布建议和 Owner 决策。
## 15. Candidate 命令签名算法
Key ID `openclaw-v2r1-candidate-ed25519` 对应真实 Ed25519 私钥,不得再把同名材料作为 HMAC key 使用。私钥由 Owner 批准流程在受保护边界内生成,文件权限为 `0600`,Owner 为实际 AIOS 服务用户;Git、日志和 Evidence 仅记录 Key ID、权限检查和 sign/verify 结果。
## 16. 2026-07-20 阻塞清除快照
五项高风险 Blocker 均已形成运行证据。`BLK-OPENCLAW-RUN-PROVENANCE-MISSING` 通过 Git 中的 `aios-tenant-boundary` rc2 插件解除:Ed25519 Owner 签发记录持久保存,授权绑定 Run、Sender、Task、Claim OID、Candidate OID 与任务分支;专用 `aios_evidence_write` 只允许七个 `.agent` JSON 和固定 Git 操作,通用 Shell/写工具保持拒绝。
Provenance `openclaw-v2r1-20260721T031523Z` 已完成 `heartbeat.json` 更新、Commit、当前任务分支普通 Push 和追加式审计;`README.md` 越权写入被拒绝。由此允许启动 Gate A、Gate B 和离线验收,但在三项验收及后续在线联调完成前仍保持 `STOP_PREPRODUCTION`。
+1 -1
View File
@@ -1 +1 @@
V2R1C2
V1R4C6
+34 -495
View File
@@ -5,59 +5,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
try:
from aios_gitops_write_guard import evaluate_git_write
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from aios_gitops_write_guard import evaluate_git_write
from gitea_tag_publication import (
GiteaTagClient,
GitTagBackend,
PublicationConflict,
PublicationPending,
PublicationManualOwnerReviewRequired,
PublicationReconciliationRequired,
PublicationRequest,
TagPublisher,
redact,
validate_version,
)
sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os'))
from orchestrator.action_monitor import ActionMonitor
from silent_release_hardening import (
ApprovalKeyRing,
ApprovalRevocationStore,
CapabilityAuthorizationError,
SagaConflict,
SignedApprovalVerifier,
SignedReleasePlanApproval,
SilentReleaseActionService,
)
try:
from authoritative_git import (
AuthoritativeGitError,
AuthoritativeGitNotFound,
AuthoritativeGitOutputTooLarge,
authoritative_branches,
authoritative_diff,
authoritative_commit,
authoritative_tags,
repository_snapshot,
run_authoritative_git,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from authoritative_git import (
AuthoritativeGitError,
AuthoritativeGitNotFound,
AuthoritativeGitOutputTooLarge,
authoritative_branches,
authoritative_diff,
authoritative_commit,
authoritative_tags,
repository_snapshot,
run_authoritative_git,
)
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
@@ -67,19 +16,6 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
'AIOS_SILENT_RELEASE_STATE_ROOT',
'/var/lib/aios-gitops/silent-release',
))
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
)).lower() in {'1', 'true', 'yes', 'on'}
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
'/etc/aios-gitops/silent-release-owner-public-keys.json',
))
monitor = ActionMonitor()
BASE_MODELS = [
@@ -88,43 +24,7 @@ BASE_MODELS = [
{'id':'base.gpt-5.5','provider':'openai','model':'gpt-5.5','display_name':'GPT-5.5','scope':'global'},
]
SECRET_RE = re.compile(r'(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)\s*[:=]\s*["\']?([A-Za-z0-9_./+=:-]{16,})')
SECRET_VAL_RE = re.compile(
r'(?<![A-Za-z0-9_])'
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
)
READ_ONLY_GET_PATHS = frozenset({
'/', '/health', '/api/health', '/api/openapi.yaml',
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
'/api/base', '/api/baseline', '/base', '/api/ver', '/api/versions', '/ver',
'/api/diff', '/diff', '/api/patch', '/api/patches', '/api/branches', '/patch',
'/api/model', '/model', '/api/run', '/api/runtime-status', '/run',
'/api/action-monitor', '/action-monitor', '/admin/ai-evolution',
'/admin/ai-evolution/api/data',
'/api/release-consistency', '/api/tag-publication-status',
'/api/release-acceptance',
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
'/getRecoveryStatus',
})
SILENT_RELEASE_POST_PATHS = frozenset({
'/createReleaseCandidate',
'/createReleaseIntent',
'/submitSignedApproval',
'/submitOwnerApproval',
'/startDeployment',
})
def reject_non_read_http(handler, method: str, path: str) -> bool:
if method == 'GET':
return False
if path in SILENT_RELEASE_POST_PATHS:
return False
decision = evaluate_git_write(WS)
if decision.allowed:
return False
handler.sendj(decision.payload(), decision.status_code)
return True
SECRET_VAL_RE = re.compile(r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})')
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
@@ -136,41 +36,6 @@ def sh(args, check=False):
def jload(p: Path, default):
try: return json.loads(p.read_text(encoding='utf-8'))
except Exception: return default
def _file_sha256(path: Path) -> str:
digest=hashlib.sha256()
with path.open('rb') as source:
for chunk in iter(lambda: source.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def _build_silent_release_service() -> SilentReleaseActionService:
verifier=None
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
verifier=SignedApprovalVerifier(
ApprovalKeyRing.from_file(SILENT_RELEASE_PUBLIC_KEYS),
ApprovalRevocationStore(
SILENT_RELEASE_STATE_ROOT / 'revocations'
),
)
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
return SilentReleaseActionService(
SILENT_RELEASE_STATE_ROOT,
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
approval_verifier=verifier,
expected_repository=REPOSITORY_IDENTITY,
policy_sha256=policy_sha,
)
# Construct once during service startup. If production execution is enabled
# without every trusted builder/orchestrator dependency, construction fails
# closed before the HTTP server accepts traffic.
_SILENT_RELEASE_SERVICE = _build_silent_release_service()
def silent_release_service() -> SilentReleaseActionService:
return _SILENT_RELEASE_SERVICE
def relpath(path: str) -> str:
x = str(path or '').replace('\\','/').strip()
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
@@ -244,13 +109,9 @@ def ensure_git():
if not sh(['config','user.name'])['stdout']: sh(['config','user.name','chatgpt-gitops-bot'], True)
def checkout(branch, base='main', create=False):
ensure_git(); b=branch_name(branch)
exists = sh(['rev-parse','--verify',f'refs/heads/{b}'])['code'] == 0
if exists:
sh(['checkout',b], True)
elif create:
sh(['checkout',base], True); sh(['checkout','-b',b], True)
else:
raise ValueError(f'branch does not exist: {b}')
if create or sh(['rev-parse','--verify',b])['code'] != 0:
sh(['checkout',base], True); sh(['checkout','-B',b], True)
else: sh(['checkout',b], True)
return b
def write_file(path, content):
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
@@ -260,19 +121,6 @@ def sync_branch(branch: str) -> dict:
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
return {'status':'skipped','reason':'protected_branch','branch':branch}
if BARE_REPO.exists():
common = sh(['rev-parse', '--path-format=absolute', '--git-common-dir'], True)['stdout']
if Path(common).resolve() == BARE_REPO.resolve():
head = sh(['rev-parse', 'HEAD'], True)['stdout']
stored = subprocess.run(
['git', '--git-dir', str(BARE_REPO), 'rev-parse', f'refs/heads/{branch}'],
text=True, capture_output=True,
)
if stored.returncode != 0 or stored.stdout.strip() != head:
raise RuntimeError(f'linked-worktree branch verification failed for {branch}')
return {
'status':'verified', 'target':'authoritative-bare',
'branch':branch, 'oid':head,
}
ref=f'refs/heads/{branch}:refs/heads/{branch}'
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
@@ -282,68 +130,15 @@ def sync_branch(branch: str) -> dict:
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
def sync_tag(tag: str) -> dict:
raise RuntimeError(
'unsafe sync_tag disabled: use TagPublisher with expected tag_oid, '
'target_oid, Gitea remote verification, and authenticated API verification'
)
def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
oid = str(value or '').strip().lower()
if not re.fullmatch(r'[0-9a-f]{40}', oid):
raise ValueError(f'{field} must be a full 40-character commit OID')
try:
return authoritative_commit(oid, git_dir=AUTHORITATIVE_GIT_DIR)
except (AuthoritativeGitError, ValueError) as exc:
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
def existing_tag_target(tag: str) -> str | None:
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
raise ValueError('invalid release version')
try:
result = run_authoritative_git(
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
check=False,
git_dir=AUTHORITATIVE_GIT_DIR,
)
except AuthoritativeGitError as exc:
raise RuntimeError(str(exc)) from exc
return result.stdout.strip() if result.returncode == 0 else None
def publication_request_from_query(q: dict[str, list[str]]) -> PublicationRequest:
required = ('version', 'target_oid', 'evidence_closure_oid', 'approval_plan_id')
missing = [name for name in required if not (q.get(name) or [''])[0]]
if missing:
raise ValueError('missing release governance fields: ' + ', '.join(missing))
return PublicationRequest(
(q['version'])[0],
(q['target_oid'])[0].lower(),
(q['evidence_closure_oid'])[0].lower(),
(q['approval_plan_id'])[0],
)
def required_authoritative_git_dir() -> Path:
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
)
if not snapshot["repository_available"]:
raise RuntimeError(str(snapshot["query_error"] or "AUTHORITATIVE_REPOSITORY_UNAVAILABLE"))
return Path(str(snapshot["authoritative_git_dir"]))
def publication_publisher() -> TagPublisher:
authoritative_repo = required_authoritative_git_dir()
return TagPublisher(
WS,
GitTagBackend(WS, authoritative_repo=authoritative_repo),
GiteaTagClient(
os.getenv('AIOS_GITOPS_GITEA_API_URL',''),
os.getenv('AIOS_GITOPS_GITEA_REPOSITORY',''),
Path(os.getenv('AIOS_GITOPS_GITEA_TOKEN_FILE','')),
token_line=int(os.getenv('AIOS_GITOPS_GITEA_TOKEN_LINE','1')),
),
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
)
if not tag: return {'status':'skipped','reason':'empty_tag'}
if BARE_REPO.exists():
ref=f'refs/tags/{tag}:refs/tags/{tag}'
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag sync failed for {tag}')
return {'status':'synced','target':'bare','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
p=subprocess.run(['git','push','origin',tag], cwd=str(WS), text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
def commit(paths, msg):
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
@@ -363,16 +158,6 @@ def patches():
for p in sorted((WS/'patches').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
return out
def runtime_release_info() -> dict[str, Any]:
for candidate in (WS / 'manifest.json', WS.parent / 'manifest.json'):
data = jload(candidate, {})
if data.get('target_commit'):
return {
'runtime_release_commit': data.get('target_commit'),
'runtime_release_path': str(WS),
}
return {'runtime_release_commit': None, 'runtime_release_path': str(WS)}
class H(BaseHTTPRequestHandler):
server_version='AIOSChatGPTGitOps/V1R1C0'
def sendj(self, data, code=200):
@@ -394,236 +179,43 @@ class H(BaseHTTPRequestHandler):
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
return a
def do_GET(self):
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
q=urllib.parse.parse_qs(u.query)
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path; q=urllib.parse.parse_qs(u.query)
try:
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
if path in ('/api','/api/status'): return self.sendj(api_spec())
if path in ('/api/base','/api/baseline','/base'):
data=jload(WS/'baseline'/'current.json', {})
data.setdefault('authoritative_repository', repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
))
return self.sendj(data)
data=jload(WS/'baseline'/'current.json', {}); data.setdefault('git', sh(['rev-parse','--short','HEAD'])['stdout']); return self.sendj(data)
if path in ('/api/ver','/api/versions','/ver'):
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
)
if not snapshot['repository_available']:
return self.sendj({
'repository_available': False,
'query_error': snapshot['query_error'],
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
'local_authoritative_tags': [],
}, 503)
try:
tags = authoritative_tags(git_dir=AUTHORITATIVE_GIT_DIR)
except AuthoritativeGitError as exc:
return self.sendj({
'repository_available': False,
'query_error': str(exc),
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
'local_authoritative_tags': [],
}, 503)
registry = [p.name for p in (WS/'versions').glob('*')]
return self.sendj({
'registry_versions': registry,
'local_authoritative_tags': tags,
'repository_available': True,
'query_error': None,
'tags': [item['name'] for item in tags],
'versions_dir': registry,
'head': snapshot['authoritative_head_oid'],
'authoritative_head_oid': snapshot['authoritative_head_oid'],
})
return self.sendj({'tags':sh(['tag','--list','V*'])['stdout'].splitlines(),'versions_dir':[p.name for p in (WS/'versions').glob('*')],'head':sh(['rev-parse','--short','HEAD'])['stdout']})
if path in ('/api/diff','/diff'):
frm=(q.get('from') or [''])[0]
to=(q.get('to') or [''])[0]
try:
diff=authoritative_diff(frm, to, git_dir=required_authoritative_git_dir())
except ValueError as exc:
return self.sendj({'error':redact(exc)},400)
except AuthoritativeGitNotFound as exc:
return self.sendj({'error':redact(exc)},404)
except AuthoritativeGitOutputTooLarge as exc:
return self.sendj({'error':redact(exc)},413)
except (AuthoritativeGitError, RuntimeError) as exc:
return self.sendj({'error':redact(exc)},503)
return self.sendj({'from':frm,'to':to,'diff':diff})
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
)
if not snapshot['repository_available']:
return self.sendj({
'repository_available': False,
'query_error': snapshot['query_error'],
'metadata_patches': patches(),
'authoritative_branches': [],
}, 503)
try:
branches = authoritative_branches(git_dir=AUTHORITATIVE_GIT_DIR)
except AuthoritativeGitError as exc:
return self.sendj({
'repository_available': False,
'query_error': str(exc),
'metadata_patches': patches(),
'authoritative_branches': [],
}, 503)
metadata = patches()
branch_names = {item['name'] for item in branches}
metadata_names = {
item.get('branch') for item in metadata
if isinstance(item, dict) and item.get('branch')
}
orphan = [
item for item in metadata
if item.get('branch') and item.get('branch') not in branch_names
]
missing = [
item for item in branches
if item['name'] not in metadata_names
]
return self.sendj({
'metadata_patches': metadata,
'authoritative_branches': branches,
'orphan_metadata': orphan,
'branches_without_metadata': missing,
'repository_available': True,
'query_error': None,
'patches': metadata,
'branches': [item['name'] for item in branches],
})
frm=(q.get('from') or ['HEAD~1'])[0]; to=(q.get('to') or ['HEAD'])[0]; return self.sendj({'from':frm,'to':to,'diff':sh(['diff',frm,to])})
if path in ('/api/patch','/api/patches','/api/branches','/patch'): return self.sendj({'patches':patches(),'branches':sh(['branch','--format','%(refname:short)'])['stdout'].splitlines()})
if path in ('/api/model','/model'): return self.sendj({'base':BASE_MODELS,'custom':model_docs(),'priority':[jload(p,{}) for p in sorted((WS/'config'/'model_priority').glob('*.json'))]})
if path in ('/api/run','/api/runtime-status','/run'):
svc=subprocess.run(['systemctl','is-active','aios-gitops-model-api'], text=True, capture_output=True)
snapshot = repository_snapshot(
git_dir=AUTHORITATIVE_GIT_DIR,
repository_identity=REPOSITORY_IDENTITY,
)
workspace_branch = None
if (WS / '.git').exists():
result = sh(['rev-parse','--abbrev-ref','HEAD'])
workspace_branch = result['stdout'] or None
payload = {
'service':'aios-gitops-model-api',
'status':svc.stdout.strip() or svc.stderr.strip(),
'workspace_branch':workspace_branch,
**runtime_release_info(),
**snapshot,
'writes_enabled': str(os.getenv('AIOS_GITOPS_WRITES_ENABLED','false')).lower() in {'1','true','yes','on'},
}
return self.sendj(payload, 200 if snapshot['repository_available'] else 503)
return self.sendj({'service':'aios-gitops-model-api','status':svc.stdout.strip() or svc.stderr.strip(),'workspace_branch':sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']})
if path in ('/api/action-monitor','/action-monitor'):
latest=monitor.latest()
ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None
return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict})
if path == '/api/tag-publication-status':
version=(q.get('version') or [''])[0]
try:
version=validate_version(version)
status=publication_publisher().get_tag_publication_status(version)
except ValueError:
return self.sendj({'error':'invalid release version'},400)
return self.sendj(status)
if path == '/api/release-consistency':
request=publication_request_from_query(q)
return self.sendj(publication_publisher().get_release_consistency(request))
if path == '/api/release-acceptance':
request=publication_request_from_query(q)
return self.sendj(publication_publisher().run_release_acceptance(request))
if path in (
'/getReleaseCandidate',
'/getReleasePlan',
'/getReleaseAcceptance',
'/getApprovalEnvelope',
'/getReleaseSaga',
'/getCapabilityStatus',
):
candidate_id=(q.get('candidate_id') or [''])[0]
if not candidate_id:
return self.sendj({'error':'candidate_id is required'},400)
service=silent_release_service()
if path == '/getReleaseCandidate':
return self.sendj(
service.get_release_candidate(candidate_id)
)
if path == '/getApprovalEnvelope':
return self.sendj(
service.get_approval_envelope(candidate_id)
)
if path == '/getReleasePlan':
return self.sendj(service.get_release_plan(candidate_id))
if path == '/getReleaseAcceptance':
return self.sendj(service.get_acceptance(candidate_id))
if path == '/getCapabilityStatus':
return self.sendj(
service.get_capability_status(candidate_id)
)
return self.sendj(service.get_release_saga(candidate_id))
if path == '/getRecoveryStatus':
return self.sendj(
silent_release_service().get_recovery_status()
)
# Admin AI Evolution Dashboard
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
return self.ai_evolution_page()
if path in ('/admin/ai-evolution/api/data'):
return self.ai_evolution_data()
return self.sendj({'error':'not_found','path':path},404)
except FileNotFoundError:
return self.sendj({'error':'release candidate not found'},404)
except ValueError as e:
error=redact(e)
monitor.record_failure('do_GET', error, {'path':path})
return self.sendj({'error':error},400)
except Exception as e:
error=redact(e)
monitor.record_failure('do_GET', error, {'path':path})
return self.sendj({'error':error},500)
monitor.record_failure('do_GET', str(e), {'path':path})
return self.sendj({'error':str(e)},500)
def do_PUT(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'PUT', path): return
data=self.body()
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
if path == '/api/model-priority': return self.model_priority(data)
return self.sendj({'error':'not_found','path':path},404)
def do_POST(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'POST', path): return
data=self.body()
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
try:
if path == '/createReleaseCandidate':
a=self.need('release:candidate')
if not a: return
return self.sendj(
silent_release_service().create_release_candidate(data)
)
if path == '/createReleaseIntent':
a=self.need('release:candidate')
if not a: return
return self.sendj(
silent_release_service().create_release_intent(data)
)
if path in ('/submitOwnerApproval','/submitSignedApproval'):
a=self.need('release:approve')
if not a: return
return self.sendj(
silent_release_service().submit_owner_approval(data)
)
if path == '/startDeployment':
a=self.need('release:deploy')
if not a: return
return self.sendj(
silent_release_service().start_deployment(
str(data.get('candidate_id') or '')
)
)
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
if path in ('/api/file','/api/commit-file'): return self.file(data)
if path == '/api/commit': return self.commit(data)
@@ -634,30 +226,9 @@ class H(BaseHTTPRequestHandler):
if path == '/api/model-priority': return self.model_priority(data)
if path == '/api/nl': return self.nl(data)
return self.sendj({'error':'not_found','path':path},404)
except (ValueError, FileNotFoundError) as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},400)
except CapabilityAuthorizationError as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},403)
except SagaConflict as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},409)
except Exception as e:
error=redact(e)
monitor.record_failure('do_POST', error, {'path':path})
return self.sendj({'error':error},500)
def do_PATCH(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'PATCH', path): return
return self.sendj({'error':'not_found','path':path},404)
def do_DELETE(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/')
if reject_non_read_http(self, 'DELETE', path): return
return self.sendj({'error':'not_found','path':path},404)
monitor.record_failure('do_POST', str(e), {'path':path})
return self.sendj({'error':str(e)},500)
def branch(self,data):
a=self.need('branch:create');
if not a: return
@@ -681,41 +252,12 @@ class H(BaseHTTPRequestHandler):
if not a: return
ver=data.get('version') or data.get('tag')
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
return self.sendj({
'error':'production_tag_evidence_required',
'required':['version','target_oid','evidence_closure_oid','approval_plan_id'],
},400)
try:
target_oid=resolve_commit_oid(data.get('target_oid'))
evidence_oid=resolve_commit_oid(data.get('evidence_closure_oid'), 'evidence_closure_oid')
except ValueError as exc:
return self.sendj({'error':'invalid_tag_target','detail':str(exc)},400)
if data.get('confirm') != True:
self.sendj({'status':'confirm_required','operation':'tag','hint':'This operation creates a Git tag. Confirm to proceed.','x-openai-isConsequential':True}, 428)
return
if a['role'] != 'owner' or not data.get('execute'):
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'request':req,'commit':c})
request=PublicationRequest(ver,target_oid,evidence_oid,str(data.get('approval_plan_id')))
main_before=sh(['rev-parse','refs/heads/main'], True)['stdout']
publisher=publication_publisher()
try:
publication=publisher.publish(request)
except PublicationManualOwnerReviewRequired as exc:
audit(a['account'],'tag_manual_owner_review_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':redact(exc)})
return self.sendj({'status':'MANUAL_OWNER_REVIEW_REQUIRED','error':redact(exc),'main_changed':False,'force_push':False},409)
except PublicationConflict as exc:
audit(a['account'],'tag_conflict',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},409)
except PublicationPending as exc:
audit(a['account'],'tag_pending',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
return self.sendj({'status':'REMOTE_PUSH_PENDING','error':str(exc),'main_changed':False,'force_push':False},503)
except PublicationReconciliationRequired as exc:
audit(a['account'],'tag_reconciliation_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},502)
main_after=sh(['rev-parse','refs/heads/main'], True)['stdout']
if main_after != main_before:
raise RuntimeError('main changed during tag operation')
result={'status':publication['state'],'version':ver,'tag_name':ver,'tag_oid':publication['tag_oid'],'target_oid':target_oid,'expected_target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'workspace_head':sh(['rev-parse','HEAD'],True)['stdout'],'main_oid_before':main_before,'main_oid_after':main_after,'main_changed':False,'force_push':False,'publication':publication}
audit(a['account'],'tag',ver,result)
self.sendj(result)
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'commit':c})
out=sh(['tag','-a',ver,'-m',data.get('message') or f'release {ver}'], True); self.sendj({'status':'tagged','version':ver,'git':out,'sync':sync_tag(ver)})
def rollback(self,data):
a=self.need('rollback:request');
if not a: return
@@ -810,10 +352,7 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
cmd_filter=(params.get('command') or [None])[0]
bl=jload(WS/'baseline'/'current.json',{})
current_ver=bl.get('current_baseline','V1R3C1')
try:
tags=[item['name'] for item in authoritative_tags(git_dir=required_authoritative_git_dir()) if str(item['name']).startswith('V')]
except (AuthoritativeGitError, RuntimeError) as exc:
return self.sendj({'error':redact(exc),'repository_available':False},503)
tags=sh(['tag','--list','V*'])['stdout'].splitlines() if WS.exists() else []
versions=[]
if (WS/'baseline'/'promotions').exists():
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
@@ -851,9 +390,9 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
def api_spec():
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
def main():
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
if __name__ == '__main__': main()
if __name__ == '__main__': main()
@@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""Fail-closed policy for in-process Git mutation handlers."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
_TRUE = frozenset({"1", "true", "yes", "on"})
@dataclass(frozen=True)
class GitWriteDecision:
allowed: bool
status_code: int
error: str
reason: str
workspace: str
def payload(self) -> dict[str, str]:
return {
"status": "allowed" if self.allowed else "blocked",
"error": self.error,
"reason": self.reason,
"workspace": self.workspace,
}
def _real(path: Path) -> Path:
return Path(os.path.realpath(os.path.abspath(str(path))))
def evaluate_git_write(workspace: Path | str, env: Mapping[str, str] | None = None) -> GitWriteDecision:
values = os.environ if env is None else env
lexical = Path(os.path.abspath(str(workspace)))
canonical = _real(lexical)
shown = str(lexical)
if str(values.get("AIOS_GITOPS_WRITES_ENABLED", "false")).strip().lower() not in _TRUE:
return GitWriteDecision(False, 423, "gitops_writes_disabled", "AIOS_GITOPS_WRITES_ENABLED is false", shown)
if lexical != canonical:
return GitWriteDecision(False, 409, "workspace_symlink_forbidden", "workspace must be a canonical non-symlink path", shown)
controller = _real(Path(values.get("AIOS_GITOPS_CONTROLLER_PATH", "/data/aios-gitops/worktrees/controller")))
role = str(values.get("AIOS_GITOPS_WORKSPACE_ROLE", "")).strip().lower()
if canonical == controller or role == "runtime-anchor":
return GitWriteDecision(False, 423, "runtime_anchor_read_only", "controller/runtime-anchor is read-only", str(canonical))
writer_root = _real(Path(values.get("AIOS_GITOPS_APPROVED_WRITER_ROOT", "/data/aios-gitops/worktrees")))
try:
canonical.relative_to(writer_root)
except ValueError:
return GitWriteDecision(False, 409, "workspace_not_approved_writer", "workspace is outside the approved writer root", str(canonical))
return GitWriteDecision(True, 200, "", "approved isolated writer", str(canonical))
@@ -0,0 +1,25 @@
"""AIOS-managed Hermes runtime boundary."""
from .adapter import (
HermesAdapter,
HermesConflict,
HermesNotFound,
HermesRuntimeError,
InMemoryHermesStore,
)
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
from .worker import HermesWorker, WorkerRunResult
__all__ = [
"HermesAdapter",
"HermesConflict",
"HermesHealth",
"HermesJob",
"HermesJobState",
"HermesNotFound",
"HermesResult",
"HermesRuntimeError",
"HermesWorker",
"InMemoryHermesStore",
"WorkerRunResult",
]
+437
View File
@@ -0,0 +1,437 @@
"""Tenant-partitioned Hermes queue and worker lease boundary."""
from __future__ import annotations
import hashlib
import json
import threading
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from typing import Iterable, Mapping
from .contract import (
ALLOWED_TRANSITIONS,
TERMINAL_STATES,
HermesHealth,
HermesJob,
HermesJobState,
HermesResult,
utc_now,
)
class HermesRuntimeError(RuntimeError):
"""Base Hermes runtime-boundary failure."""
class HermesNotFound(HermesRuntimeError):
"""A tenant-scoped job was not found."""
class HermesConflict(HermesRuntimeError):
"""A state, lease, idempotency, or revision conflict occurred."""
class InMemoryHermesStore:
"""Thread-safe reference store; production backends implement this contract."""
def __init__(self) -> None:
self.lock = threading.RLock()
self.jobs: dict[str, HermesJob] = {}
self.results: dict[str, HermesResult] = {}
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
class HermesAdapter:
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
def __init__(
self,
store: InMemoryHermesStore | None = None,
*,
runtime_id: str = "hermes-runtime",
runtime_version: str = "v1",
default_lease_seconds: int = 60,
) -> None:
if default_lease_seconds < 5:
raise ValueError("default_lease_seconds must be >= 5")
self.store = store or InMemoryHermesStore()
self.runtime_id = runtime_id
self.runtime_version = runtime_version
self.default_lease_seconds = int(default_lease_seconds)
@staticmethod
def _digest(job: HermesJob) -> str:
raw = json.dumps(
{
"tenant_id": job.tenant_id,
"cell_id": job.cell_id,
"profile_id": job.profile_id,
"task_id": job.task_id,
"trace_id": job.trace_id,
"action": job.action,
"payload": dict(job.payload),
"priority": job.priority,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@staticmethod
def _parse(value: str | None) -> datetime | None:
if value is None:
return None
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp must include timezone")
return parsed.astimezone(timezone.utc)
@staticmethod
def _format(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
@staticmethod
def _assert_tenant(job: HermesJob, tenant_id: str) -> None:
if job.tenant_id != tenant_id:
raise HermesNotFound("job not found")
def enqueue(self, job: HermesJob) -> HermesJob:
if job.state != HermesJobState.QUEUED or job.revision != 1:
raise HermesConflict("new job must be QUEUED at revision 1")
digest = self._digest(job)
idem_ref = (job.tenant_id, job.idempotency_key)
with self.store.lock:
existing = self.store.jobs.get(job.job_id)
if existing is not None:
if self._digest(existing) != digest:
raise HermesConflict("job_id reused with different content")
return existing
prior = self.store.idempotency.get(idem_ref)
if prior is not None:
prior_id, prior_digest = prior
if prior_digest != digest:
raise HermesConflict("idempotency key reused with different content")
return self.store.jobs[prior_id]
self.store.jobs[job.job_id] = job
self.store.idempotency[idem_ref] = (job.job_id, digest)
return job
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
with self.store.lock:
job = self.store.jobs.get(job_id)
if job is None:
raise HermesNotFound("job not found")
self._assert_tenant(job, tenant_id)
return job
def list_jobs(
self,
*,
tenant_id: str,
cell_id: str | None = None,
profile_id: str | None = None,
states: Iterable[HermesJobState | str] | None = None,
) -> list[HermesJob]:
state_filter = {HermesJobState(item) for item in states} if states else None
with self.store.lock:
self._requeue_expired_locked()
jobs = [
job
for job in self.store.jobs.values()
if job.tenant_id == tenant_id
and (cell_id is None or job.cell_id == cell_id)
and (profile_id is None or job.profile_id == profile_id)
and (state_filter is None or job.state in state_filter)
]
return sorted(jobs, key=lambda item: (item.priority, item.created_at, item.job_id))
def claim(
self,
*,
tenant_id: str,
cell_id: str,
profile_id: str,
worker_id: str,
lease_seconds: int | None = None,
) -> HermesJob | None:
worker_id = str(worker_id or "").strip()
if not worker_id:
raise ValueError("worker_id is required")
duration = int(lease_seconds or self.default_lease_seconds)
if duration < 5:
raise ValueError("lease_seconds must be >= 5")
with self.store.lock:
self._requeue_expired_locked()
candidates = [
job
for job in self.store.jobs.values()
if job.tenant_id == tenant_id
and job.cell_id == cell_id
and job.profile_id == profile_id
and job.state == HermesJobState.QUEUED
]
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
if not candidates:
return None
current = sorted(
candidates,
key=lambda item: (item.priority, item.created_at, item.job_id),
)[0]
claimed = self._transition_locked(
current,
HermesJobState.CLAIMED,
assigned_worker_id=worker_id,
lease_expires_at=self._format(
datetime.now(timezone.utc) + timedelta(seconds=duration)
),
)
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
return claimed
def start(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
expected_revision: int | None = None,
) -> HermesJob:
with self.store.lock:
current = self._owned_job_locked(job_id, tenant_id, worker_id)
self._assert_revision(current, expected_revision)
return self._transition_locked(current, HermesJobState.RUNNING)
def heartbeat(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
lease_seconds: int | None = None,
) -> HermesJob:
duration = int(lease_seconds or self.default_lease_seconds)
with self.store.lock:
current = self._owned_job_locked(job_id, tenant_id, worker_id)
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
raise HermesConflict("job does not accept heartbeat")
updated = replace(
current,
revision=current.revision + 1,
lease_expires_at=self._format(
datetime.now(timezone.utc) + timedelta(seconds=duration)
),
updated_at=utc_now(),
)
self.store.jobs[job_id] = updated
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
return updated
def complete(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
output: Mapping[str, object],
expected_revision: int | None = None,
) -> HermesResult:
return self._finish(
job_id,
tenant_id=tenant_id,
worker_id=worker_id,
state=HermesJobState.COMPLETED,
output=output,
expected_revision=expected_revision,
)
def fail(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
error: Mapping[str, object],
expected_revision: int | None = None,
) -> HermesResult:
return self._finish(
job_id,
tenant_id=tenant_id,
worker_id=worker_id,
state=HermesJobState.FAILED,
error=error,
expected_revision=expected_revision,
)
def cancel(
self,
job_id: str,
*,
tenant_id: str,
expected_revision: int | None = None,
) -> HermesResult:
with self.store.lock:
current = self.store.jobs.get(job_id)
if current is None:
raise HermesNotFound("job not found")
self._assert_tenant(current, tenant_id)
self._assert_revision(current, expected_revision)
if current.state in TERMINAL_STATES:
result = self.store.results.get(job_id)
if result is None:
raise HermesConflict("terminal result missing")
return result
updated = self._transition_locked(current, HermesJobState.CANCELLED)
result = HermesResult(
job_id=updated.job_id,
tenant_id=updated.tenant_id,
task_id=updated.task_id,
trace_id=updated.trace_id,
state=HermesJobState.CANCELLED,
worker_id=updated.assigned_worker_id,
error={"code": "cancelled", "message": "cancelled by AIOS"},
finished_at=utc_now(),
)
self.store.results[job_id] = result
return result
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
with self.store.lock:
job = self.store.jobs.get(job_id)
if job is None:
raise HermesNotFound("job not found")
self._assert_tenant(job, tenant_id)
result = self.store.results.get(job_id)
if result is None:
raise HermesConflict("job has no terminal result")
return result
def health(self) -> HermesHealth:
with self.store.lock:
self._requeue_expired_locked()
active_jobs = sum(
1 for job in self.store.jobs.values()
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
)
queued_jobs = sum(
1 for job in self.store.jobs.values()
if job.state == HermesJobState.QUEUED
)
partitions = {
(job.tenant_id, job.cell_id, job.profile_id)
for job in self.store.jobs.values()
}
active_workers = sum(
1 for heartbeat, _ in self.store.worker_heartbeats.values()
if self._parse(heartbeat)
and self._parse(heartbeat) >= datetime.now(timezone.utc) - timedelta(seconds=120)
)
return HermesHealth(
runtime_id=self.runtime_id,
status="healthy",
version=self.runtime_version,
heartbeat_at=utc_now(),
active_workers=active_workers,
active_jobs=active_jobs,
queued_jobs=queued_jobs,
tenant_partitions=len(partitions),
profile_isolation_enforced=True,
details={"store": type(self.store).__name__},
)
def _finish(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
state: HermesJobState,
output: Mapping[str, object] | None = None,
error: Mapping[str, object] | None = None,
expected_revision: int | None,
) -> HermesResult:
with self.store.lock:
current = self._owned_job_locked(job_id, tenant_id, worker_id)
self._assert_revision(current, expected_revision)
updated = self._transition_locked(current, state)
result = HermesResult(
job_id=updated.job_id,
tenant_id=updated.tenant_id,
task_id=updated.task_id,
trace_id=updated.trace_id,
state=state,
worker_id=worker_id,
output=output,
error=error,
started_at=current.updated_at,
finished_at=utc_now(),
)
self.store.results[job_id] = result
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
return result
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
current = self.store.jobs.get(job_id)
if current is None:
raise HermesNotFound("job not found")
self._assert_tenant(current, tenant_id)
if current.assigned_worker_id != worker_id:
raise HermesConflict("worker does not own job lease")
return current
@staticmethod
def _assert_revision(job: HermesJob, expected: int | None) -> None:
if expected is not None and job.revision != expected:
raise HermesConflict("job revision changed")
def _transition_locked(
self,
current: HermesJob,
target: HermesJobState,
*,
assigned_worker_id: str | None = None,
lease_expires_at: str | None = None,
) -> HermesJob:
if target not in ALLOWED_TRANSITIONS[current.state]:
raise HermesConflict(
f"invalid transition {current.state.value} -> {target.value}"
)
updated = replace(
current,
state=target,
revision=current.revision + 1,
assigned_worker_id=(
assigned_worker_id
if assigned_worker_id is not None
else current.assigned_worker_id
),
lease_expires_at=(
lease_expires_at
if lease_expires_at is not None
else (None if target in TERMINAL_STATES else current.lease_expires_at)
),
updated_at=utc_now(),
)
self.store.jobs[current.job_id] = updated
return updated
def _requeue_expired_locked(self) -> None:
now = datetime.now(timezone.utc)
for current in list(self.store.jobs.values()):
expiry = self._parse(current.lease_expires_at)
if (
current.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
and expiry is not None
and expiry <= now
):
self.store.jobs[current.job_id] = replace(
current,
state=HermesJobState.QUEUED,
revision=current.revision + 1,
assigned_worker_id=None,
lease_expires_at=None,
updated_at=utc_now(),
)
+153
View File
@@ -0,0 +1,153 @@
"""Tenant-scoped Hermes runtime contracts."""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Mapping
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
class HermesJobState(str, Enum):
QUEUED = "QUEUED"
CLAIMED = "CLAIMED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
HermesJobState.RUNNING: frozenset(
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
),
HermesJobState.COMPLETED: frozenset(),
HermesJobState.FAILED: frozenset(),
HermesJobState.CANCELLED: frozenset(),
}
TERMINAL_STATES = frozenset(
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
)
@dataclass(frozen=True)
class HermesJob:
job_id: str
tenant_id: str
cell_id: str
profile_id: str
task_id: str
trace_id: str
action: str
payload: Mapping[str, Any] = field(default_factory=dict)
idempotency_key: str = ""
priority: int = 100
state: HermesJobState = HermesJobState.QUEUED
revision: int = 1
assigned_worker_id: str | None = None
lease_expires_at: str | None = None
created_at: str = field(default_factory=utc_now)
updated_at: str = field(default_factory=utc_now)
def __post_init__(self) -> None:
for name in (
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
):
object.__setattr__(self, name, _required(getattr(self, name), name))
object.__setattr__(self, "payload", _safe_mapping(self.payload))
object.__setattr__(self, "state", HermesJobState(self.state))
if not 0 <= int(self.priority) <= 1000:
raise ValueError("priority must be between 0 and 1000")
if self.revision < 1:
raise ValueError("revision must be >= 1")
@classmethod
def create(
cls,
*,
tenant_id: str,
cell_id: str,
profile_id: str,
task_id: str,
trace_id: str,
action: str,
payload: Mapping[str, Any] | None,
idempotency_key: str,
priority: int = 100,
) -> "HermesJob":
return cls(
job_id=f"hermes_{uuid.uuid4().hex}",
tenant_id=tenant_id,
cell_id=cell_id,
profile_id=profile_id,
task_id=task_id,
trace_id=trace_id,
action=action,
payload=_safe_mapping(payload),
idempotency_key=idempotency_key,
priority=priority,
)
@dataclass(frozen=True)
class HermesResult:
job_id: str
tenant_id: str
task_id: str
trace_id: str
state: HermesJobState
worker_id: str | None = None
output: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
started_at: str | None = None
finished_at: str | None = None
def __post_init__(self) -> None:
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
object.__setattr__(self, name, _required(getattr(self, name), name))
object.__setattr__(self, "state", HermesJobState(self.state))
if self.output is not None:
object.__setattr__(self, "output", _safe_mapping(self.output))
if self.error is not None:
object.__setattr__(self, "error", _safe_mapping(self.error))
@dataclass(frozen=True)
class HermesHealth:
runtime_id: str
status: str
version: str
heartbeat_at: str
active_workers: int = 0
active_jobs: int = 0
queued_jobs: int = 0
tenant_partitions: int = 0
profile_isolation_enforced: bool = True
details: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("runtime_id", "status", "version", "heartbeat_at"):
object.__setattr__(self, name, _required(getattr(self, name), name))
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
if int(getattr(self, name)) < 0:
raise ValueError(f"{name} must be >= 0")
object.__setattr__(self, "details", _safe_mapping(self.details))
+155
View File
@@ -0,0 +1,155 @@
"""Hermes worker loop with lease renewal and bounded recovery."""
from __future__ import annotations
import threading
from dataclasses import dataclass
from typing import Any, Callable, Mapping
from .adapter import HermesAdapter, HermesConflict
from .contract import HermesJob, HermesJobState
JobHandler = Callable[[HermesJob], Mapping[str, Any]]
@dataclass(frozen=True)
class WorkerRunResult:
claimed: bool
job_id: str | None = None
state: str | None = None
error: str | None = None
class HermesWorker:
"""Poll one tenant/cell/profile partition and execute jobs safely."""
def __init__(
self,
adapter: HermesAdapter,
handler: JobHandler,
*,
worker_id: str,
tenant_id: str,
cell_id: str,
profile_id: str,
poll_seconds: float = 1.0,
heartbeat_seconds: float = 10.0,
) -> None:
if poll_seconds <= 0 or heartbeat_seconds <= 0:
raise ValueError("poll_seconds and heartbeat_seconds must be positive")
for name, value in {
"worker_id": worker_id,
"tenant_id": tenant_id,
"cell_id": cell_id,
"profile_id": profile_id,
}.items():
if not str(value or "").strip():
raise ValueError(f"{name} is required")
self.adapter = adapter
self.handler = handler
self.worker_id = worker_id
self.tenant_id = tenant_id
self.cell_id = cell_id
self.profile_id = profile_id
self.poll_seconds = float(poll_seconds)
self.heartbeat_seconds = float(heartbeat_seconds)
self._stop = threading.Event()
def stop(self) -> None:
self._stop.set()
def run_once(self) -> WorkerRunResult:
job = self.adapter.claim(
tenant_id=self.tenant_id,
cell_id=self.cell_id,
profile_id=self.profile_id,
worker_id=self.worker_id,
)
if job is None:
return WorkerRunResult(claimed=False)
running = self.adapter.start(
job.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
expected_revision=job.revision,
)
heartbeat_stop = threading.Event()
heartbeat_thread = threading.Thread(
target=self._heartbeat_loop,
args=(running.job_id, heartbeat_stop),
name=f"hermes-heartbeat-{self.worker_id}",
daemon=True,
)
heartbeat_thread.start()
output: Mapping[str, Any] | None = None
execution_error: Exception | None = None
try:
output = dict(self.handler(running))
except Exception as exc: # runtime boundary: normalize below
execution_error = exc
finally:
# Stop the lease heartbeat before reading the final revision. This
# prevents a revision increment racing with complete/fail.
heartbeat_stop.set()
heartbeat_thread.join(timeout=max(self.heartbeat_seconds * 2, 1.0))
try:
current = self.adapter.get(running.job_id, tenant_id=self.tenant_id)
if execution_error is None:
result = self.adapter.complete(
running.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
output=dict(output or {}),
expected_revision=current.revision,
)
return WorkerRunResult(
claimed=True,
job_id=result.job_id,
state=result.state.value,
)
result = self.adapter.fail(
running.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
error={
"code": "worker_error",
"message": str(execution_error),
},
expected_revision=current.revision,
)
return WorkerRunResult(
claimed=True,
job_id=result.job_id,
state=result.state.value,
error=str(execution_error),
)
except HermesConflict as exc:
return WorkerRunResult(
claimed=True,
job_id=running.job_id,
state=HermesJobState.FAILED.value,
error=f"reconciliation_conflict: {exc}",
)
def run_forever(self) -> None:
while not self._stop.is_set():
result = self.run_once()
if not result.claimed:
self._stop.wait(self.poll_seconds)
def _heartbeat_loop(self, job_id: str, stop_event: threading.Event) -> None:
while not stop_event.wait(self.heartbeat_seconds):
try:
self.adapter.heartbeat(
job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
)
except Exception:
stop_event.set()
return
@@ -1,179 +0,0 @@
"""Task-scoped hardened wrapper around the existing Action API."""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import aios_chatgpt_actions_api as api
import aios_isolated_writer_policy as policy
_reject = api.reject_non_read_http
_write_file = api.write_file
_sync_branch = api.sync_branch
def reject_non_read_http(handler, method: str, path: str) -> bool:
if _reject(handler, method, path):
return True
try:
policy.validate_endpoint(method, path, api.WS, api.BARE_REPO)
return False
except PermissionError as exc:
handler.sendj({"error": str(exc)}, 403)
return True
def checkout(branch, base="main", create=False):
api.ensure_git()
target = api.branch_name(branch)
policy.validate_branch_request({"branch": target, "base": base})
exists = api.sh(["rev-parse", "--verify", target])["code"] == 0
if exists:
if api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"] != target:
api.sh(["checkout", target], True)
elif create:
api.sh(["checkout", "-b", target, base], True)
else:
raise PermissionError("isolated_writer_branch_missing")
policy.require_service_invariants(api.WS, api.BARE_REPO)
return target
def write_file(path, content):
policy.validate_path(path, api.WS)
return _write_file(path, content)
def sync_branch(branch: str):
policy.validate_sync_branch(branch)
local = _sync_branch(branch)
remote = os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", "").strip()
if remote != "gitea-http":
raise PermissionError("isolated_writer_gitea_remote_rejected")
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
pushed = subprocess.run(
["git", "push", "--porcelain", remote, f"HEAD:refs/heads/{branch}"],
cwd=api.WS,
text=True,
capture_output=True,
)
if pushed.returncode != 0:
raise RuntimeError("isolated_writer_gitea_push_failed")
verified = subprocess.run(
["git", "ls-remote", "--heads", remote, f"refs/heads/{branch}"],
cwd=api.WS,
text=True,
capture_output=True,
)
if verified.returncode != 0:
raise RuntimeError("isolated_writer_gitea_verification_failed")
fields = verified.stdout.strip().split()
if len(fields) != 2 or fields[0] != head or fields[1] != f"refs/heads/{branch}":
raise RuntimeError("isolated_writer_gitea_oid_mismatch")
return {
"status": "verified",
"target": "authoritative-bare+gitea",
"branch": branch,
"oid": head,
"local": local.get("status"),
}
def commit(paths, msg):
policy.validate_commit_paths(paths, api.WS)
api.ensure_git()
selected = [api.relpath(p) for p in paths] if paths else ["."]
api.sh(["add", *selected], True)
policy.validate_staged_paths(api.WS)
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
if not api.sh(["status", "--short"])["stdout"]:
return {"status": "no_changes", "branch": branch, "sync": sync_branch(branch)}
out = api.sh(["commit", "-m", msg], True)
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
return {
"status": "committed",
"branch": branch,
"rev": api.sh(["rev-parse", "--short", "HEAD"], True)["stdout"],
"git": out,
"sync": sync_branch(branch),
}
def audit(actor, action, target, payload):
policy.append_audit_record(
{
"at": api.now(),
"actor": actor,
"action": action,
"target": target,
"payload": api.sanitize(payload),
}
)
def _validate_request(self, data, validate):
try:
validate(data)
return True
except PermissionError as exc:
self.sendj({"error": str(exc)}, 403)
return False
def _checked_branch(self, data):
if not _validate_request(self, data, policy.validate_branch_request):
return
return _branch(self, data)
def _checked_file(self, data):
data = dict(data)
data.setdefault("branch", policy.allowed_branch())
if not _validate_request(self, data, policy.validate_branch_request):
return
if not _validate_request(
self,
{"path": data.get("path", "")},
lambda item: policy.validate_path(item["path"], api.WS),
):
return
return _file(self, data)
def _checked_commit(self, data):
data = dict(data)
data.setdefault("branch", policy.allowed_branch())
if not _validate_request(self, data, policy.validate_branch_request):
return
return _commit(self, data)
def _checked_patch(self, data):
data = dict(data)
data["branch"] = policy.allowed_branch()
if not _validate_request(self, data, policy.validate_branch_request):
return
return _patch(self, data)
_branch, _file, _commit, _patch = api.H.branch, api.H.file, api.H.commit, api.H.patch
api.reject_non_read_http = reject_non_read_http
api.checkout, api.write_file, api.sync_branch, api.commit, api.audit = (
checkout,
write_file,
sync_branch,
commit,
audit,
)
api.H.branch, api.H.file, api.H.commit, api.H.patch = (
_checked_branch,
_checked_file,
_checked_commit,
_checked_patch,
)
if __name__ == "__main__":
policy.require_service_invariants(api.WS, api.BARE_REPO)
api.ThreadingHTTPServer((api.HOST, api.PORT), api.H).serve_forever()
@@ -1,280 +0,0 @@
"""Fail-closed policy used only by the task-scoped Action writer."""
from __future__ import annotations
import os
import json
import stat
import subprocess
import re
from pathlib import Path, PurePosixPath, PureWindowsPath
from urllib.parse import unquote
WRITE_ENDPOINTS = frozenset({
"/api/branch", "/api/create-branch", "/api/file", "/api/commit-file",
"/api/commit", "/api/patch", "/api/create-patch",
})
ALLOWED_ROOTS = (
"active-runtime",
"docs",
"patches",
"reports",
"scripts",
"test",
"tests",
)
DENIED_ROOTS = ("baseline", "config", "deploy", "deployments", "ops", "runtime", "services")
_TRUE = frozenset({"1", "true", "yes", "on"})
_FALSE = frozenset({"0", "false", "no", "off"})
_DENY_FLAGS = (
"AIOS_GITOPS_ALLOW_MAIN_WRITE",
"AIOS_GITOPS_ALLOW_TAG_WRITE",
"AIOS_GITOPS_ALLOW_BASELINE_WRITE",
"AIOS_GITOPS_ALLOW_REGISTRY_WRITE",
"AIOS_GITOPS_ALLOW_ROLLBACK",
"AIOS_GITOPS_ALLOW_DEPLOYMENT",
)
def enforcement_required() -> bool:
return os.getenv("AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT", "") == "required"
def allowed_branch() -> str:
value = os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", "").strip()
if not value:
raise PermissionError("isolated_writer_missing_allowed_branch")
return value
def allowed_base_oid() -> str:
value = os.getenv("AIOS_GITOPS_ALLOWED_BASE_OID", "").strip().lower()
if len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value):
raise PermissionError("isolated_writer_invalid_allowed_base_oid")
return value
def _git(workspace: Path, *args: str) -> str:
result = subprocess.run(["git", *args], cwd=workspace, text=True, capture_output=True)
if result.returncode != 0:
raise PermissionError("isolated_writer_git_validation_failed")
return result.stdout.strip()
def require_service_invariants(workspace: Path, bare_repo: Path) -> None:
if not enforcement_required():
return
if os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").strip().lower() not in _TRUE:
raise PermissionError("isolated_writer_write_gate_required")
if os.getenv("AIOS_GITOPS_RUNTIME_ROLE", "") != "isolated-writer":
raise PermissionError("isolated_writer_role_required")
if os.getenv("AIOS_GITOPS_WORKSPACE_ROLE", "") != "isolated-writer":
raise PermissionError("isolated_writer_workspace_role_required")
for name in _DENY_FLAGS:
if os.getenv(name, "").strip().lower() not in _FALSE:
raise PermissionError("isolated_writer_forbidden_capability_flag")
if os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "") != "aios/aios":
raise PermissionError("isolated_writer_repository_identity_rejected")
configured_bare_value = os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "").strip()
if not configured_bare_value:
raise PermissionError("isolated_writer_authoritative_repo_rejected")
configured_bare = Path(configured_bare_value)
if configured_bare.resolve(strict=True) != bare_repo.resolve(strict=True):
raise PermissionError("isolated_writer_authoritative_repo_rejected")
writer_root_value = os.getenv("AIOS_GITOPS_APPROVED_WRITER_ROOT", "").strip()
if not writer_root_value:
raise PermissionError("isolated_writer_workspace_outside_approved_root")
writer_root = Path(writer_root_value).resolve(strict=True)
lexical_workspace = Path(os.path.abspath(str(workspace)))
canonical_workspace = lexical_workspace.resolve(strict=True)
if lexical_workspace != canonical_workspace:
raise PermissionError("isolated_writer_workspace_symlink_rejected")
try:
canonical_workspace.relative_to(writer_root)
except (ValueError, FileNotFoundError):
raise PermissionError("isolated_writer_workspace_outside_approved_root") from None
if _git(workspace, "rev-parse", "--abbrev-ref", "HEAD") != allowed_branch():
raise PermissionError("isolated_writer_current_branch_rejected")
approved_base = allowed_base_oid()
try:
_git(workspace, "cat-file", "-e", f"{approved_base}^{{commit}}")
subprocess.run(
["git", "merge-base", "--is-ancestor", approved_base, "HEAD"],
cwd=workspace,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except (PermissionError, subprocess.CalledProcessError):
raise PermissionError("isolated_writer_approved_base_not_ancestor") from None
if _git(workspace, "merge-base", approved_base, "HEAD") != approved_base:
raise PermissionError("isolated_writer_merge_base_rejected")
common = Path(_git(workspace, "rev-parse", "--git-common-dir"))
if not common.is_absolute():
common = (workspace / common).resolve()
if common.resolve() != bare_repo.resolve():
raise PermissionError("isolated_writer_common_repo_rejected")
def approved_audit_log_path() -> Path:
root_value = os.getenv("AIOS_GITOPS_APPROVED_AUDIT_ROOT", "").strip()
log_value = os.getenv("AIOS_GITOPS_AUDIT_LOG", "").strip()
if not root_value or not log_value:
raise PermissionError("isolated_writer_audit_path_missing")
lexical_root = Path(os.path.abspath(root_value))
lexical_log = Path(os.path.abspath(log_value))
root = lexical_root.resolve(strict=True)
if lexical_root != root or root.is_symlink() or not root.is_dir():
raise PermissionError("isolated_writer_audit_root_rejected")
try:
lexical_log.relative_to(root)
except ValueError:
raise PermissionError("isolated_writer_audit_path_outside_approved_root") from None
if lexical_log.parent.resolve(strict=True) != lexical_log.parent:
raise PermissionError("isolated_writer_audit_parent_symlink_rejected")
if lexical_log.exists() and lexical_log.is_symlink():
raise PermissionError("isolated_writer_audit_log_symlink_rejected")
os.chmod(root, 0o750)
os.chmod(lexical_log.parent, 0o750)
return lexical_log
def append_audit_record(record: dict) -> None:
path = approved_audit_log_path()
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o600)
try:
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode):
raise PermissionError("isolated_writer_audit_log_not_regular")
os.fchmod(descriptor, 0o600)
line = json.dumps(
_audit_sanitize(record),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8") + b"\n"
os.write(descriptor, line)
os.fsync(descriptor)
finally:
os.close(descriptor)
parent_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(parent_fd)
finally:
os.close(parent_fd)
def _audit_sanitize(value):
if isinstance(value, dict):
return {
key: (
"[REDACTED]"
if any(
marker in str(key).lower()
for marker in (
"authorization",
"credential",
"password",
"secret",
"token",
"private_key",
)
)
else _audit_sanitize(inner)
)
for key, inner in value.items()
}
if isinstance(value, list):
return [_audit_sanitize(item) for item in value]
if isinstance(value, str) and re.search(
r"(?i)(authorization\s*:\s*bearer|(?:token|password|secret)\s*[:=])",
value,
):
return "[REDACTED]"
return value
def validate_endpoint(method: str, path: str, workspace: Path, bare_repo: Path) -> None:
if not enforcement_required():
return
if method != "POST" or path not in WRITE_ENDPOINTS:
raise PermissionError("isolated_writer_operation_forbidden")
require_service_invariants(workspace, bare_repo)
def validate_branch_request(data: dict) -> None:
if not enforcement_required():
return
branch = allowed_branch() if "branch" not in data else str(data.get("branch") or "").strip()
if branch != allowed_branch():
raise PermissionError("isolated_writer_branch_rejected")
if str(data.get("base", "main")).strip() != "main":
raise PermissionError("isolated_writer_base_rejected")
if data.get("force") or data.get("force_push"):
raise PermissionError("isolated_writer_force_rejected")
def validate_path(path: str, workspace: Path | None = None) -> str:
if not isinstance(path, str):
raise PermissionError("isolated_writer_path_rejected")
value = path
if not value or value != value.strip() or "\x00" in value or "\\" in value:
raise PermissionError("isolated_writer_path_rejected")
try:
decoded = unquote(value, errors="strict")
except (UnicodeDecodeError, ValueError):
raise PermissionError("isolated_writer_path_rejected") from None
if decoded != value:
raise PermissionError("isolated_writer_path_rejected")
windows = PureWindowsPath(value)
posix = PurePosixPath(value)
parts = value.split("/")
if (
posix.is_absolute()
or windows.is_absolute()
or bool(windows.drive)
or any(part in {"", ".", ".."} for part in parts)
):
raise PermissionError("isolated_writer_path_rejected")
first = parts[0]
if first in DENIED_ROOTS:
raise PermissionError("isolated_writer_protected_path_rejected")
if value != "README.md" and first not in ALLOWED_ROOTS:
raise PermissionError("isolated_writer_path_outside_allowed_roots")
if workspace is not None:
lexical_root = Path(os.path.abspath(str(workspace)))
root = lexical_root.resolve(strict=True)
if lexical_root != root:
raise PermissionError("isolated_writer_workspace_symlink_rejected")
current = root
for part in parts:
current = current / part
if current.is_symlink():
raise PermissionError("isolated_writer_path_symlink_rejected")
candidate = (root / value).resolve(strict=False)
try:
candidate.relative_to(root)
except ValueError:
raise PermissionError("isolated_writer_path_escapes_workspace") from None
return value
def validate_commit_paths(paths: list[str], workspace: Path | None = None) -> None:
if enforcement_required():
for path in paths:
if str(path).strip() != ".":
validate_path(str(path), workspace)
def validate_staged_paths(workspace: Path) -> None:
if not enforcement_required():
return
for path in _git(workspace, "diff", "--cached", "--name-only").splitlines():
validate_path(path, workspace)
def validate_sync_branch(branch: str) -> None:
if enforcement_required() and branch != allowed_branch():
raise PermissionError("isolated_writer_sync_branch_rejected")
@@ -0,0 +1,37 @@
"""AIOS-managed OpenClaw execution adapter."""
from .adapter import (
AdapterError,
CommandConflict,
CommandNotFound,
CommandRejected,
InMemoryCallbackSink,
OpenClawAdapter,
)
from .contract import (
CommandState,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
sign_command,
verify_command_signature,
)
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
__all__ = [
"AdapterError",
"CommandConflict",
"CommandNotFound",
"CommandRejected",
"CommandState",
"HTTPRuntimeTransport",
"InMemoryCallbackSink",
"InProcessRuntimeTransport",
"OpenClawAdapter",
"OpenClawCommand",
"OpenClawResult",
"RuntimeHealth",
"RuntimeTransport",
"sign_command",
"verify_command_signature",
]
+228
View File
@@ -0,0 +1,228 @@
"""Signed, tenant-isolated execution adapter for OpenClaw runtimes."""
from __future__ import annotations
import threading
from dataclasses import replace
from typing import Callable, Mapping, Protocol
from .contract import (
CommandState,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
utc_now,
verify_command_signature,
)
class AdapterError(RuntimeError):
"""Base adapter failure."""
class CommandRejected(AdapterError):
"""A command failed signature or contract validation."""
class CommandNotFound(AdapterError):
"""A tenant-scoped command is not visible."""
class CommandConflict(AdapterError):
"""A command or idempotency key conflicts with an existing execution."""
class RuntimeTransport(Protocol):
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
def cancel(self, command_id: str) -> bool: ...
def health(self) -> RuntimeHealth: ...
class CallbackSink(Protocol):
def on_started(self, result: OpenClawResult) -> None: ...
def on_completed(self, result: OpenClawResult) -> None: ...
def on_failed(self, result: OpenClawResult) -> None: ...
class InMemoryCallbackSink:
def __init__(self) -> None:
self._lock = threading.RLock()
self.started: list[OpenClawResult] = []
self.completed: list[OpenClawResult] = []
self.failed: list[OpenClawResult] = []
def on_started(self, result: OpenClawResult) -> None:
with self._lock:
self.started.append(result)
def on_completed(self, result: OpenClawResult) -> None:
with self._lock:
self.completed.append(result)
def on_failed(self, result: OpenClawResult) -> None:
with self._lock:
self.failed.append(result)
SigningMaterialResolver = Callable[[str, str], bytes | str]
class OpenClawAdapter:
"""Verify, de-duplicate, execute, cancel and reconcile commands."""
def __init__(
self,
transport: RuntimeTransport,
signing_material_resolver: SigningMaterialResolver,
*,
callback_sink: CallbackSink | None = None,
default_timeout_seconds: float = 300.0,
) -> None:
if default_timeout_seconds <= 0:
raise ValueError("default_timeout_seconds must be positive")
self.transport = transport
self.signing_material_resolver = signing_material_resolver
self.callback_sink = callback_sink or InMemoryCallbackSink()
self.default_timeout_seconds = float(default_timeout_seconds)
self._lock = threading.RLock()
self._commands: dict[str, OpenClawCommand] = {}
self._results: dict[str, OpenClawResult] = {}
self._idempotency: dict[tuple[str, str], tuple[str, str]] = {}
def _verify(self, command: OpenClawCommand) -> None:
try:
key_material = self.signing_material_resolver(command.tenant_id, command.key_id)
except Exception as exc:
raise CommandRejected("signing material unavailable") from exc
if not verify_command_signature(command, key_material):
raise CommandRejected("invalid or expired command signature")
@staticmethod
def _validate_result(command: OpenClawCommand, result: OpenClawResult) -> None:
if (
result.command_id != command.command_id
or result.tenant_id != command.tenant_id
or result.task_id != command.task_id
or result.trace_id != command.trace_id
):
raise AdapterError("runtime returned mismatched command identity")
if result.state not in {
CommandState.COMPLETED,
CommandState.FAILED,
CommandState.CANCELLED,
CommandState.TIMED_OUT,
}:
raise AdapterError("runtime returned a non-terminal result")
def execute(
self,
command: OpenClawCommand,
*,
timeout_seconds: float | None = None,
) -> OpenClawResult:
timeout = self.default_timeout_seconds if timeout_seconds is None else float(timeout_seconds)
if timeout <= 0:
raise ValueError("timeout_seconds must be positive")
self._verify(command)
digest = command.signature
idem_ref = (command.tenant_id, command.idempotency_key)
with self._lock:
prior_command = self._commands.get(command.command_id)
if prior_command is not None:
if prior_command.signature != command.signature:
raise CommandConflict("command_id reused with different content")
return self._results[command.command_id]
prior_idem = self._idempotency.get(idem_ref)
if prior_idem is not None:
prior_id, prior_digest = prior_idem
if prior_digest != digest:
raise CommandConflict("idempotency key reused with different content")
return self._results[prior_id]
running = OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState.RUNNING,
started_at=utc_now(),
)
self._commands[command.command_id] = command
self._results[command.command_id] = running
self._idempotency[idem_ref] = (command.command_id, digest)
self.callback_sink.on_started(running)
try:
result = self.transport.execute(command, timeout)
self._validate_result(command, result)
except TimeoutError as exc:
result = replace(
running,
state=CommandState.TIMED_OUT,
error={"code": "runtime_timeout", "message": str(exc) or "runtime timeout"},
finished_at=utc_now(),
)
except Exception as exc:
result = replace(
running,
state=CommandState.FAILED,
error={"code": "runtime_error", "message": str(exc)},
finished_at=utc_now(),
)
if result.started_at is None:
result = replace(result, started_at=running.started_at)
if result.finished_at is None:
result = replace(result, finished_at=utc_now())
with self._lock:
self._results[command.command_id] = result
if result.state == CommandState.COMPLETED:
self.callback_sink.on_completed(result)
else:
self.callback_sink.on_failed(result)
return result
def status(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
with self._lock:
command = self._commands.get(command_id)
result = self._results.get(command_id)
if command is None or result is None or command.tenant_id != tenant_id:
raise CommandNotFound("command not found")
return result
def cancel(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
with self._lock:
command = self._commands.get(command_id)
current = self._results.get(command_id)
if command is None or current is None or command.tenant_id != tenant_id:
raise CommandNotFound("command not found")
if current.state in {
CommandState.COMPLETED,
CommandState.FAILED,
CommandState.CANCELLED,
CommandState.TIMED_OUT,
CommandState.REJECTED,
}:
return current
if not self.transport.cancel(command_id):
raise AdapterError("runtime rejected cancellation")
cancelled = replace(
current,
state=CommandState.CANCELLED,
error={"code": "cancelled", "message": "cancelled by AIOS"},
finished_at=utc_now(),
)
with self._lock:
self._results[command_id] = cancelled
self.callback_sink.on_failed(cancelled)
return cancelled
def health(self) -> RuntimeHealth:
return self.transport.health()
def snapshot(self, *, tenant_id: str) -> Mapping[str, OpenClawResult]:
with self._lock:
return {
command_id: result
for command_id, result in self._results.items()
if self._commands[command_id].tenant_id == tenant_id
}
@@ -0,0 +1,97 @@
"""Audit-oriented lifecycle callbacks for OpenClaw execution."""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol
from .contract import OpenClawResult, utc_now
class EventPublisher(Protocol):
def publish(self, event: Mapping[str, Any]) -> None: ...
@dataclass(frozen=True)
class ExecutionEvent:
event_type: str
tenant_id: str
task_id: str
trace_id: str
command_id: str
state: str
created_at: str = field(default_factory=utc_now)
details: Mapping[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"event_type": self.event_type,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"trace_id": self.trace_id,
"command_id": self.command_id,
"state": self.state,
"created_at": self.created_at,
"details": json.loads(
json.dumps(dict(self.details), ensure_ascii=False, default=str)
),
}
class InMemoryEventPublisher:
"""Thread-safe publisher used by tests and first integration slice."""
def __init__(self) -> None:
self._lock = threading.RLock()
self.events: list[dict[str, Any]] = []
def publish(self, event: Mapping[str, Any]) -> None:
with self._lock:
self.events.append(
json.loads(json.dumps(dict(event), ensure_ascii=False, default=str))
)
class AuditCallbackSink:
"""Convert runtime callbacks into tenant-scoped audit events."""
def __init__(self, publisher: EventPublisher) -> None:
self.publisher = publisher
@staticmethod
def _event(
event_type: str,
result: OpenClawResult,
) -> ExecutionEvent:
details: dict[str, Any] = {
"runtime_id": result.runtime_id,
"attempts": result.attempts,
}
if result.error:
details["error"] = dict(result.error)
if result.output:
details["output_present"] = True
return ExecutionEvent(
event_type=event_type,
tenant_id=result.tenant_id,
task_id=result.task_id,
trace_id=result.trace_id,
command_id=result.command_id,
state=result.state.value,
details=details,
)
def on_started(self, result: OpenClawResult) -> None:
self.publisher.publish(self._event("OPENCLAW_STARTED", result).as_dict())
def on_completed(self, result: OpenClawResult) -> None:
self.publisher.publish(self._event("OPENCLAW_COMPLETED", result).as_dict())
def on_failed(self, result: OpenClawResult) -> None:
event_type = {
"CANCELLED": "OPENCLAW_CANCELLED",
"TIMED_OUT": "OPENCLAW_TIMED_OUT",
"REJECTED": "OPENCLAW_REJECTED",
}.get(result.state.value, "OPENCLAW_FAILED")
self.publisher.publish(self._event(event_type, result).as_dict())
@@ -0,0 +1,230 @@
"""Versioned AIOS to OpenClaw execution contracts."""
from __future__ import annotations
import base64
import json
import uuid
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Mapping
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
CONTRACT_VERSION = "aios.openclaw.command.v1"
DEFAULT_CLOCK_SKEW_SECONDS = 300
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _parse_timestamp(value: str) -> datetime:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("timestamp is required")
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp must include a timezone")
return parsed.astimezone(timezone.utc)
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
class CommandState(str, Enum):
ACCEPTED = "ACCEPTED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
TIMED_OUT = "TIMED_OUT"
REJECTED = "REJECTED"
@dataclass(frozen=True)
class OpenClawCommand:
command_id: str
tenant_id: str
task_id: str
trace_id: str
action: str
payload: Mapping[str, Any] = field(default_factory=dict)
idempotency_key: str = ""
created_at: str = field(default_factory=utc_now)
expires_at: str | None = None
contract_version: str = CONTRACT_VERSION
key_id: str = "default"
signature: str = ""
def __post_init__(self) -> None:
for field_name in (
"command_id", "tenant_id", "task_id", "trace_id", "action",
"idempotency_key", "created_at", "contract_version", "key_id",
):
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
if self.contract_version != CONTRACT_VERSION:
raise ValueError(f"unsupported contract_version: {self.contract_version}")
object.__setattr__(self, "payload", _safe_mapping(self.payload))
_parse_timestamp(self.created_at)
if self.expires_at is not None:
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
_parse_timestamp(self.expires_at)
@classmethod
def create(
cls,
*,
tenant_id: str,
task_id: str,
trace_id: str,
action: str,
payload: Mapping[str, Any] | None,
idempotency_key: str,
expires_at: str | None = None,
key_id: str = "default",
command_id: str | None = None,
) -> "OpenClawCommand":
return cls(
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
tenant_id=tenant_id,
task_id=task_id,
trace_id=trace_id,
action=action,
payload=_safe_mapping(payload),
idempotency_key=idempotency_key,
expires_at=expires_at,
key_id=key_id,
)
def unsigned_dict(self) -> dict[str, Any]:
return {
"command_id": self.command_id,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"trace_id": self.trace_id,
"action": self.action,
"payload": _safe_mapping(self.payload),
"idempotency_key": self.idempotency_key,
"created_at": self.created_at,
"expires_at": self.expires_at,
"contract_version": self.contract_version,
"key_id": self.key_id,
}
@dataclass(frozen=True)
class OpenClawResult:
command_id: str
tenant_id: str
task_id: str
trace_id: str
state: CommandState
output: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
started_at: str | None = None
finished_at: str | None = None
runtime_id: str | None = None
attempts: int = 1
def __post_init__(self) -> None:
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
object.__setattr__(self, "state", CommandState(self.state))
if self.output is not None:
object.__setattr__(self, "output", _safe_mapping(self.output))
if self.error is not None:
object.__setattr__(self, "error", _safe_mapping(self.error))
if self.started_at is not None:
_parse_timestamp(self.started_at)
if self.finished_at is not None:
_parse_timestamp(self.finished_at)
if self.attempts < 1:
raise ValueError("attempts must be >= 1")
@dataclass(frozen=True)
class RuntimeHealth:
runtime: str
runtime_id: str
status: str
version: str
heartbeat_at: str
active_tasks: int = 0
workspace_isolated: bool = False
credential_ref_configured: bool = False
details: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_name in ("runtime", "runtime_id", "status", "version", "heartbeat_at"):
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
_parse_timestamp(self.heartbeat_at)
if self.active_tasks < 0:
raise ValueError("active_tasks must be >= 0")
object.__setattr__(self, "details", _safe_mapping(self.details))
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
return json.dumps(
command.unsigned_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
def _ed25519_private_key(key_material: bytes | str) -> Ed25519PrivateKey:
material = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
if material.startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(material, password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("signing material is not an Ed25519 private key")
return key
if len(material) != 32:
raise ValueError("Ed25519 raw private signing material must be exactly 32 bytes")
return Ed25519PrivateKey.from_private_bytes(material)
def _decode_signature(value: str) -> bytes:
encoded = value.encode("ascii")
return base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
key = _ed25519_private_key(key_material)
signature = base64.urlsafe_b64encode(key.sign(canonical_command_bytes(command))).decode("ascii").rstrip("=")
return replace(command, signature=signature)
def verify_command_signature(
command: OpenClawCommand,
key_material: bytes | str,
*,
now: datetime | None = None,
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
) -> bool:
if not command.signature:
return False
try:
key = _ed25519_private_key(key_material)
key.public_key().verify(_decode_signature(command.signature), canonical_command_bytes(command))
except (InvalidSignature, ValueError, TypeError, UnicodeError):
return False
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
created = _parse_timestamp(command.created_at)
if created.timestamp() - current.timestamp() > clock_skew_seconds:
return False
if command.expires_at is not None:
expires = _parse_timestamp(command.expires_at)
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
return False
return True
+103
View File
@@ -0,0 +1,103 @@
"""Health policy and self-healing decisions for OpenClaw runtimes."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Mapping
from .contract import RuntimeHealth
def _parse(value: str) -> datetime:
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("heartbeat timestamp must include timezone")
return parsed.astimezone(timezone.utc)
@dataclass(frozen=True)
class HealthPolicy:
max_heartbeat_age_seconds: int = 90
require_workspace_isolation: bool = True
require_credential_ref: bool = True
accepted_statuses: frozenset[str] = field(
default_factory=lambda: frozenset({"healthy", "ready"})
)
def __post_init__(self) -> None:
if self.max_heartbeat_age_seconds < 5:
raise ValueError("max_heartbeat_age_seconds must be >= 5")
if not self.accepted_statuses:
raise ValueError("accepted_statuses must not be empty")
@dataclass(frozen=True)
class HealthAssessment:
healthy: bool
reasons: tuple[str, ...]
recoverable: bool
recommended_action: str
details: Mapping[str, object] = field(default_factory=dict)
class RuntimeHealthMonitor:
"""Evaluate runtime health and recommend bounded recovery actions."""
def __init__(self, policy: HealthPolicy | None = None) -> None:
self.policy = policy or HealthPolicy()
def assess(
self,
health: RuntimeHealth,
*,
now: datetime | None = None,
) -> HealthAssessment:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
heartbeat = _parse(health.heartbeat_at)
age = max(0.0, current.timestamp() - heartbeat.timestamp())
reasons: list[str] = []
if health.status.lower() not in self.policy.accepted_statuses:
reasons.append("runtime_status_unhealthy")
if age > self.policy.max_heartbeat_age_seconds:
reasons.append("heartbeat_stale")
if self.policy.require_workspace_isolation and not health.workspace_isolated:
reasons.append("workspace_not_isolated")
if self.policy.require_credential_ref and not health.credential_ref_configured:
reasons.append("credential_reference_missing")
if not reasons:
return HealthAssessment(
healthy=True,
reasons=(),
recoverable=True,
recommended_action="none",
details={"heartbeat_age_seconds": age},
)
non_recoverable = {
"workspace_not_isolated",
"credential_reference_missing",
}.intersection(reasons)
if non_recoverable:
action = "quarantine_runtime"
recoverable = False
elif "heartbeat_stale" in reasons:
action = "restart_runtime"
recoverable = True
else:
action = "drain_and_restart"
recoverable = True
return HealthAssessment(
healthy=False,
reasons=tuple(reasons),
recoverable=recoverable,
recommended_action=action,
details={
"heartbeat_age_seconds": age,
"runtime_id": health.runtime_id,
"active_tasks": health.active_tasks,
},
)
@@ -0,0 +1,214 @@
"""Runtime transport implementations for the OpenClaw adapter."""
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable, Mapping, Protocol
from .contract import (
CommandState,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
utc_now,
)
class RuntimeTransport(Protocol):
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
def cancel(self, command_id: str) -> bool: ...
def health(self) -> RuntimeHealth: ...
class TransportError(RuntimeError):
"""Runtime transport or protocol failure."""
RuntimeHandler = Callable[[OpenClawCommand], Mapping[str, Any] | OpenClawResult]
class InProcessRuntimeTransport:
"""Deterministic runtime used by tests and controlled local integrations."""
def __init__(
self,
handler: RuntimeHandler,
*,
runtime_id: str = "openclaw-in-process",
version: str = "v1",
workspace_isolated: bool = True,
credential_ref_configured: bool = True,
) -> None:
self.handler = handler
self.runtime_id = runtime_id
self.version = version
self.workspace_isolated = workspace_isolated
self.credential_ref_configured = credential_ref_configured
self._lock = threading.RLock()
self._running: set[str] = set()
self._cancelled: set[str] = set()
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
with self._lock:
if command.command_id in self._cancelled:
return OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState.CANCELLED,
error={"code": "cancelled", "message": "cancelled before execution"},
finished_at=utc_now(),
runtime_id=self.runtime_id,
)
self._running.add(command.command_id)
try:
raw = self.handler(command)
if isinstance(raw, OpenClawResult):
return raw
if not isinstance(raw, Mapping):
raise TransportError("runtime handler must return a mapping or OpenClawResult")
return OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState(str(raw.get("state", "COMPLETED"))),
output=raw.get("output"),
error=raw.get("error"),
started_at=str(raw.get("started_at") or utc_now()),
finished_at=str(raw.get("finished_at") or utc_now()),
runtime_id=str(raw.get("runtime_id") or self.runtime_id),
attempts=int(raw.get("attempts", 1)),
)
finally:
with self._lock:
self._running.discard(command.command_id)
def cancel(self, command_id: str) -> bool:
with self._lock:
self._cancelled.add(command_id)
return command_id in self._running
def health(self) -> RuntimeHealth:
with self._lock:
active = len(self._running)
return RuntimeHealth(
runtime="openclaw",
runtime_id=self.runtime_id,
status="healthy",
version=self.version,
heartbeat_at=utc_now(),
active_tasks=active,
workspace_isolated=self.workspace_isolated,
credential_ref_configured=self.credential_ref_configured,
details={"transport": "in_process"},
)
class HTTPRuntimeTransport:
"""Private HTTP transport for an OpenClaw runtime service."""
def __init__(
self,
base_url: str,
*,
runtime_id: str = "openclaw-http",
credential_ref: str | None = None,
max_response_bytes: int = 2_097_152,
) -> None:
normalized = str(base_url).rstrip("/")
if not normalized.startswith(("http://", "https://")):
raise ValueError("base_url must use http or https")
self.base_url = normalized
self.runtime_id = runtime_id
self.credential_ref = credential_ref
self.max_response_bytes = int(max_response_bytes)
def _request(
self,
method: str,
path: str,
*,
payload: Mapping[str, Any] | None,
timeout_seconds: float,
) -> Mapping[str, Any]:
body = None
headers = {"Accept": "application/json", "User-Agent": "AIOS-OpenClaw/1"}
if payload is not None:
body = json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")
headers["Content-Type"] = "application/json"
if self.credential_ref:
headers["X-AIOS-Credential-Ref"] = self.credential_ref
request = urllib.request.Request(
f"{self.base_url}{path}",
data=body,
method=method,
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
raw = response.read(self.max_response_bytes + 1)
except urllib.error.HTTPError as exc:
raise TransportError(f"runtime returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise TransportError(f"runtime unavailable: {exc.reason}") from exc
if len(raw) > self.max_response_bytes:
raise TransportError("runtime response exceeded size limit")
decoded = json.loads(raw.decode("utf-8"))
if not isinstance(decoded, Mapping):
raise TransportError("runtime response must be a JSON object")
return decoded
@staticmethod
def _command_payload(command: OpenClawCommand) -> dict[str, Any]:
return {**command.unsigned_dict(), "signature": command.signature}
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
value = self._request(
"POST",
"/v1/execute",
payload=self._command_payload(command),
timeout_seconds=timeout_seconds,
)
return OpenClawResult(
command_id=str(value["command_id"]),
tenant_id=str(value["tenant_id"]),
task_id=str(value["task_id"]),
trace_id=str(value["trace_id"]),
state=CommandState(str(value["state"])),
output=value.get("output"),
error=value.get("error"),
started_at=value.get("started_at"),
finished_at=value.get("finished_at"),
runtime_id=str(value.get("runtime_id") or self.runtime_id),
attempts=int(value.get("attempts", 1)),
)
def cancel(self, command_id: str) -> bool:
value = self._request(
"POST",
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel",
payload={},
timeout_seconds=10.0,
)
return bool(value.get("accepted"))
def health(self) -> RuntimeHealth:
value = self._request("GET", "/v1/health", payload=None, timeout_seconds=5.0)
return RuntimeHealth(
runtime=str(value.get("runtime") or "openclaw"),
runtime_id=str(value.get("runtime_id") or self.runtime_id),
status=str(value.get("status") or "unknown"),
version=str(value.get("version") or "unknown"),
heartbeat_at=str(value.get("heartbeat_at") or utc_now()),
active_tasks=int(value.get("active_tasks", 0)),
workspace_isolated=bool(value.get("workspace_isolated", False)),
credential_ref_configured=bool(value.get("credential_ref_configured", False)),
details=value.get("details") if isinstance(value.get("details"), Mapping) else {},
)
+46
View File
@@ -0,0 +1,46 @@
"""AIOS V2 control plane and execution orchestration."""
from .control_plane import (
AgentRecord,
AuditEvent,
ControlPlaneError,
ControlPlaneService,
IdempotencyConflict,
InMemoryControlPlaneStore,
InvalidTransition,
NotFound,
PolicyDenied,
RequestContext,
RevisionConflict,
TaskRecord,
TaskState,
TenantIsolationError,
TenantRecord,
Unauthorized,
)
from .execution_tracking import ExecutionEvidence, ExecutionTracker
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
__all__ = [
"AgentRecord",
"AuditEvent",
"ControlPlaneError",
"ControlPlaneService",
"DispatchRecord",
"ExecutionEvidence",
"ExecutionOrchestrator",
"ExecutionTracker",
"IdempotencyConflict",
"InMemoryControlPlaneStore",
"InvalidTransition",
"NotFound",
"OrchestrationError",
"PolicyDenied",
"RequestContext",
"RevisionConflict",
"TaskRecord",
"TaskState",
"TenantIsolationError",
"TenantRecord",
"Unauthorized",
]
+357
View File
@@ -0,0 +1,357 @@
"""Production-candidate AIOS V2 application entrypoint.
This process owns the public control-plane API and read-only web console. It
loads durable control-plane state, dispatches approved work through runtime
adapters, exposes tenant-scoped execution evidence and sanitized GitOps status,
and never exposes runtime credentials to the browser.
"""
from __future__ import annotations
import dataclasses
import json
import os
import stat
import urllib.parse
from enum import Enum
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Mapping
from aios_hermes import HermesAdapter, HermesJobState, HermesResult
from aios_openclaw import HTTPRuntimeTransport, OpenClawAdapter
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
from .console import CONSOLE_HTML
from .control_plane import AuditEvent, RequestContext
from .execution_tracking import ExecutionTracker
from .gateway import GatewayApplication, error_response
from .gitops_status import collect_gitops_status
from .orchestrator import ExecutionOrchestrator
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
def _json_value(value: Any) -> Any:
if dataclasses.is_dataclass(value):
return {
item.name: _json_value(getattr(value, item.name))
for item in dataclasses.fields(value)
}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_value(item) for item in value]
return value
class FileSigningMaterialResolver:
"""Resolve runtime signing material from a root-owned file reference."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
def __call__(self, tenant_id: str, key_id: str) -> bytes:
del tenant_id, key_id
metadata = self.path.stat()
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("signing material reference is not a regular file")
if metadata.st_mode & 0o077:
raise RuntimeError("signing material file permissions must be 0600 or stricter")
value = self.path.read_bytes()
if len(value) < 32:
raise RuntimeError("signing material is too short")
return value
class ControlPlaneEventPublisher(EventPublisher):
"""Append OpenClaw runtime events to the tenant audit stream."""
def __init__(self, service: DurableControlPlaneService) -> None:
self.service = service
def publish(self, event: Mapping[str, Any]) -> None:
tenant_id = str(event.get("tenant_id") or "").strip()
trace_id = str(event.get("trace_id") or "").strip()
command_id = str(event.get("command_id") or "").strip()
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
if not tenant_id or not trace_id or not command_id:
raise ValueError("runtime audit event is missing tenant identity")
state_value = str(event.get("state") or "unknown").lower()
outcome = "allowed" if state_value == "completed" else state_value
audit = AuditEvent(
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
tenant_id=tenant_id,
principal_id="openclaw-runtime",
trace_id=trace_id,
action=event_type,
resource_type="openclaw_command",
resource_id=command_id,
outcome=outcome,
policy_version="runtime-v1",
details=dict(event.get("details") or {}),
)
with self.service.store.lock:
self.service.store.audit_events.append(audit)
self.service.repository.save(self.service._snapshot())
class CandidateApplication:
"""Compose durable control plane, runtimes, evidence and console routes."""
def __init__(
self,
gateway: GatewayApplication,
orchestrator: ExecutionOrchestrator,
tracker: ExecutionTracker,
hermes: HermesAdapter,
openclaw: OpenClawAdapter | None,
) -> None:
self.gateway = gateway
self.orchestrator = orchestrator
self.tracker = tracker
self.hermes = hermes
self.openclaw = openclaw
def context(self, headers: Mapping[str, str]) -> RequestContext:
return self.gateway.context_from_headers(headers)
def handle_json(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if method == "GET" and path == "/api/v2/gitops":
context = self.context(headers)
return HTTPStatus.OK, {
"tenant_id": context.tenant_id,
"gitops": collect_gitops_status().as_dict(),
}
if method == "GET" and path == "/api/v2/runtime":
context = self.context(headers)
runtimes: list[dict[str, Any]] = []
if self.openclaw is None:
runtimes.append(
{
"name": "openclaw",
"status": "disabled",
"details": {"reason": "runtime_not_configured"},
}
)
else:
try:
health = self.openclaw.health()
runtimes.append(
{
"name": "openclaw",
"status": health.status,
"details": _json_value(health),
}
)
except Exception as exc:
runtimes.append(
{
"name": "openclaw",
"status": "unavailable",
"details": {"message": str(exc)},
}
)
hermes_health = self.hermes.health()
runtimes.append(
{
"name": "hermes",
"status": hermes_health.status,
"details": _json_value(hermes_health),
}
)
return HTTPStatus.OK, {
"tenant_id": context.tenant_id,
"runtimes": runtimes,
}
if method == "GET" and path == "/api/v2/executions":
context = self.context(headers)
return HTTPStatus.OK, {
"executions": _json_value(
self.tracker.list(tenant_id=context.tenant_id)
)
}
dispatch_match = __import__("re").fullmatch(
r"/api/v2/tasks/([^/]+)/dispatch",
path,
)
if method == "POST" and dispatch_match:
context = self.context(headers)
task_id = urllib.parse.unquote(dispatch_match.group(1))
payload = body if isinstance(body, Mapping) else {}
timeout = payload.get("timeout_seconds")
record = self.orchestrator.dispatch(
context,
task_id=task_id,
timeout_seconds=float(timeout) if timeout is not None else None,
)
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
context = self.context(headers)
if not isinstance(body, Mapping):
raise ValueError("JSON body must be an object")
result = HermesResult(
job_id=str(body["job_id"]),
tenant_id=str(body["tenant_id"]),
task_id=str(body["task_id"]),
trace_id=str(body["trace_id"]),
state=HermesJobState(str(body["state"])),
worker_id=(
str(body["worker_id"])
if body.get("worker_id") is not None
else None
),
output=body.get("output"),
error=body.get("error"),
started_at=body.get("started_at"),
finished_at=body.get("finished_at"),
)
task = self.orchestrator.reconcile_hermes(context, result)
return HTTPStatus.OK, {"task": _json_value(task)}
return self.gateway.handle(method, raw_path, headers, body)
class CandidateRequestHandler(BaseHTTPRequestHandler):
server_version = "AIOSV2Candidate/1.0"
max_body_bytes = 1_048_576
@property
def application(self) -> CandidateApplication:
return self.server.application # type: ignore[attr-defined]
def _read_body(self) -> Any:
length = int(self.headers.get("Content-Length", "0") or "0")
if length < 0 or length > self.max_body_bytes:
raise ValueError("request body is too large")
if length == 0:
return {}
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
if content_type != "application/json":
raise ValueError("Content-Type must be application/json")
return json.loads(self.rfile.read(length).decode("utf-8"))
def _security_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
)
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(
_json_value(payload),
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
self.send_response(int(status_code))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self._security_headers()
self.end_headers()
self.wfile.write(encoded)
def _send_console(self) -> None:
encoded = CONSOLE_HTML.encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self._security_headers()
self.end_headers()
self.wfile.write(encoded)
def _dispatch(self, method: str) -> None:
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
if method == "GET" and path in {"/", "/console"}:
self._send_console()
return
try:
body = self._read_body() if method == "POST" else {}
status_code, payload = self.application.handle_json(
method,
self.path,
{key: value for key, value in self.headers.items()},
body,
)
except BaseException as exc:
status_code, payload = error_response(exc)
self._send_json(status_code, payload)
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
class CandidateHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
self.application = application
super().__init__(address, CandidateRequestHandler)
def build_application() -> CandidateApplication:
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
state_dir.mkdir(parents=True, exist_ok=True)
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
service = DurableControlPlaneService(repository)
tracker = ExecutionTracker(state_dir / "executions")
hermes = HermesAdapter()
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
material_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
openclaw = None
resolver = None
if openclaw_url and material_file:
resolver = FileSigningMaterialResolver(material_file)
transport = HTTPRuntimeTransport(
openclaw_url,
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
)
openclaw = OpenClawAdapter(
transport,
resolver,
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
)
gateway = GatewayApplication(service)
orchestrator = ExecutionOrchestrator(
service,
openclaw=openclaw,
hermes=hermes,
openclaw_signing_material_resolver=resolver,
)
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
def main() -> None:
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_PORT", "18920"))
server = CandidateHTTPServer((host, port), build_application())
print(f"AIOS V2 candidate listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
"""Dependency-free AIOS V2 web console assets.
Authentication and tenant identity are injected by the trusted reverse proxy.
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
"""
CONSOLE_HTML = r'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>AIOS V2 Console</title>
<style>
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
</style>
</head>
<body>
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
<main>
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
<section class="cards">
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
</section>
<section class="grid">
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
</section>
</main>
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
async function refresh(){
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
try{
const [agents,tasks,audit,runtime,executions]=await Promise.all([
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
]);
document.getElementById('agentCount').textContent=agents.agents.length;
document.getElementById('taskCount').textContent=tasks.tasks.length;
document.getElementById('auditCount').textContent=audit.events.length;
document.getElementById('executionCount').textContent=executions.executions.length;
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
}
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
</script>
</body>
</html>'''
+627
View File
@@ -0,0 +1,627 @@
"""Tenant-isolated AIOS V2 control-plane domain service.
This module deliberately contains no OpenClaw or Hermes implementation details.
It owns authoritative tenant, agent, task-lifecycle, idempotency and audit state.
Runtime adapters consume approved tasks through a separate boundary.
"""
from __future__ import annotations
import copy
import hashlib
import json
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Callable, Iterable, Mapping
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex}"
def _canonical_digest(value: Mapping[str, Any]) -> str:
encoded = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
class ControlPlaneError(RuntimeError):
"""Base error returned by the control-plane domain."""
class Unauthorized(ControlPlaneError):
"""The caller is not authenticated or lacks the required role."""
class TenantIsolationError(ControlPlaneError):
"""A request attempted to cross a tenant boundary."""
class NotFound(ControlPlaneError):
"""A tenant-scoped resource does not exist or is not visible."""
class InvalidTransition(ControlPlaneError):
"""A task state transition violates the lifecycle contract."""
class IdempotencyConflict(ControlPlaneError):
"""An idempotency key was reused for a different command."""
class RevisionConflict(ControlPlaneError):
"""Optimistic task revision does not match current state."""
class PolicyDenied(ControlPlaneError):
"""The policy gate denied a control-plane operation."""
@dataclass(frozen=True)
class RequestContext:
"""Authenticated request identity propagated across all AIOS layers."""
tenant_id: str
principal_id: str
trace_id: str
roles: frozenset[str] = field(default_factory=frozenset)
session_id: str | None = None
policy_version: str = "v1"
def __post_init__(self) -> None:
for name in ("tenant_id", "principal_id", "trace_id"):
if not str(getattr(self, name, "")).strip():
raise ValueError(f"{name} is required")
@classmethod
def create(
cls,
*,
tenant_id: str,
principal_id: str,
roles: Iterable[str] = (),
trace_id: str | None = None,
session_id: str | None = None,
policy_version: str = "v1",
) -> "RequestContext":
return cls(
tenant_id=tenant_id.strip(),
principal_id=principal_id.strip(),
trace_id=trace_id or _new_id("trace"),
roles=frozenset(str(role).strip() for role in roles if str(role).strip()),
session_id=session_id,
policy_version=policy_version,
)
@dataclass(frozen=True)
class TenantRecord:
tenant_id: str
display_name: str
status: str = "active"
metadata: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class AgentRecord:
agent_id: str
tenant_id: str
display_name: str
runtime: str
capabilities: tuple[str, ...] = field(default_factory=tuple)
enabled: bool = True
metadata: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
class TaskState(str, Enum):
CREATED = "CREATED"
APPROVED = "APPROVED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
TERMINAL_TASK_STATES = frozenset(
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
)
ALLOWED_TASK_TRANSITIONS: Mapping[TaskState, frozenset[TaskState]] = {
TaskState.CREATED: frozenset({TaskState.APPROVED, TaskState.CANCELLED}),
TaskState.APPROVED: frozenset({TaskState.RUNNING, TaskState.CANCELLED}),
TaskState.RUNNING: frozenset(
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
),
TaskState.COMPLETED: frozenset(),
TaskState.FAILED: frozenset(),
TaskState.CANCELLED: frozenset(),
}
@dataclass(frozen=True)
class TaskRecord:
task_id: str
tenant_id: str
agent_id: str
action: str
payload: Mapping[str, Any]
state: TaskState = TaskState.CREATED
revision: int = 1
idempotency_key: str | None = None
request_digest: str = ""
result: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
created_by: str = ""
approved_by: str | None = None
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class AuditEvent:
event_id: str
tenant_id: str
principal_id: str
trace_id: str
action: str
resource_type: str
resource_id: str
outcome: str
policy_version: str
details: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class PolicyDecision:
allowed: bool
reason: str
policy_version: str
PolicyEvaluator = Callable[[RequestContext, str, Mapping[str, Any]], PolicyDecision]
class InMemoryControlPlaneStore:
"""Thread-safe reference store used by tests and the first gateway slice.
Production backends must preserve the same tenant-scoped interface and add
database row-level security, migrations, backup and HA evidence.
"""
def __init__(self) -> None:
self.lock = threading.RLock()
self.tenants: dict[str, TenantRecord] = {}
self.agents: dict[str, AgentRecord] = {}
self.tasks: dict[str, TaskRecord] = {}
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
self.audit_events: list[AuditEvent] = []
class ControlPlaneService:
"""Authoritative tenant, agent and task lifecycle service."""
def __init__(
self,
store: InMemoryControlPlaneStore | None = None,
*,
policy_evaluator: PolicyEvaluator | None = None,
) -> None:
self.store = store or InMemoryControlPlaneStore()
self.policy_evaluator = policy_evaluator or self._default_policy
@staticmethod
def _default_policy(
context: RequestContext,
action: str,
resource: Mapping[str, Any],
) -> PolicyDecision:
del resource
role_requirements = {
"tenant.register": {"platform_admin"},
"tenant.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"agent.register": {"tenant_admin", "operator", "platform_admin"},
"agent.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"task.create": {"tenant_admin", "operator", "platform_admin"},
"task.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"task.approve": {"tenant_admin", "approver", "platform_admin"},
"task.start": {"runtime", "operator", "platform_admin"},
"task.complete": {"runtime", "operator", "platform_admin"},
"task.fail": {"runtime", "operator", "platform_admin"},
"task.cancel": {"tenant_admin", "operator", "approver", "platform_admin"},
"audit.read": {"tenant_admin", "auditor", "platform_admin"},
}
required = role_requirements.get(action, {"platform_admin"})
allowed = bool(context.roles.intersection(required))
return PolicyDecision(
allowed=allowed,
reason="role_allowed" if allowed else "required_role_missing",
policy_version=context.policy_version,
)
def _require_context(self, context: RequestContext) -> None:
if not context.principal_id or not context.tenant_id or not context.trace_id:
raise Unauthorized("authenticated tenant context is required")
def _authorize(
self,
context: RequestContext,
action: str,
resource: Mapping[str, Any],
) -> PolicyDecision:
self._require_context(context)
decision = self.policy_evaluator(context, action, resource)
if not decision.allowed:
self._audit(
context,
action=action,
resource_type=str(resource.get("resource_type", "policy")),
resource_id=str(resource.get("resource_id", "unknown")),
outcome="denied",
details={"reason": decision.reason},
)
raise PolicyDenied(f"{action} denied: {decision.reason}")
return decision
@staticmethod
def _assert_same_tenant(context: RequestContext, tenant_id: str) -> None:
if context.tenant_id != tenant_id and "platform_admin" not in context.roles:
raise TenantIsolationError("cross-tenant access denied")
def _audit(
self,
context: RequestContext,
*,
action: str,
resource_type: str,
resource_id: str,
outcome: str,
details: Mapping[str, Any] | None = None,
) -> AuditEvent:
event = AuditEvent(
event_id=_new_id("audit"),
tenant_id=context.tenant_id,
principal_id=context.principal_id,
trace_id=context.trace_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
outcome=outcome,
policy_version=context.policy_version,
details=copy.deepcopy(dict(details or {})),
)
self.store.audit_events.append(event)
return event
def register_tenant(
self,
context: RequestContext,
*,
tenant_id: str,
display_name: str,
metadata: Mapping[str, Any] | None = None,
) -> TenantRecord:
tenant_id = tenant_id.strip()
if not tenant_id or not display_name.strip():
raise ValueError("tenant_id and display_name are required")
self._authorize(
context,
"tenant.register",
{"resource_type": "tenant", "resource_id": tenant_id},
)
self._assert_same_tenant(context, tenant_id)
with self.store.lock:
if tenant_id in self.store.tenants:
raise ControlPlaneError(f"tenant already exists: {tenant_id}")
tenant = TenantRecord(
tenant_id=tenant_id,
display_name=display_name.strip(),
metadata=copy.deepcopy(dict(metadata or {})),
)
self.store.tenants[tenant_id] = tenant
self._audit(
context,
action="tenant.register",
resource_type="tenant",
resource_id=tenant_id,
outcome="allowed",
)
return tenant
def get_tenant(self, context: RequestContext, tenant_id: str | None = None) -> TenantRecord:
tenant_id = tenant_id or context.tenant_id
self._authorize(
context,
"tenant.read",
{"resource_type": "tenant", "resource_id": tenant_id},
)
self._assert_same_tenant(context, tenant_id)
with self.store.lock:
tenant = self.store.tenants.get(tenant_id)
if tenant is None:
raise NotFound("tenant not found")
return tenant
def register_agent(
self,
context: RequestContext,
*,
agent_id: str,
display_name: str,
runtime: str,
capabilities: Iterable[str] = (),
metadata: Mapping[str, Any] | None = None,
) -> AgentRecord:
self._authorize(
context,
"agent.register",
{"resource_type": "agent", "resource_id": agent_id},
)
tenant = self.get_tenant(context)
if tenant.status != "active":
raise ControlPlaneError("tenant is not active")
normalized_capabilities = tuple(
sorted({str(item).strip() for item in capabilities if str(item).strip()})
)
if not agent_id.strip() or not display_name.strip() or not runtime.strip():
raise ValueError("agent_id, display_name and runtime are required")
with self.store.lock:
existing = self.store.agents.get(agent_id)
if existing is not None and existing.tenant_id != context.tenant_id:
raise TenantIsolationError("agent id belongs to another tenant")
if existing is not None:
raise ControlPlaneError(f"agent already exists: {agent_id}")
agent = AgentRecord(
agent_id=agent_id.strip(),
tenant_id=context.tenant_id,
display_name=display_name.strip(),
runtime=runtime.strip(),
capabilities=normalized_capabilities,
metadata=copy.deepcopy(dict(metadata or {})),
)
self.store.agents[agent.agent_id] = agent
self._audit(
context,
action="agent.register",
resource_type="agent",
resource_id=agent.agent_id,
outcome="allowed",
details={"runtime": agent.runtime},
)
return agent
def get_agent(self, context: RequestContext, agent_id: str) -> AgentRecord:
self._authorize(
context,
"agent.read",
{"resource_type": "agent", "resource_id": agent_id},
)
with self.store.lock:
agent = self.store.agents.get(agent_id)
if agent is None:
raise NotFound("agent not found")
self._assert_same_tenant(context, agent.tenant_id)
return agent
def list_agents(self, context: RequestContext) -> list[AgentRecord]:
self._authorize(
context,
"agent.read",
{"resource_type": "agent_collection", "resource_id": context.tenant_id},
)
with self.store.lock:
return [
agent
for agent in self.store.agents.values()
if agent.tenant_id == context.tenant_id
]
def create_task(
self,
context: RequestContext,
*,
agent_id: str,
action: str,
payload: Mapping[str, Any] | None = None,
idempotency_key: str | None = None,
) -> TaskRecord:
self._authorize(
context,
"task.create",
{"resource_type": "task", "resource_id": idempotency_key or "new"},
)
agent = self.get_agent(context, agent_id)
if not agent.enabled:
raise ControlPlaneError("agent is disabled")
if action not in agent.capabilities and "*" not in agent.capabilities:
raise PolicyDenied(f"agent does not advertise capability: {action}")
normalized_payload = copy.deepcopy(dict(payload or {}))
request_digest = _canonical_digest(
{"agent_id": agent_id, "action": action, "payload": normalized_payload}
)
normalized_key = idempotency_key.strip() if idempotency_key else None
with self.store.lock:
if normalized_key:
idempotency_ref = (context.tenant_id, normalized_key)
existing = self.store.idempotency.get(idempotency_ref)
if existing is not None:
existing_task_id, existing_digest = existing
if existing_digest != request_digest:
raise IdempotencyConflict(
"idempotency key was reused with a different request"
)
return self.store.tasks[existing_task_id]
task = TaskRecord(
task_id=_new_id("task"),
tenant_id=context.tenant_id,
agent_id=agent_id,
action=action,
payload=normalized_payload,
idempotency_key=normalized_key,
request_digest=request_digest,
created_by=context.principal_id,
)
self.store.tasks[task.task_id] = task
if normalized_key:
self.store.idempotency[(context.tenant_id, normalized_key)] = (
task.task_id,
request_digest,
)
self._audit(
context,
action="task.create",
resource_type="task",
resource_id=task.task_id,
outcome="allowed",
details={"agent_id": agent_id, "action": action},
)
return task
def get_task(self, context: RequestContext, task_id: str) -> TaskRecord:
self._authorize(
context,
"task.read",
{"resource_type": "task", "resource_id": task_id},
)
with self.store.lock:
task = self.store.tasks.get(task_id)
if task is None:
raise NotFound("task not found")
self._assert_same_tenant(context, task.tenant_id)
return task
def list_tasks(
self,
context: RequestContext,
*,
states: Iterable[TaskState | str] | None = None,
) -> list[TaskRecord]:
self._authorize(
context,
"task.read",
{"resource_type": "task_collection", "resource_id": context.tenant_id},
)
state_filter = (
{TaskState(item) for item in states}
if states is not None
else None
)
with self.store.lock:
return [
task
for task in self.store.tasks.values()
if task.tenant_id == context.tenant_id
and (state_filter is None or task.state in state_filter)
]
def transition_task(
self,
context: RequestContext,
*,
task_id: str,
target_state: TaskState | str,
expected_revision: int | None = None,
result: Mapping[str, Any] | None = None,
error: Mapping[str, Any] | None = None,
) -> TaskRecord:
target = TaskState(target_state)
action_name = {
TaskState.APPROVED: "task.approve",
TaskState.RUNNING: "task.start",
TaskState.COMPLETED: "task.complete",
TaskState.FAILED: "task.fail",
TaskState.CANCELLED: "task.cancel",
}.get(target)
if action_name is None:
raise InvalidTransition(f"cannot transition to {target.value}")
self._authorize(
context,
action_name,
{"resource_type": "task", "resource_id": task_id},
)
with self.store.lock:
current = self.store.tasks.get(task_id)
if current is None:
raise NotFound("task not found")
self._assert_same_tenant(context, current.tenant_id)
if expected_revision is not None and current.revision != expected_revision:
raise RevisionConflict(
f"expected revision {expected_revision}, current {current.revision}"
)
if target not in ALLOWED_TASK_TRANSITIONS[current.state]:
raise InvalidTransition(
f"invalid transition {current.state.value} -> {target.value}"
)
if target == TaskState.COMPLETED and result is None:
raise ValueError("completed task requires result")
if target == TaskState.FAILED and error is None:
raise ValueError("failed task requires error")
updated = TaskRecord(
task_id=current.task_id,
tenant_id=current.tenant_id,
agent_id=current.agent_id,
action=current.action,
payload=current.payload,
state=target,
revision=current.revision + 1,
idempotency_key=current.idempotency_key,
request_digest=current.request_digest,
result=copy.deepcopy(dict(result)) if result is not None else current.result,
error=copy.deepcopy(dict(error)) if error is not None else current.error,
created_by=current.created_by,
approved_by=(
context.principal_id
if target == TaskState.APPROVED
else current.approved_by
),
created_at=current.created_at,
updated_at=_utc_now(),
)
self.store.tasks[task_id] = updated
self._audit(
context,
action=action_name,
resource_type="task",
resource_id=task_id,
outcome="allowed",
details={
"from": current.state.value,
"to": target.value,
"revision": updated.revision,
},
)
return updated
def list_audit_events(
self,
context: RequestContext,
*,
resource_id: str | None = None,
) -> list[AuditEvent]:
self._authorize(
context,
"audit.read",
{"resource_type": "audit", "resource_id": resource_id or context.tenant_id},
)
with self.store.lock:
return [
event
for event in self.store.audit_events
if event.tenant_id == context.tenant_id
and (resource_id is None or event.resource_id == resource_id)
]
@@ -0,0 +1,195 @@
"""Git-backed agent execution evidence for AIOS V2.
Evidence is tenant-scoped, append-friendly, and contains no runtime credentials.
It provides a deterministic source for claim, heartbeat, progress, result and
verification state across Codex, OpenClaw and Hermes executors.
"""
from __future__ import annotations
import json
import os
import threading
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, default=str))
@dataclass(frozen=True)
class ExecutionEvidence:
execution_id: str
tenant_id: str
task_id: str
agent_id: str
executor: str
branch: str
status: str = "claimed"
progress_completed: int = 0
progress_total: int = 1
current_step: str = "claim"
last_commit: str | None = None
test_status: str = "not_run"
test_summary: Mapping[str, Any] = field(default_factory=dict)
result_summary: Mapping[str, Any] = field(default_factory=dict)
claimed_at: str = field(default_factory=utc_now)
heartbeat_at: str = field(default_factory=utc_now)
completed_at: str | None = None
def __post_init__(self) -> None:
for name in (
"execution_id",
"tenant_id",
"task_id",
"agent_id",
"executor",
"branch",
"status",
"current_step",
"claimed_at",
"heartbeat_at",
):
object.__setattr__(self, name, _required(getattr(self, name), name))
if self.progress_completed < 0 or self.progress_total < 1:
raise ValueError("invalid progress values")
if self.progress_completed > self.progress_total:
raise ValueError("progress_completed cannot exceed progress_total")
object.__setattr__(self, "test_summary", _safe(self.test_summary))
object.__setattr__(self, "result_summary", _safe(self.result_summary))
class ExecutionTracker:
"""Persist one JSON evidence record per tenant and execution."""
def __init__(self, root: str | Path) -> None:
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = _required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ValueError("invalid evidence path component")
return normalized
def _path(self, tenant_id: str, execution_id: str) -> Path:
tenant = self._component(tenant_id)
execution = self._component(execution_id)
return self.root / tenant / f"{execution}.json"
def claim(self, evidence: ExecutionEvidence) -> ExecutionEvidence:
path = self._path(evidence.tenant_id, evidence.execution_id)
with self._lock:
if path.exists():
current = self._read(path)
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
raise RuntimeError("execution_id already belongs to another task")
return current
self._write(path, evidence)
return evidence
def heartbeat(
self,
*,
tenant_id: str,
execution_id: str,
status: str | None = None,
current_step: str | None = None,
progress_completed: int | None = None,
progress_total: int | None = None,
last_commit: str | None = None,
) -> ExecutionEvidence:
path = self._path(tenant_id, execution_id)
with self._lock:
current = self._read(path)
updated = replace(
current,
status=status or current.status,
current_step=current_step or current.current_step,
progress_completed=(
progress_completed
if progress_completed is not None
else current.progress_completed
),
progress_total=(
progress_total if progress_total is not None else current.progress_total
),
last_commit=last_commit or current.last_commit,
heartbeat_at=utc_now(),
)
self._write(path, updated)
return updated
def complete(
self,
*,
tenant_id: str,
execution_id: str,
status: str,
test_status: str,
test_summary: Mapping[str, Any] | None = None,
result_summary: Mapping[str, Any] | None = None,
last_commit: str | None = None,
) -> ExecutionEvidence:
path = self._path(tenant_id, execution_id)
with self._lock:
current = self._read(path)
updated = replace(
current,
status=_required(status, "status"),
current_step="complete",
progress_completed=current.progress_total,
last_commit=last_commit or current.last_commit,
test_status=_required(test_status, "test_status"),
test_summary=_safe(test_summary),
result_summary=_safe(result_summary),
heartbeat_at=utc_now(),
completed_at=utc_now(),
)
self._write(path, updated)
return updated
def get(self, *, tenant_id: str, execution_id: str) -> ExecutionEvidence:
return self._read(self._path(tenant_id, execution_id))
def list(self, *, tenant_id: str) -> list[ExecutionEvidence]:
tenant_dir = self.root / self._component(tenant_id)
if not tenant_dir.exists():
return []
records = [self._read(path) for path in tenant_dir.glob("*.json")]
return sorted(records, key=lambda item: (item.heartbeat_at, item.execution_id), reverse=True)
@staticmethod
def _read(path: Path) -> ExecutionEvidence:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise KeyError("execution evidence not found") from exc
if not isinstance(value, Mapping):
raise RuntimeError("invalid execution evidence file")
return ExecutionEvidence(**dict(value))
@staticmethod
def _write(path: Path, evidence: ExecutionEvidence) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
+357
View File
@@ -0,0 +1,357 @@
"""HTTP/JSON gateway for the AIOS V2 control plane.
The gateway is intentionally runtime-neutral: browsers and external clients
call this API, while OpenClaw and Hermes remain behind authenticated adapters.
Identity headers are accepted only from a trusted reverse proxy in v1; direct
public exposure is prohibited until signed identity middleware is enabled.
"""
from __future__ import annotations
import dataclasses
import json
import os
import re
import urllib.parse
from enum import Enum
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Mapping
from .control_plane import (
ControlPlaneError,
ControlPlaneService,
IdempotencyConflict,
InvalidTransition,
NotFound,
PolicyDenied,
RequestContext,
RevisionConflict,
TaskState,
TenantIsolationError,
Unauthorized,
)
TENANT_HEADER = "X-AIOS-Tenant-ID"
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
ROLES_HEADER = "X-AIOS-Roles"
TRACE_HEADER = "X-AIOS-Trace-ID"
SESSION_HEADER = "X-AIOS-Session-ID"
POLICY_HEADER = "X-AIOS-Policy-Version"
AUTHENTICATED_HEADER = "X-AIOS-Authenticated"
_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
def _json_value(value: Any) -> Any:
if dataclasses.is_dataclass(value):
return {
field.name: _json_value(getattr(value, field.name))
for field in dataclasses.fields(value)
}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_value(item) for item in value]
return value
def _require_mapping(value: Any, *, label: str = "JSON body") -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ValueError(f"{label} must be an object")
return value
def _require_resource_id(value: Any, *, label: str) -> str:
normalized = str(value or "").strip()
if not _RESOURCE_ID_RE.fullmatch(normalized):
raise ValueError(f"invalid {label}")
return normalized
class GatewayApplication:
"""Pure request router that can be tested without a listening socket."""
def __init__(self, service: ControlPlaneService | None = None) -> None:
self.service = service or ControlPlaneService()
@staticmethod
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
# HTTP field names are case-insensitive. BaseHTTPRequestHandler commonly
# presents them as title-cased (for example ``X-Aios-Authenticated``),
# so copying the message into a plain dict must not make identity lookup
# case-sensitive.
normalized_headers = {
str(key).strip().lower(): value for key, value in headers.items()
}
def header(name: str, default: str = "") -> str:
return str(normalized_headers.get(name.lower(), default))
authenticated = header(AUTHENTICATED_HEADER).strip().lower()
if authenticated not in {"1", "true", "yes"}:
raise Unauthorized("trusted authentication assertion is required")
roles = [
role.strip()
for role in header(ROLES_HEADER).split(",")
if role.strip()
]
return RequestContext.create(
tenant_id=header(TENANT_HEADER),
principal_id=header(PRINCIPAL_HEADER),
trace_id=header(TRACE_HEADER).strip() or None,
session_id=header(SESSION_HEADER).strip() or None,
policy_version=header(POLICY_HEADER, "v1").strip() or "v1",
roles=roles,
)
def handle(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any = None,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
method = method.upper()
if method == "GET" and path in {"/healthz", "/api/v2/health"}:
return HTTPStatus.OK, {
"service": "aios-v2-control-plane",
"status": "ok",
"store": type(self.service.store).__name__,
}
context = self.context_from_headers(headers)
payload = _require_mapping(body or {})
if method == "POST" and path == "/api/v2/tenants":
tenant = self.service.register_tenant(
context,
tenant_id=_require_resource_id(payload.get("tenant_id"), label="tenant_id"),
display_name=str(payload.get("display_name", "")),
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
)
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
if method == "GET" and path == "/api/v2/tenants/current":
tenant = self.service.get_tenant(context)
return HTTPStatus.OK, {"tenant": _json_value(tenant)}
if method == "POST" and path == "/api/v2/agents":
agent = self.service.register_agent(
context,
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
display_name=str(payload.get("display_name", "")),
runtime=str(payload.get("runtime", "")),
capabilities=payload.get("capabilities", ()),
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
)
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
if method == "GET" and path == "/api/v2/agents":
return HTTPStatus.OK, {
"agents": _json_value(self.service.list_agents(context))
}
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
if method == "GET" and agent_match:
agent_id = _require_resource_id(
urllib.parse.unquote(agent_match.group(1)), label="agent_id"
)
return HTTPStatus.OK, {
"agent": _json_value(self.service.get_agent(context, agent_id))
}
if method == "POST" and path == "/api/v2/tasks":
task = self.service.create_task(
context,
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
action=str(payload.get("action", "")).strip(),
payload=_require_mapping(payload.get("payload", {}), label="payload"),
idempotency_key=(
str(payload["idempotency_key"]).strip()
if payload.get("idempotency_key") is not None
else None
),
)
return HTTPStatus.CREATED, {"task": _json_value(task)}
if method == "GET" and path == "/api/v2/tasks":
requested_states = query.get("state")
tasks = self.service.list_tasks(context, states=requested_states)
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
if method == "GET" and task_match:
task_id = _require_resource_id(
urllib.parse.unquote(task_match.group(1)), label="task_id"
)
return HTTPStatus.OK, {
"task": _json_value(self.service.get_task(context, task_id))
}
transition_match = re.fullmatch(
r"/api/v2/tasks/([^/]+)/(approve|start|complete|fail|cancel)", path
)
if method == "POST" and transition_match:
task_id = _require_resource_id(
urllib.parse.unquote(transition_match.group(1)), label="task_id"
)
transition = transition_match.group(2)
target = {
"approve": TaskState.APPROVED,
"start": TaskState.RUNNING,
"complete": TaskState.COMPLETED,
"fail": TaskState.FAILED,
"cancel": TaskState.CANCELLED,
}[transition]
expected_revision = payload.get("expected_revision")
if expected_revision is not None:
expected_revision = int(expected_revision)
task = self.service.transition_task(
context,
task_id=task_id,
target_state=target,
expected_revision=expected_revision,
result=(
_require_mapping(payload.get("result"), label="result")
if payload.get("result") is not None
else None
),
error=(
_require_mapping(payload.get("error"), label="error")
if payload.get("error") is not None
else None
),
)
return HTTPStatus.OK, {"task": _json_value(task)}
if method == "GET" and path == "/api/v2/audit":
resource_id = query.get("resource_id", [None])[0]
events = self.service.list_audit_events(
context,
resource_id=(
_require_resource_id(resource_id, label="resource_id")
if resource_id
else None
),
)
return HTTPStatus.OK, {"events": _json_value(events)}
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "route not found",
}
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
(Unauthorized, HTTPStatus.UNAUTHORIZED),
(PolicyDenied, HTTPStatus.FORBIDDEN),
(TenantIsolationError, HTTPStatus.NOT_FOUND),
(NotFound, HTTPStatus.NOT_FOUND),
(IdempotencyConflict, HTTPStatus.CONFLICT),
(RevisionConflict, HTTPStatus.CONFLICT),
(InvalidTransition, HTTPStatus.CONFLICT),
(ValueError, HTTPStatus.BAD_REQUEST),
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
)
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
for error_type, status in _ERROR_STATUS:
if isinstance(exc, error_type):
return status, {
"error": error_type.__name__,
"message": str(exc),
}
return HTTPStatus.INTERNAL_SERVER_ERROR, {
"error": "InternalServerError",
"message": "internal server error",
}
class GatewayRequestHandler(BaseHTTPRequestHandler):
server_version = "AIOSV2Gateway/1.0"
max_body_bytes = 1_048_576
@property
def application(self) -> GatewayApplication:
return self.server.application # type: ignore[attr-defined]
def _read_body(self) -> Any:
length = int(self.headers.get("Content-Length", "0") or "0")
if length < 0 or length > self.max_body_bytes:
raise ValueError("request body is too large")
if length == 0:
return {}
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
if content_type != "application/json":
raise ValueError("Content-Type must be application/json")
raw = self.rfile.read(length)
return json.loads(raw.decode("utf-8"))
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(
_json_value(payload), ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
self.send_response(int(status))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(encoded)
def _dispatch(self, method: str) -> None:
try:
body = self._read_body() if method in {"POST", "PUT", "PATCH"} else {}
status, payload = self.application.handle(
method,
self.path,
{key: value for key, value in self.headers.items()},
body,
)
except BaseException as exc: # handler boundary; sanitized below
status, payload = error_response(exc)
self._send_json(status, payload)
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
def log_message(self, format: str, *args: Any) -> None:
# Production deployment routes structured access logs through the
# reverse proxy; do not log identity headers or request bodies here.
super().log_message(format, *args)
class GatewayHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(
self,
server_address: tuple[str, int],
application: GatewayApplication,
) -> None:
self.application = application
super().__init__(server_address, GatewayRequestHandler)
def main() -> None:
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
server = GatewayHTTPServer((host, port), GatewayApplication())
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
"""Sanitized GitOps runtime status for the AIOS Web control plane."""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Mapping
@dataclass(frozen=True)
class GitOpsStatus:
available: bool
service_status: str
workspace_mode: str
workspace_branch: str | None
workspace_ref: str | None
workspace_dirty: bool | None
candidate_branch: str
release_channel: str
release_version: str
sync_status: str
details: Mapping[str, Any]
def as_dict(self) -> dict[str, Any]:
return asdict(self)
def _run_git(worktree: Path, *args: str) -> tuple[int, str, str]:
process = subprocess.run(
["git", "-C", str(worktree), *args],
text=True,
capture_output=True,
timeout=5,
)
return process.returncode, process.stdout.strip(), process.stderr.strip()
def collect_gitops_status() -> GitOpsStatus:
"""Return only non-sensitive Git/workspace metadata.
Remote URLs, environment values, credentials, filesystem contents and
command stderr are deliberately excluded from the Web response.
"""
candidate_branch = os.getenv(
"AIOS_CANDIDATE_BRANCH",
"integration/aios-v2r1-cto-production-candidate-20260720",
)
release_channel = os.getenv("AIOS_RELEASE_CHANNEL", "candidate")
release_version = os.getenv("AIOS_RELEASE_VERSION", "V2R1C0-candidate")
sync_status = os.getenv("AIOS_GITOPS_SYNC_STATUS", "unknown")
worktree = Path(
os.getenv(
"AIOS_GITOPS_CONTROLLER_WORKTREE",
"/data/aios-gitops/worktrees/controller",
)
)
if not worktree.is_dir():
return GitOpsStatus(
available=False,
service_status="unknown",
workspace_mode="unavailable",
workspace_branch=None,
workspace_ref=None,
workspace_dirty=None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={"reason": "controller_worktree_unavailable"},
)
inside_code, inside, _ = _run_git(worktree, "rev-parse", "--is-inside-work-tree")
if inside_code != 0 or inside != "true":
return GitOpsStatus(
available=False,
service_status="unknown",
workspace_mode="invalid",
workspace_branch=None,
workspace_ref=None,
workspace_dirty=None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={"reason": "controller_worktree_invalid"},
)
branch_code, branch, _ = _run_git(worktree, "branch", "--show-current")
ref_code, ref_value, _ = _run_git(worktree, "rev-parse", "HEAD")
dirty_code, dirty_value, _ = _run_git(worktree, "status", "--porcelain=v1")
service_status = "unknown"
try:
process = subprocess.run(
["systemctl", "is-active", "aios-gitops-model-api"],
text=True,
capture_output=True,
timeout=3,
)
service_status = process.stdout.strip() or "inactive"
except (FileNotFoundError, subprocess.TimeoutExpired):
service_status = "unavailable"
branch_value = branch if branch_code == 0 and branch else None
return GitOpsStatus(
available=True,
service_status=service_status,
workspace_mode="branch" if branch_value else "detached",
workspace_branch=branch_value,
workspace_ref=ref_value if ref_code == 0 else None,
workspace_dirty=bool(dirty_value) if dirty_code == 0 else None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={
"controller_clean": dirty_code == 0 and not dirty_value,
"controller_detached": branch_code == 0 and not branch,
},
)
+264
View File
@@ -0,0 +1,264 @@
"""AIOS V2 execution orchestrator.
The orchestrator is the only component allowed to translate an approved AIOS
task into an OpenClaw command or Hermes job. OpenClaw and Hermes remain pure
execution runtimes and never own tenant policy or task lifecycle.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping
from aios_hermes import HermesAdapter, HermesJob, HermesResult
from aios_openclaw import (
CommandState,
OpenClawAdapter,
OpenClawCommand,
OpenClawResult,
sign_command,
)
from .control_plane import (
AgentRecord,
ControlPlaneService,
RequestContext,
TaskRecord,
TaskState,
)
class OrchestrationError(RuntimeError):
"""AIOS failed to dispatch or reconcile a runtime execution."""
@dataclass(frozen=True)
class DispatchRecord:
tenant_id: str
task_id: str
agent_id: str
runtime: str
runtime_execution_id: str
state: str
trace_id: str
class ExecutionOrchestrator:
"""Dispatch approved AIOS tasks to runtime adapters and reconcile results."""
def __init__(
self,
control_plane: ControlPlaneService,
*,
openclaw: OpenClawAdapter | None = None,
hermes: HermesAdapter | None = None,
openclaw_signing_material_resolver=None,
openclaw_key_id: str = "default",
default_cell_id: str = "default",
default_profile_id: str = "default",
) -> None:
self.control_plane = control_plane
self.openclaw = openclaw
self.hermes = hermes
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
self.openclaw_key_id = openclaw_key_id
self.default_cell_id = default_cell_id
self.default_profile_id = default_profile_id
self._dispatches: dict[tuple[str, str], DispatchRecord] = {}
def dispatch(
self,
context: RequestContext,
*,
task_id: str,
timeout_seconds: float | None = None,
) -> DispatchRecord:
task = self.control_plane.get_task(context, task_id)
if task.state != TaskState.APPROVED:
raise OrchestrationError("only APPROVED tasks may be dispatched")
agent = self.control_plane.get_agent(context, task.agent_id)
existing = self._dispatches.get((task.tenant_id, task.task_id))
if existing is not None:
return existing
running = self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.RUNNING,
expected_revision=task.revision,
)
runtime = agent.runtime.strip().lower()
try:
if runtime == "openclaw":
record = self._dispatch_openclaw(
context,
agent,
running,
timeout_seconds=timeout_seconds,
)
elif runtime == "hermes":
record = self._dispatch_hermes(context, agent, running)
else:
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
except Exception as exc:
current = self.control_plane.get_task(context, task.task_id)
if current.state == TaskState.RUNNING:
self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.FAILED,
expected_revision=current.revision,
error={"code": "dispatch_failed", "message": str(exc)},
)
raise
self._dispatches[(task.tenant_id, task.task_id)] = record
return record
def _dispatch_openclaw(
self,
context: RequestContext,
agent: AgentRecord,
task: TaskRecord,
*,
timeout_seconds: float | None,
) -> DispatchRecord:
if self.openclaw is None or self.openclaw_signing_material_resolver is None:
raise OrchestrationError("OpenClaw runtime is not configured")
key_material = self.openclaw_signing_material_resolver(
task.tenant_id,
self.openclaw_key_id,
)
command = OpenClawCommand.create(
tenant_id=task.tenant_id,
task_id=task.task_id,
trace_id=context.trace_id,
action=task.action,
payload=task.payload,
idempotency_key=task.idempotency_key or task.task_id,
key_id=self.openclaw_key_id,
)
command = sign_command(command, key_material)
result = self.openclaw.execute(command, timeout_seconds=timeout_seconds)
self.reconcile_openclaw(context, result)
return DispatchRecord(
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
runtime="openclaw",
runtime_execution_id=command.command_id,
state=result.state.value,
trace_id=context.trace_id,
)
def _dispatch_hermes(
self,
context: RequestContext,
agent: AgentRecord,
task: TaskRecord,
) -> DispatchRecord:
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
metadata = dict(agent.metadata)
job = HermesJob.create(
tenant_id=task.tenant_id,
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
task_id=task.task_id,
trace_id=context.trace_id,
action=task.action,
payload=task.payload,
idempotency_key=task.idempotency_key or task.task_id,
priority=int(metadata.get("priority", 100)),
)
queued = self.hermes.enqueue(job)
return DispatchRecord(
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
runtime="hermes",
runtime_execution_id=queued.job_id,
state=queued.state.value,
trace_id=context.trace_id,
)
def reconcile_openclaw(
self,
context: RequestContext,
result: OpenClawResult,
) -> TaskRecord:
task = self.control_plane.get_task(context, result.task_id)
if result.tenant_id != task.tenant_id:
raise OrchestrationError("OpenClaw result tenant mismatch")
if task.state != TaskState.RUNNING:
return task
if result.state == CommandState.COMPLETED:
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.COMPLETED,
expected_revision=task.revision,
result=result.output or {},
)
if result.state in {
CommandState.FAILED,
CommandState.TIMED_OUT,
CommandState.REJECTED,
}:
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.FAILED,
expected_revision=task.revision,
error=result.error or {"code": result.state.value.lower()},
)
if result.state == CommandState.CANCELLED:
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.CANCELLED,
expected_revision=task.revision,
)
return task
def reconcile_hermes(
self,
context: RequestContext,
result: HermesResult,
) -> TaskRecord:
task = self.control_plane.get_task(context, result.task_id)
if result.tenant_id != task.tenant_id:
raise OrchestrationError("Hermes result tenant mismatch")
if task.state != TaskState.RUNNING:
return task
if result.state.value == "COMPLETED":
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.COMPLETED,
expected_revision=task.revision,
result=result.output or {},
)
if result.state.value == "FAILED":
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.FAILED,
expected_revision=task.revision,
error=result.error or {"code": "hermes_failed"},
)
if result.state.value == "CANCELLED":
return self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.CANCELLED,
expected_revision=task.revision,
)
return task
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
return {
task_id: record
for (record_tenant, task_id), record in self._dispatches.items()
if record_tenant == tenant_id
}
+181
View File
@@ -0,0 +1,181 @@
"""Durable single-process persistence for the AIOS V2 control plane.
The candidate uses an atomic SQLite snapshot so a gateway restart does not lose
tenants, agents, task state, idempotency records, or audit events. The domain
service remains storage-agnostic; a later HA backend can replace this module
without changing API or runtime-adapter contracts.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from dataclasses import asdict
from pathlib import Path
from typing import Any, Callable
from .control_plane import (
AgentRecord,
AuditEvent,
ControlPlaneService,
InMemoryControlPlaneStore,
TaskRecord,
TaskState,
TenantRecord,
)
class SQLiteSnapshotRepository:
"""Store one versioned control-plane snapshot in SQLite."""
SCHEMA_VERSION = 1
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=30.0)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS control_plane_snapshot (
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
schema_version INTEGER NOT NULL,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def load(self) -> dict[str, Any] | None:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT schema_version, payload_json FROM control_plane_snapshot WHERE singleton_id = 1"
).fetchone()
if row is None:
return None
schema_version, payload_json = row
if int(schema_version) != self.SCHEMA_VERSION:
raise RuntimeError(
f"unsupported control-plane snapshot version: {schema_version}"
)
decoded = json.loads(str(payload_json))
if not isinstance(decoded, dict):
raise RuntimeError("invalid control-plane snapshot")
return decoded
def save(self, payload: dict[str, Any]) -> None:
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
INSERT INTO control_plane_snapshot (
singleton_id, schema_version, payload_json, updated_at
) VALUES (1, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(singleton_id) DO UPDATE SET
schema_version = excluded.schema_version,
payload_json = excluded.payload_json,
updated_at = CURRENT_TIMESTAMP
""",
(self.SCHEMA_VERSION, encoded),
)
connection.commit()
class DurableControlPlaneService(ControlPlaneService):
"""Control-plane service that persists after every successful mutation."""
def __init__(
self,
repository: SQLiteSnapshotRepository,
*,
policy_evaluator=None,
) -> None:
self.repository = repository
store = InMemoryControlPlaneStore()
self._restore(store, repository.load())
super().__init__(store, policy_evaluator=policy_evaluator)
@staticmethod
def _restore(
store: InMemoryControlPlaneStore,
snapshot: dict[str, Any] | None,
) -> None:
if not snapshot:
return
for item in snapshot.get("tenants", []):
record = TenantRecord(**item)
store.tenants[record.tenant_id] = record
for item in snapshot.get("agents", []):
normalized = dict(item)
normalized["capabilities"] = tuple(normalized.get("capabilities", ()))
record = AgentRecord(**normalized)
store.agents[record.agent_id] = record
for item in snapshot.get("tasks", []):
normalized = dict(item)
normalized["state"] = TaskState(normalized["state"])
record = TaskRecord(**normalized)
store.tasks[record.task_id] = record
store.audit_events.extend(AuditEvent(**item) for item in snapshot.get("audit", []))
for item in snapshot.get("idempotency", []):
store.idempotency[(item["tenant_id"], item["key"])] = (
item["task_id"],
item["digest"],
)
def _snapshot(self) -> dict[str, Any]:
with self.store.lock:
return {
"schema_version": self.repository.SCHEMA_VERSION,
"tenants": [asdict(item) for item in self.store.tenants.values()],
"agents": [asdict(item) for item in self.store.agents.values()],
"tasks": [
{**asdict(item), "state": item.state.value}
for item in self.store.tasks.values()
],
"audit": [asdict(item) for item in self.store.audit_events],
"idempotency": [
{
"tenant_id": tenant_id,
"key": key,
"task_id": task_id,
"digest": digest,
}
for (tenant_id, key), (task_id, digest)
in self.store.idempotency.items()
],
}
def _mutate(self, operation: Callable[[], Any]) -> Any:
with self.store.lock:
result = operation()
self.repository.save(self._snapshot())
return result
def register_tenant(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).register_tenant(*args, **kwargs))
def register_agent(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).register_agent(*args, **kwargs))
def create_task(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).create_task(*args, **kwargs))
def transition_task(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).transition_task(*args, **kwargs))
-355
View File
@@ -1,355 +0,0 @@
"""Fail-closed access to the authoritative bare Git repository."""
from __future__ import annotations
import os
import re
import subprocess
import urllib.parse
from pathlib import Path
from typing import Iterable
_ALLOWED_COMMANDS = frozenset(
{
"cat-file",
"config",
"diff",
"for-each-ref",
"ls-remote",
"merge-base",
"push",
"remote",
"rev-parse",
"show-ref",
"tag",
}
)
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
_MAX_OUTPUT = 1_048_576
_TIMEOUT = 8.0
class AuthoritativeGitError(RuntimeError):
"""A fail-closed authoritative repository or ref query error."""
class AuthoritativeGitNotFound(AuthoritativeGitError):
"""A validated revision does not exist in the authoritative repository."""
class AuthoritativeGitOutputTooLarge(AuthoritativeGitError):
"""A controlled Git query exceeded the response-size boundary."""
def _redact(text: str) -> str:
value = str(text or "")
value = re.sub(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+", r"\1REDACTED", value)
value = re.sub(r"(?i)((?:access_)?token\s*[:=]\s*)\S+", r"\1REDACTED", value)
value = re.sub(r"(?i)(https?://[^/\s:@]+:)[^@\s/]+@", r"\1REDACTED@", value)
return value
def _configured_path(git_dir: str | Path | None = None) -> Path:
raw = str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
if not raw:
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
path = Path(raw)
if not path.is_absolute() or ".." in path.parts:
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE") from exc
if not resolved.is_dir():
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
return resolved
def _run(
git_dir: Path,
args: list[str],
*,
check: bool,
timeout: float = _TIMEOUT,
) -> subprocess.CompletedProcess[str]:
if not args or args[0] not in _ALLOWED_COMMANDS:
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
try:
result = subprocess.run(
["git", "--git-dir", str(git_dir), *args],
text=True,
capture_output=True,
timeout=timeout,
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
if len(result.stdout) + len(result.stderr) > _MAX_OUTPUT:
raise AuthoritativeGitOutputTooLarge("GIT_OUTPUT_LIMIT_EXCEEDED")
if check and result.returncode != 0:
detail = _redact(result.stderr.strip() or result.stdout.strip())
raise AuthoritativeGitError(
"GIT_REF_QUERY_FAILED" + (f": {detail[:160]}" if detail else "")
)
return result
def _repository_identity_from_remote(url: str) -> str:
value = str(url or "").strip().split("?", 1)[0].rstrip("/")
if not value:
return ""
if "://" in value:
value = urllib.parse.urlparse(value).path
elif ":" in value:
value = value.rsplit(":", 1)[1]
value = value.rsplit("@", 1)[-1].strip("/")
value = re.sub(r"\.git$", "", value, flags=re.IGNORECASE)
parts = [part for part in value.split("/") if part]
if len(parts) >= 2:
value = "/".join(parts[-2:])
return value.strip("/").lower()
def verify_repository(
git_dir: str | Path | None = None,
repository_identity: str | None = None,
) -> tuple[Path, str]:
path = _configured_path(git_dir)
expected = str(
repository_identity
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
).strip().strip("/")
if not expected or expected.count("/") != 1:
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
bare = _run(path, ["rev-parse", "--is-bare-repository"], check=True)
if bare.stdout.strip().lower() != "true":
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
remote = _run(path, ["config", "--get", "remote.gitea.url"], check=False)
actual = _repository_identity_from_remote(remote.stdout)
if actual != expected.lower():
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
return path, expected
def run_authoritative_git(
args: list[str],
*,
check: bool = True,
git_dir: str | Path | None = None,
timeout: float = _TIMEOUT,
) -> subprocess.CompletedProcess[str]:
path, _ = verify_repository(git_dir=git_dir)
return _run(path, list(args), check=check, timeout=timeout)
def authoritative_head_oid(
*,
git_dir: str | Path | None = None,
) -> str:
result = run_authoritative_git(["rev-parse", "--verify", "refs/heads/main"], git_dir=git_dir)
oid = result.stdout.strip().lower()
if not _OID_RE.fullmatch(oid):
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
return oid
def authoritative_tags(
*,
git_dir: str | Path | None = None,
) -> list[dict[str, str | None]]:
result = run_authoritative_git(
[
"for-each-ref",
"refs/tags",
"--format=%(refname:strip=2)\t%(objectname)\t%(objecttype)",
],
git_dir=git_dir,
)
tags: list[dict[str, str | None]] = []
for line in result.stdout.splitlines():
name, oid, object_type = (line.split("\t", 2) + ["", ""])[:3]
if not name or not _OID_RE.fullmatch(oid.lower()):
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
peeled_result = run_authoritative_git(
["rev-parse", "--verify", f"refs/tags/{name}^{{commit}}"],
git_dir=git_dir,
)
peeled = peeled_result.stdout.strip().lower()
if not _OID_RE.fullmatch(peeled):
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
tags.append(
{
"name": name,
"tag_oid": oid.lower(),
"object_type": object_type,
"peeled_target_oid": peeled,
}
)
return tags
def authoritative_branches(
*,
git_dir: str | Path | None = None,
) -> list[dict[str, str]]:
result = run_authoritative_git(
[
"for-each-ref",
"refs/heads",
"--format=%(refname:strip=2)\t%(objectname)",
],
git_dir=git_dir,
)
branches: list[dict[str, str]] = []
for line in result.stdout.splitlines():
name, oid = (line.split("\t", 1) + [""])[:2]
if not name or not _OID_RE.fullmatch(oid.lower()):
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
branches.append({"name": name, "oid": oid.lower()})
return branches
def authoritative_commit(
oid: str,
*,
git_dir: str | Path | None = None,
) -> str:
value = str(oid or "").strip().lower()
if not _OID_RE.fullmatch(value):
raise ValueError("commit OID must be a full 40-character value")
result = run_authoritative_git(
["rev-parse", "--verify", f"{value}^{{commit}}"],
git_dir=git_dir,
)
resolved = result.stdout.strip().lower()
if resolved != value:
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
return resolved
def authoritative_lineage(
ancestor: str,
descendant: str,
*,
git_dir: str | Path | None = None,
) -> None:
authoritative_commit(ancestor, git_dir=git_dir)
authoritative_commit(descendant, git_dir=git_dir)
result = run_authoritative_git(
["merge-base", "--is-ancestor", ancestor, descendant],
check=False,
git_dir=git_dir,
)
if result.returncode != 0:
raise AuthoritativeGitError("evidence lineage mismatch")
_SAFE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
def _safe_ref_name(value: str) -> bool:
return bool(
_SAFE_REF_RE.fullmatch(value)
and ".." not in value
and "//" not in value
and "@{" not in value
and not value.endswith(("/", ".", ".lock"))
and not any(part in {"", ".", ".."} for part in value.split("/"))
)
def validate_git_revision(
value: str,
field: str,
*,
git_dir: str | Path | None = None,
) -> str:
revision = str(value or "").strip()
if not revision or revision != str(value or "") or revision.startswith("-"):
raise ValueError(f"invalid {field} revision")
if _OID_RE.fullmatch(revision.lower()):
try:
return authoritative_commit(revision.lower(), git_dir=git_dir)
except AuthoritativeGitError as exc:
raise AuthoritativeGitNotFound(f"{field} revision not found") from exc
if not _safe_ref_name(revision) or any(token in revision for token in ("^", "~", ":", "\\")):
raise ValueError(f"invalid {field} revision")
if revision.startswith("refs/"):
if not revision.startswith(("refs/heads/", "refs/tags/")):
raise ValueError(f"invalid {field} revision")
candidates = [revision]
else:
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
matches: list[str] = []
for candidate in candidates:
result = run_authoritative_git(
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
check=False,
git_dir=git_dir,
)
oid = result.stdout.strip().lower()
if result.returncode == 0 and _OID_RE.fullmatch(oid):
matches.append(oid)
if not matches:
raise AuthoritativeGitNotFound(f"{field} revision not found")
if len(set(matches)) != 1 or len(matches) != 1:
raise ValueError(f"ambiguous {field} revision")
return matches[0]
def authoritative_diff(
from_revision: str,
to_revision: str,
*,
git_dir: str | Path | None = None,
) -> dict[str, object]:
path, _ = verify_repository(git_dir=git_dir)
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
result = run_authoritative_git(args, git_dir=path)
return {
"code": 0,
"stdout": result.stdout,
"stderr": "",
"cmd": ["git", f"--git-dir={path}", *args],
"from_oid": from_oid,
"to_oid": to_oid,
}
def repository_snapshot(
*,
git_dir: str | Path | None = None,
repository_identity: str | None = None,
) -> dict[str, object]:
configured = bool(
str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
)
try:
path, identity = verify_repository(
git_dir=git_dir,
repository_identity=repository_identity,
)
head = authoritative_head_oid(git_dir=path)
return {
"repository_available": True,
"authoritative_git_dir_configured": configured,
"authoritative_git_dir_readable": True,
"authoritative_git_dir": str(path),
"authoritative_head_oid": head,
"repository_identity": identity,
"query_error": None,
}
except (AuthoritativeGitError, ValueError) as exc:
return {
"repository_available": False,
"authoritative_git_dir_configured": configured,
"authoritative_git_dir_readable": False,
"authoritative_git_dir": None,
"authoritative_head_oid": None,
"repository_identity": str(
repository_identity
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
).strip() or None,
"query_error": _redact(str(exc)),
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,564 +0,0 @@
#!/usr/bin/env python3
"""Execution-readiness adapters and the fail-closed Shadow controller service."""
from __future__ import annotations
import argparse
import base64
import dataclasses
import hashlib
import json
import os
import subprocess
import threading
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable, Mapping, Protocol
from urllib.parse import parse_qs, urlparse
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
)
from silent_release_controller import _sanitize, utc_now
from silent_release_hardening import (
BuilderAttestation,
CapabilityAuthorizationError,
ReleaseRecoveryWorker,
SignedApprovalVerifier,
SignedReleasePlanApproval,
SilentReleaseActionService,
TrustedCandidateBundle,
_canonical_json,
_durable_json,
_safe_child,
)
class BuilderSigner(Protocol):
@property
def key_id(self) -> str:
...
def sign(self, payload: bytes) -> bytes:
...
class LocalTestBuilderSigner:
"""Ephemeral in-memory signer. It is forbidden for production execution."""
test_only = True
def __init__(self, key_id: str, private_key: Ed25519PrivateKey):
self._key_id = str(key_id)
self._private_key = private_key
@property
def key_id(self) -> str:
return self._key_id
@classmethod
def generate(
cls, key_id: str = "shadow-builder-test-key"
) -> "LocalTestBuilderSigner":
return cls(key_id, Ed25519PrivateKey.generate())
@property
def public_key(self):
return self._private_key.public_key()
def sign(self, payload: bytes) -> bytes:
return self._private_key.sign(payload)
class ExternalKmsBuilderSigner:
"""Signer facade whose private operation remains in KMS/HSM."""
def __init__(
self,
key_id: str,
credential_reference: str,
sign_callback: Callable[[str, bytes], bytes],
):
if not str(credential_reference).startswith(
("kms://", "hsm://", "secret-manager://")
):
raise ValueError("production builder requires a KMS/HSM reference")
self._key_id = str(key_id)
self.credential_reference = str(credential_reference)
self._sign_callback = sign_callback
@property
def key_id(self) -> str:
return self._key_id
def sign(self, payload: bytes) -> bytes:
return bytes(self._sign_callback(self.credential_reference, payload))
class SignedTrustedBuilderAdapter:
"""Signs evidence produced by a trusted collector, never caller evidence."""
def __init__(
self,
collector: Callable[[Mapping[str, Any]], TrustedCandidateBundle],
signer: BuilderSigner,
):
self.collector = collector
self.signer = signer
def build(self, intent: Mapping[str, Any]) -> TrustedCandidateBundle:
forbidden = {
"artifact_sha256",
"canary",
"checks",
"manifest_sha256",
"tests",
"tree_oid",
}
if forbidden.intersection(intent):
raise CapabilityAuthorizationError(
"trusted builder rejects caller-supplied evidence"
)
unsigned = self.collector(dict(intent))
if unsigned.attestation is not None:
raise CapabilityAuthorizationError(
"collector cannot self-attest its evidence"
)
candidate = unsigned.candidate
test_sha = hashlib.sha256(
_canonical_json(_sanitize(candidate.tests))
).hexdigest()
canary_sha = hashlib.sha256(
_canonical_json(_sanitize(candidate.canary))
).hexdigest()
attestation_id = "builder-attestation-" + hashlib.sha256(
(
candidate.candidate_id
+ "\0"
+ candidate.source_commit
+ "\0"
+ candidate.artifact_sha256
).encode("utf-8")
).hexdigest()[:24]
attestation = BuilderAttestation(
builder_attestation_id=attestation_id,
builder_key_id=self.signer.key_id,
source_commit=candidate.source_commit,
tree_oid=candidate.tree_oid,
artifact_sha256=candidate.artifact_sha256,
manifest_sha256=candidate.manifest_sha256,
package_closure_sha256=(
unsigned.release_plan.deployment.package_closure_sha256
),
test_evidence_sha256=test_sha,
canary_evidence_sha256=canary_sha,
issued_at=utc_now(),
signature="",
)
attestation = dataclasses.replace(
attestation,
signature=base64.b64encode(
self.signer.sign(attestation.canonical_bytes())
).decode("ascii"),
)
return dataclasses.replace(unsigned, attestation=attestation)
class ApprovalBackendAdapter(Protocol):
@property
def available(self) -> bool:
...
def verify(
self,
approval: SignedReleasePlanApproval,
*,
candidate,
release_plan,
policy_sha256: str,
) -> SignedReleasePlanApproval:
...
class LocalTestApprovalSigner:
"""Test-only approval signer used solely for Shadow acceptance."""
test_only = True
def __init__(self, key_id: str, private_key: Ed25519PrivateKey):
self.key_id = str(key_id)
self.private_key = private_key
@classmethod
def generate(
cls, key_id: str = "shadow-owner-test-key"
) -> "LocalTestApprovalSigner":
return cls(key_id, Ed25519PrivateKey.generate())
def sign(
self, approval: SignedReleasePlanApproval
) -> SignedReleasePlanApproval:
unsigned = dataclasses.replace(
approval, key_id=self.key_id, signature=""
)
return dataclasses.replace(
unsigned,
signature=base64.b64encode(
self.private_key.sign(unsigned.canonical_bytes())
).decode("ascii"),
)
class ProductionApprovalBackendAdapter:
"""Verification-only production adapter configured by a secret/KMS ref."""
def __init__(
self,
verifier: SignedApprovalVerifier,
credential_reference: str,
):
if not str(credential_reference).startswith(
("kms://", "hsm://", "secret-manager://")
):
raise ValueError(
"production approval backend requires a KMS/secret reference"
)
self.verifier = verifier
self.credential_reference = str(credential_reference)
@property
def available(self) -> bool:
return True
def verify(
self,
approval: SignedReleasePlanApproval,
*,
candidate,
release_plan,
policy_sha256: str,
) -> SignedReleasePlanApproval:
return self.verifier.verify_release_plan(
approval, candidate, release_plan, policy_sha256
)
class UnavailableProductionApprovalBackend:
credential_reference = None
@property
def available(self) -> bool:
return False
def verify(self, *args, **kwargs):
raise CapabilityAuthorizationError(
"production approval backend is not configured"
)
class LeaderLock:
def __init__(self, path: Path):
self.path = Path(path)
self.handle = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(self.path.parent, 0o700)
self.handle = self.path.open("a+", encoding="utf-8")
os.chmod(self.path, 0o600)
try:
import fcntl
fcntl.flock(
self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
)
except BlockingIOError as exc:
self.handle.close()
self.handle = None
raise RuntimeError("silent release leader is already active") from exc
return self
def __exit__(self, exc_type, exc, tb):
if self.handle is not None:
try:
import fcntl
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
finally:
self.handle.close()
self.handle = None
class PersistentSilentReleaseController:
"""Idempotent main-event controller with durable startup/recovery reports."""
def __init__(
self,
root: Path,
service: SilentReleaseActionService,
*,
main_probe: Callable[[], str],
recovery_worker: ReleaseRecoveryWorker | None = None,
):
self.root = Path(root)
self.service = service
self.main_probe = main_probe
self.recovery_worker = recovery_worker
self.leader_path = self.root / "controller.leader.lock"
self.status_path = self.root / "controller-status.json"
def startup_verify(self) -> dict[str, Any]:
checked = 0
failed = 0
for journal in sorted(self.service.sagas.root.glob("*.events.jsonl")):
candidate_id = journal.name[: -len(".events.jsonl")]
saga = self.service.sagas.load(candidate_id)
checked += 1
if not saga.journal_integrity:
failed += 1
status = {
"checked_sagas": checked,
"execution_enabled": self.service.execution_enabled,
"failed_closed": failed,
"shadow_mode": not self.service.execution_enabled,
"side_effects_executed": False,
"status": "READY" if failed == 0 else "FAIL_CLOSED",
}
_durable_json(self.status_path, status)
return status
def run_once(self) -> dict[str, Any]:
with LeaderLock(self.leader_path):
startup = self.startup_verify()
if startup["status"] != "READY":
result = {
"execution_enabled": self.service.execution_enabled,
"intent": None,
"notification": None,
"recovery": None,
"shadow_mode": not self.service.execution_enabled,
"side_effects_executed": False,
"startup": startup,
"status": "FAIL_CLOSED",
}
_durable_json(self.status_path, result)
return _sanitize(result)
source_commit = str(self.main_probe())
event_id = f"main-{source_commit}"
intent = self.service.create_release_intent(
{"event_id": event_id, "source_commit": source_commit}
)
notification = None
if intent.get("candidate_id"):
notification = self.service.ensure_owner_notification(
str(intent["candidate_id"])
)
recovery = (
self.recovery_worker.run_once()
if self.recovery_worker is not None
else self.service.get_recovery_status()
)
result = {
"execution_enabled": self.service.execution_enabled,
"intent": intent,
"notification": notification,
"recovery": recovery,
"shadow_mode": not self.service.execution_enabled,
"side_effects_executed": False,
"startup": startup,
}
_durable_json(self.status_path, result)
return _sanitize(result)
class ShadowActionHandler(BaseHTTPRequestHandler):
service: SilentReleaseActionService
controller: PersistentSilentReleaseController
def log_message(self, fmt, *args):
return
def _json(self, payload: Mapping[str, Any], status=HTTPStatus.OK):
body = json.dumps(_sanitize(dict(payload)), sort_keys=True).encode()
self.send_response(int(status))
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body(self) -> dict[str, Any]:
size = int(self.headers.get("Content-Length", "0"))
if size > 1024 * 1024:
raise ValueError("request body is too large")
return json.loads(self.rfile.read(size) or b"{}")
def do_GET(self):
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
candidate_id = str((query.get("candidate_id") or [""])[0])
try:
if parsed.path == "/healthz":
return self._json(
{
"execution_enabled": False,
"shadow_mode": True,
"side_effects_executed": False,
"status": "ok",
}
)
routes = {
"/getReleaseCandidate": self.service.get_release_candidate,
"/getReleasePlan": self.service.get_release_plan,
"/getReleaseAcceptance": self.service.get_acceptance,
"/getReleaseSaga": self.service.get_release_saga,
"/getCapabilityStatus": self.service.get_capability_status,
}
if parsed.path in routes:
return self._json(routes[parsed.path](candidate_id))
if parsed.path == "/getRecoveryStatus":
return self._json(self.service.get_recovery_status())
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
except (ValueError, FileNotFoundError) as exc:
self._json(
{"error": type(exc).__name__}, HTTPStatus.BAD_REQUEST
)
def do_POST(self):
parsed = urlparse(self.path)
try:
body = self._body()
if parsed.path == "/createReleaseIntent":
return self._json(self.service.create_release_intent(body))
if parsed.path in {
"/submitSignedApproval",
"/submitOwnerApproval",
}:
return self._json(self.service.submit_owner_approval(body))
if parsed.path == "/startDeployment":
return self._json(
self.service.start_deployment(
str(body.get("candidate_id") or "")
)
)
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
except (ValueError, FileNotFoundError, CapabilityAuthorizationError) as exc:
self._json(
{"error": type(exc).__name__}, HTTPStatus.BAD_REQUEST
)
def git_main_probe(repository: Path) -> Callable[[], str]:
repository = Path(repository)
def probe() -> str:
completed = subprocess.run(
[
"git",
f"--git-dir={repository}",
"rev-parse",
"refs/heads/main",
],
check=True,
capture_output=True,
text=True,
timeout=15,
env={"PATH": os.environ.get("PATH", "")},
)
return completed.stdout.strip()
return probe
def serve_shadow(
*,
state_root: Path,
repository: Path,
host: str,
port: int,
poll_seconds: float,
) -> None:
service = SilentReleaseActionService.shadow(state_root)
controller = PersistentSilentReleaseController(
state_root,
service,
main_probe=git_main_probe(repository),
)
controller.run_once()
stop = threading.Event()
def controller_loop() -> None:
while not stop.wait(poll_seconds):
try:
controller.run_once()
except Exception as exc: # fail closed and keep the API available
_durable_json(
controller.status_path,
{
"error": type(exc).__name__,
"execution_enabled": False,
"shadow_mode": True,
"side_effects_executed": False,
"status": "FAIL_CLOSED",
},
)
handler = type(
"ConfiguredShadowActionHandler",
(ShadowActionHandler,),
{"service": service, "controller": controller},
)
server = ThreadingHTTPServer((host, port), handler)
api_thread = threading.Thread(
target=server.serve_forever,
name="silent-release-shadow-api",
daemon=False,
)
controller_thread = threading.Thread(
target=controller_loop,
name="silent-release-shadow-controller",
daemon=True,
)
api_thread.start()
controller_thread.start()
try:
while api_thread.is_alive():
api_thread.join(timeout=1)
finally:
stop.set()
server.shutdown()
server.server_close()
controller_thread.join(timeout=max(1.0, poll_seconds + 1.0))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--state-root", required=True, type=Path)
parser.add_argument("--repository", required=True, type=Path)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", default=18891, type=int)
parser.add_argument("--poll-seconds", default=30.0, type=float)
arguments = parser.parse_args()
if arguments.poll_seconds <= 0:
raise SystemExit("poll seconds must be positive")
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
raise SystemExit("GitOps writes must remain disabled")
if (
os.environ.get(
"AIOS_SILENT_RELEASE_EXECUTION_ENABLED", "false"
).lower()
!= "false"
):
raise SystemExit("Silent Release execution must remain disabled")
serve_shadow(
state_root=arguments.state_root,
repository=arguments.repository,
host=arguments.host,
port=arguments.port,
poll_seconds=arguments.poll_seconds,
)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
"""Task Plane runtime package."""
from .models import (
EvidenceEnvelope,
ProbeEvidence,
ProbeOutcome,
TaskPlan,
TaskRecord,
TaskStatus,
TaskStateTransitionError,
)
__all__ = [
"EvidenceEnvelope",
"ProbeEvidence",
"ProbeOutcome",
"TaskPlan",
"TaskRecord",
"TaskStatus",
"TaskStateTransitionError",
]
@@ -1,17 +0,0 @@
"""Adapters for executing tasks via worker backends."""
from .base import WorkerAdapter
from .openclaw import OpenClawAdapter
from .codex import CodexAdapter
from .hermes import HermesAdapter
from .a2a import A2AAdapter
from .systemd_worker import SystemdWorkerAdapter
__all__ = [
"WorkerAdapter",
"OpenClawAdapter",
"CodexAdapter",
"HermesAdapter",
"A2AAdapter",
"SystemdWorkerAdapter",
]
@@ -1,31 +0,0 @@
from __future__ import annotations
from .base import WorkerAdapter
class A2AAdapter(WorkerAdapter):
worker_name = "a2a"
def capabilities(self) -> list[str]:
return ["task.exchange", "agent.handoff"]
def claim(self, task):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
def execute(self, task, claim):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
def heartbeat(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
def report_progress(self, claim, progress):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
def probe(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
def cancel(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
def collect_result(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
@@ -1,41 +0,0 @@
"""Base worker adapter contract."""
from __future__ import annotations
import abc
class WorkerAdapter(abc.ABC):
worker_name = "base"
@abc.abstractmethod
def capabilities(self) -> list[str]:
raise NotImplementedError
@abc.abstractmethod
def claim(self, task):
raise NotImplementedError
@abc.abstractmethod
def execute(self, task, claim):
raise NotImplementedError
@abc.abstractmethod
def heartbeat(self, claim):
raise NotImplementedError
@abc.abstractmethod
def report_progress(self, claim, progress):
raise NotImplementedError
@abc.abstractmethod
def probe(self, claim):
raise NotImplementedError
@abc.abstractmethod
def cancel(self, claim):
raise NotImplementedError
@abc.abstractmethod
def collect_result(self, claim):
raise NotImplementedError
@@ -1,31 +0,0 @@
from __future__ import annotations
from .base import WorkerAdapter
class CodexAdapter(WorkerAdapter):
worker_name = "codex"
def capabilities(self) -> list[str]:
return ["code.read", "code.write.patch", "test.python", "review.security"]
def claim(self, task):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
def execute(self, task, claim):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
def heartbeat(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
def report_progress(self, claim, progress):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
def probe(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
def cancel(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
def collect_result(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
@@ -1,31 +0,0 @@
from __future__ import annotations
from .base import WorkerAdapter
class HermesAdapter(WorkerAdapter):
worker_name = "hermes"
def capabilities(self) -> list[str]:
return ["architecture.review", "policy.review", "threat.model"]
def claim(self, task):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
def execute(self, task, claim):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
def heartbeat(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
def report_progress(self, claim, progress):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
def probe(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
def cancel(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
def collect_result(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
@@ -1,31 +0,0 @@
from __future__ import annotations
from .base import WorkerAdapter
class OpenClawAdapter(WorkerAdapter):
worker_name = "openclaw"
def capabilities(self) -> list[str]:
return ["code.read", "code.write.patch", "test.execute", "runtime.shadow"]
def claim(self, task):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
def execute(self, task, claim):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
def heartbeat(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
def report_progress(self, claim, progress):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
def probe(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
def cancel(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
def collect_result(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
@@ -1,31 +0,0 @@
from __future__ import annotations
from .base import WorkerAdapter
class SystemdWorkerAdapter(WorkerAdapter):
worker_name = "systemd_worker"
def capabilities(self) -> list[str]:
return ["systemd.exec", "runtime.shell"]
def claim(self, task):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
def execute(self, task, claim):
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
def heartbeat(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
def report_progress(self, claim, progress):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
def probe(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
def cancel(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
def collect_result(self, claim):
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
@@ -1,27 +0,0 @@
"""Agent registry abstraction."""
from __future__ import annotations
from .errors import NotFoundError
class AgentRegistry:
def __init__(self):
self._agents: dict[str, dict] = {}
def register(self, agent_id: str, metadata: dict) -> None:
self._agents[agent_id] = {"agent_id": agent_id, **metadata}
def unregister(self, agent_id: str) -> None:
if agent_id not in self._agents:
raise NotFoundError("agent")
del self._agents[agent_id]
def list(self) -> list[dict]:
return list(self._agents.values())
def get(self, agent_id: str) -> dict:
try:
return self._agents[agent_id]
except KeyError:
raise NotFoundError("agent")
-557
View File
@@ -1,557 +0,0 @@
"""Task Plane HTTP API implementation (stdlib-only)."""
from __future__ import annotations
import json
import os
import time
import uuid
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable
from .delegation import DelegationEngine
from .errors import (
ConflictError,
ForbiddenError,
NotFoundError,
PayloadTooLargeError,
TaskPlaneHTTPError,
UnauthorizedError,
ValidationError,
)
from .evidence import EvidenceService
from .journal import TaskJournal
from .lease import LeaseManager
from .models import (
EvidenceEnvelope,
ResultEnvelope,
TaskPlan,
TaskRecord,
iso8601_now,
contains_secret_text,
validate_identifier,
)
from .policy import PolicyEngine
from .store import TaskPlaneStore
from .worker_registry import WorkerRegistry
def _json_payload(data: dict[str, Any], status: int = 200) -> tuple[int, bytes]:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
return status, body
def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
try:
number = int(value)
except (TypeError, ValueError) as exc:
raise ValidationError(f"invalid {name}") from exc
if not minimum <= number <= maximum:
raise ValidationError(f"invalid {name}")
return number
class TaskPlaneService:
def __init__(self, store: TaskPlaneStore, policy: PolicyEngine | None = None):
self.store = store
self.policy = policy or PolicyEngine(
execution_enabled=False,
identity_capabilities={
"anon": {"task.creator", "task.read", "task.audit"}
},
)
self.journal = TaskJournal(store)
self.lease = LeaseManager(store)
self.workers = WorkerRegistry(store)
self.evidence = EvidenceService(store)
self.delegation = DelegationEngine(store)
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
if body.get("parent_task_id"):
raise ForbiddenError("child tasks must use the delegate endpoint")
issuer = body.get("issuer", {})
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
self.policy.assert_allowed("create", actor)
try:
plan = TaskPlan.from_dict(body)
except (TypeError, ValueError) as exc:
raise ValidationError("invalid task plan") from exc
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
return task.to_dict()
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
if actor is not None:
self.policy.assert_allowed("read", actor)
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
if actor is not None:
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
return self.store.get_task(task_id).to_dict()
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
if actor is not None:
self.policy.assert_allowed("read", actor)
return self.workers.get(worker_id).to_dict()
def list_workers(self, actor: str | None = None) -> list[dict[str, Any]]:
if actor is not None:
self.policy.assert_allowed("read", actor)
return [w.to_dict() for w in self.workers.list()]
def register_worker(self, body: dict[str, Any]) -> dict[str, Any]:
actor = str(body.get("actor_identity") or body.get("identity") or "")
is_admin = actor in self.policy.admin_identities
try:
existing = self.store.get_worker(str(body.get("worker_id") or ""))
except NotFoundError:
existing = None
if existing is not None and str(body.get("identity") or "") != existing.identity:
raise ConflictError("worker identity takeover rejected")
self.policy.assert_readonly_worker_registration(actor, body)
self.policy.assert_allowed(
"worker.admin" if is_admin else "worker.register", actor
)
try:
worker = self.workers.register(
body,
actor_identity=actor,
is_admin=is_admin,
expected_revision=body.get("expected_revision"),
)
except (TypeError, ValueError, KeyError) as exc:
raise ValidationError("invalid worker registration") from exc
return worker.to_dict()
def worker_heartbeat(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
interval = payload.get("heartbeat_interval_seconds")
actor = str(payload.get("actor_identity") or "")
self.policy.assert_readonly_worker_binding(actor, worker_id)
self.policy.assert_allowed("worker.register", actor)
return self.workers.heartbeat(
worker_id,
heartbeat_interval_seconds=interval,
actor_identity=actor,
expected_revision=payload.get("expected_revision"),
).to_dict()
def disable_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
actor = str(payload.get("actor_identity") or "")
self.policy.assert_allowed("worker.admin", actor)
return self.store.disable_worker(
worker_id,
actor_identity=actor,
expected_revision=int(payload.get("expected_revision")),
reason=str(payload.get("reason") or "disabled by administrator"),
).to_dict()
def restore_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
actor = str(payload.get("actor_identity") or "")
self.policy.assert_allowed("worker.admin", actor)
return self.store.restore_worker(
worker_id,
actor_identity=actor,
expected_revision=int(payload.get("expected_revision")),
).to_dict()
def claim(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
self.policy.assert_allowed("claim", actor)
worker_id = body.get("worker_id")
if not worker_id:
raise ValidationError("worker_id required")
task = self.store.get_task(task_id)
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
self.policy.assert_allowed("claim", actor, task=task)
lease_seconds = _bounded_int(
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
)
claim = self.lease.claim(
task_id=task_id,
worker_id=worker_id,
worker_identity=actor,
lease_seconds=lease_seconds,
expected_revision=task.revision,
operation_digest=task.task_plan.operation_digest(),
)
return {"claim_id": claim.claim_id, **claim.to_dict()}
def heartbeat(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
claim_id = body.get("claim_id")
if not claim_id:
raise ValidationError("claim_id required")
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
task = self.store.get_task(task_id)
claim = self.store.get_claim(claim_id)
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
self.policy.assert_allowed("heartbeat", actor, task=task)
if claim.worker_identity != actor:
raise ForbiddenError("claim identity mismatch")
lease_seconds = _bounded_int(
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
)
claim = self.lease.heartbeat(task_id, claim_id, lease_seconds)
return {"claim_id": claim_id, "lease_until": claim.lease_until}
def progress(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
claim_id = body.get("claim_id")
if not claim_id:
raise ValidationError("claim_id required")
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
progress = body.get("progress")
stage = body.get("stage", "RUNNING")
message = body.get("message")
if progress is None:
raise ValidationError("progress required")
task = self.store.get_task(task_id)
claim = self.store.get_claim(claim_id)
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
self.policy.assert_allowed("progress", actor, task=task)
if task.task_plan.data_classification == "credential-secret":
if message not in {None, ""} or contains_secret_text(stage):
raise ValidationError(
"credential-secret progress must contain metadata only"
)
lease_seconds = _bounded_int(
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
)
claim = self.store.record_progress(
task_id,
claim_id,
actor,
progress=progress,
stage=str(stage),
message=None if message is None else str(message),
lease_seconds=lease_seconds,
)
return {"claim_id": claim.claim_id, "status": claim.status, "progress": progress}
def complete(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
claim_id = body.get("claim_id")
if not claim_id:
raise ValidationError("claim_id required")
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
task = self.store.get_task(task_id)
status = body.get("status", "COMPLETED")
outputs = body.get("outputs", [])
evidences = body.get("evidence", [])
if not isinstance(outputs, list):
raise ValidationError("outputs must be list")
if not isinstance(evidences, list):
raise ValidationError("evidence must be list")
if any(not isinstance(ev, dict) for ev in evidences):
raise ValidationError("evidence items must be objects")
claim = self.store.get_claim(claim_id)
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
self.policy.assert_allowed("result", actor, task=task)
if claim.worker_identity != actor:
raise ForbiddenError("claim identity mismatch")
if task.task_plan.data_classification == "credential-secret":
allowed_result_keys = {
"kind",
"hash",
"sha256",
"count",
"status",
"error_code",
"signature",
"attestation_ref",
}
for entry in [*outputs, *evidences]:
if (
not isinstance(entry, dict)
or set(entry) - allowed_result_keys
or contains_secret_text(entry)
):
raise ValidationError(
"credential-secret result must contain hash/count/status only"
)
result = ResultEnvelope(
task_id=task_id,
claim_id=claim_id,
worker_identity=actor,
status=status,
outputs=outputs,
evidence=evidences,
started_at=str(body.get("started_at") or iso8601_now()),
completed_at=str(body.get("completed_at") or iso8601_now()),
result_digest=body.get("result_digest"),
signature=body.get("signature"),
attestation_ref=body.get("attestation_ref"),
operation_digest=claim.operation_digest,
)
evidence_decision = self.policy.evidence_verifier.verify(task, evidences)
if not evidence_decision.allowed:
raise ForbiddenError(evidence_decision.reason)
result_decision = self.policy.verify_result(result, task)
if not result_decision.allowed:
raise ForbiddenError(result_decision.reason)
result.result_digest = result.computed_digest()
previous = self.store._last_evidence_hash(task_id)
evidence_entries = []
for evidence in evidences:
envelope = EvidenceEnvelope.create(
task_id=task_id,
claim_id=claim_id,
kind=str(evidence.get("kind") or evidence.get("type") or "generic"),
worker_identity=actor,
payload=evidence,
previous_evidence_hash=previous,
signature=evidence.get("signature"),
attestation_ref=evidence.get("attestation_ref"),
)
evidence_entries.append(envelope)
previous = envelope.evidence_digest
self.store.complete_claim_with_evidence_and_event(
task_id,
claim_id,
status
if status
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
else "COMPLETED",
result.to_dict(),
evidence_entries,
)
return result.to_dict()
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
claim_id = body.get("claim_id")
if not claim_id:
raise ValidationError("claim_id required")
reason = body.get("reason", "failed")
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
self.policy.assert_allowed("fail", actor)
task = self.store.get_task(task_id)
self.policy.assert_allowed("fail", actor, task=task)
claim = self.store.get_claim(claim_id)
if claim.worker_identity != actor:
raise ForbiddenError("claim identity mismatch")
status = "FAILED_RECOVERABLE" if body.get("recoverable", False) else "FAILED_FINAL"
self.store.fail_claim_with_event(task_id, claim_id, status, str(reason))
return {"task_id": task_id, "status": status, "reason": reason}
def cancel(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
self.policy.assert_allowed("cancel", actor)
reason = body.get("reason", "cancelled")
task = self.store.get_task(task_id)
self.policy.assert_allowed("cancel", actor, task=task)
updated = self.store.cancel_task_with_event(
task_id, task.revision, reason=reason
)
return updated.to_dict()
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
actor = body.get("actor_identity") or "owner"
self.policy.assert_allowed("delegate", actor)
parent = self.store.get_task(task_id)
self.policy.assert_allowed("delegate", actor, task=parent)
if "task" in body:
try:
child_plan = TaskPlan.from_dict(body["task"])
except (TypeError, ValueError) as exc:
raise ValidationError("invalid child task plan") from exc
else:
plan_body = dict(body)
plan_body.pop("actor_identity", None)
try:
child_plan = TaskPlan.from_dict(plan_body)
except (TypeError, ValueError) as exc:
raise ValidationError("invalid child task plan") from exc
child_plan.parent_task_id = task_id
child = self.delegation.delegate(task_id, child_plan, caller=actor)
return child.to_dict()
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
if actor is not None:
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
self.store.get_task(task_id)
return self.store.list_events(task_id)
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
if actor is not None:
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
self.store.get_task(task_id)
return self.evidence.list(task_id)
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
if actor is not None:
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
self.store.get_task(task_id)
return self.store.list_children(task_id)
class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
service: TaskPlaneService | None = None
authenticator: Callable[[str], str | None] | None = None
max_body_bytes = 1_000_000
def log_message(self, format: str, *args: Any) -> None:
# BaseHTTPRequestHandler logs the raw request target; suppress it so
# attacker-controlled paths or query strings cannot leak credentials.
return
def _json(self) -> tuple[str | None, dict[str, Any]]:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError as exc:
raise ValidationError("invalid content length") from exc
if length < 0:
raise ValidationError("invalid content length")
if length > self.max_body_bytes:
raise PayloadTooLargeError("payload exceeds limit")
raw = self.rfile.read(length) if length > 0 else b""
if not raw:
return None, {}
try:
decoded = raw.decode("utf-8")
payload = json.loads(decoded)
if not isinstance(payload, dict):
raise ValidationError("JSON payload must be an object")
return decoded, payload
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValidationError("invalid JSON payload") from exc
def _response(self, status: int, payload: Any) -> None:
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def _authenticated_identity(self) -> str:
authenticator = type(self).authenticator
if authenticator is None:
raise UnauthorizedError("authentication unavailable")
header = self.headers.get("Authorization", "")
if not header.startswith("Bearer "):
raise UnauthorizedError("bearer authentication required")
token = header[7:]
if not token or any(ord(ch) < 33 for ch in token):
raise UnauthorizedError("invalid bearer token")
identity = authenticator(token)
if not identity:
raise UnauthorizedError("invalid credentials")
return str(identity)
def _handle(self, handler):
try:
if self.service is None:
raise TaskPlaneHTTPError("service not configured")
result = handler()
if isinstance(result, tuple):
status, payload = result
else:
status, payload = 200, result
self._response(status, payload)
except TaskPlaneHTTPError as exc:
self._response(exc.status_code, exc.to_payload())
except Exception as exc: # defensive
if isinstance(exc, TaskPlaneHTTPError):
raise
self._response(
500,
{"error": "internal_error", "message": "internal server error"},
)
def do_GET(self): # noqa: N802
def route():
path = urllib.parse.urlparse(self.path).path
parts = [p for p in path.split("/") if p]
for part in parts[1:]:
if part not in {"events", "evidence", "children", "heartbeat"}:
try:
validate_identifier(part, "resource identifier")
except ValueError as exc:
raise ValidationError("invalid resource identifier") from exc
identity = self._authenticated_identity()
if len(parts) == 1 and parts[0] == "tasks":
return 200, self.service.list_tasks(identity)
if len(parts) == 2 and parts[0] == "tasks":
task_id = parts[1]
return 200, self.service.get_task(task_id, identity)
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "events":
return 200, self.service.get_events(parts[1], identity)
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "evidence":
return 200, self.service.get_evidence(parts[1], identity)
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "children":
return 200, self.service.get_children(parts[1], identity)
if len(parts) == 1 and parts[0] == "workers":
return 200, self.service.list_workers(identity)
if len(parts) == 2 and parts[0] == "workers":
return 200, self.service.get_worker(parts[1], identity)
raise NotFoundError("unknown endpoint")
self._handle(route)
def do_POST(self): # noqa: N802
def route():
body_text, body = self._json()
identity = self._authenticated_identity()
path = urllib.parse.urlparse(self.path).path
parts = [p for p in path.split("/") if p]
headers = {k.lower(): v for k, v in self.headers.items()}
if len(parts) == 1 and parts[0] == "tasks":
issuer = body.get("issuer")
if issuer and (
not isinstance(issuer, dict) or issuer.get("id") != identity
):
raise ForbiddenError("issuer identity mismatch")
body["issuer"] = {"type": "authenticated", "id": identity}
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
if body.get("actor_identity") not in {None, identity}:
raise ForbiddenError("actor identity mismatch")
body["actor_identity"] = identity
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "claim":
return 200, self.service.claim(parts[1], body or {})
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "heartbeat":
return 200, self.service.heartbeat(parts[1], body or {})
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "progress":
return 200, self.service.progress(parts[1], body or {})
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "result":
return 200, self.service.complete(parts[1], body or {})
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "fail":
return 200, self.service.fail(parts[1], body or {})
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel":
return 200, self.service.cancel(parts[1], body or {})
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate":
return 200, self.service.delegate(parts[1], body or {})
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
if (
body.get("identity") != identity
and identity not in self.service.policy.admin_identities
):
raise ForbiddenError("worker identity mismatch")
return 201, self.service.register_worker(body or {})
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "heartbeat":
return 200, self.service.worker_heartbeat(parts[1], body or {})
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "disable":
return 200, self.service.disable_worker(parts[1], body or {})
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "restore":
return 200, self.service.restore_worker(parts[1], body or {})
raise NotFoundError("unknown endpoint")
self._handle(route)
def run_server(
host: str,
port: int,
service: TaskPlaneService,
*,
authenticator: Callable[[str], str | None] | None = None,
):
handler = TaskPlaneRequestHandler
handler.service = service
handler.authenticator = authenticator
server = ThreadingHTTPServer((host, port), handler)
server.serve_forever()
@@ -1,36 +0,0 @@
"""Capability matcher helpers."""
from __future__ import annotations
from fnmatch import fnmatch
def _normalize_capability(capability: str) -> str:
return capability.strip().lower()
def capability_matches(granted: str, required: str) -> bool:
granted = _normalize_capability(granted)
required = _normalize_capability(required)
if granted == required:
return True
# A granted wildcard such as "test.*" should match required "test.unit"
if "*" in granted:
return fnmatch(required, granted)
# A required wildcard is treated as a suffix/prefix requirement for callers.
if "*" in required:
return fnmatch(granted, required)
return False
def has_capabilities(granted_caps: list[str], required_caps: list[str]) -> bool:
granted = [_normalize_capability(c) for c in granted_caps]
for required in required_caps:
required = _normalize_capability(required)
if not any(capability_matches(g, required) for g in granted):
return False
return True
def normalize_capabilities(caps: list[str]) -> list[str]:
return sorted({_normalize_capability(cap) for cap in caps})
@@ -1,87 +0,0 @@
"""Delegation guardrails."""
from __future__ import annotations
from .errors import ConflictError, ForbiddenError, NotFoundError
from .models import Budget, TaskPlan, TaskRecord
from .store import TaskPlaneStore
class DelegationEngine:
def __init__(self, store: TaskPlaneStore):
self.store = store
def can_delegate(self, parent_id: str, child_plan: TaskPlan) -> bool:
parent = self.store.get_task(parent_id)
if parent.status == "COMPLETED":
raise ConflictError("cannot delegate completed task")
if parent.task_plan.max_delegation_depth <= 0:
raise ConflictError("max delegation depth not configured")
depth = self._depth(parent_id)
if depth >= parent.task_plan.max_delegation_depth:
raise ConflictError("max delegation depth reached")
if child_plan.parent_task_id not in {None, parent_id}:
raise ConflictError("delegation parent mismatch")
current = parent_id
while current:
if child_plan.task_id and current == child_plan.task_id:
raise ConflictError("delegation cycle")
current = self.store.parent_of(current)
return True
def delegate(self, parent_id: str, child_plan: TaskPlan, *, caller: str) -> TaskRecord:
self.can_delegate(parent_id, child_plan)
parent = self.store.get_task(parent_id)
if child_plan.required_capabilities and not set(child_plan.required_capabilities).issubset(
set(parent.task_plan.required_capabilities or [])
):
raise ForbiddenError("child capabilities exceed parent constraints")
risk_rank = {"low": 1, "medium": 2, "high": 3}
if risk_rank.get(child_plan.risk, 99) > risk_rank.get(parent.task_plan.risk, -1):
raise ForbiddenError("child risk exceeds parent constraints")
child_plan.parent_task_id = parent_id
child_plan.correlation_id = parent.task_plan.correlation_id or parent.task_id
child_plan.preferred_workers = parent.task_plan.preferred_workers
child_plan.max_delegation_depth = min(
child_plan.max_delegation_depth,
parent.task_plan.max_delegation_depth,
)
child_plan.approval_policy = parent.task_plan.approval_policy
parent_budget = self.store.get_task_budget(parent_id)
required = Budget()
for name in ("time_seconds", "attempts", "tokens", "cost"):
available = getattr(parent_budget, name)
requested = getattr(child_plan.budgets, name)
if name == "attempts" and requested is None:
requested = 1
if available is not None:
requested = 0 if requested is None else requested
if requested > available:
raise ConflictError(f"budget exceeded for {name}")
setattr(child_plan.budgets, name, requested)
setattr(required, name, requested)
# A retried identical delegation is a read, not a second budget charge.
if child_plan.task_id:
try:
existing = self.store.get_task(child_plan.task_id)
except NotFoundError:
existing = None
if existing is not None:
if (
existing.task_plan.parent_task_id == parent_id
and existing.task_plan.operation_digest()
== child_plan.operation_digest()
):
return existing
raise ConflictError("delegation task_id conflict")
return self.store.delegate_task_with_budget_and_events(
parent_id, child_plan, caller=caller
)
def _depth(self, task_id: str) -> int:
return self.store._compute_depth(task_id)
-71
View File
@@ -1,71 +0,0 @@
"""Error types for task plane operations."""
from __future__ import annotations
class TaskPlaneError(Exception):
"""Base exception for API-compatible failures."""
status_code = 500
error_code = "task_plane_error"
def __init__(self, message: str, *, code: str | None = None, details: dict | None = None):
super().__init__(message)
self.message = message
self.details = details or {}
if code:
self.error_code = code
def to_payload(self) -> dict:
return {"error": self.error_code, "message": self.message, "details": self.details}
class TaskPlaneHTTPError(TaskPlaneError):
"""Base class with explicit HTTP status."""
status_code = 500
class ValidationError(TaskPlaneHTTPError):
status_code = 400
error_code = "validation_error"
class UnauthorizedError(TaskPlaneHTTPError):
status_code = 401
error_code = "unauthorized"
class ForbiddenError(TaskPlaneHTTPError):
status_code = 403
error_code = "forbidden"
class NotFoundError(TaskPlaneHTTPError):
status_code = 404
error_code = "not_found"
class ConflictError(TaskPlaneHTTPError):
status_code = 409
error_code = "conflict"
class PayloadTooLargeError(TaskPlaneHTTPError):
status_code = 413
error_code = "payload_too_large"
class FailedDependencyError(TaskPlaneHTTPError):
status_code = 424
error_code = "failed_dependency"
class LockedError(TaskPlaneHTTPError):
status_code = 423
error_code = "locked"
class ServiceUnavailableError(TaskPlaneHTTPError):
status_code = 503
error_code = "service_unavailable"
-108
View File
@@ -1,108 +0,0 @@
"""CloudEvents-compatible event envelope and durable outbox helpers."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Protocol
from .store import TaskPlaneStore
@dataclass
class CloudEvent:
specversion: str = "1.0"
id: str = ""
type: str = ""
source: str = "spiffe://aios/task-plane"
subject: str = ""
datacontenttype: str = "application/json"
data: dict | None = None
@property
def time(self) -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def asdict(self) -> dict:
return {
"specversion": self.specversion,
"id": self.id,
"type": self.type,
"source": self.source,
"subject": self.subject,
"time": self.time,
"datacontenttype": self.datacontenttype,
"data": self.data or {},
}
NOT_DELIVERED = "NOT_DELIVERED"
DELIVERED = "DELIVERED"
FAILED_DELIVERY = "FAILED"
class NotificationAdapter(Protocol):
name: str
def deliver(self, envelope: dict) -> str | None:
"""Return a provider receipt, or None when delivery is unconfirmed."""
class CallbackNotificationAdapter:
"""Adapter seam for webhook, NATS, GPT, dashboard, and WeChat transports."""
def __init__(self, name: str, callback: Callable[[dict], str | None]):
self.name = name
self._callback = callback
def deliver(self, envelope: dict) -> str | None:
return self._callback(envelope)
class WebhookAdapter(CallbackNotificationAdapter):
def __init__(self, callback: Callable[[dict], str | None]):
super().__init__("webhook", callback)
class NatsJetStreamAdapter(CallbackNotificationAdapter):
def __init__(self, callback: Callable[[dict], str | None]):
super().__init__("nats-jetstream", callback)
class GPTResultCallbackAdapter(CallbackNotificationAdapter):
def __init__(self, callback: Callable[[dict], str | None]):
super().__init__("gpt-result-callback", callback)
class DashboardAdapter(CallbackNotificationAdapter):
def __init__(self, callback: Callable[[dict], str | None]):
super().__init__("dashboard", callback)
class WeChatGatewayAdapter(CallbackNotificationAdapter):
def __init__(self, callback: Callable[[dict], str | None]):
super().__init__("wechat-gateway", callback)
class DurableOutboxDispatcher:
def __init__(self, store: TaskPlaneStore):
self.store = store
def dispatch(self, message_id: str, adapter: NotificationAdapter) -> dict:
row = self.store.get_outbox_message(message_id)
import json
envelope = json.loads(row["payload"])
try:
receipt = adapter.deliver(envelope)
except Exception:
return self.store.record_outbox_delivery(
message_id,
receipt=None,
error=f"{adapter.name} delivery failed",
)
return self.store.record_outbox_delivery(
message_id,
receipt=str(receipt) if receipt else None,
error=None if receipt else f"{adapter.name} receipt unavailable",
)
-31
View File
@@ -1,31 +0,0 @@
"""Evidence helpers."""
from __future__ import annotations
import hashlib
import json
from .store import TaskPlaneStore
def evidence_digest(payload: dict) -> str:
body = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(body.encode("utf-8")).hexdigest()
class EvidenceService:
def __init__(self, store: TaskPlaneStore):
self.store = store
def add(self, task_id: str, kind: str, payload: dict, worker_identity: str) -> str:
return self.store.add_evidence(task_id, kind, payload, worker_identity)
def list(self, task_id: str) -> list[dict]:
return self.store.list_evidence(task_id)
def verify(self, task_id: str, required_kinds: list[str]) -> bool:
evidences = self.list(task_id)
observed = {e["kind"] for e in evidences}
return set(required_kinds).issubset(
observed
) and self.store.verify_evidence_integrity(task_id)
-22
View File
@@ -1,22 +0,0 @@
"""Event journaling helpers."""
from __future__ import annotations
from .errors import ValidationError
from .store import TaskPlaneStore
class TaskJournal:
def __init__(self, store: TaskPlaneStore):
self.store = store
def append(self, task_id: str, event_type: str, data: dict, *, subject: str | None = None) -> None:
if not event_type:
raise ValidationError("event_type required")
self.store.append_event(task_id, event_type, data, subject=subject)
def list(self, task_id: str) -> list[dict]:
return self.store.list_events(task_id)
def check_chain(self, task_id: str) -> bool:
return self.store.verify_event_chain(task_id)
-32
View File
@@ -1,32 +0,0 @@
"""Claim and lease management helpers."""
from __future__ import annotations
from .errors import LockedError
from .models import ProbeEvidence
from .store import TaskPlaneStore
class LeaseManager:
def __init__(self, store: TaskPlaneStore):
self.store = store
def claim(self, task_id: str, worker_id: str, worker_identity: str, lease_seconds: int, expected_revision: int, operation_digest: str):
return self.store.claim_task(task_id, worker_id, worker_identity, lease_seconds, expected_revision, operation_digest)
def heartbeat(self, task_id: str, claim_id: str, lease_seconds: int):
claim = self.store.heartbeat_claim(task_id, claim_id, lease_seconds)
return claim
def probe(self, claim_id: str):
claim = self.store.get_claim(claim_id)
if claim.status != "ACTIVE":
raise LockedError("claim not active")
return claim
def recover(self, task_id: str, claim_id: str, probe: ProbeEvidence):
return self.store.recover_expired_claim(
task_id,
claim_id,
probe,
)
-829
View File
@@ -1,829 +0,0 @@
"""Core data models for the task plane."""
from __future__ import annotations
import hashlib
import json
import re
import time
import uuid
import urllib.parse
from enum import StrEnum
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
SENSITIVE_KEYS = {
"secret",
"password",
"token",
"api_key",
"access_token",
"refresh_token",
"authorization",
"private_key",
"client_secret",
"credential",
}
IDENTIFIER_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
TYPE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
RISK_LEVELS = {"low", "medium", "high"}
DATA_CLASSIFICATIONS = {
"public",
"internal",
"confidential",
"credential-secret",
}
SECRET_TEXT_PATTERNS = (
re.compile(r"(?i)\bAuthorization\s*:\s*Bearer\s+\S+"),
re.compile(r"(?i)\b(?:password|passwd|api[_-]?key|access[_-]?token|secret)\s*[:=]\s*\S+"),
re.compile(r"\bsk-[A-Za-z0-9_-]{12,}\b"),
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
)
TASK_PLAN_KEYS = {
"schema_version",
"task_id",
"task_type",
"issuer",
"intent",
"required_capabilities",
"preferred_workers",
"risk",
"approval_policy",
"dependencies",
"max_delegation_depth",
"budgets",
"evidence_requirements",
"parent_task_id",
"correlation_id",
"metadata",
"data_classification",
"execution_zone",
"network_policy",
"logging_policy",
"allowed_input_paths",
"allowed_output_paths",
"output_policy",
}
def _now_ts() -> float:
return time.time()
def canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def compute_sha256_hex(value: Any) -> str:
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
def contains_secret_text(value: Any) -> bool:
if isinstance(value, str):
return any(pattern.search(value) for pattern in SECRET_TEXT_PATTERNS)
if isinstance(value, dict):
return any(contains_secret_text(item) for item in value.values())
if isinstance(value, (list, tuple)):
return any(contains_secret_text(item) for item in value)
return False
def validate_relative_paths(values: Any, name: str) -> list[str]:
paths = _normalize_list(values)
for value in paths:
decoded = urllib.parse.unquote(value)
parts = value.replace("\\", "/").split("/")
if (
decoded != value
or "\\" in value
or value.startswith("/")
or re.match(r"^[A-Za-z]:", value)
or any(part in {"", ".", ".."} for part in parts)
):
raise ValueError(f"invalid {name}")
return paths
def validate_identifier(value: Any, name: str) -> str:
text = str(value or "")
if (
not IDENTIFIER_RE.fullmatch(text)
or urllib.parse.unquote(text) != text
or any(ord(ch) < 32 or ord(ch) == 127 for ch in text)
):
raise ValueError(f"invalid {name}")
return text
def validate_type(value: Any, name: str) -> str:
text = str(value or "")
if not TYPE_RE.fullmatch(text):
raise ValueError(f"invalid {name}")
return text
def redact_value(value: Any) -> Any:
if isinstance(value, dict):
redacted = {}
for key, inner in value.items():
lowered = str(key).lower()
if lowered in SENSITIVE_KEYS or lowered.endswith(
("_password", "_secret", "_access_token", "_refresh_token", "_private_key")
):
redacted[key] = "[REDACTED]"
else:
redacted[key] = redact_value(inner)
return redacted
if isinstance(value, list):
return [redact_value(v) for v in value]
if isinstance(value, tuple):
return tuple(redact_value(v) for v in value)
return value
class TaskStateTransitionError(ValueError):
pass
class TaskStatus(StrEnum):
CREATED = "CREATED"
QUEUED = "QUEUED"
CLAIMED = "CLAIMED"
RUNNING = "RUNNING"
WAITING_CHILD = "WAITING_CHILD"
VERIFYING = "VERIFYING"
COMPLETED = "COMPLETED"
FAILED_RECOVERABLE = "FAILED_RECOVERABLE"
FAILED_FINAL = "FAILED_FINAL"
CANCELLED = "CANCELLED"
MANUAL_REVIEW_REQUIRED = "MANUAL_REVIEW_REQUIRED"
TASK_STATES = {
status.value for status in TaskStatus
}
TASK_NEXT_STATES = {
"CREATED": {"QUEUED", "CLAIMED", "CANCELLED", "MANUAL_REVIEW_REQUIRED"},
"QUEUED": {"CLAIMED", "CANCELLED", "FAILED_RECOVERABLE", "FAILED_FINAL"},
"CLAIMED": {"RUNNING", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
"RUNNING": {"VERIFYING", "WAITING_CHILD", "COMPLETED", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
"WAITING_CHILD": {"RUNNING", "VERIFYING", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
"VERIFYING": {"COMPLETED", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
"COMPLETED": set(),
"FAILED_RECOVERABLE": {"CLAIMED", "RUNNING", "CANCELLED"},
"FAILED_FINAL": set(),
"CANCELLED": set(),
"MANUAL_REVIEW_REQUIRED": {"RUNNING", "CANCELLED"},
}
@dataclass
class Budget:
time_seconds: int | None = None
attempts: int | None = None
tokens: int | None = None
cost: float | None = None
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Budget":
if not data:
return cls()
if not isinstance(data, dict) or set(data) - {
"time_seconds",
"attempts",
"tokens",
"cost",
}:
raise ValueError("invalid budget")
for name in ("time_seconds", "attempts", "tokens", "cost"):
value = data.get(name)
if value is not None:
try:
if float(value) < 0:
raise ValueError("budget values must be non-negative")
except (TypeError, ValueError) as exc:
raise ValueError("invalid budget value") from exc
return cls(
time_seconds=_to_positive_int(data.get("time_seconds")),
attempts=_to_positive_int(data.get("attempts")),
tokens=_to_positive_int(data.get("tokens")),
cost=_to_float(data.get("cost")),
)
def to_dict(self) -> dict[str, Any]:
return {
"time_seconds": self.time_seconds,
"attempts": self.attempts,
"tokens": self.tokens,
"cost": self.cost,
}
def merge(self, other: "Budget") -> "Budget":
return Budget(
time_seconds=_first_non_none(self.time_seconds, other.time_seconds),
attempts=_first_non_none(self.attempts, other.attempts),
tokens=_first_non_none(self.tokens, other.tokens),
cost=_first_non_none(self.cost, other.cost),
)
@dataclass
class TaskPlan:
schema_version: int = 1
task_id: str | None = None
task_type: str = "code.change"
issuer: dict[str, Any] = field(default_factory=dict)
intent: str | None = None
required_capabilities: list[str] = field(default_factory=list)
preferred_workers: list[str] = field(default_factory=list)
risk: str = "medium"
approval_policy: str = "patch-only"
dependencies: list[str] = field(default_factory=list)
max_delegation_depth: int = 5
budgets: Budget = field(default_factory=Budget)
evidence_requirements: list[str] = field(default_factory=list)
parent_task_id: str | None = None
correlation_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
data_classification: str = "internal"
execution_zone: str = "isolated-development"
network_policy: str = "controlled"
logging_policy: str = "redacted"
allowed_input_paths: list[str] = field(default_factory=list)
allowed_output_paths: list[str] = field(default_factory=list)
output_policy: str = "standard"
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "TaskPlan":
if not isinstance(data, dict):
raise ValueError("task plan payload must be an object")
unknown = set(data) - TASK_PLAN_KEYS
if unknown:
raise ValueError("unknown task plan fields")
if "task_id" not in data or data["task_id"] is None:
data = dict(data)
data["task_id"] = f"task-{uuid.uuid4()}"
risk = str(data.get("risk", "medium"))
if risk not in RISK_LEVELS:
raise ValueError("invalid risk")
issuer = data.get("issuer", {})
if not isinstance(issuer, dict):
raise ValueError("issuer must be an object")
schema_version = int(data.get("schema_version", 1))
if schema_version != 1:
raise ValueError("unsupported schema_version")
max_depth = _to_positive_int(data.get("max_delegation_depth"), default=5)
if max_depth is None or max_depth > 32:
raise ValueError("invalid max_delegation_depth")
classification = str(data.get("data_classification", "internal"))
if classification not in DATA_CLASSIFICATIONS:
raise ValueError("invalid data_classification")
execution_zone = validate_type(
data.get("execution_zone", "isolated-development"), "execution_zone"
)
network_policy = validate_type(
data.get("network_policy", "controlled"), "network_policy"
)
logging_policy = validate_type(
data.get("logging_policy", "redacted"), "logging_policy"
)
output_policy = validate_type(
data.get("output_policy", "standard"), "output_policy"
)
if contains_secret_text(
{
"intent": data.get("intent"),
"metadata": data.get("metadata", {}),
}
):
raise ValueError("task content contains secret material")
if classification == "credential-secret":
if execution_zone != "secure-file-transformer":
raise ValueError("credential-secret requires secure-file-transformer")
if network_policy != "denied":
raise ValueError("credential-secret requires network denied")
if logging_policy != "metadata-only":
raise ValueError("credential-secret requires metadata-only logging")
if output_policy != "hash-count-status-only":
raise ValueError("credential-secret requires restricted output")
return cls(
schema_version=schema_version,
task_id=validate_identifier(data["task_id"], "task_id"),
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
issuer=issuer,
intent=data.get("intent"),
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
preferred_workers=_normalize_list(data.get("preferred_workers", [])),
risk=risk,
approval_policy=validate_type(
data.get("approval_policy", "patch-only"), "approval_policy"
),
dependencies=_normalize_list(data.get("dependencies", [])),
max_delegation_depth=max_depth,
budgets=Budget.from_dict(data.get("budgets")),
evidence_requirements=_normalize_list(data.get("evidence_requirements", [])),
parent_task_id=data.get("parent_task_id"),
correlation_id=data.get("correlation_id"),
metadata=data.get("metadata", {}) if isinstance(data.get("metadata", {}), dict) else {},
data_classification=classification,
execution_zone=execution_zone,
network_policy=network_policy,
logging_policy=logging_policy,
allowed_input_paths=validate_relative_paths(
data.get("allowed_input_paths", []), "allowed_input_paths"
),
allowed_output_paths=validate_relative_paths(
data.get("allowed_output_paths", []), "allowed_output_paths"
),
output_policy=output_policy,
)
def to_dict(self, *, redact: bool = False) -> dict[str, Any]:
payload = {
"schema_version": self.schema_version,
"task_id": self.task_id,
"task_type": self.task_type,
"issuer": self.issuer,
"intent": self.intent,
"required_capabilities": list(self.required_capabilities),
"preferred_workers": list(self.preferred_workers),
"risk": self.risk,
"approval_policy": self.approval_policy,
"dependencies": list(self.dependencies),
"max_delegation_depth": self.max_delegation_depth,
"budgets": self.budgets.to_dict(),
"evidence_requirements": list(self.evidence_requirements),
"parent_task_id": self.parent_task_id,
"correlation_id": self.correlation_id,
"metadata": dict(self.metadata),
"data_classification": self.data_classification,
"execution_zone": self.execution_zone,
"network_policy": self.network_policy,
"logging_policy": self.logging_policy,
"allowed_input_paths": list(self.allowed_input_paths),
"allowed_output_paths": list(self.allowed_output_paths),
"output_policy": self.output_policy,
}
if redact:
payload = redact_value(payload)
return payload
def operation_digest(self) -> str:
return compute_sha256_hex(self.to_dict())
@dataclass
class TaskRecord:
task_id: str
status: str
revision: int
task_plan: TaskPlan
created_at: float
updated_at: float
root_task_id: str
idempotency_key: str | None = None
failure_reason: str | None = None
def with_status(self, status: str) -> "TaskRecord":
allowed = TASK_NEXT_STATES.get(self.status, set())
if status != self.status and (not allowed and self.status not in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}):
raise TaskStateTransitionError(f"Cannot transition {self.status} -> {status}")
if status not in TASK_STATES:
raise TaskStateTransitionError(f"Invalid status {status}")
if status != self.status and self.status in TASK_NEXT_STATES and status not in TASK_NEXT_STATES[self.status]:
raise TaskStateTransitionError(f"Cannot transition {self.status} -> {status}")
return TaskRecord(
task_id=self.task_id,
status=status,
revision=self.revision + 1,
task_plan=self.task_plan,
created_at=self.created_at,
updated_at=_now_ts(),
root_task_id=self.root_task_id,
idempotency_key=self.idempotency_key,
failure_reason=self.failure_reason,
)
def to_dict(self, *, include_task=True, redact_payload: bool = True) -> dict[str, Any]:
payload = {
"task_id": self.task_id,
"status": self.status,
"revision": self.revision,
"root_task_id": self.root_task_id,
"parent_task_id": self.task_plan.parent_task_id,
"created_at": _fmt_ts(self.created_at),
"updated_at": _fmt_ts(self.updated_at),
"failure_reason": self.failure_reason,
}
if include_task:
payload["plan"] = self.task_plan.to_dict(redact=redact_payload)
if self.idempotency_key:
payload["idempotency_key"] = self.idempotency_key
return payload
@dataclass
class WorkerRegistration:
worker_id: str
worker_type: str
identity: str
capabilities: list[str]
trust_zone: str
max_concurrency: int
heartbeat_interval_seconds: int
supported_protocols: list[str]
registered_at: float
last_heartbeat: float
status: str = "active"
disabled: bool = False
owner_identity: str = ""
registration_revision: int = 1
registration_policy: str = "self"
created_at: float = 0.0
updated_at: float = 0.0
disabled_reason: str | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkerRegistration":
if not isinstance(data, dict):
raise ValueError("worker registration payload must be an object")
now = _now_ts()
worker_id = validate_identifier(data.get("worker_id"), "worker_id")
worker_type = validate_type(data.get("worker_type"), "worker_type")
identity = str(data.get("identity") or "").strip()
if not identity or any(ord(ch) < 32 for ch in identity):
raise ValueError("invalid identity")
max_concurrency = _to_positive_int(data.get("max_concurrency"), default=1)
heartbeat_interval = _to_positive_int(
data.get("heartbeat_interval_seconds"), default=15
)
if not max_concurrency:
raise ValueError("max_concurrency must be positive")
if not heartbeat_interval:
raise ValueError("heartbeat_interval_seconds must be positive")
return cls(
worker_id=worker_id,
worker_type=worker_type,
identity=identity,
capabilities=_normalize_list(data.get("capabilities", [])),
trust_zone=str(data.get("trust_zone") or "default"),
max_concurrency=max_concurrency,
heartbeat_interval_seconds=heartbeat_interval,
supported_protocols=_normalize_list(
data.get("supported_protocols", ["aios-task-v1"])
),
registered_at=float(data.get("registered_at") or now),
last_heartbeat=float(data.get("last_heartbeat") or now),
status=str(data.get("status") or "active"),
disabled=bool(data.get("disabled", False)),
owner_identity=str(data.get("owner_identity") or identity),
registration_revision=int(data.get("registration_revision") or 1),
registration_policy=str(data.get("registration_policy") or "self"),
created_at=float(data.get("created_at") or data.get("registered_at") or now),
updated_at=float(data.get("updated_at") or now),
disabled_reason=data.get("disabled_reason"),
)
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
payload = {
"worker_id": self.worker_id,
"worker_type": self.worker_type,
"identity": self.identity,
"capabilities": list(self.capabilities),
"trust_zone": self.trust_zone,
"max_concurrency": self.max_concurrency,
"heartbeat_interval_seconds": self.heartbeat_interval_seconds,
"supported_protocols": list(self.supported_protocols),
"registered_at": _fmt_ts(self.registered_at),
"last_heartbeat": _fmt_ts(self.last_heartbeat),
"status": self.status,
"disabled": self.disabled,
"owner_identity": self.owner_identity,
"registration_revision": self.registration_revision,
"registration_policy": self.registration_policy,
"created_at": _fmt_ts(self.created_at or self.registered_at),
"updated_at": _fmt_ts(self.updated_at or self.last_heartbeat),
"disabled_reason": self.disabled_reason,
}
if redact_payload:
payload = redact_value(payload)
return payload
@dataclass(frozen=True)
class EvidenceEnvelope:
schema_version: int
evidence_id: str
task_id: str
claim_id: str
kind: str
worker_identity: str
payload: dict[str, Any]
created_at: float
previous_evidence_hash: str
evidence_digest: str
signature: str | None = None
attestation_ref: str | None = None
@classmethod
def create(
cls,
*,
task_id: str,
claim_id: str,
kind: str,
worker_identity: str,
payload: dict[str, Any],
previous_evidence_hash: str,
signature: str | None = None,
attestation_ref: str | None = None,
evidence_id: str | None = None,
created_at: float | None = None,
) -> "EvidenceEnvelope":
envelope = cls(
schema_version=1,
evidence_id=evidence_id or f"evd-{uuid.uuid4()}",
task_id=validate_identifier(task_id, "task_id"),
claim_id=validate_identifier(claim_id, "claim_id"),
kind=validate_type(kind, "evidence kind"),
worker_identity=str(worker_identity),
payload=redact_value(payload),
created_at=float(created_at or _now_ts()),
previous_evidence_hash=str(previous_evidence_hash or ""),
evidence_digest="",
signature=signature,
attestation_ref=attestation_ref,
)
return dataclass_replace(
envelope, evidence_digest=compute_sha256_hex(envelope.digest_payload())
)
def digest_payload(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"evidence_id": self.evidence_id,
"task_id": self.task_id,
"claim_id": self.claim_id,
"kind": self.kind,
"worker_identity": self.worker_identity,
"payload": self.payload,
"created_at": self.created_at,
"previous_evidence_hash": self.previous_evidence_hash,
"attestation_ref": self.attestation_ref,
}
def verify(
self,
*,
expected_task_id: str,
expected_claim_id: str,
expected_previous_hash: str,
attestation_verifier: Any = None,
) -> bool:
if (
self.schema_version != 1
or self.task_id != expected_task_id
or self.claim_id != expected_claim_id
or self.previous_evidence_hash != expected_previous_hash
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
):
return False
if self.signature or self.attestation_ref:
return True if attestation_verifier is None else bool(attestation_verifier(self))
return True
def to_dict(self) -> dict[str, Any]:
return {
**self.digest_payload(),
"evidence_digest": self.evidence_digest,
"signature": self.signature,
}
class ProbeOutcome(StrEnum):
NOT_APPLIED = "NOT_APPLIED"
APPLIED_VERIFIED = "APPLIED_VERIFIED"
INDETERMINATE = "INDETERMINATE"
CONFLICT = "CONFLICT"
@dataclass(frozen=True)
class ProbeEvidence:
schema_version: int
probe_id: str
task_id: str
claim_id: str
worker_id: str
worker_identity: str
operation_digest: str
outcome: ProbeOutcome
observed_at: float
expires_at: float
probe_executor_identity: str
evidence_digest: str
signature: str | None = None
attestation_ref: str | None = None
@classmethod
def create(cls, **data: Any) -> "ProbeEvidence":
outcome = data["outcome"]
if not isinstance(outcome, ProbeOutcome):
outcome = ProbeOutcome(str(outcome))
probe = cls(
schema_version=1,
probe_id=data.get("probe_id") or f"probe-{uuid.uuid4()}",
task_id=validate_identifier(data["task_id"], "task_id"),
claim_id=validate_identifier(data["claim_id"], "claim_id"),
worker_id=validate_identifier(data["worker_id"], "worker_id"),
worker_identity=str(data["worker_identity"]),
operation_digest=str(data["operation_digest"]),
outcome=outcome,
observed_at=float(data["observed_at"]),
expires_at=float(data["expires_at"]),
probe_executor_identity=str(data["probe_executor_identity"]),
evidence_digest="",
signature=data.get("signature"),
attestation_ref=data.get("attestation_ref"),
)
return dataclass_replace(
probe, evidence_digest=compute_sha256_hex(probe.digest_payload())
)
def digest_payload(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"probe_id": self.probe_id,
"task_id": self.task_id,
"claim_id": self.claim_id,
"worker_id": self.worker_id,
"worker_identity": self.worker_identity,
"operation_digest": self.operation_digest,
"outcome": self.outcome.value,
"observed_at": self.observed_at,
"expires_at": self.expires_at,
"probe_executor_identity": self.probe_executor_identity,
"attestation_ref": self.attestation_ref,
}
def verify(self, *, now: float, attestation_verifier: Any) -> bool:
if (
self.schema_version != 1
or self.expires_at <= now
or self.observed_at > now
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
or not (self.signature or self.attestation_ref)
):
return False
return bool(attestation_verifier and attestation_verifier(self))
def to_dict(self) -> dict[str, Any]:
return {
**self.digest_payload(),
"evidence_digest": self.evidence_digest,
"signature": self.signature,
}
def dataclass_replace(value: Any, **changes: Any) -> Any:
from dataclasses import replace
return replace(value, **changes)
@dataclass
class WorkerClaim:
claim_id: str
task_id: str
worker_id: str
worker_identity: str
claimed_at: float
lease_until: float
attempt: int
task_revision: int
operation_digest: str
status: str = "ACTIVE"
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
payload = {
"claim_id": self.claim_id,
"task_id": self.task_id,
"worker_id": self.worker_id,
"worker_identity": self.worker_identity,
"claimed_at": _fmt_ts(self.claimed_at),
"lease_until": _fmt_ts(self.lease_until),
"attempt": self.attempt,
"task_revision": self.task_revision,
"operation_digest": self.operation_digest,
"status": self.status,
}
if redact_payload:
payload = redact_value(payload)
return payload
@dataclass
class ResultEnvelope:
task_id: str
claim_id: str
worker_identity: str
status: str
outputs: list[dict[str, Any]]
evidence: list[dict[str, Any]]
started_at: str
completed_at: str
result_digest: str | None = None
signature: str | None = None
attestation_ref: str | None = None
operation_digest: str | None = None
trust_status: str = "untrusted_shadow_result"
def digest_payload(self) -> dict[str, Any]:
return {
"task_id": self.task_id,
"claim_id": self.claim_id,
"worker_identity": self.worker_identity,
"status": self.status,
"outputs": self.outputs,
"evidence": self.evidence,
"started_at": self.started_at,
"completed_at": self.completed_at,
"operation_digest": self.operation_digest,
"attestation_ref": self.attestation_ref,
}
def computed_digest(self) -> str:
return compute_sha256_hex(self.digest_payload())
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
payload = {
"task_id": self.task_id,
"claim_id": self.claim_id,
"worker_identity": self.worker_identity,
"status": self.status,
"outputs": self.outputs,
"evidence": self.evidence,
"started_at": self.started_at,
"completed_at": self.completed_at,
"result_digest": self.result_digest,
"signature": self.signature,
"attestation_ref": self.attestation_ref,
"operation_digest": self.operation_digest,
"trust_status": self.trust_status,
}
if redact_payload:
payload = redact_value(payload)
return payload
def _normalize_list(value: Any) -> list[str]:
if not isinstance(value, list):
raise ValueError("expected list")
if len(value) > 128 or any(
not isinstance(v, str)
or not v
or len(v) > 256
or any(ord(ch) < 32 for ch in v)
for v in value
):
raise ValueError("invalid list item")
return list(value)
def _to_positive_int(value: Any, default: int | None = None) -> int | None:
if value is None:
return default
try:
number = int(value)
except (TypeError, ValueError):
return default
return number if number >= 0 else default
def _to_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _first_non_none(primary: Any, fallback: Any) -> Any:
if primary is not None:
return primary
return fallback
def iso8601_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _fmt_ts(value: float | None) -> str | None:
if value is None:
return None
return datetime.fromtimestamp(value, tz=timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
-226
View File
@@ -1,226 +0,0 @@
"""Policy and verification logic (fail-closed)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from .errors import ForbiddenError, LockedError
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
READONLY_WORKER_ACTIONS = frozenset(
{"claim", "result", "fail", "progress", "heartbeat"}
)
ALL_ACTION_CAPABILITIES = frozenset(
{
"task.creator",
"worker.self_register",
"worker.admin",
"task.claim",
"task.cancel",
"task.delegate",
"task.read",
"task.audit",
}
)
@dataclass
class PolicyDecision:
allowed: bool
reason: str = ""
@dataclass(frozen=True)
class ReadonlyWorkerProfile:
"""A narrowly-scoped identity allowed to process safe Shadow tasks only."""
worker_id: str
identity: str
capabilities: frozenset[str]
task_types: frozenset[str] = frozenset({"code.review", "test.execute"})
def permits_task(self, task: TaskRecord) -> bool:
plan = task.task_plan
if plan.task_type not in self.task_types or plan.risk != "low":
return False
if plan.data_classification not in {"public", "internal"}:
return False
if plan.metadata.get("production_side_effects") is not False:
return False
return set(plan.required_capabilities).issubset(self.capabilities)
class IdentityVerifier:
def __init__(self, trusted_identities: set[str] | None = None):
self.trusted_identities = trusted_identities
def verify(self, identity: str) -> bool:
if self.trusted_identities is None:
return True
return identity in self.trusted_identities
class EvidenceVerifier:
def __init__(self, mandatory: list[str] | None = None):
self.mandatory = set(mandatory or [])
def verify(self, task: TaskRecord, evidences: list[dict]) -> PolicyDecision:
required = set(task.task_plan.evidence_requirements or [])
if not required and not self.mandatory:
return PolicyDecision(True)
observed = {e.get("kind") for e in evidences}
missing = required.union(self.mandatory) - observed
if missing:
return PolicyDecision(False, f"missing evidence: {', '.join(sorted(missing))}")
return PolicyDecision(True)
class PolicyEngine:
"""
Fail-closed policy engine with minimal but explicit decisions.
"""
def __init__(
self,
*,
identity_verifier: IdentityVerifier | None = None,
evidence_verifier: EvidenceVerifier | None = None,
execution_enabled: bool = False,
max_risk: str = "medium",
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
identity_capabilities: dict[str, set[str]] | None = None,
admin_identities: set[str] | None = None,
readonly_worker_execution_enabled: bool = False,
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
) -> None:
if max_risk not in RISK_RANK:
raise ValueError("invalid max_risk")
self.identity_verifier = identity_verifier or IdentityVerifier()
self.evidence_verifier = evidence_verifier or EvidenceVerifier()
self.execution_enabled = execution_enabled
self.max_risk = max_risk
self.result_signature_verifier = result_signature_verifier
self.probe_attestation_verifier = probe_attestation_verifier
self.identity_capabilities = {
identity: set(capabilities)
for identity, capabilities in (identity_capabilities or {}).items()
}
self.admin_identities = set(admin_identities or ())
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
self.readonly_worker_profiles = {
profile.identity: profile for profile in (readonly_worker_profiles or [])
}
def _readonly_worker_allows(
self, action: str, actor: str, task: TaskRecord | None
) -> bool:
profile = self.readonly_worker_profiles.get(actor)
return bool(
self.readonly_worker_execution_enabled
and action in READONLY_WORKER_ACTIONS
and profile is not None
and (task is None or profile.permits_task(task))
)
def assert_readonly_worker_binding(self, actor: str, worker_id: str) -> None:
profile = self.readonly_worker_profiles.get(actor)
if profile is not None and profile.worker_id != worker_id:
raise ForbiddenError("worker identity binding mismatch")
def assert_readonly_worker_registration(self, actor: str, payload: dict) -> None:
profile = self.readonly_worker_profiles.get(actor)
if profile is None:
return
if (
str(payload.get("worker_id") or "") != profile.worker_id
or str(payload.get("identity") or "") != profile.identity
or set(payload.get("capabilities") or []) != set(profile.capabilities)
or str(payload.get("trust_zone") or "") != "isolated-review"
or int(payload.get("max_concurrency") or 0) != 1
or int(payload.get("heartbeat_interval_seconds") or 0) != 15
):
raise ForbiddenError("readonly worker registration mismatch")
def verify_result(
self, result: ResultEnvelope, task: TaskRecord | None = None
) -> PolicyDecision:
computed = result.computed_digest()
if result.result_digest and result.result_digest != computed:
return PolicyDecision(False, "result digest mismatch")
sensitive = bool(
task
and (
task.task_plan.data_classification
in {"confidential", "credential-secret"}
or any(
capability.startswith(
("code.write", "deploy", "secret", "production")
)
for capability in task.task_plan.required_capabilities
)
)
)
if sensitive and not (result.signature or result.attestation_ref):
return PolicyDecision(False, "trusted result proof required")
if result.signature is not None or result.attestation_ref is not None:
if self.result_signature_verifier is None:
return PolicyDecision(False, "result signature verifier unavailable")
if not self.result_signature_verifier(result):
return PolicyDecision(False, "result signature invalid")
result.trust_status = "trusted_result"
else:
result.trust_status = "untrusted_shadow_result"
return PolicyDecision(True)
def authorize(self, action: str, actor: str, task: TaskRecord | None = None, context: dict | None = None) -> PolicyDecision:
context = context or {}
if not self.identity_verifier.verify(actor):
return PolicyDecision(False, "identity untrusted")
action_capabilities = {
"create": "task.creator",
"read": "task.read",
"audit": "task.audit",
"claim": "task.claim",
"result": "task.claim",
"fail": "task.claim",
"progress": "task.claim",
"heartbeat": "task.claim",
"cancel": "task.cancel",
"delegate": "task.delegate",
"worker.register": "worker.self_register",
"worker.admin": "worker.admin",
}
required_capability = action_capabilities.get(action)
if required_capability:
granted = set(
self.identity_capabilities.get(
actor, self.identity_capabilities.get("*", set())
)
)
if required_capability not in granted:
return PolicyDecision(False, f"action {action} not permitted")
if action in {"claim", "result", "fail", "cancel", "progress", "delegate", "heartbeat"} and not self.execution_enabled:
if not self._readonly_worker_allows(action, actor, task):
return PolicyDecision(False, "execution disabled")
if action == "delegate" and task and task.status not in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}:
risk = str(context.get("risk", task.task_plan.risk))
if RISK_RANK.get(risk, 2) > RISK_RANK.get(self.max_risk, 1):
return PolicyDecision(False, f"risk {risk} exceeds policy")
return PolicyDecision(True)
def assert_allowed(self, action: str, actor: str, task: TaskRecord | None = None, context: dict | None = None):
decision = self.authorize(action, actor, task, context=context)
if not decision.allowed:
if decision.reason == "identity untrusted":
raise ForbiddenError(decision.reason)
if decision.reason == "execution disabled":
raise LockedError("execution disabled")
raise ForbiddenError(decision.reason)
@@ -1,76 +0,0 @@
"""Task scheduler primitives."""
from __future__ import annotations
import time
from .errors import NotFoundError
from .capabilities import has_capabilities
from .models import WorkerClaim
from .store import TaskPlaneStore
class TaskScheduler:
def __init__(self, store: TaskPlaneStore):
self.store = store
def next_queued_task(self, worker_id: str):
worker = self.store.get_worker(worker_id)
tasks = sorted(
self.store.list_tasks(),
key=lambda task: (
-int(task.task_plan.metadata.get("priority", 0)),
task.created_at,
task.task_id,
),
)
for task in tasks:
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
continue
if task.task_plan.task_type == "worker.registry.audit":
continue
if task.status == "FAILED_RECOVERABLE" and not self.store.task_has_retryable_probe(
task.task_id
):
continue
if not has_capabilities(
worker.capabilities, task.task_plan.required_capabilities
):
continue
if any(
self.store.get_task(dependency).status != "COMPLETED"
for dependency in task.task_plan.dependencies
):
continue
if (
task.task_plan.data_classification == "credential-secret"
and worker.worker_type != "secure_file_transformer"
and "secure_file_transformer" not in worker.capabilities
):
continue
return task
return None
def schedule_for_worker(self, worker_id: str, lease_seconds: int = 15) -> WorkerClaim:
worker = self.store.get_worker(worker_id)
if worker.disabled:
from .errors import LockedError
raise LockedError("worker disabled")
if time.time() > worker.last_heartbeat + (
worker.heartbeat_interval_seconds * 3
):
from .errors import LockedError
raise LockedError("worker offline")
task = self.next_queued_task(worker_id)
if task is None:
raise NotFoundError("no queued tasks available")
claim = self.store.claim_task(
task.task_id,
worker_id,
worker.identity,
lease_seconds=lease_seconds,
expected_revision=task.revision,
operation_digest=task.task_plan.operation_digest(),
)
return claim
File diff suppressed because it is too large Load Diff
@@ -1,68 +0,0 @@
"""Worker registry abstraction and read helpers."""
from __future__ import annotations
import time
from .capabilities import has_capabilities
from .errors import NotFoundError
from .models import WorkerRegistration
from .store import TaskPlaneStore
class WorkerRegistry:
def __init__(self, store: TaskPlaneStore):
self.store = store
def register(
self,
payload: dict,
*,
actor_identity: str,
is_admin: bool,
expected_revision: int | None,
) -> WorkerRegistration:
reg = WorkerRegistration.from_dict(payload)
return self.store.register_worker(
reg,
actor_identity=actor_identity,
is_admin=is_admin,
expected_revision=expected_revision,
)
def heartbeat(
self,
worker_id: str,
heartbeat_interval_seconds: int | None = None,
*,
actor_identity: str,
expected_revision: int | None,
) -> WorkerRegistration:
return self.store.heartbeat_worker(
worker_id,
heartbeat_interval_seconds=heartbeat_interval_seconds,
actor_identity=actor_identity,
expected_revision=expected_revision,
)
def list(self):
return self.store.list_workers()
def get(self, worker_id: str) -> WorkerRegistration:
try:
return self.store.get_worker(worker_id)
except NotFoundError:
raise
def can_execute(self, worker_id: str, required_capabilities: list[str]) -> bool:
worker = self.store.get_worker(worker_id)
return has_capabilities(worker.capabilities, required_capabilities)
def is_online(self, worker_id: str, *, now: float | None = None) -> bool:
worker = self.store.get_worker(worker_id)
deadline = worker.last_heartbeat + (worker.heartbeat_interval_seconds * 3)
return not worker.disabled and (time.time() if now is None else now) <= deadline
def stale_workers(self, *, now: float | None = None) -> list[WorkerRegistration]:
current = time.time() if now is None else now
return [worker for worker in self.list() if not self.is_online(worker.worker_id, now=current)]
@@ -1 +0,0 @@
"""Isolated, Shadow-attested Codex Task Plane Worker components."""
@@ -1,212 +0,0 @@
"""Shadow-only Codex read/review/test worker lifecycle and CLI."""
from __future__ import annotations
import argparse
import json
import os
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from .result_attestation import TaskResultEnvelope, TrustPolicy, task_result_envelope
from .task_client import CredentialSource, StageAClientConfig, TaskPlaneClient
MAX_CONCURRENCY = 1
HEARTBEAT_INTERVAL_SECONDS = 15
CLAIM_LEASE_SECONDS = 120
SHADOW_ONLY = True
DEFAULT_TRUST_LEVEL = TrustPolicy.SHADOW_ATTESTED
DEFAULT_PRODUCTION_TRUST = False
READONLY_CAPABILITIES = ("code.read", "test.python", "review.security")
class CodexReviewTaskError(ValueError):
pass
@dataclass(frozen=True)
class CodexReviewTaskPolicy:
worker_id: str
identity: str
trust_zone: str = "isolated-review"
max_concurrency: int = MAX_CONCURRENCY
heartbeat_interval_seconds: int = HEARTBEAT_INTERVAL_SECONDS
claim_lease_seconds: int = CLAIM_LEASE_SECONDS
shadow_only: bool = SHADOW_ONLY
def registration_payload(self) -> dict[str, object]:
return {
"worker_id": self.worker_id, "worker_type": "codex-review",
"identity": self.identity, "owner_identity": self.identity,
"capabilities": list(READONLY_CAPABILITIES), "trust_zone": self.trust_zone,
"max_concurrency": self.max_concurrency,
"heartbeat_interval_seconds": self.heartbeat_interval_seconds,
"supported_protocols": ["aios-task-v1"], "registration_policy": "self",
}
def assert_shadow_only_task(self, task: dict[str, Any]) -> None:
plan = task.get("plan", task)
metadata = plan.get("metadata", {}) if isinstance(plan, dict) else {}
required = set(plan.get("required_capabilities", [])) if isinstance(plan, dict) else set()
if self.max_concurrency != 1 or self.heartbeat_interval_seconds != 15 or self.claim_lease_seconds != 120:
raise CodexReviewTaskError("worker timing/concurrency contract rejected")
if not isinstance(plan, dict) or metadata.get("production_side_effects") is not False:
raise CodexReviewTaskError("task must declare production_side_effects=false")
if plan.get("risk") != "low" or plan.get("task_type") not in {"code.review", "test.execute", None}:
raise CodexReviewTaskError("task risk or type rejected")
if plan.get("data_classification", "internal") not in {"public", "internal"}:
raise CodexReviewTaskError("task classification rejected")
if not required.issubset(set(READONLY_CAPABILITIES)):
raise CodexReviewTaskError("task capability rejected")
if any(value.startswith(("code.write", "deploy", "secret", "production")) for value in required):
raise CodexReviewTaskError("side-effect capability rejected")
def default_result_envelope(self, *, task_id: str, claim_id: str, worker_identity: str) -> TaskResultEnvelope:
return task_result_envelope(
task_id=task_id, claim_id=claim_id, worker_id=self.worker_id,
worker_identity=worker_identity, status="CREATED", outputs=[], evidence=[],
started_at="1970-01-01T00:00:00Z", completed_at="1970-01-01T00:00:00Z",
)
class _LeaseGuard:
def __init__(self, client: TaskPlaneClient, task_id: str, claim_id: str) -> None:
self.client, self.task_id, self.claim_id = client, task_id, claim_id
self.stop_event = threading.Event()
self.lost = threading.Event()
self.thread = threading.Thread(target=self._run, daemon=True)
def _run(self) -> None:
while not self.stop_event.wait(HEARTBEAT_INTERVAL_SECONDS):
try:
self.client.claim_heartbeat(self.task_id, claim_id=self.claim_id, lease_seconds=CLAIM_LEASE_SECONDS)
except Exception:
self.lost.set()
self.stop_event.set()
def __enter__(self) -> "_LeaseGuard":
self.thread.start()
return self
def __exit__(self, *_: object) -> None:
self.stop_event.set()
self.thread.join(timeout=2)
def require_active(self) -> None:
if self.lost.is_set():
raise CodexReviewTaskError("claim lease lost")
class CodexReviewWorker:
def __init__(self, client: TaskPlaneClient, policy: CodexReviewTaskPolicy, source_commit: str) -> None:
self.client, self.policy, self.source_commit = client, policy, source_commit
def register(self) -> dict[str, Any]:
return self.client.register_worker(**{
"worker_id": self.policy.worker_id, "worker_type": "codex-review",
"identity": self.policy.identity, "capabilities": READONLY_CAPABILITIES,
"trust_zone": self.policy.trust_zone, "max_concurrency": 1,
"heartbeat_interval_seconds": 15,
})
def poll_safe_task(self) -> dict[str, Any] | None:
for task in self.client.list_tasks():
if task.get("status") not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
continue
try:
full = self.client.get_task(str(task["task_id"]))
self.policy.assert_shadow_only_task(full)
return full
except (KeyError, CodexReviewTaskError):
continue
return None
def run_once(self, executor: Callable[[dict[str, Any], Callable[[float, str], None]], tuple[str, list[dict[str, Any]], list[dict[str, Any]]]]) -> str | None:
self.register()
task = self.poll_safe_task()
if task is None:
return None
task_id = str(task["task_id"])
claim = self.client.claim(task_id, worker_id=self.policy.worker_id, lease_seconds=120)
claim_id = str(claim["claim_id"])
started = datetime.now(timezone.utc).isoformat()
with _LeaseGuard(self.client, task_id, claim_id) as guard:
def emit(value: float, stage: str) -> None:
guard.require_active()
self.client.progress(task_id, claim_id=claim_id, progress=value, stage=stage)
summary, outputs, evidence = executor(task, emit)
guard.require_active()
completed = datetime.now(timezone.utc).isoformat()
envelope = task_result_envelope(
task_id=task_id, claim_id=claim_id,
task_revision=int(claim["task_revision"]),
worker_id=self.policy.worker_id, worker_identity=self.policy.identity,
operation_digest=str(claim["operation_digest"]),
source_commit=self.source_commit, status="COMPLETED", summary=summary,
outputs=outputs, evidence=evidence, started_at=started, completed_at=completed,
)
guard.require_active()
envelope_payload = envelope.to_dict()
self.client.result(
task_id,
claim_id=claim_id,
status=str(envelope_payload["status"]),
outputs=list(envelope_payload["outputs"]),
evidence=list(envelope_payload["evidence"]),
started_at=str(envelope_payload["started_at"]),
completed_at=str(envelope_payload["completed_at"]),
trust_level=TrustPolicy.SHADOW_ATTESTED.value,
production_trust=False,
)
return task_id
def build_default_task_policy(worker_id: str, worker_identity: str) -> CodexReviewTaskPolicy:
return CodexReviewTaskPolicy(worker_id=worker_id, identity=worker_identity)
def make_shadow_result_payload(*, task_id: str, claim_id: str, worker_identity: str) -> TaskResultEnvelope:
return task_result_envelope(
task_id=task_id, claim_id=claim_id, worker_identity=worker_identity,
status="COMPLETED", outputs=[], evidence=[],
started_at="1970-01-01T00:00:00Z", completed_at="1970-01-01T00:00:00Z",
)
def _assert_disabled_production() -> None:
for name in ("AIOS_GITOPS_WRITES_ENABLED", "AIOS_SILENT_RELEASE_EXECUTION_ENABLED", "AIOS_TASK_PLANE_EXECUTION_ENABLED"):
if os.environ.get(name, "false").lower() != "false":
raise CodexReviewTaskError(f"{name} must remain false")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--self-check", action="store_true")
parser.add_argument("--once", action="store_true")
args = parser.parse_args(argv)
_assert_disabled_production()
config = StageAClientConfig(os.environ.get("AIOS_TASK_PLANE_ENDPOINT", "http://127.0.0.1:18923"))
if args.self_check:
print(json.dumps({"status": "PASS", "shadow_only": True, "production_trust": False}, sort_keys=True))
return 0
if os.environ.get("AIOS_CODEX_WORKER_SHADOW_CANARY_ENABLED", "false").lower() != "true":
raise SystemExit("Codex worker execution is disabled")
credential = CredentialSource(Path(os.environ["AIOS_TASK_PLANE_WORKER_TOKEN_FILE"])).read_token()
source_commit = os.environ.get("AIOS_CODEX_WORKER_SOURCE_COMMIT", "")
policy = CodexReviewTaskPolicy(
worker_id=os.environ["AIOS_CODEX_WORKER_ID"],
identity=os.environ["AIOS_CODEX_WORKER_IDENTITY"],
)
worker = CodexReviewWorker(TaskPlaneClient(config, credential), policy, source_commit)
if args.once:
worker.run_once(lambda _task, emit: (emit(100, "VERIFIED") or ("shadow self-check", [], [])))
return 0
raise SystemExit("continuous real worker execution is not enabled")
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,62 +0,0 @@
"""Fail-closed command and environment policy for the isolated Codex Worker."""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
OID_RE = re.compile(r"[0-9a-f]{40}")
DENIED_ENV = ("TOKEN", "PASSWORD", "SECRET", "CREDENTIAL", "AUTHORIZATION", "COOKIE", "PRIVATE_KEY", "AWS_", "GITHUB_", "GITEA_")
class CommandPolicyError(ValueError):
pass
def isolated_environment(home: Path, temp_root: Path, pythonpath: str) -> dict[str, str]:
home.mkdir(mode=0o700, parents=True, exist_ok=True)
temp_root.mkdir(mode=0o700, parents=True, exist_ok=True)
allowed = {key: value for key, value in os.environ.items() if key in {"PATH", "LANG", "LC_ALL"}}
allowed.update({"HOME": str(home), "TMPDIR": str(temp_root), "PYTHONPATH": pythonpath, "PYTHONDONTWRITEBYTECODE": "1"})
if any(any(marker in key.upper() for marker in DENIED_ENV) for key in allowed):
raise CommandPolicyError("secret environment retained")
return allowed
class CommandPolicy:
def __init__(self, repo: Path, result_root: Path, *, timeout_seconds: int = 300, output_limit: int = 1_000_000):
self.repo = repo.resolve()
self.result_root = result_root.resolve()
self.timeout_seconds = timeout_seconds
self.output_limit = output_limit
def _oid(self, value: str) -> str:
if not OID_RE.fullmatch(value):
raise CommandPolicyError("exact commit oid required")
return value
def build(self, kind: str, *, oid: str, paths: list[str] | None = None, output: Path | None = None) -> list[str]:
oid = self._oid(oid)
paths = paths or []
if any(not path or path.startswith("-") or Path(path).is_absolute() or ".." in Path(path).parts for path in paths):
raise CommandPolicyError("invalid allowlisted path")
if kind == "diff-check":
return ["git", "-C", str(self.repo), "diff", "--check"]
if kind == "git-show" and len(paths) == 1:
return ["git", "-C", str(self.repo), "show", f"{oid}:{paths[0]}"]
if kind == "pytest" and paths and all(path.startswith("tests/task_plane/") or path == "tests/test_isolated_writer_policy.py" for path in paths):
return ["python3", "-m", "pytest", "-q", *paths]
if kind == "compileall" and paths and all(path.startswith(("active-runtime/app/task_plane", "runtime/agent-os")) for path in paths):
return ["python3", "-m", "compileall", "-q", *paths]
if kind == "acceptance" and output is not None:
target = output.resolve()
if target.parent != self.result_root:
raise CommandPolicyError("output escapes result root")
return ["bash", "scripts/task-plane/run_task_plane_acceptance.sh", str(target)]
raise CommandPolicyError("command template not allowlisted")
def run(self, argv: list[str], env: dict[str, str]) -> tuple[int, str]:
completed = subprocess.run(argv, cwd=self.repo, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=self.timeout_seconds, start_new_session=True, check=False)
output = completed.stdout[: self.output_limit]
return completed.returncode, output
@@ -1,56 +0,0 @@
"""Bounded progress payload helpers."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class ProgressEnvelope:
claim_id: str
progress: int | float
stage: str
lease_seconds: int = 120
message: str | None = None
def validate(self) -> None:
if not self.claim_id:
raise ValueError("claim_id is required")
numeric = float(self.progress)
if not math.isfinite(numeric) or not 0 <= numeric <= 100:
raise ValueError("progress must be finite and within [0, 100]")
if not 1 <= self.lease_seconds <= 3600:
raise ValueError("lease_seconds out of range")
if not self.stage or len(self.stage) > 64 or any(ord(ch) < 32 for ch in self.stage):
raise ValueError("stage is invalid")
if self.message is not None and len(self.message) > 512:
raise ValueError("message is too long")
def as_request(self) -> dict[str, Any]:
self.validate()
return {
"claim_id": self.claim_id, "progress": float(self.progress),
"stage": self.stage, "lease_seconds": self.lease_seconds,
"message": self.message,
}
def clamp_progress(value: float) -> float:
numeric = float(value)
if not math.isfinite(numeric):
raise ValueError("progress must be finite")
return max(0.0, min(100.0, numeric))
def safe_stage_name(stage: str | None) -> str:
value = str(stage or "").strip()
if not value:
return "RUNNING"
if any(ord(ch) < 32 for ch in value):
raise ValueError("stage is invalid")
return value[:64]
def build_progress_payload(*, claim_id: str, progress: float, stage: str = "RUNNING", lease_seconds: int = 120, message: str | None = None) -> dict[str, Any]:
return ProgressEnvelope(claim_id, clamp_progress(progress), safe_stage_name(stage), lease_seconds, message).as_request()
@@ -1,157 +0,0 @@
"""Canonical, Shadow-only result envelopes for the Codex review worker."""
from __future__ import annotations
import hashlib
import hmac
import json
import re
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
OID_RE = re.compile(r"^[0-9a-f]{40}$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
class TrustPolicy(StrEnum):
SHADOW_ATTESTED = "SHADOW_ATTESTED"
SHADOW_UNATTESTED = "SHADOW_UNATTESTED"
def _canonical_sha256(payload: object) -> str:
encoded = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
@dataclass(frozen=True)
class TaskResultEnvelope:
task_id: str
claim_id: str
worker_identity: str
status: str
outputs: list[dict[str, Any]]
evidence: list[dict[str, Any]]
started_at: str
completed_at: str
task_revision: int = 1
worker_id: str = ""
operation_digest: str = ""
source_commit: str = "0" * 40
summary: str = ""
evidence_digests: tuple[str, ...] = ()
result_digest: str | None = None
trust_level: TrustPolicy = TrustPolicy.SHADOW_ATTESTED
production_trust: bool = False
signature: str | None = None
attestation_ref: str | None = None
def digest_payload(self) -> dict[str, Any]:
return {
"task_id": self.task_id,
"claim_id": self.claim_id,
"task_revision": self.task_revision,
"worker_id": self.worker_id,
"worker_identity": self.worker_identity,
"operation_digest": self.operation_digest,
"source_commit": self.source_commit,
"status": self.status,
"summary": self.summary,
"outputs": self.outputs,
"evidence": self.evidence,
"evidence_digests": list(self.evidence_digests),
"started_at": self.started_at,
"completed_at": self.completed_at,
"trust_level": TrustPolicy.SHADOW_ATTESTED.value,
"production_trust": False,
"attestation_ref": self.attestation_ref,
}
def computed_digest(self) -> str:
return _canonical_sha256(self.digest_payload())
def validate(self) -> None:
required = {
"task_id": self.task_id,
"claim_id": self.claim_id,
"worker_id": self.worker_id,
"worker_identity": self.worker_identity,
"operation_digest": self.operation_digest,
"status": self.status,
"started_at": self.started_at,
"completed_at": self.completed_at,
}
if any(not str(value).strip() for value in required.values()):
raise ValueError("result envelope required field missing")
if self.task_revision < 1:
raise ValueError("task_revision must be positive")
if not OID_RE.fullmatch(self.source_commit):
raise ValueError("source_commit must be a full commit OID")
if not SHA256_RE.fullmatch(self.operation_digest):
raise ValueError("operation_digest must be SHA-256")
if any(not SHA256_RE.fullmatch(value) for value in self.evidence_digests):
raise ValueError("evidence digest must be SHA-256")
observed = tuple(_canonical_sha256(item) for item in self.evidence)
if observed != self.evidence_digests:
raise ValueError("evidence digest binding mismatch")
if self.trust_level is not TrustPolicy.SHADOW_ATTESTED:
raise ValueError("Codex review results must remain SHADOW_ATTESTED")
if self.production_trust is not False:
raise ValueError("production_trust is forbidden")
if self.result_digest is not None and not hmac.compare_digest(
self.result_digest, self.computed_digest()
):
raise ValueError("result digest mismatch")
def finalized(self) -> "TaskResultEnvelope":
from dataclasses import replace
envelope = replace(self, result_digest=self.computed_digest())
envelope.validate()
return envelope
def to_dict(self) -> dict[str, Any]:
envelope = self if self.result_digest is not None else self.finalized()
envelope.validate()
return {**envelope.digest_payload(), "result_digest": envelope.result_digest}
def task_result_envelope(
*,
task_id: str,
claim_id: str,
worker_identity: str,
status: str,
outputs: list[dict[str, Any]],
evidence: list[dict[str, Any]],
started_at: str,
completed_at: str,
task_revision: int = 1,
worker_id: str | None = None,
operation_digest: str | None = None,
source_commit: str = "0" * 40,
summary: str = "",
trust_level: TrustPolicy = TrustPolicy.SHADOW_ATTESTED,
production_trust: bool = False,
) -> TaskResultEnvelope:
evidence_digests = tuple(_canonical_sha256(item) for item in evidence)
envelope = TaskResultEnvelope(
task_id=task_id,
claim_id=claim_id,
task_revision=task_revision,
worker_id=worker_id or worker_identity,
worker_identity=worker_identity,
operation_digest=operation_digest or ("0" * 64),
source_commit=source_commit,
status=status,
summary=summary,
outputs=outputs,
evidence=evidence,
evidence_digests=evidence_digests,
started_at=started_at,
completed_at=completed_at,
trust_level=trust_level,
production_trust=production_trust,
)
return envelope.finalized()
@@ -1,138 +0,0 @@
"""Fixed-command, no-shell runner for a read-only Codex workspace."""
from __future__ import annotations
import os
import re
import signal
import subprocess
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from .command_policy import CommandPolicyError
from .workspace import WorkspacePermissionError, assert_private_path
OID_RE = re.compile(r"^[0-9a-f]{40}$")
FORBIDDEN_SNIPPETS = ("http://", "https://", ";", "&&", "||", "|", "`", "${", "$(")
class CommandRunnerError(ValueError):
pass
class CommandDisallowedError(CommandRunnerError):
pass
class CommandPolicyViolation(CommandRunnerError):
pass
@dataclass(frozen=True)
class CommandResult:
returncode: int
output: str
class SafeCommandRunner:
allowed_kinds = ("git-diff", "diff-check", "git-show", "pytest", "compileall", "acceptance")
def __init__(self, repo: Path, result_root: Path, *, policy=None, timeout_seconds: int = 300, output_limit: int = 1_000_000) -> None:
self.repo = assert_private_path(repo)
self.result_root = assert_private_path(result_root)
self.timeout_seconds, self.output_limit = timeout_seconds, output_limit
@staticmethod
def _oid(value: str) -> str:
if not OID_RE.fullmatch(str(value)):
raise CommandPolicyError("exact commit oid required")
return value
@staticmethod
def _forbidden(value: str) -> bool:
lowered = value.lower()
return (
re.search(r"\b(?:bash|eval)\b", lowered) is not None
or re.search(r"(^|\s)-c(\s|$)", lowered) is not None
or any(marker in value for marker in FORBIDDEN_SNIPPETS)
)
def _path(self, value: str, *, allowed_prefixes: tuple[str, ...]) -> str:
if not value or value.startswith("-") or "\\" in value or self._forbidden(value):
raise CommandDisallowedError("unsafe path input")
pure = PurePosixPath(value)
if pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
raise CommandPolicyError("path traversal is forbidden")
if not any(value == prefix or value.startswith(prefix + "/") for prefix in allowed_prefixes):
raise CommandPolicyError("path outside allowlist")
try:
candidate = assert_private_path(self.repo / value)
except WorkspacePermissionError as exc:
raise CommandPolicyError("symlink path is forbidden") from exc
try:
candidate.relative_to(self.repo)
except ValueError:
raise CommandPolicyError("path escapes repository") from None
return value
def _output(self, output: Path) -> Path:
if Path(output).is_absolute() or Path(output).name != str(output):
raise CommandPolicyError("output must be a direct result file")
target = assert_private_path(self.result_root / output)
try:
target.relative_to(self.result_root)
except ValueError:
raise CommandPolicyError("output escapes result root") from None
return target
def build(self, kind: str, *, oid: str, other_oid: str | None = None, paths: list[str] | None = None, output: Path | None = None) -> list[str]:
if kind not in self.allowed_kinds:
raise CommandDisallowedError("command kind not allowed")
first = self._oid(oid)
values = paths or []
if kind == "git-diff":
second = self._oid(str(other_oid or ""))
return ["git", "-C", str(self.repo), "diff", "--no-ext-diff", "--no-textconv", first, second]
if kind == "diff-check":
return ["git", "-C", str(self.repo), "diff", "--check"]
if kind == "git-show" and len(values) == 1:
path = self._path(values[0], allowed_prefixes=("active-runtime", "runtime", "scripts", "tests", "docs"))
return ["git", "-C", str(self.repo), "show", f"{first}:{path}"]
if kind == "pytest" and values:
safe = [self._path(value, allowed_prefixes=("tests/task_plane", "tests/test_isolated_writer_policy.py")) for value in values]
return ["python3", "-m", "pytest", "-q", *safe]
if kind == "compileall" and values:
safe = [self._path(value, allowed_prefixes=("active-runtime/app/task_plane", "runtime/agent-os")) for value in values]
return ["python3", "-m", "compileall", "-q", *safe]
if kind == "acceptance" and output is not None and not values:
return ["bash", "scripts/task-plane/run_task_plane_acceptance.sh", str(self._output(output))]
raise CommandPolicyError("command template not allowlisted")
def run(self, kind: str, *, oid: str, other_oid: str | None = None, paths: list[str] | None = None, output: Path | None = None, env: dict[str, str] | None = None) -> CommandResult:
if env and any(marker in key.upper() for key in env for marker in ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "COOKIE", "PRIVATE_KEY")):
raise CommandPolicyViolation("secret environment rejected")
command = self.build(kind, oid=oid, other_oid=other_oid, paths=paths, output=output)
safe_env = {
"HOME": str(self.result_root),
"PATH": os.environ.get("PATH", ""),
"LANG": "C.UTF-8",
"PYTHONDONTWRITEBYTECODE": "1",
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
}
process = subprocess.Popen(
command, cwd=self.repo, env=safe_env, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
start_new_session=True,
)
try:
text, _ = process.communicate(timeout=self.timeout_seconds)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
text, _ = process.communicate()
raise CommandRunnerError("command timeout")
return CommandResult(process.returncode, text[: self.output_limit])
Runner = SafeCommandRunner
FixedCommandRunner = SafeCommandRunner
CommandResultEnvelope = CommandResult
@@ -1,300 +0,0 @@
"""Fail-closed loopback HTTP client for the Codex review worker."""
from __future__ import annotations
import hashlib
import json
import os
import re
import stat
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
MAX_HTTP_BODY_BYTES = 1_000_000
_RETRYABLE_STATUS = {429, 503}
_RETRY_BACKOFF_SECONDS = (0.25, 0.5, 1.0, 2.0)
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class TaskClientError(RuntimeError):
pass
class TaskClientProtocolError(TaskClientError):
pass
class TaskClientAuthError(TaskClientError):
pass
class TaskClientResponseTooLargeError(TaskClientError):
pass
def _safe_identifier(value: str, name: str) -> str:
candidate = str(value or "")
if not _IDENTIFIER_RE.fullmatch(candidate):
raise TaskClientProtocolError(f"invalid {name}")
return urllib.parse.quote(candidate, safe="")
def _stable_key(operation: str, payload: object) -> str:
encoded = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return f"codex-worker:{operation}:{hashlib.sha256(encoded).hexdigest()}"
@dataclass(frozen=True)
class CredentialSource:
token_path: Path
token_owner: str = "aiman"
token_group: str = "gitops"
expected_mode: int = 0o600
def read_token(self) -> str:
import grp
import pwd
path = Path(self.token_path)
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
fd = os.open(path, flags)
except OSError as exc:
raise TaskClientAuthError("credential file unavailable") from exc
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode):
raise TaskClientAuthError("credential file must be regular")
if stat.S_IMODE(info.st_mode) != self.expected_mode:
raise TaskClientAuthError("credential file mode rejected")
try:
owner = pwd.getpwuid(info.st_uid).pw_name
group = grp.getgrgid(info.st_gid).gr_name
except KeyError as exc:
raise TaskClientAuthError("credential ownership unresolved") from exc
if owner != self.token_owner or group != self.token_group:
raise TaskClientAuthError("credential ownership rejected")
value = os.read(fd, 4097)
if len(value) > 4096:
raise TaskClientAuthError("credential file too large")
token = value.decode("utf-8", errors="strict").strip()
if len(token.encode("utf-8")) < 32:
raise TaskClientAuthError("credential unavailable")
return token
finally:
os.close(fd)
@dataclass(frozen=True)
class StageAClientConfig:
base_url: str = "http://127.0.0.1:18923"
timeout_seconds: float = 12.0
max_request_bytes: int = MAX_HTTP_BODY_BYTES
max_response_bytes: int = MAX_HTTP_BODY_BYTES
max_retry_attempts: int = 4
default_lease_seconds: int = 120
default_worker_heartbeat_interval_seconds: int = 15
default_max_concurrency: int = 1
def __post_init__(self) -> None:
parsed = urllib.parse.urlsplit(self.base_url)
if (
parsed.scheme != "http"
or parsed.hostname not in {"127.0.0.1", "::1"}
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or parsed.path not in {"", "/"}
or parsed.port is None
):
raise ValueError("Task Plane endpoint must be explicit loopback HTTP")
class TaskPlaneClient:
def __init__(self, config: StageAClientConfig, token: str) -> None:
if len(str(token).encode("utf-8")) < 16:
raise TaskClientAuthError("credential is required")
self.config = config
self.__token = str(token)
def __repr__(self) -> str:
return f"TaskPlaneClient(base_url={self.config.base_url!r}, credential=<redacted>)"
def _encode(self, payload: dict[str, Any] | None) -> bytes | None:
if payload is None:
return None
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(body) > self.config.max_request_bytes:
raise TaskClientResponseTooLargeError("request payload exceeds configured limit")
return body
def _read_response(self, handle: Any) -> tuple[int, Any]:
status_code = int(getattr(handle, "status", 200))
raw = handle.read(self.config.max_response_bytes + 1)
if len(raw) > self.config.max_response_bytes:
raise TaskClientResponseTooLargeError("response payload exceeds configured limit")
if not raw:
return status_code, {}
try:
return status_code, json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise TaskClientProtocolError("response is not valid JSON") from exc
def _request(
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
*,
idempotency_key: str | None = None,
) -> tuple[int, Any]:
if not path.startswith("/") or "//" in path or "?" in path or "#" in path:
raise TaskClientProtocolError("invalid API path")
body = self._encode(payload)
attempts = max(1, min(self.config.max_retry_attempts, 4))
for index in range(attempts):
headers = {"Authorization": f"Bearer {self.__token}", "Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
request = urllib.request.Request(
f"{self.config.base_url.rstrip('/')}{path}",
method=method,
data=body,
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=self.config.timeout_seconds) as response:
return self._read_response(response)
except urllib.error.HTTPError as exc:
status_code, response = self._read_response(exc)
if status_code in {401, 403}:
raise TaskClientAuthError(f"request rejected: HTTP {status_code}")
if status_code in _RETRYABLE_STATUS and index + 1 < attempts:
time.sleep(_RETRY_BACKOFF_SECONDS[index])
continue
code = response.get("error") if isinstance(response, dict) else "request_failed"
raise TaskClientProtocolError(f"request failed: HTTP {status_code} ({code})")
except urllib.error.URLError as exc:
if index + 1 < attempts:
time.sleep(_RETRY_BACKOFF_SECONDS[index])
continue
raise TaskClientProtocolError("request transport error") from exc
raise TaskClientProtocolError("request failed")
def _post(self, path: str, payload: dict[str, Any], *, idempotency_key: str | None = None) -> dict[str, Any]:
status_code, response = self._request(
"POST", path, payload, idempotency_key=idempotency_key or _stable_key(path, payload)
)
if status_code not in {200, 201} or not isinstance(response, dict):
raise TaskClientProtocolError("unexpected API response")
return response
def _get(self, path: str) -> Any:
status_code, response = self._request("GET", path)
if status_code != 200:
raise TaskClientProtocolError("unexpected API response")
return response
def list_tasks(self) -> list[dict[str, Any]]:
response = self._get("/tasks")
if not isinstance(response, list) or any(not isinstance(item, dict) for item in response):
raise TaskClientProtocolError("invalid task list")
return response
def get_task(self, task_id: str) -> dict[str, Any]:
response = self._get(f"/tasks/{_safe_identifier(task_id, 'task_id')}")
if not isinstance(response, dict):
raise TaskClientProtocolError("invalid task response")
return response
def register_worker(
self, *, worker_id: str, worker_type: str, identity: str,
capabilities: Iterable[str], trust_zone: str, max_concurrency: int,
heartbeat_interval_seconds: int, supported_protocols: Iterable[str] = ("aios-task-v1",),
) -> dict[str, Any]:
payload = {
"worker_id": worker_id, "worker_type": worker_type, "identity": identity,
"capabilities": sorted(set(capabilities)), "trust_zone": trust_zone,
"max_concurrency": max_concurrency,
"heartbeat_interval_seconds": heartbeat_interval_seconds,
"supported_protocols": list(supported_protocols),
"owner_identity": identity, "registration_policy": "self",
}
return self._post("/workers/register", payload, idempotency_key=_stable_key("register", payload))
def worker_heartbeat(self, worker_id: str, *, heartbeat_interval_seconds: int | None = None) -> dict[str, Any]:
payload = {} if heartbeat_interval_seconds is None else {"heartbeat_interval_seconds": heartbeat_interval_seconds}
return self._post(f"/workers/{_safe_identifier(worker_id, 'worker_id')}/heartbeat", payload)
def claim(self, task_id: str, *, worker_id: str, lease_seconds: int | None = None) -> dict[str, Any]:
payload = {"worker_id": worker_id, "lease_seconds": lease_seconds or self.config.default_lease_seconds}
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/claim", payload)
def claim_heartbeat(self, task_id: str, *, claim_id: str, lease_seconds: int | None = None) -> dict[str, Any]:
if not claim_id:
raise TaskClientProtocolError("claim_id is required")
payload = {"claim_id": claim_id, "lease_seconds": lease_seconds or self.config.default_lease_seconds}
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/heartbeat", payload)
def progress(self, task_id: str, *, claim_id: str, progress: int | float, stage: str, message: str | None = None, lease_seconds: int | None = None) -> dict[str, Any]:
if not claim_id:
raise TaskClientProtocolError("claim_id is required for progress")
payload = {"claim_id": claim_id, "progress": progress, "stage": stage, "message": message, "lease_seconds": lease_seconds or self.config.default_lease_seconds}
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/progress", payload)
def result(self, task_id: str, *, claim_id: str, status: str, outputs: list[dict[str, Any]], evidence: list[dict[str, Any]], started_at: str, completed_at: str, result_digest: str | None = None, operation_digest: str | None = None, trust_level: str | None = None, production_trust: bool | None = None, **_: Any) -> dict[str, Any]:
if not claim_id:
raise TaskClientProtocolError("claim_id is required for result submission")
if production_trust not in {None, False}:
raise TaskClientProtocolError("production_trust is forbidden")
payload = {"claim_id": claim_id, "status": status, "outputs": outputs, "evidence": evidence, "started_at": started_at, "completed_at": completed_at}
if result_digest:
payload["result_digest"] = result_digest
if operation_digest:
payload["operation_digest"] = operation_digest
if trust_level:
payload["trust_level"] = trust_level
payload["production_trust"] = False
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/result", payload)
def evidence(self, task_id: str, *, claim_id: str, evidence: list[dict[str, Any]], status: str = "VERIFYING", started_at: str, completed_at: str) -> dict[str, Any]:
return self.result(task_id, claim_id=claim_id, status=status, outputs=[], evidence=evidence, started_at=started_at, completed_at=completed_at)
def fail(self, task_id: str, *, claim_id: str, reason: str = "failed", recoverable: bool = False) -> dict[str, Any]:
if not claim_id:
raise TaskClientProtocolError("claim_id is required for fail")
return self._post(f"/tasks/{_safe_identifier(task_id, 'task_id')}/fail", {"claim_id": claim_id, "reason": reason, "recoverable": recoverable})
@dataclass(frozen=True)
class WorkerConfig:
worker_id: str
worker_type: str
identity: str
trust_zone: str
max_concurrency: int = 1
heartbeat_interval_seconds: int = 15
claim_lease_seconds: int = 120
supported_protocols: tuple[str, ...] = ("aios-task-v1",)
capabilities: tuple[str, ...] = ("code.read", "test.python", "review.security")
def to_registration_payload(self) -> dict[str, Any]:
return {
"worker_id": self.worker_id, "worker_type": self.worker_type,
"identity": self.identity, "capabilities": list(self.capabilities),
"trust_zone": self.trust_zone, "max_concurrency": self.max_concurrency,
"heartbeat_interval_seconds": self.heartbeat_interval_seconds,
"supported_protocols": list(self.supported_protocols),
"owner_identity": self.identity, "registration_policy": "self",
}
@@ -1,162 +0,0 @@
"""Private, symlink-safe per-claim workspaces."""
from __future__ import annotations
import os
import shutil
import stat
from dataclasses import dataclass
from pathlib import Path
from .command_policy import isolated_environment
WORKSPACE_DIR_MODE = 0o700
RESULT_FILE_MODE = 0o600
class WorkspaceError(RuntimeError):
pass
class WorkspacePermissionError(WorkspaceError):
pass
class WorkspaceCleanupError(WorkspaceError):
pass
def _lexical(path: Path) -> Path:
return Path(os.path.abspath(os.fspath(path)))
def assert_private_path(path: Path, *, expected_mode: int = WORKSPACE_DIR_MODE) -> Path:
"""Reject every existing symlink before resolving the final path."""
lexical = _lexical(Path(path))
current = Path(lexical.anchor)
for component in lexical.parts[1:]:
current = current / component
try:
info = current.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(info.st_mode):
raise WorkspacePermissionError("symlink path is forbidden")
return lexical
def _ensure_directory(path: Path, *, mode: int = WORKSPACE_DIR_MODE) -> None:
lexical = assert_private_path(path)
lexical.mkdir(mode=mode, parents=True, exist_ok=True)
if lexical.is_symlink() or not lexical.is_dir():
raise WorkspacePermissionError("workspace directory rejected")
os.chmod(lexical, mode)
if stat.S_IMODE(lexical.stat().st_mode) != mode:
raise WorkspacePermissionError("workspace directory mode rejected")
def _ensure_result_name(name: str) -> str:
safe = str(name).strip()
if not safe or safe in {".", ".."} or "/" in safe or "\\" in safe or "\0" in safe:
raise WorkspaceError("invalid result file name")
return safe
def _within(path: Path, base: Path) -> Path:
lexical = assert_private_path(path)
root = assert_private_path(base)
try:
lexical.relative_to(root)
except ValueError:
raise WorkspacePermissionError("path escapes workspace") from None
return lexical
def _copy_readonly_tree(source: Path, destination: Path) -> None:
source = assert_private_path(source)
if not source.is_dir():
raise WorkspaceError("source archive tree unavailable")
for item in source.rglob("*"):
if item.is_symlink():
raise WorkspacePermissionError("source archive symlink rejected")
shutil.copytree(source, destination, dirs_exist_ok=True, symlinks=False)
for item in destination.rglob("*"):
if item.is_file():
os.chmod(item, 0o400)
elif item.is_dir():
os.chmod(item, 0o500)
@dataclass(frozen=True)
class WorkspaceLayout:
workspace_root: Path
repo_root: Path
result_root: Path
home: Path
tmp: Path
@dataclass
class TaskWorkspace:
layout: WorkspaceLayout
task_id: str
claim_id: str
def __init__(self, root: Path, task_id: str, claim_id: str, *, repo_subdir: str = "repo") -> None:
workspace_root = assert_private_path(root)
_ensure_directory(workspace_root)
self.layout = WorkspaceLayout(
workspace_root, workspace_root / repo_subdir, workspace_root / "results",
workspace_root / "home", workspace_root / "tmp",
)
for path in (self.layout.repo_root, self.layout.result_root, self.layout.home, self.layout.tmp):
_ensure_directory(path)
self.task_id, self.claim_id = task_id, claim_id
@classmethod
def create(cls, root: Path, task_id: str, claim_id: str, *, repo_subdir: str = "repo") -> "TaskWorkspace":
return cls(root, task_id, claim_id, repo_subdir=repo_subdir)
def __enter__(self) -> "TaskWorkspace":
return self
def __exit__(self, *_: object) -> None:
self.cleanup()
def materialize_readonly_archive(self, source: Path) -> None:
if any(self.layout.repo_root.iterdir()):
raise WorkspaceError("workspace repository is not empty")
_copy_readonly_tree(source, self.layout.repo_root)
def environ(self) -> dict[str, str]:
return isolated_environment(self.layout.home, self.layout.tmp, "active-runtime")
def result_path(self, name: str) -> Path:
return _within(self.layout.result_root / _ensure_result_name(name), self.layout.result_root)
def write_result(self, *, name: str, payload: str) -> Path:
path = self.result_path(name)
flags = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(path, flags, RESULT_FILE_MODE)
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise WorkspacePermissionError("result path is not regular")
os.write(fd, payload.encode("utf-8"))
os.fsync(fd)
os.fchmod(fd, RESULT_FILE_MODE)
finally:
os.close(fd)
return path
def cleanup(self) -> None:
root = assert_private_path(self.layout.workspace_root)
if not root.exists():
return
try:
shutil.rmtree(root)
except OSError as exc:
raise WorkspaceCleanupError("workspace cleanup failed") from exc
Workspace = TaskWorkspace
+12 -24
View File
@@ -1,25 +1,13 @@
{
"current_baseline": "V2R1C2",
"previous_baseline": "V2R1C1",
"promoted_at": "2026-07-22T01:40:51Z",
"git": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"promoted_by": "codex/owner-approved-gitops",
"promotion_reason": "Owner-authorized V2R1C2 Unified Portal production release after exact tag binding, complete evidence verification, and production acceptance PASS.",
"version": "V2R1C2",
"release_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"tag_oid": "a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36",
"target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
"release_run_id": "AIOS-V2R1C2-PRODUCTION-20260722T011836Z",
"status": "PRODUCTION_DEPLOYED",
"production_service": "aios-v2r1c2-production.service",
"production_port": 18920,
"rollback_version": "V2R1C1",
"constraints": {
"product_code_changed": true,
"production_redeployed": true,
"production_traffic_changed": true,
"tag_rewritten": false,
"force_push_allowed": false,
"rollback_assets_preserved": true
}
}
"current_baseline": "V1R4C6",
"previous_baseline": "V1R4C5",
"promoted_at": "2026-07-11T21:00:00+08:00",
"git": "e204a90398bf646d46245b3088226749fa056edd",
"promoted_by": "openclaw/admin",
"promotion_reason": "V1R4C6 admin search agent integration, search gateway, agent platform, VERSION bump, merge tests deploy pipeline — not yet deployed to production",
"constraints": {
"memory_search_enabled": false,
"force_push_allowed": false,
"gitea_db_mutation_requires_owner_approval": true
}
}
-103
View File
@@ -1,103 +0,0 @@
{
"schema_version": 4,
"automatic": [
"build",
"test",
"artifact",
"canary",
"evidence"
],
"owner": [
"signed_release_plan"
],
"forbidden": [
"force_push",
"tag_rewrite",
"direct_main_push",
"baseline_mutation",
"registry_mutation"
],
"runtime_deployment": {
"approval_action": "signed_release_plan",
"required_capability": "runtime_switch",
"tag_operations_allowed": false,
"baseline_mutation_allowed": false,
"registry_mutation_allowed": false,
"writer_default": false,
"rollback_required": true
},
"tag_release": {
"approval_action": "signed_release_plan",
"required_capability": "tag_publish",
"separate_controller": true,
"duplicate_conflict": "fail_closed",
"rewrite_allowed": false,
"force_push_allowed": false
},
"baseline_registry_finalize": {
"approval_action": "signed_release_plan",
"required_capability": "baseline_registry_finalize",
"separate_controller": true,
"remote_tag_verification_required": true,
"gitea_api_verification_required": true,
"registry_baseline_consistency_required": true
},
"approval": {
"algorithm": "Ed25519",
"trust_domain": "aios.production",
"maximum_lifetime_seconds": 3600,
"release_plan_sha256_required": true,
"not_before_required": true,
"replay_protection": true,
"revocation_fail_closed": true,
"key_rotation_supported": true,
"capabilities": [
"runtime_switch",
"tag_publish",
"baseline_registry_finalize"
]
},
"execution": {
"environment_variable": "AIOS_SILENT_RELEASE_EXECUTION_ENABLED",
"default_enabled": false,
"shadow_side_effects_allowed": false,
"durable_saga_required": true,
"pre_switch_revalidation_required": true,
"trusted_candidate_builder_required": true,
"actual_plan_regeneration_required": true,
"startup_fail_closed_on_missing_component": true,
"recovery_worker_shadow_default": true,
"production_approval_backend_required": true,
"production_builder_signer": "kms-hsm-secret-manager-only",
"production_execution_without_backend": "unavailable"
},
"durability": {
"journal": "sha256-hash-chain",
"probe_journal": "sha256-hash-chain",
"exclusive_capability_claims": true,
"side_effect_probe_before_lease_takeover": true,
"side_effect_probe_before_failed_recoverable_retry": true,
"probe_results": [
"NOT_APPLIED",
"APPLIED_VERIFIED",
"INDETERMINATE",
"CONFLICT"
],
"probe_operation_digest_required": true,
"probe_freshness_required": true,
"tamper_result": "MANUAL_OWNER_REVIEW_REQUIRED"
},
"tag_pending": {
"required_observations": [
"remote_git_ref",
"annotated_tag_object",
"peeled_target",
"gitea_api",
"gitea_listing",
"repository_identity"
],
"indeterminate_result": "RECONCILIATION_REQUIRED",
"conflict_result": "MANUAL_OWNER_REVIEW_REQUIRED",
"automatic_reconciliation": false
}
}
-59
View File
@@ -1,59 +0,0 @@
version: 1
policy_id: gitops-isolated-worktree-governance-20260714T232948UTC8
runtime_identity:
user: aiman
group: gitops
umask: "0002"
prohibit_root_workspace_writes: true
repository:
source_worktree: /data/aios-gitops/workspace
bare_repo: /data/aios-gitops/repo.git
worktree_root: /data/aios-gitops/worktrees
remote: origin
worktrees:
controller: /data/aios-gitops/worktrees/controller
task_pattern: /data/aios-gitops/worktrees/{agent}/{task_id}
allowed_agents:
- openclaw
- chatgpt
- controller
require_clean_start: true
remove_after_successful_push: true
retain_on_failure: true
permissions:
owner: aiman
group: gitops
directory_mode: "2775"
file_mode: "0664"
default_acl:
user:aiman: rwx
group:gitops: rwx
apply_to:
- /data/aios-gitops
- /data/aios-gitops/worktrees
preflight:
required_user: aiman
required_group: gitops
required_umask: "0002"
require_writable_git_dir: true
require_writable_task_dir: true
require_writable_result_dir: true
reject_shared_source_worktree: true
reject_existing_git_lock: true
notifications:
owner_on_received: required
owner_on_terminal: required
preferred_channel: weixin
redact_secrets: true
rollback:
preserve_source_worktree: true
remove_created_worktrees: true
restore_service_dropins: true
restore_service_state: true
@@ -1,14 +0,0 @@
# Install as a systemd drop-in for GitOps/OpenClaw writer services.
# Example: /etc/systemd/system/<service>.service.d/10-runtime-identity.conf
[Service]
User=aiman
Group=gitops
SupplementaryGroups=gitops
UMask=0002
Environment=AIOS_GITOPS_ROOT=/data/aios-gitops
Environment=AIOS_BARE_REPO=/data/aios-gitops/repo.git
Environment=AIOS_WORKTREE_ROOT=/data/aios-gitops/worktrees
Environment=AIOS_RUNTIME_USER=aiman
Environment=AIOS_RUNTIME_GROUP=gitops
Environment=AIOS_RUNTIME_UMASK=0002
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_ROOT="${AIOS_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
BASE_REF="${AIOS_DOCS_BASE_REF:-}"
RUN_LIVE_HEALTHCHECK="${AIOS_RUN_LIVE_HEALTHCHECK:-0}"
RESULT_DIR="${AIOS_ACCEPTANCE_RESULT_DIR:-$REPO_ROOT/.aios-evidence/acceptance}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_DIR="$RESULT_DIR/$STAMP"
SUMMARY="$RUN_DIR/summary.json"
install -d -m 0750 "$RUN_DIR"
pass=0
failed=0
warnings=0
steps=()
record() {
local name="$1" status="$2" code="$3" log="$4"
steps+=("$name|$status|$code|$log")
case "$status" in
通过) pass=$((pass + 1)) ;;
失败) failed=$((failed + 1)) ;;
警告) warnings=$((warnings + 1)) ;;
esac
printf '%s:%s(退出码 %s)\n' "$status" "$name" "$code"
}
run_step() {
local name="$1"
shift
local safe_name
safe_name="$(printf '%s' "$name" | tr ' /:' '___')"
local log="$RUN_DIR/${safe_name}.log"
set +e
(cd "$REPO_ROOT" && "$@") >"$log" 2>&1
local code=$?
set -e
if [[ "$code" -eq 0 ]]; then
record "$name" "通过" "$code" "$log"
else
record "$name" "失败" "$code" "$log"
fi
}
run_optional_step() {
local name="$1"
shift
local safe_name
safe_name="$(printf '%s' "$name" | tr ' /:' '___')"
local log="$RUN_DIR/${safe_name}.log"
set +e
(cd "$REPO_ROOT" && "$@") >"$log" 2>&1
local code=$?
set -e
if [[ "$code" -eq 0 ]]; then
record "$name" "通过" "$code" "$log"
else
record "$name" "警告" "$code" "$log"
fi
}
printf 'AIOS V2R1 Candidate 全量验收开始\n'
printf '仓库:%s\n' "$REPO_ROOT"
printf '结果目录:%s\n' "$RUN_DIR"
[[ -d "$REPO_ROOT" ]] || { echo "错误:仓库不存在" >&2; exit 2; }
DOCS_ARGS=(python3 tools/aios_v2_docs_code_check.py --repo "$REPO_ROOT")
if [[ -n "$BASE_REF" ]]; then
DOCS_ARGS+=(--base-ref "$BASE_REF")
fi
run_step "文档与代码匹配" "${DOCS_ARGS[@]}"
run_step "Python 语法编译" python3 -m compileall -q \
active-runtime/app/aios_v2 \
active-runtime/app/aios_openclaw \
active-runtime/app/aios_hermes
run_step "候选单元与集成测试" python3 -m unittest discover \
-s tests/aios_v2_candidate \
-p 'test_*.py' \
-v
run_step "Shell 脚本语法" bash -c '
set -Eeuo pipefail
while IFS= read -r -d "" path; do
bash -n "$path"
done < <(find deploy/aios-v2 -type f -name "*.sh" -print0)
'
run_step "文档 Git 冲突标记" bash -c '
set -Eeuo pipefail
if grep -RInE "^(<<<<<<<|=======|>>>>>>>)" docs/aios-v2; then
exit 1
fi
'
run_step "明文密钥基础扫描" python3 -c '
from pathlib import Path
import re
import sys
roots = [Path("active-runtime/app"), Path("deploy/aios-v2"), Path("docs/aios-v2"), Path("tasks")]
patterns = [
re.compile(r"(?i)(api[_-]?key|password|private[_-]?key|access[_-]?token)\s*[:=]\s*[\"\x27][^\"\x27\n]{12,}[\"\x27]"),
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
]
violations = []
for root in roots:
if not root.exists():
continue
for path in root.rglob("*"):
if not path.is_file() or path.suffix in {".pyc", ".db", ".sqlite3"}:
continue
text = path.read_text(encoding="utf-8", errors="ignore")
if any(pattern.search(text) for pattern in patterns):
violations.append(str(path))
if violations:
print("疑似明文密钥文件:", *violations, sep="\n", file=sys.stderr)
raise SystemExit(1)
'
if command -v systemd-analyze >/dev/null 2>&1; then
run_optional_step "systemd 单元验证" systemd-analyze verify \
deploy/aios-v2/systemd/aios-v2.service
else
record "systemd 单元验证" "警告" "127" "未安装 systemd-analyze"
fi
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
run_optional_step "Docker Compose 配置验证" docker compose \
-f deploy/aios-v2/docker/compose.yaml config
else
record "Docker Compose 配置验证" "警告" "127" "未安装 Docker Compose"
fi
if [[ "$RUN_LIVE_HEALTHCHECK" == "1" ]]; then
run_step "在线健康检查" bash deploy/aios-v2/healthcheck/check.sh
else
record "在线健康检查" "警告" "0" "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
fi
branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)"
commit="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || true)"
dirty="$(git -C "$REPO_ROOT" status --porcelain=v1 2>/dev/null || true)"
python3 - "$SUMMARY" "$branch" "$commit" "$dirty" "$pass" "$failed" "$warnings" "${steps[@]}" <<'PY'
import json
import pathlib
import sys
from datetime import datetime, timezone
summary_path = pathlib.Path(sys.argv[1])
branch, commit, dirty = sys.argv[2:5]
passed, failed, warnings = map(int, sys.argv[5:8])
steps = []
for item in sys.argv[8:]:
name, status, code, log = item.split("|", 3)
steps.append({"name": name, "status": status, "exit_code": int(code), "log": log})
payload = {
"schema": "aios.v2.acceptance.v1",
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"branch": branch or None,
"commit": commit or None,
"workspace_dirty": bool(dirty),
"passed": passed,
"failed": failed,
"warnings": warnings,
"steps": steps,
"final_status": "passed" if failed == 0 else "failed",
}
summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
printf '验收结果:通过 %s,失败 %s,警告 %s\n' "$pass" "$failed" "$warnings"
printf '证据:%s\n' "$SUMMARY"
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/opt/aios/app \
AIOS_V2_HOST=0.0.0.0 \
AIOS_V2_PORT=18920 \
AIOS_V2_STATE_DIR=/var/lib/aios-v2
RUN groupadd --system --gid 12001 aios \
&& useradd --system --uid 12001 --gid aios --home-dir /nonexistent --shell /usr/sbin/nologin aios \
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2
WORKDIR /opt/aios/app
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
USER aios:aios
EXPOSE 18920
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
CMD ["python3", "-m", "aios_v2.app"]
+44
View File
@@ -0,0 +1,44 @@
services:
aios-v2:
build:
context: ../../..
dockerfile: deploy/aios-v2/docker/Dockerfile
image: aios-v2:V2R1C0-candidate
init: true
restart: unless-stopped
ports:
- "127.0.0.1:${AIOS_V2_PORT:-18920}:18920"
environment:
AIOS_V2_HOST: 0.0.0.0
AIOS_V2_PORT: 18920
AIOS_V2_STATE_DIR: /var/lib/aios-v2
AIOS_OPENCLAW_URL: ${AIOS_OPENCLAW_URL:-}
AIOS_OPENCLAW_CREDENTIAL_REF: ${AIOS_OPENCLAW_CREDENTIAL_REF:-}
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE: ${AIOS_OPENCLAW_SIGNING_MATERIAL_FILE:-}
AIOS_RELEASE_CHANNEL: candidate
AIOS_RELEASE_VERSION: V2R1C0-candidate
volumes:
- aios-v2-state:/var/lib/aios-v2
# Mount runtime signing material explicitly when OpenClaw is enabled.
# The host file must be mode 0600; never store it in this repository.
# - /etc/aios-v2/openclaw-signing-material:/run/secrets/openclaw-signing-material:ro
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test:
- CMD
- python3
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
interval: 15s
timeout: 5s
retries: 4
start_period: 10s
volumes:
aios-v2-state:
+21
View File
@@ -0,0 +1,21 @@
# AIOS V2 candidate runtime configuration.
# Copy to /etc/aios-v2/aios-v2.env and keep ownership root:aios, mode 0640.
AIOS_V2_HOST=127.0.0.1
AIOS_V2_PORT=18920
AIOS_V2_STATE_DIR=/var/lib/aios-v2
# Optional OpenClaw runtime. Leave blank to run with OpenClaw disabled.
AIOS_OPENCLAW_URL=
AIOS_OPENCLAW_CREDENTIAL_REF=
# File path only. The referenced file must be mode 0600 or stricter and contain
# at least 32 bytes. Never place signing material in this env file or Git.
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=
# Reverse proxy must authenticate users and inject X-AIOS-* identity headers.
AIOS_TRUST_PROXY_HEADERS=true
# Candidate deployment metadata exposed through health tooling.
AIOS_RELEASE_CHANNEL=candidate
AIOS_RELEASE_VERSION=V2R1C0-candidate
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -Eeuo pipefail
BASE_URL="${AIOS_V2_BASE_URL:-http://127.0.0.1:18920}"
STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
CONTROLLER_WT="${AIOS_GITOPS_CONTROLLER_WORKTREE:-/data/aios-gitops/worktrees/controller}"
SERVICE="${AIOS_V2_SERVICE:-aios-v2}"
fail() {
echo "FAIL: $*" >&2
exit 1
}
pass() {
echo "PASS: $*"
}
command -v python3 >/dev/null || fail "python3 not installed"
if command -v systemctl >/dev/null && systemctl list-unit-files "$SERVICE.service" >/dev/null 2>&1; then
systemctl is-active --quiet "$SERVICE" || fail "$SERVICE is not active"
pass "$SERVICE is active"
fi
python3 - "$BASE_URL" <<'PY'
import json
import sys
import urllib.request
base_url = sys.argv[1].rstrip("/")
with urllib.request.urlopen(f"{base_url}/api/v2/health", timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
if payload.get("status") not in {"ok", "healthy", "ready"}:
raise SystemExit(f"unexpected health payload: {payload}")
print(f"PASS: HTTP health status={payload.get('status')}")
PY
[[ -d "$STATE_DIR" ]] || fail "state directory missing: $STATE_DIR"
[[ -w "$STATE_DIR" ]] || fail "state directory not writable: $STATE_DIR"
pass "state directory writable"
if [[ -e "$STATE_DIR/control-plane.sqlite3" ]]; then
python3 - "$STATE_DIR/control-plane.sqlite3" <<'PY'
import sqlite3
import sys
path = sys.argv[1]
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
try:
row = connection.execute("PRAGMA quick_check").fetchone()
finally:
connection.close()
if not row or row[0] != "ok":
raise SystemExit(f"SQLite quick_check failed: {row}")
print("PASS: SQLite quick_check=ok")
PY
else
echo "WARN: control-plane.sqlite3 not created yet"
fi
if [[ -d "$CONTROLLER_WT" ]] && git -C "$CONTROLLER_WT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
branch="$(git -C "$CONTROLLER_WT" branch --show-current)"
dirty="$(git -C "$CONTROLLER_WT" status --porcelain=v1)"
[[ -z "$branch" ]] || fail "GitOps controller attached to branch: $branch"
[[ -z "$dirty" ]] || fail "GitOps controller workspace is dirty"
pass "GitOps controller detached and clean"
fi
python3 - "$STATE_DIR/executions" <<'PY'
import json
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
if not root.exists():
print("WARN: execution evidence directory not created yet")
raise SystemExit(0)
for path in root.glob("*/*.json"):
value = json.loads(path.read_text(encoding="utf-8"))
for key in ("execution_id", "tenant_id", "task_id", "executor", "status"):
if not value.get(key):
raise SystemExit(f"invalid execution evidence {path}: missing {key}")
print("PASS: execution evidence files valid")
PY
pass "AIOS V2 candidate healthcheck complete"
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SERVICE="${AIOS_V2_SERVICE:-aios-v2}"
RELEASE_ROOT="${AIOS_V2_RELEASE_ROOT:-/opt/aios/releases}"
CURRENT_LINK="${AIOS_V2_CURRENT_LINK:-/opt/aios/current}"
STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
BACKUP_ROOT="${AIOS_V2_BACKUP_ROOT:-/var/backups/aios-v2}"
TARGET_RELEASE="${1:-}"
RESTORE_STATE="${AIOS_V2_RESTORE_STATE:-0}"
fail() {
echo "ERROR: $*" >&2
exit 1
}
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
if [[ "$TARGET_RELEASE" = /* ]]; then
TARGET_PATH="$(readlink -f "$TARGET_RELEASE")"
else
[[ "$TARGET_RELEASE" != *"/"* && "$TARGET_RELEASE" != "." && "$TARGET_RELEASE" != ".." ]] \
|| fail "invalid release name"
TARGET_PATH="$(readlink -f "$RELEASE_ROOT/$TARGET_RELEASE")"
fi
[[ -d "$TARGET_PATH/active-runtime/app" ]] || fail "target release is invalid: $TARGET_PATH"
[[ -f "$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh" ]] \
|| fail "target release has no healthcheck"
PREVIOUS_PATH=""
if [[ -L "$CURRENT_LINK" ]]; then
PREVIOUS_PATH="$(readlink -f "$CURRENT_LINK")"
fi
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
BACKUP_DIR="$BACKUP_ROOT/rollback-$STAMP"
install -d -m 0750 "$BACKUP_DIR"
if [[ -d "$STATE_DIR" ]]; then
tar --xattrs --acls -C "$(dirname "$STATE_DIR")" \
-czf "$BACKUP_DIR/state-before-rollback.tar.gz" \
"$(basename "$STATE_DIR")"
fi
printf '%s\n' "$PREVIOUS_PATH" > "$BACKUP_DIR/previous-release.txt"
printf '%s\n' "$TARGET_PATH" > "$BACKUP_DIR/target-release.txt"
systemctl stop "$SERVICE"
ln -sfn "$TARGET_PATH" "${CURRENT_LINK}.next"
mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
systemctl daemon-reload
systemctl start "$SERVICE"
if AIOS_V2_SERVICE="$SERVICE" \
AIOS_V2_STATE_DIR="$STATE_DIR" \
"$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh"; then
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
exit 0
fi
echo "Target release failed healthcheck; restoring previous release." >&2
systemctl stop "$SERVICE" || true
if [[ -n "$PREVIOUS_PATH" && -d "$PREVIOUS_PATH" ]]; then
ln -sfn "$PREVIOUS_PATH" "${CURRENT_LINK}.previous"
mv -Tf "${CURRENT_LINK}.previous" "$CURRENT_LINK"
fi
if [[ "$RESTORE_STATE" == "1" && -f "$BACKUP_DIR/state-before-rollback.tar.gz" ]]; then
rm -rf "$STATE_DIR"
tar --xattrs --acls -C "$(dirname "$STATE_DIR")" \
-xzf "$BACKUP_DIR/state-before-rollback.tar.gz"
fi
systemctl daemon-reload
systemctl start "$SERVICE" || true
if [[ -n "$PREVIOUS_PATH" && -x "$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" ]]; then
AIOS_V2_SERVICE="$SERVICE" \
AIOS_V2_STATE_DIR="$STATE_DIR" \
"$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" || true
fi
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
+37
View File
@@ -0,0 +1,37 @@
[Unit]
Description=AIOS V2 Control Plane Candidate
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=aios
Group=aios
WorkingDirectory=/opt/aios/current/active-runtime/app
EnvironmentFile=-/etc/aios-v2/aios-v2.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 -m aios_v2.app
Restart=on-failure
RestartSec=3
TimeoutStartSec=30
TimeoutStopSec=30
KillSignal=SIGTERM
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
ReadWritePaths=/var/lib/aios-v2
StateDirectory=aios-v2
StateDirectoryMode=0750
UMask=0027
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
@@ -1,27 +0,0 @@
# AIOS V2R1C2 Unified Portal production metadata.
# No secret values belong in this file; runtime integrations use references.
AIOS_V2_HOST=127.0.0.1
AIOS_V2_PORT=18920
AIOS_V2_STATE_DIR=/var/lib/aios-v2r1c2-production
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2r1c2-production/run-provenance.sqlite3
AIOS_EXTERNAL_TASK_STORE_FILE=/var/lib/aios-v2r1c2-production/external-task-truth.sqlite3
AIOS_EXECUTOR_CATALOG_FILE=/var/lib/aios-v2r1c2-production/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
AIOS_TARGET_VERSION=V2R1C2
AIOS_BASELINE_VERSION=V2R1C1
AIOS_PREVIOUS_VERSION=V2R1C1
AIOS_RELEASE_STATUS=PRODUCTION
AIOS_RELEASE_SOURCE_OID=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
AIOS_RELEASE_OID=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
AIOS_RELEASE_TAG=V2R1C2
AIOS_RELEASE_EVIDENCE_SHA256=2e27bb73b8e6297bd3077238f73a96d697fc3c3611c601c4dc9357e5fdcd8eee
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
AIOS_OWNER_WECHAT_TARGET_REF=AIOS_OWNER_WECHAT_TARGET_REF
@@ -1,44 +0,0 @@
[Unit]
Description=AIOS V2R1C2 Unified Portal Production
After=network-online.target
Wants=network-online.target
Conflicts=aios-orchestrator-gateway.service
ConditionPathExists=/opt/aios-v2r1c2/active-runtime/app/aios_web/release_application.py
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-production.env
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStartPre=/usr/bin/test "$AIOS_V2_PORT" = "18920"
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_SOURCE_OID" = "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24"
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_TAG" = "V2R1C2"
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-production
StateDirectory=aios-v2r1c2-production
StateDirectoryMode=0750
UMask=0027
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
@@ -1,40 +0,0 @@
[Unit]
Description=AIOS Codex Review/Test Worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=aiman
Group=gitops
WorkingDirectory=/usr/libexec/aios-codex-review-test-worker/current
Environment=AIOS_CODEX_WORKER_MODE=shadow
Environment=AIOS_TASK_PLANE_EXECUTION_ENABLED=false
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test
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
MemoryDenyWriteExecute=true
RuntimeDirectory=aios-codex-review-test-worker
StateDirectory=aios-codex-review-test-worker
CacheDirectory=aios-codex-review-test-worker
ReadOnlyPaths=/data/aios-gitops/repo.git
ReadOnlyPaths=/usr/libexec/aios-codex-review-test-worker
ReadWritePaths=/run/aios-codex-review-test-worker
ReadWritePaths=/var/lib/aios-codex-review-test-worker
ReadWritePaths=/var/cache/aios-codex-review-test-worker
UMask=0077
[Install]
WantedBy=multi-user.target
@@ -11,8 +11,6 @@ Environment=AIOS_GITOPS_WORKSPACE=/data/aios-gitops/workspace
Environment=AIOS_GITOPS_DB=/data/aios-gitops/runtime/gitops_model.sqlite3
Environment=AIOS_GITOPS_TOKEN_FILE=/data/aios-gitops/runtime/secrets/gitops.tokens
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
Environment=AIOS_GITOPS_HOST=127.0.0.1
Environment=AIOS_GITOPS_PORT=18890
ExecStart=/usr/bin/python3 /data/aios-gitops/workspace/active-runtime/app/aios_chatgpt_actions_api.py
@@ -1,25 +0,0 @@
[Unit]
Description=AIOS Silent Release Shadow Controller
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=aiman
Group=gitops
UMask=0077
Environment=AIOS_GITOPS_WRITES_ENABLED=false
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/bin/python3 /usr/libexec/aios-silent-release-shadow/current/active-runtime/app/silent_release_execution.py --state-root /var/lib/aios-silent-release-shadow --repository /data/aios-gitops/repo.git --host 127.0.0.1 --port 18891 --poll-seconds 30
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/aios-silent-release-shadow
ReadOnlyPaths=/data/aios-gitops/repo.git /usr/libexec/aios-silent-release-shadow
[Install]
WantedBy=multi-user.target
+243
View File
@@ -0,0 +1,243 @@
# AIOS V2 用户需求规格
- 文档版本:`V2R1C0-candidate`
- 状态:候选版本需求基线
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、审计与部署
## 1. 背景与目标
现有系统已经具备 OpenClaw、Hermes、Agent Router、GitOps、局部 WebUI、审计和多租户候选能力。AIOS V2 的目标不是推倒重写,而是将这些分散能力收敛到统一、可审计、可 Web 化、可多租户隔离的控制面。
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录和执行证据。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
## 2. 用户角色
| 角色 | 主要诉求 |
|---|---|
| 平台管理员 | 管理租户、运行时、发布、GitOps 状态和全局策略 |
| 租户管理员 | 管理本租户 Agent、任务、审批、审计和成员权限 |
| 操作员 | 创建任务、发起执行、取消任务、查看运行状态 |
| 审批人 | 审批高风险任务,查看策略判定依据 |
| 审计员 | 查询本租户完整审计链和执行证据 |
| Runtime Worker | 领取已批准任务、心跳、返回执行结果 |
| 开发与运维人员 | 通过 GitOps 提交、测试、部署、回滚和修复 Bug |
## 3. 核心用户需求
### UR-001 唯一控制面
所有任务创建、审批、取消、状态变化、策略判定和审计必须由 AIOS Control Plane 统一处理。
验收:
- Web Console、API、Codex、OpenClaw 和 Hermes 均不得绕过 AIOS 创建或修改任务生命周期。
- OpenClaw/Hermes 的公开接口仅限执行、状态、取消、心跳和结果回传。
### UR-002 复用现有能力
系统不得重写已经稳定的 OpenClaw 执行能力、Hermes Runtime、Agent Router 或 GitOps 基础设施。
验收:
- 通过 Adapter/Boundary 接入现有能力。
- 替换控制逻辑时保留原执行接口和可迁移数据。
### UR-003 OpenClaw 与 Hermes 解耦
OpenClaw 与 Hermes 必须从策略、租户、审批和任务生命周期中逐步解耦。
验收:
- OpenClaw 仅接收已签名、已批准、带租户上下文的命令。
- Hermes 仅接收已批准的租户分区 Job。
- 二者均不得直接写 Control Plane 权威状态。
### UR-004 全面 Web 化
所有面向用户和运维的能力必须通过 Web Console 或稳定 API 访问。
Web Console 至少展示:
- Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence、GitOps Status。
- 所有写操作由 AIOS API 完成,浏览器不得直接调用 OpenClaw/Hermes。
### UR-005 多租户隔离
所有模块必须支持租户隔离,且默认拒绝跨租户访问。
隔离范围:
- API 与会话
- Tenant/Agent/Task 数据
- OpenClaw 命令
- Hermes Queue、Cell、Profile、Worker Lease
- Memory、Cache、Artifact、SecretRef
- Audit、Execution Evidence、日志和指标
验收:
- 资源记录的 `tenant_id` 不可为空。
- 跨租户访问返回拒绝或“资源不存在”,不得泄露存在性。
- 必须有跨租户负向测试。
### UR-006 权威任务生命周期
任务必须遵循统一状态机:
```text
CREATED -> APPROVED -> RUNNING -> COMPLETED
| | |
+----------+----------+-> CANCELLED
+-> FAILED
```
验收:
- 禁止越级变化,例如 `CREATED -> COMPLETED`。
- 每次变化包含 revision、principal、trace、时间和审计事件。
### UR-007 策略与审批
所有有副作用任务必须经过策略判定;高风险任务必须支持人工审批。
验收:
- Policy Decision 包含 allow/deny、reason 和 policy version。
- 拒绝事件进入审计。
- Runtime 不得自行放宽策略。
### UR-008 幂等与并发安全
任务和 Runtime 执行必须支持幂等与乐观并发控制。
验收:
- 相同租户与幂等键、相同内容返回同一任务/执行。
- 相同幂等键不同内容返回冲突。
- revision 或 branch lease 冲突不得静默覆盖。
### UR-009 可观测与执行证据
平台必须能够确认 Codex、OpenClaw、Hermes 等执行器是否领取任务、当前进度、最近提交、测试结果和完成状态。
验收:
- 存在 claim、heartbeat、progress、result 和 evidence 记录。
- Web/API 可按租户查询。
- 执行证据不得包含明文密钥。
### UR-010 GitOps 唯一变更流程
所有代码、配置、文档和部署资产修改必须进入非 `main` 分支,通过 commit、测试、审查和受控提升。
验收:
- Controller worktree 必须 detached 且 clean。
- 本地 bare repo 与 Gitea 目标分支 OID 一致。
- 不允许直接 push `main`、直接创建生产 Tag 或绕过审批部署。
### UR-011 持久化与恢复
租户、Agent、Task、Audit、Idempotency 和 Execution Evidence 必须在服务重启后恢复。
验收:
- 候选版支持 SQLite WAL 原子快照。
- 有 restart recovery 测试。
- 生产版需进一步提供迁移、备份、恢复、HA 和容量证据。
### UR-012 审计与合规
所有控制与执行动作必须生成不可混租户的审计事件。
至少记录:
- tenant、principal、trace、action、resource、outcome、policy version、timestamp。
- OpenClaw/Hermes Runtime 生命周期。
- GitOps、部署和回滚动作。
### UR-013 密钥安全
Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key、密码、Token 或私钥。
验收:
- Git 只存 `secret_ref`、`credential_ref`、`key_id` 或受保护文件路径。
- Secret 文件权限为 `0600` 或更严格。
- Secret Scanner 默认拒绝可疑提交。
### UR-014 部署与回滚
系统必须提供 systemd、Docker/Compose、健康检查和受保护回滚入口。
验收:
- 服务以非 root 权限运行。
- 状态目录独立持久化。
- 回滚前自动备份状态。
- 默认只回滚代码,状态恢复需要显式授权。
### UR-015 文档与代码同步
任何影响用户行为、架构边界、API、数据模型、状态机、安全策略、部署或运维方式的变更,必须在同一分支同步更新 `docs/aios-v2/`。
验收:
- 架构/功能代码变化但无文档变化时,文档同步门禁失败。
- Traceability Matrix 必须指向当前代码和测试。
- 新架构决策必须增加或更新 ADR。
## 4. 非功能需求
### NFR-001 安全
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计。
### NFR-002 可靠性
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、健康检查和回滚。
### NFR-003 可维护性
- 模块边界稳定,Control Plane 与 Runtime 解耦;代码、文档、测试和部署可追踪。
### NFR-004 可扩展性
- Store、Transport、Policy、EventPublisher、Runtime Adapter 均通过接口扩展。
### NFR-005 可运维性
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告。
### NFR-006 性能候选指标
候选版上线前至少验证:
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间。
- 具体 SLO 在预发布基准测试后固化,禁止无证据承诺。
## 5. 本候选版本范围
### 已纳入代码
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、部署和回滚。
### 尚需环境验证
- 精确测试执行证据。
- OpenClaw 真实 Runtime 联调。
- 生产数据库/队列替换或容量验证。
- 预发布性能、安全、重启和回滚演练。
- Owner 对 merge、Tag 和生产部署的批准。
## 6. 用户验收场景
1. 平台管理员创建租户并注册 OpenClaw/Hermes Agent。
2. 操作员创建任务,审批人批准。
3. AIOS Orchestrator 选择 Runtime,并创建签名命令或租户分区 Job。
4. Runtime 执行并返回结果。
5. AIOS 更新 Task、Audit 和 Execution Evidence。
6. 租户用户在 Web Console 查看完整链路。
7. 跨租户访问被拒绝。
8. 服务重启后状态恢复。
9. 候选部署健康检查通过,回滚演练成功。
+461
View File
@@ -0,0 +1,461 @@
# AIOS V2 目标架构
- 文档版本:`V2R1C0-candidate`
- 架构状态:目标架构已实现为候选代码,待预发布验证
- 适用范围:Control Plane、Agent Router、OpenClaw、Hermes、Web Console、GitOps、审计、部署与运维
## 1. 架构目标
AIOS V2 的核心目标是把分散在 OpenClaw、Hermes、Agent Router、局部 WebUI 和 GitOps 中的控制能力收敛为一个权威控制面,同时保留现有执行能力,避免推倒重写。
目标状态:
```text
用户 / Web Console / Public API
|
v
AIOS Control Plane
Tenant | Identity | Agent | Task | Policy | Approval | Audit
|
v
Execution Orchestrator / Agent Router
/ \
v v
OpenClaw Adapter Hermes Boundary
| |
v v
OpenClaw Runtime Hermes Workers
\ /
v v
Result Reconciliation + Execution Evidence
```
设计结论:
- AIOS 是唯一控制面。
- Agent Router 是调度平面,不是第二控制面。
- OpenClaw 是执行适配与 Runtime。
- Hermes 是队列、Worker、Profile 与执行 Runtime。
- Web Console 只调用 AIOS API。
- GitOps 是代码、配置、文档和发布变更的唯一入口。
## 2. 从现有架构到目标架构
### 2.1 现有能力
现有系统已经具备:
- OpenClaw 执行与 Fleet 能力。
- Hermes Worker、Profile、队列和工作区能力。
- Agent Router 路由经验和部分调度接口。
- GitOps 分支、提交、审批、回滚和审计能力。
- Tenant Context、Tenant Router、Memory Scope、SecretRef、Message Ledger 和审计候选实现。
- 局部 WebUI、Gateway 和自动任务领取能力。
### 2.2 现有问题
- 控制逻辑分散,可能存在多个任务事实源。
- OpenClaw/Hermes 内部仍可能保存租户、策略或任务状态。
- 候选能力分散在多个分支,主线缺少权威实现。
- WebUI 与 API 入口不统一。
- 分支名称、代码、测试和部署证据之间缺少映射。
- 多租户隔离多为逻辑约束,尚缺全链路生产证据。
### 2.3 演进原则
- 先加边界和适配器,再迁移控制权。
- 先复用接口,再替换内部实现。
- 先在候选分支验证,再受控提升。
- 不允许同时存在两套权威任务生命周期。
- 不以“大分支存在”代替验收证据。
## 3. 逻辑分层
### 3.1 Web 与 API 层
职责:
- 提供 Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence 和 GitOps 页面/API。
- 依赖可信身份代理注入 `X-AIOS-*` 身份头。
- 不允许浏览器直接访问 OpenClaw 或 Hermes。
- 不允许客户端自行选择或覆盖 `tenant_id`。
候选实现:
- `active-runtime/app/aios_v2/app.py`
- `active-runtime/app/aios_v2/gateway.py`
- `active-runtime/app/aios_v2/console.py`
### 3.2 AIOS Control Plane
权威职责:
- Tenant Registry
- Agent Registry
- Task Lifecycle
- Policy Decision
- Approval
- Audit Event
- Runtime Inventory
- Execution Evidence
- Deployment/GitOps Status
不负责:
- OpenClaw 内部执行细节。
- Hermes Worker 内部执行细节。
- 直接持有 Runtime 凭据明文。
候选实现:
- `active-runtime/app/aios_v2/control_plane.py`
- `active-runtime/app/aios_v2/persistence.py`
- `active-runtime/app/aios_v2/execution_tracking.py`
- `active-runtime/app/aios_v2/gitops_status.py`
### 3.3 Execution Orchestrator / Agent Router
职责:
- 只接受 `APPROVED` 任务。
- 根据 Agent Registry 和 Policy Result 选择 Runtime。
- 生成 OpenClawCommand 或 HermesJob。
- 传播 tenant、task、trace、idempotency 和 policy version。
- 将 Runtime Result 回写 AIOS Task。
不负责:
- 创建租户。
- 修改策略。
- 绕过审批。
- 保存第二套任务状态机。
候选实现:
- `active-runtime/app/aios_v2/orchestrator.py`
### 3.4 OpenClaw Adapter
职责:
- 验证签名命令。
- 执行、查询、取消、健康检查和回调。
- 支持幂等、超时和租户隔离。
- 把 Runtime 生命周期转换为 AIOS 审计事件。
不负责:
- Tenant、Policy、Approval、Task Lifecycle。
- 向浏览器暴露公网控制接口。
候选实现:
- `active-runtime/app/aios_openclaw/contract.py`
- `active-runtime/app/aios_openclaw/adapter.py`
- `active-runtime/app/aios_openclaw/transport.py`
- `active-runtime/app/aios_openclaw/health.py`
- `active-runtime/app/aios_openclaw/callback.py`
### 3.5 Hermes Runtime Boundary
职责:
- Tenant/Cell/Profile 分区队列。
- Worker claim、start、heartbeat、complete、fail、cancel。
- Lease 超时和自动回队列。
- Result 回传和 Runtime Health。
不负责:
- Policy、Approval、Tenant Registry、权威 Task Lifecycle。
候选实现:
- `active-runtime/app/aios_hermes/contract.py`
- `active-runtime/app/aios_hermes/adapter.py`
- `active-runtime/app/aios_hermes/worker.py`
### 3.6 Persistence
候选实现使用 SQLite WAL 原子快照,持久化:
- Tenant
- Agent
- Task
- Audit
- Idempotency
- Execution Evidence 文件
生产演进:
- Store 接口保持稳定。
- 可替换为 PostgreSQL、对象存储或分布式事件存储。
- 生产替换必须提供迁移、备份、恢复、HA、容量和租户隔离证据。
## 4. 多租户架构
### 4.1 权威租户上下文
每次请求和执行至少传播:
```text
tenant_id
principal_id
session_id
trace_id
task_id
policy_version
```
Runtime 还传播:
```text
command_id / job_id
cell_id
profile_id
idempotency_key
worker_id
```
### 4.2 隔离层级
| 层级 | 隔离要求 |
|---|---|
| HTTP/API | 可信身份头、租户不可由浏览器覆盖 |
| Control Plane | 所有资源带 `tenant_id`,跨租户默认拒绝 |
| OpenClaw | 签名命令绑定 tenant/task/trace |
| Hermes | tenant/cell/profile 三层分区 |
| Storage | 候选为显式租户字段;生产需 RLS 或物理隔离策略 |
| Evidence/Audit | 仅按当前租户查询 |
| Cache/Memory | key 必须包含 tenant namespace |
| Secret | 只保存引用,不保存明文 |
| Logs/Metrics | 必须包含 tenant/task/trace 标签,并限制查询范围 |
### 4.3 失败行为
- 跨租户资源访问返回拒绝或“未找到”。
- 不泄露其他租户资源存在性。
- 缺少 tenant context 时 fail closed。
- Runtime 返回的 tenant/task/trace 不匹配时拒绝回写。
## 5. 任务与执行数据流
### 5.1 任务创建与审批
```text
User/API
|
v
Trusted Identity
|
v
AIOS Gateway
|
v
Policy Gate
|
v
Task CREATED
|
v
Approval
|
v
Task APPROVED
```
### 5.2 OpenClaw 执行
```text
APPROVED Task
|
v
Execution Orchestrator
|
v
Signed OpenClawCommand
|
v
OpenClaw Adapter
|
v
Runtime Result
|
v
Task Reconciliation + Audit + Evidence
```
### 5.3 Hermes 执行
```text
APPROVED Task
|
v
Execution Orchestrator
|
v
Tenant/Cell/Profile HermesJob
|
v
Worker Claim + Lease + Heartbeat
|
v
HermesResult
|
v
Task Reconciliation + Audit + Evidence
```
## 6. 安全架构
### 6.1 身份边界
- Public API 位于可信反向代理之后。
- 反向代理完成登录、会话、MFA 和身份头注入。
- 候选应用拒绝未认证身份断言。
- 生产不得允许客户端直接伪造 `X-AIOS-*`。
### 6.2 Runtime 命令完整性
OpenClawCommand 使用规范化序列化和 HMAC-SHA256:
- 签名覆盖 tenant、task、trace、action、payload、idempotency 和时间。
- 支持 key ID、到期时间和时钟偏差。
- Runtime 密钥通过受保护文件或外部 Secret Provider 解析。
### 6.3 Secret 边界
Git 允许:
```text
secret_ref
credential_ref
key_id
受保护文件路径
```
Git 禁止:
```text
API Key
密码
Token
私钥
签名材料
```
### 6.4 Web 安全
候选应用设置:
- no-store
- nosniff
- DENY frame
- no-referrer
- CSP
- 请求体大小限制
生产还需验证:
- CSRF/CORS
- Rate Limit
- Session Timeout
- CSP Nonce/Hash
- WAF 和反向代理访问策略
## 7. 可靠性架构
### 7.1 幂等
- Control Plane:`tenant_id + idempotency_key + request_digest`。
- OpenClaw:`tenant_id + idempotency_key + signature`。
- Hermes:`tenant_id + idempotency_key + job_digest`。
### 7.2 并发
- Task 使用 revision。
- Hermes 使用 revision + worker lease。
- GitOps 使用 detached controller + branch lease。
- 持久化使用 SQLite `BEGIN IMMEDIATE` 和原子快照。
### 7.3 恢复
- Controller restart:detached guard。
- AIOS restart:SQLite restore。
- Worker crash:lease 到期后 Job 回到 QUEUED。
- Release failure:健康检查失败自动恢复 previous release。
## 8. 可观测架构
统一关联键:
```text
tenant_id
principal_id
trace_id
task_id
agent_id
command_id / job_id
worker_id
commit
```
Web Console 展示:
- Tasks
- Agents
- Runtime Health
- Audit
- Execution Evidence
- GitOps Status
生产还需接入:
- 结构化日志
- Metrics
- Distributed Tracing
- Alerting
- SLO Dashboard
## 9. 部署架构
支持两种候选部署:
### systemd
- 非 root `aios` 用户。
- `ProtectSystem=strict`、`NoNewPrivileges` 等安全选项。
- 状态目录 `/var/lib/aios-v2`。
### Docker/Compose
- 非 root UID/GID。
- Read-only root filesystem。
- Drop all capabilities。
- State volume。
- HTTP healthcheck。
## 10. 架构约束
以下行为视为架构违规:
1. OpenClaw/Hermes 直接创建或批准 AIOS Task。
2. Web Console 直接调用 Runtime。
3. 新模块不携带 `tenant_id`。
4. 新增第二套任务状态机或租户事实源。
5. 在 Git 中提交明文凭据。
6. 绕过 GitOps 修改生产代码/配置。
7. 架构或功能变化未同步更新 `docs/aios-v2/`。
8. 未通过测试和预发布门禁直接提升候选版本。
## 11. 目标架构完成判定
目标架构只有在以下证据齐全时才视为完成:
- 文档与代码映射完整。
- Candidate tests 对精确 commit 执行通过。
- OpenClaw/Hermes 实际 Runtime 联调通过。
- 多租户负向测试通过。
- 重启恢复和回滚演练通过。
- GitOps 本地/Gitea OID 一致。
- Owner 批准 merge、Tag 和生产部署。
+313
View File
@@ -0,0 +1,313 @@
# AIOS V2 迁移与交付策略
- 文档版本:`V2R1C0-candidate`
- 交付原则:增量迁移、能力复用、控制权收敛、GitOps 唯一变更入口
## 1. 迁移目标
AIOS V2 不通过一次性重写替换现有系统,而是在保留 OpenClaw、Hermes、Agent Router、GitOps 和既有运行能力的前提下,将租户、策略、任务、审批、审计和发布治理收敛到 AIOS Control Plane。
迁移完成的标志不是“新代码存在”,而是:
- AIOS 成为唯一权威控制面;
- OpenClaw/Hermes 不再保存第二套控制状态;
- 所有控制功能 Web/API 化;
- 所有模块通过租户隔离、测试和预发布门禁;
- 本地 Git、Gitea、测试、部署和回滚证据可追踪。
## 2. 为什么采用增量迁移
### 2.1 避免能力回退
OpenClaw、Hermes 和 Agent Router 已经包含大量运行经验、边界处理和执行能力。整体重写会扩大风险面,并导致业务能力、操作流程和历史数据回退。
### 2.2 保持可回滚
每个迁移步骤必须:
- 独立分支;
- 小范围提交;
- 独立测试;
- 可单独回滚;
- 不要求立即关闭旧执行路径。
### 2.3 先收敛控制权,再优化内部实现
迁移优先级:
```text
唯一入口
-> 权威租户/任务/策略
-> Runtime Adapter
-> 数据层生产化
-> Web 与可观测
-> 性能与 HA
```
## 3. 分阶段迁移路线
### Phase 0:GitOps 与架构事实源
目标:建立可靠变更链和权威设计基线。
已完成候选能力:
- Controller detached guard;
- 避免 fetch 到已检出本地分支;
- Gitea SSH 镜像;
- `docs/aios-v2/` 架构事实源;
- 候选分支、部署和回滚资产。
退出条件:
- Controller detached 且 clean;
- 本地/Gitea OID 一致;
- 架构与需求文档纳入版本。
### Phase 1:Control Plane 权威化
目标:将 Tenant、Agent、Task、Policy、Approval 和 Audit 收敛到 AIOS。
候选实现:
- `aios_v2/control_plane.py`
- `aios_v2/persistence.py`
- `aios_v2/gateway.py`
- `aios_v2/app.py`
退出条件:
- 任务状态机统一;
- 幂等、revision、审计和跨租户拒绝通过测试;
- 服务重启后权威状态恢复。
### Phase 2:OpenClaw 解耦
目标:OpenClaw 从控制逻辑转为执行适配器。
步骤:
1. 定义签名命令合同;
2. 引入 Adapter;
3. 将直接调用迁移到 AIOS Orchestrator;
4. Runtime 只返回执行结果和健康状态;
5. 关闭或网络隔离旧公共控制入口。
退出条件:
- 未批准任务无法生成有效命令;
- 命令绑定 tenant/task/trace/idempotency;
- 旧控制接口冻结并记录替代路径。
### Phase 3:Hermes 解耦
目标:Hermes 只承担租户分区队列和 Worker Runtime。
步骤:
1. 定义 HermesJob/HermesResult;
2. 强制 tenant/cell/profile 分区;
3. 引入 claim/lease/heartbeat;
4. 将结果统一回写 AIOS;
5. 移除 Hermes 内部策略和任务事实源。
退出条件:
- Worker 无权创建 AIOS Task;
- 租约过期后 Job 可恢复;
- 跨租户 claim/read/result 被拒绝。
### Phase 4:Web 与运维统一
目标:所有用户与运维功能通过 AIOS Web/API。
范围:
- Tenant、Agent、Task、Runtime、Audit、Execution Evidence、GitOps;
- systemd/Docker/Compose;
- healthcheck、rollback、release report。
退出条件:
- 浏览器不直接调用 Runtime;
- 生产身份代理和权限模型通过验证;
- 部署、重启和回滚演练通过。
### Phase 5:生产数据层与 HA
目标:将候选 SQLite/内存运行状态替换或强化为生产级后端。
要求:
- 数据迁移;
- RLS 或物理隔离;
- 备份恢复;
- HA 与容量;
- 多实例并发;
- 灾难恢复目标。
该阶段不得改变上层合同,避免 Runtime 和 Web 再次耦合存储实现。
## 4. 分支与提交策略
### 4.1 分支类型
| 类型 | 用途 |
|---|---|
| `task/*` | 可执行任务合同与主体实现 |
| `patch/*` | 独立修复或窄范围增强 |
| `integration/*` | 多模块候选收敛 |
| `result/*` | 自动执行结果与证据 |
| `release/*` | 受控发布候选 |
### 4.2 规则
- 禁止直接修改 `main`;
- 一个提交应可独立审阅和回滚;
- 大文件和大范围变更应拆分;
- 所有行为变化必须同步文档和测试;
- 生产 Tag、部署和回滚需要 Owner 批准。
## 5. GitOps 交付流程
```text
需求/架构更新
|
v
任务分支 + 文档 + 测试
|
v
代码提交 + Secret Gate
|
v
本地 bare repo 同步
|
v
Gitea 镜像与 OID 校验
|
v
Candidate 集成
|
v
自动测试与预发布
|
v
Owner 审批
|
v
Merge / Tag / Production Deploy
```
### 5.1 同步状态
GitOps 返回必须区分:
- local sync;
- Gitea sync;
- branch OID;
- workspace mode;
- dirty state。
仅返回 `common_git_dir synced` 不足以证明 Web 远端可见。
## 6. 发布门禁
### Gate A:设计完整性
- 用户需求、目标架构、模块契约、ADR 和追踪矩阵同步;
- 无冲突标记、断链或过期路径。
### Gate B:代码质量
- 包可导入;
- 静态编译通过;
- Secret Scanner 通过;
- 无已知越权旁路。
### Gate C:测试
- 单元、集成、多租户负向、重启恢复、Runtime 边界测试通过;
- 记录 commit、命令、exit code、pass/fail 和 artifact digest。
### Gate D:预发布
- systemd 或 Compose 启动成功;
- 健康检查通过;
- OpenClaw/Hermes 真实联调通过;
- 备份、重启和回滚演练通过;
- 日志中无敏感信息。
### Gate E:生产批准
- Owner 批准 merge、Tag 和生产部署;
- 发布窗口、回滚负责人和观察指标明确;
- 生产变更通过 GitOps 记录。
## 7. 回滚策略
### 7.1 代码回滚
- 使用 `/opt/aios/current` 原子 symlink 切换;
- 回滚前备份状态;
- 健康检查失败自动恢复 previous release。
### 7.2 状态回滚
默认不自动回滚状态,避免覆盖候选运行期间新增的 Task、Audit 和 Evidence。
状态恢复只有在:
- 明确确认 schema 不兼容;
- 数据备份可验证;
- Owner 显式授权;
时启用。
### 7.3 Runtime 回滚
OpenClaw/Hermes Adapter 采用稳定合同。若候选执行层失败,可恢复旧 Runtime 版本,但不得恢复旧控制权旁路。
## 8. Bug 修复流程
生产或预发布 Bug:
1. 创建 `patch/bugfix/*`;
2. 添加可复现测试;
3. 修复代码;
4. 更新相关需求/架构/契约/ADR;
5. 运行候选验收;
6. 进入新的 release candidate;
7. Owner 批准部署。
禁止在生产目录直接编辑文件后不回写 Git。
## 9. 文档同步规则
以下变化必须更新文档:
- 用户行为或页面;
- API 路径、Header、请求/响应;
- 数据模型、状态机、策略;
- OpenClaw/Hermes/Router 边界;
- 多租户与安全模型;
- 部署、健康检查、回滚;
- 测试或发布门禁。
对应关系:
- 需求变化:更新 `01-USER-REQUIREMENTS.md`;
- 架构变化:更新 `02-TARGET-ARCHITECTURE.md` 和 ADR;
- 迁移/发布变化:更新本文件;
- 合同变化:更新 `04-MODULE-CONTRACTS.md`;
- 代码/测试路径变化:更新 `05-TRACEABILITY-MATRIX.md`。
## 10. 当前候选后续步骤
1. 完成文档同步门禁和 Acceptance Runner;
2. 在精确 Candidate commit 执行测试;
3. 推送 Gitea 并校验 OID;
4. 部署隔离预发布环境;
5. 联调真实 OpenClaw/Hermes;
6. 执行重启、性能、安全和回滚演练;
7. 生成最终 evidence;
8. 请求 Owner 的 merge/Tag/production approval。
+504
View File
@@ -0,0 +1,504 @@
# AIOS V2 模块契约
- 文档版本:`V2R1C0-candidate`
- 状态:候选实现契约基线
- 原则:Control Plane 权威、Runtime 执行、全链路租户隔离、所有状态可审计
## 1. 通用上下文契约
所有控制与执行请求必须携带以下上下文:
| 字段 | 必填 | 说明 |
|---|---:|---|
| `tenant_id` | 是 | 权威租户标识 |
| `principal_id` | 控制请求是 | 已认证主体 |
| `trace_id` | 是 | 全链路追踪 |
| `task_id` | 执行阶段是 | AIOS 权威任务 |
| `session_id` | Web/交互请求建议 | 用户会话 |
| `policy_version` | 控制请求是 | 策略版本 |
| `idempotency_key` | 创建/执行是 | 幂等键 |
规则:
- 缺少必填字段时 fail closed。
- 客户端不得覆盖由可信代理或 AIOS 生成的 `tenant_id`。
- Runtime 返回的 tenant/task/trace 必须与请求完全匹配。
- 所有持久化键、缓存键和队列分区必须包含租户命名空间。
## 2. Tenant Contract
### 2.1 TenantRecord
```text
tenant_id
display_name
status
metadata
created_at
updated_at
```
约束:
- `tenant_id` 不可为空且不可变。
- `status` 至少支持 `active` 和受控停用状态。
- Tenant 只由 AIOS Control Plane 创建和维护。
- Runtime 不得创建租户。
实现:`active-runtime/app/aios_v2/control_plane.py`
## 3. Agent Contract
### 3.1 AgentRecord
```text
agent_id
tenant_id
display_name
runtime
capabilities
enabled
metadata
created_at
updated_at
```
约束:
- Agent 属于唯一租户。
- `runtime` 由 Registry 决定,可取 `openclaw`、`hermes` 或后续注册 Runtime。
- `capabilities` 是允许调度的动作集合。
- `metadata` 可存 `cell_id`、`profile_id`、priority 等非敏感引用。
- 不得在 Agent metadata 中保存明文凭据。
## 4. Task Contract
### 4.1 TaskRecord
```text
task_id
tenant_id
agent_id
action
payload
state
revision
idempotency_key
request_digest
result
error
created_by
approved_by
created_at
updated_at
```
### 4.2 状态机
```text
CREATED -> APPROVED -> RUNNING -> COMPLETED
| | |
+----------+----------+-> CANCELLED
+-> FAILED
```
允许转换:
| 当前 | 允许目标 |
|---|---|
| CREATED | APPROVED, CANCELLED |
| APPROVED | RUNNING, CANCELLED |
| RUNNING | COMPLETED, FAILED, CANCELLED |
| COMPLETED | 无 |
| FAILED | 无 |
| CANCELLED | 无 |
约束:
- 每次转换必须校验 `revision`。
- COMPLETED 必须带 `result`。
- FAILED 必须带 `error`。
- 每次转换生成 AuditEvent。
- Runtime 不得直接写 Task Store,只能通过 Orchestrator reconcile。
## 5. Policy Contract
### 5.1 PolicyDecision
```text
allowed
reason
policy_version
```
约束:
- Policy 默认拒绝。
- Runtime 不得覆盖 PolicyDecision。
- 拒绝必须审计。
- 高风险动作必须经过 Approval。
## 6. Audit Contract
### 6.1 AuditEvent
```text
event_id
tenant_id
principal_id
trace_id
action
resource_type
resource_id
outcome
policy_version
details
created_at
```
约束:
- 按租户查询。
- `details` 不得包含明文凭据。
- OpenClaw/Hermes 生命周期事件必须映射到统一 AuditEvent。
- 生产版需增加不可变外部固化和完整性验证。
## 7. OpenClaw Contract
### 7.1 OpenClawCommand
```text
contract_version
command_id
tenant_id
task_id
trace_id
action
payload
idempotency_key
created_at
expires_at
key_id
signature
```
签名覆盖:
- command_id
- tenant_id
- task_id
- trace_id
- action
- payload
- idempotency_key
- created_at / expires_at
- contract_version / key_id
约束:
- 使用规范化 JSON 和 HMAC-SHA256。
- 只接受已批准任务生成的命令。
- 过期、未来时间超差、签名错误命令必须拒绝。
- `key_id` 只引用运行时材料,不包含材料本身。
### 7.2 OpenClawResult
```text
command_id
tenant_id
task_id
trace_id
state
output
error
started_at
finished_at
runtime_id
attempts
```
状态:
```text
ACCEPTED
RUNNING
COMPLETED
FAILED
CANCELLED
TIMED_OUT
REJECTED
```
约束:
- Adapter 对外只接受终态结果用于 reconcile。
- 身份字段不匹配时拒绝回写。
- 结果通过 Orchestrator 更新 AIOS Task。
### 7.3 RuntimeHealth
```text
runtime
runtime_id
status
version
heartbeat_at
active_tasks
workspace_isolated
credential_ref_configured
details
```
健康策略:
- heartbeat 不得超时。
- workspace isolation 与 credential reference 必须符合策略。
- 不安全 Runtime 应 quarantine,不得继续接收新任务。
实现:`active-runtime/app/aios_openclaw/`
## 8. Hermes Contract
### 8.1 HermesJob
```text
job_id
tenant_id
cell_id
profile_id
task_id
trace_id
action
payload
idempotency_key
priority
state
revision
assigned_worker_id
lease_expires_at
created_at
updated_at
```
状态机:
```text
QUEUED -> CLAIMED -> RUNNING -> COMPLETED
| | |
+---------+----------+-> CANCELLED
+-> FAILED
```
约束:
- `tenant_id + cell_id + profile_id` 形成执行分区。
- claim 只能领取同分区 Job。
- Worker ID 不得跨分区复用。
- Lease 到期 Job 回到 QUEUED。
- revision 冲突不得覆盖。
### 8.2 HermesResult
```text
job_id
tenant_id
task_id
trace_id
state
worker_id
output
error
started_at
finished_at
```
约束:
- COMPLETED 必须有 output。
- FAILED 必须有 error。
- 只允许 AIOS Orchestrator reconcile。
### 8.3 HermesHealth
```text
runtime_id
status
version
heartbeat_at
active_workers
active_jobs
queued_jobs
tenant_partitions
profile_isolation_enforced
details
```
实现:`active-runtime/app/aios_hermes/`
## 9. Execution Evidence Contract
### 9.1 ExecutionEvidence
```text
execution_id
tenant_id
task_id
agent_id
executor
branch
status
progress_completed
progress_total
current_step
last_commit
test_status
test_summary
result_summary
claimed_at
heartbeat_at
completed_at
```
状态建议:
```text
claimed
running
blocked
complete
failed
cancelled
```
约束:
- 按租户存储和查询。
- 每个阻塞必须记录原因、责任方和解除条件。
- 每次结果必须记录 branch、commit、测试命令和状态。
- 不得包含 Secret 或完整敏感 payload。
## 10. GitOps Status Contract
```text
available
service_status
workspace_mode
workspace_branch
workspace_ref
workspace_dirty
candidate_branch
release_channel
release_version
sync_status
details
```
约束:
- Web 响应不得包含 remote URL、凭据或 stderr。
- Controller 必须 `detached + clean`。
- `sync_status` 必须区分 local 和 Gitea 镜像状态。
## 11. Web/API Contract
### 11.1 身份头
可信代理注入:
```text
X-AIOS-Authenticated
X-AIOS-Tenant-ID
X-AIOS-Principal-ID
X-AIOS-Roles
X-AIOS-Trace-ID
X-AIOS-Session-ID
X-AIOS-Policy-Version
```
生产约束:
- 代理必须删除客户端同名头后重新注入。
- 应用不得直接暴露公网端口。
- HTTP 字段名按协议不区分大小写;应用把 Header 集合转换为普通映射后,必须先统一字段名大小写再解析身份。`X-AIOS-*`、`X-Aios-*` 与全小写形式必须得到相同的 `RequestContext`,但字段值本身不得被改写。
- 真实监听器回归测试必须覆盖 `BaseHTTPRequestHandler` 的 title-case 行为,避免纯函数测试使用固定大写键而漏掉在线 401。
### 11.2 核心 API
```text
GET /api/v2/health
POST /api/v2/tenants
GET /api/v2/tenants/current
POST /api/v2/agents
GET /api/v2/agents
POST /api/v2/tasks
GET /api/v2/tasks
GET /api/v2/tasks/{id}
POST /api/v2/tasks/{id}/approve
POST /api/v2/tasks/{id}/start
POST /api/v2/tasks/{id}/complete
POST /api/v2/tasks/{id}/fail
POST /api/v2/tasks/{id}/cancel
POST /api/v2/tasks/{id}/dispatch
GET /api/v2/audit
GET /api/v2/runtime
GET /api/v2/executions
GET /api/v2/gitops
POST /api/v2/hermes/results/reconcile
```
## 12. Persistence Contract
候选版:
- SQLite WAL
- `BEGIN IMMEDIATE`
- 原子 snapshot
- schema version
- restart restore
持久化范围:
- tenants
- agents
- tasks
- audit
- idempotency
生产替换要求:
- 不改变 Domain/API Contract。
- 提供 migration、backup、restore、HA、RLS/隔离和容量测试。
## 13. Deployment Contract
### systemd
- 非 root `aios` 用户。
- 状态目录 `/var/lib/aios-v2`。
- 最小权限和文件系统保护。
### Docker
- 非 root UID/GID。
- read-only root filesystem。
- drop all capabilities。
- 独立状态 volume。
### Rollback
- 回滚前备份状态。
- 默认只回滚代码。
- 状态恢复需显式授权。
- 回滚后必须执行 healthcheck。
## 14. 契约变更规则
任何以下变化必须在同一提交或同一分支更新本文档:
- 字段、状态、Header、API、错误语义;
- 租户或安全约束;
- OpenClaw/Hermes 边界;
- Persistence 或 Deployment Contract;
- Evidence/Audit 格式。
不兼容变化必须新增 ADR 和版本号,不得静默修改。
+131
View File
@@ -0,0 +1,131 @@
# AIOS V2 需求—架构—代码—测试—部署追踪矩阵
- 文档版本:`V2R1C0-candidate`
- 目的:确保每项用户需求都能追踪到架构设计、代码实现、自动测试、部署资产和验收命令
## 1. 追踪规则
每条需求至少具备:
1. 需求编号;
2. 架构章节;
3. 实现路径;
4. 测试路径;
5. 部署/运维资产;
6. 验收状态。
状态定义:
- `Implemented`:代码已进入 Candidate 分支;
- `Test-defined`:测试代码已进入 Candidate 分支;
- `Test-verified`:精确 commit 的测试已运行并保存证据;
- `Pre-production verified`:预发布部署、健康检查和回滚演练已通过;
- `Production released`:Owner 批准并完成 merge/tag/deploy。
## 2. 核心追踪矩阵
| 需求 | 设计依据 | 代码实现 | 测试 | 部署/运维 | 当前状态 |
|---|---|---|---|---|---|
| UR-001 AIOS 唯一控制面 | `02-TARGET-ARCHITECTURE.md` §3.2/§3.3;ADR-001 | `aios_v2/control_plane.py`、`aios_v2/orchestrator.py`、`aios_v2/app.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/systemd/aios-v2.service` | Implemented / Test-defined |
| UR-002 复用现有能力 | `02-TARGET-ARCHITECTURE.md` §2;`03-MIGRATION-AND-DELIVERY.md` §2 | `aios_openclaw/`、`aios_hermes/` | OpenClaw/Hermes flow tests | Candidate branch + adapter boundaries | Implemented / Test-defined |
| UR-003 OpenClaw/Hermes 解耦 | `02-TARGET-ARCHITECTURE.md` §3.4/§3.5;ADR-002 | `aios_openclaw/adapter.py`、`aios_hermes/adapter.py` | runtime boundary tests in candidate flow suite | systemd/Docker runtime configuration | Implemented / Test-defined |
| UR-004 全面 Web 化 | `02-TARGET-ARCHITECTURE.md` §3.1/§8 | `aios_v2/console.py`、`aios_v2/gateway.py`、`aios_v2/app.py` | API/console smoke test pending | Docker/systemd + reverse proxy requirement | Implemented / Test pending |
| UR-005 多租户隔离 | `02-TARGET-ARCHITECTURE.md` §4;ADR-004 | `control_plane.py`、`orchestrator.py`、`aios_openclaw/*`、`aios_hermes/*`、`execution_tracking.py` | `test_cross_tenant_task_read_is_denied` | tenant-scoped state/evidence directories | Implemented / Test-defined |
| UR-006 权威任务生命周期 | `04-MODULE-CONTRACTS.md` §4 | `control_plane.py` | OpenClaw/Hermes full-flow tests | persisted state + audit | Implemented / Test-defined |
| UR-007 策略与审批 | `04-MODULE-CONTRACTS.md` §5 | `control_plane.py`、`gateway.py` | role/policy negative tests需补充 | reverse proxy identity + audit | Implemented / Partial test |
| UR-008 幂等与并发安全 | `02-TARGET-ARCHITECTURE.md` §7 | `control_plane.py`、`aios_openclaw/adapter.py`、`aios_hermes/adapter.py`、`worker.py` | flow + restart tests;并发专项测试需补充 | GitOps detached guard + SQLite WAL | Implemented / Partial test |
| UR-009 执行证据 | `04-MODULE-CONTRACTS.md` §9 | `execution_tracking.py` | `test_execution_tracking_persists_claim_heartbeat_and_result` | `/var/lib/aios-v2/executions` | Implemented / Test-defined |
| UR-010 GitOps 唯一变更流程 | `03-MIGRATION-AND-DELIVERY.md` §4/§5;ADR-003 | GitOps Runtime、`gitops_status.py` | healthcheck 的 detached/clean 检查 | `deploy/aios-v2/healthcheck/check.sh` | Implemented / Runtime verified partially |
| UR-011 持久化与恢复 | `02-TARGET-ARCHITECTURE.md` §3.6 | `persistence.py` | `test_durable_service_recovers_state_after_restart` | state dir + backup/rollback | Implemented / Test-defined |
| UR-012 审计与合规 | `04-MODULE-CONTRACTS.md` §6 | `control_plane.py`、`aios_openclaw/callback.py`、`app.py` | flow test + audit assertions需补充 | Console audit view | Implemented / Partial test |
| UR-013 密钥安全 | `02-TARGET-ARCHITECTURE.md` §6;ADR-006 | `FileSigningMaterialResolver`、credential refs、Secret Gate | permission/secret-scan test需补充 | env template + external protected file | Implemented / Partial test |
| UR-014 部署与回滚 | `03-MIGRATION-AND-DELIVERY.md` §7 | deployment scripts/assets | acceptance runner + healthcheck | `deploy/aios-v2/` | Implemented / Pre-prod pending |
| UR-015 文档与代码同步 | `03-MIGRATION-AND-DELIVERY.md` §9;ADR-005 | `tools/aios_v2_docs_code_check.py` | checker self-test / acceptance runner | CI/Gitea Actions 可调用 | Implemented after checker commit |
## 3. API 追踪
| API | 需求 | 实现 | 测试状态 |
|---|---|---|---|
| `GET /api/v2/health` | UR-004, UR-014 | `gateway.py` | healthcheck 定义完成 |
| `POST /api/v2/tenants` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | 需增加 HTTP 集成测试 |
| `POST /api/v2/agents` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
| `POST /api/v2/tasks` | UR-006, UR-008 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
| `POST /api/v2/tasks/{id}/approve` | UR-006, UR-007 | `gateway.py` | Domain flow 已覆盖 |
| `POST /api/v2/tasks/{id}/dispatch` | UR-001, UR-003 | `app.py` / `orchestrator.py` | OpenClaw/Hermes flow 已覆盖 |
| `GET /api/v2/runtime` | UR-004, UR-009 | `app.py` | HTTP smoke test待执行 |
| `GET /api/v2/executions` | UR-009 | `app.py` / `execution_tracking.py` | Evidence persistence test已定义 |
| `GET /api/v2/gitops` | UR-010 | `app.py` / `gitops_status.py` | Runtime/healthcheck验证待执行 |
| `POST /api/v2/hermes/results/reconcile` | UR-003, UR-006 | `app.py` / `orchestrator.py` | Hermes flow已覆盖 Domain 路径 |
## 4. 运行时契约追踪
| 契约 | 设计 | 代码 | 测试 |
|---|---|---|---|
| OpenClawCommand | `04-MODULE-CONTRACTS.md` §7.1 | `aios_openclaw/contract.py` | OpenClaw full-flow;签名负向测试需补充 |
| OpenClawResult | §7.2 | `aios_openclaw/contract.py` | OpenClaw full-flow |
| RuntimeHealth | §7.3 | `aios_openclaw/contract.py`、`health.py` | health policy测试需补充 |
| HermesJob | §8.1 | `aios_hermes/contract.py` | Hermes full-flow |
| HermesResult | §8.2 | `aios_hermes/contract.py` | Hermes full-flow |
| HermesHealth | §8.3 | `aios_hermes/contract.py`、`adapter.py` | healthcheck/worker test待执行 |
| ExecutionEvidence | §9 | `execution_tracking.py` | evidence persistence test |
| GitOpsStatus | §10 | `gitops_status.py` | healthcheck detached/clean检查 |
### 4.1 核心控制面模型说明
`RequestContext` 是每次控制面请求的不可缺失上下文。它承载租户、调用主体、追踪标识和请求来源,所有读取、写入、审批与调度都必须先用该上下文完成租户边界和身份检查。适配器不得自行补造租户,也不得把缺失上下文解释为默认管理权限。上下文校验失败时,控制面必须拒绝请求并写入不含密钥值的审计记录。
`TaskState` 是权威任务生命周期状态,不是前端展示标签。任务从创建、批准、调度、运行到完成、失败或取消,只能沿合同允许的方向迁移;OpenClaw 与 Hermes 回调只能请求迁移,最终状态由唯一控制面持久化。重复回调依靠幂等键收敛,跨租户回调、旧租约回调和非法逆向迁移必须被拒绝。
`TenantRecord`、`AgentRecord` 与 `TaskRecord` 共同定义所有权:租户拥有 Agent 和 Task,Agent 只能处理其租户与授权 Cell/Profile 范围内的任务。任何查询若缺少租户过滤条件,都不能作为正式实现。`AuditEvent` 记录主体、动作、对象、结果和追踪标识,但不得保存 Token、密码、私钥或签名材料。
`OpenClawCommand` 与 `OpenClawResult` 描述短执行路径;前者必须绑定任务、租户、追踪标识、命令期限和签名合同,后者必须回传相同身份域并接受幂等校验。`RuntimeHealth` 只报告可验证运行事实,不能把本地 Bare Repo 更新误报为 Gitea 完全同步。
`HermesJob`、`HermesResult` 与 `HermesHealth` 描述长任务路径。Job 的领取、心跳和 Lease 续期必须持久化;Result 只有在任务、租户、执行器和租约全部匹配时才能改变权威状态。Worker 崩溃后可重新领取过期 Lease,但不得重复产生业务副作用。
`ExecutionEvidence` 保存 Claim、Heartbeat、Result 与必要的追踪关系,用于重启恢复和审计闭环。Evidence 的写入必须限定任务分支与固定路径;本轮增加的 Owner-signed Provenance Writer 只允许七个 `.agent` JSON,并以普通 Commit 与 Push 固化,不授予通用 Shell 或任意仓库写权限。
## 5. 部署追踪
| 能力 | 文件 | 验收命令 |
|---|---|---|
| systemd | `deploy/aios-v2/systemd/aios-v2.service` | `systemctl status aios-v2` |
| 环境模板 | `deploy/aios-v2/env/aios-v2.env.example` | `systemd-analyze verify` + 文件权限检查 |
| Docker | `deploy/aios-v2/docker/Dockerfile` | `docker build` |
| Compose | `deploy/aios-v2/docker/compose.yaml` | `docker compose config` / `up` |
| 健康检查 | `deploy/aios-v2/healthcheck/check.sh` | 直接执行 |
| 回滚 | `deploy/aios-v2/rollback/rollback.sh` | 预发布回滚演练 |
| 全量验收 | `deploy/aios-v2/acceptance/run-all.sh` | 直接执行 |
## 6. 文档覆盖追踪
| 文档 | 覆盖内容 | 代码/变更触发条件 |
|---|---|---|
| `README.md` | 事实源索引、状态语言、实现地图 | 新模块/目录/版本 |
| `01-USER-REQUIREMENTS.md` | 用户需求、NFR、验收场景 | 用户行为、功能范围、验收变化 |
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户 | 架构边界、数据流、运行时变化 |
| `03-MIGRATION-AND-DELIVERY.md` | 迁移、GitOps、Gate、回滚 | 发布流程、迁移阶段、门禁变化 |
| `04-MODULE-CONTRACTS.md` | 字段、API、状态机、错误语义 | Contract/API/模型变化 |
| `05-TRACEABILITY-MATRIX.md` | 需求到代码测试部署映射 | 代码、测试、部署路径变化 |
| `adr/*` | 关键不可逆或跨模块决策 | 新架构决策或旧决策替换 |
## 7. 当前未完成验证项
以下项目代码或测试定义已存在,但尚不能标记为 `Test-verified` 或 `Pre-production verified`:
1. 精确 Candidate commit 的测试执行结果;
2. HTTP API 与 Web Console 冒烟测试;
3. 真实 OpenClaw Runtime 签名、取消、超时和健康联调;
4. Hermes 长任务心跳、Worker 崩溃和 Lease 恢复演练;
5. 多实例 SQLite/并发容量边界;
6. systemd、Docker、Compose、restart 和 rollback 演练;
7. local/Gitea Candidate OID 一致证明;
8. 生产身份代理、防伪 Header、CSRF/CORS/Rate Limit 验证;
9. 文档—代码同步检查器在 CI/Acceptance 中执行通过。
## 8. 更新要求
任何代码、API、部署或测试路径变化,必须同步更新本矩阵。验收脚本会检查本文档引用的关键路径存在,并检查架构/功能代码变化是否同时包含 `docs/aios-v2/` 变化。
### OpenClaw 命令签名材料
`openclaw-v2r1-candidate-ed25519` 使用 Ed25519 私钥对 canonical command bytes 签名,签名以 URL-safe Base64 编码。运行材料只允许是 32 字节 raw private key 或 Ed25519 PEM;`FileSigningMaterialResolver` 不得对 raw bytes 执行 `strip()`。签名、篡改拒绝、过期拒绝由候选测试与运行时 sign/verify 探针共同验证。
+272
View File
@@ -0,0 +1,272 @@
# AIOS V2 文档治理规范
- 文档版本:`V2R1C0-candidate`
- 适用范围:`docs/aios-v2/`、架构决策记录、生产候选报告、在线任务合同、部署与验收说明
- 状态:已采纳
## 1. 目的
本规范用于保证 AIOS V2 的需求、架构、代码、测试、部署和运行事实始终保持一致,避免出现以下问题:
- 代码已经变化,但架构文档仍描述旧边界;
- API、字段或状态机变化后,测试与追踪矩阵未同步;
- OpenClaw、Hermes 或 Agent Router 的控制职责发生漂移;
- 候选分支存在实现,但版本无法解释为什么这样设计;
- 部署、回滚或安全策略发生变化,却没有形成可审计文档;
- 归档包只包含摘要或占位符,无法作为正式交付材料。
## 2. 文档语言要求
所有面向用户、产品、架构、运维、审计和发布的正式文档统一使用中文。
以下内容保留原始英文技术标识:
- 类名、函数名、字段名、环境变量;
- API 路径、HTTP 方法和 Header;
- Git 分支、Commit、Tag;
- OpenClaw、Hermes、Agent Router、GitOps 等专有名称;
- systemd、Docker、SQLite、HMAC、HTTP、JSON 等标准术语。
禁止在同一文档中无必要地切换中英文叙述。
## 3. 文档完整性要求
每份正式文档至少包含:
1. 文档目的;
2. 适用范围;
3. 当前版本和状态;
4. 完整正文;
5. 与代码、测试或部署的映射;
6. 验收规则;
7. 已知限制;
8. 后续演进方向;
9. 更新触发条件。
正式归档包禁止使用以下内容代替正文:
- “后续补充”;
- “待完善”;
- 只有标题没有说明;
- 只包含目录摘要;
- 与候选分支代码不一致的旧文档。
确实尚未验证的项目,应明确标记为“已实现但尚未验证”,并说明验证命令、责任方和进入下一状态的条件。
## 4. 文档分类与维护责任
| 文档 | 主要内容 | 触发更新的变化 |
|---|---|---|
| `README.md` | 文档索引、版本状态、实现地图 | 新增模块、目录、版本或交付物 |
| `01-USER-REQUIREMENTS.md` | 用户目标、功能需求、非功能需求、验收场景 | 用户行为、产品范围、验收标准变化 |
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户、部署架构 | 控制权、Runtime 边界、数据流或安全模型变化 |
| `03-MIGRATION-AND-DELIVERY.md` | 迁移阶段、GitOps、发布门禁、回滚、Bug 修复 | 迁移路线、发布流程、门禁或回滚方式变化 |
| `04-MODULE-CONTRACTS.md` | 数据模型、API、状态机、错误语义、Runtime Contract | 字段、接口、状态机、Header、错误行为变化 |
| `05-TRACEABILITY-MATRIX.md` | 需求到代码、测试、部署、验收的映射 | 代码、测试、部署路径或状态变化 |
| `adr/*` | 跨模块、长期或不可逆架构决策 | 关键决策新增、替换或废弃 |
| `AIOS-V2R1-PRODUCTION-CANDIDATE.md` | 候选版本总览、测试、部署、风险和发布结论 | Candidate SHA、测试证据、部署或风险变化 |
| `tasks/*` | OpenClaw、Codex 等在线执行任务 | 职责、边界、阻塞、验收或执行流程变化 |
## 5. 代码变化与文档同步规则
以下代码或配置路径发生变化时,必须同步更新 `docs/aios-v2/`:
### 5.1 Control Plane
触发路径:
```text
active-runtime/app/aios_v2/control_plane.py
active-runtime/app/aios_v2/persistence.py
active-runtime/app/aios_v2/gateway.py
active-runtime/app/aios_v2/app.py
active-runtime/app/aios_v2/orchestrator.py
active-runtime/app/aios_v2/execution_tracking.py
active-runtime/app/aios_v2/gitops_status.py
```
至少检查:
- 用户需求;
- 目标架构;
- 模块契约;
- 追踪矩阵;
- 必要时新增或更新 ADR。
### 5.2 OpenClaw
触发路径:
```text
active-runtime/app/aios_openclaw/
```
至少检查:
- OpenClawCommand、OpenClawResult、RuntimeHealth;
- 签名、幂等、取消、超时和审计边界;
- ADR-002;
- 真实 Runtime 联调要求。
### 5.3 Hermes
触发路径:
```text
active-runtime/app/aios_hermes/
```
至少检查:
- HermesJob、HermesResult、HermesHealth;
- tenant/cell/profile 分区;
- Worker Lease、Heartbeat、Recovery;
- ADR-002、ADR-004。
### 5.4 部署与运维
触发路径:
```text
deploy/aios-v2/
```
至少检查:
- 迁移与交付策略;
- 部署契约;
- 健康检查;
- 回滚;
- 候选报告。
### 5.5 API、Web 与用户行为
任何 API 路径、Header、请求/响应、页面或用户操作变化,必须同步:
- `01-USER-REQUIREMENTS.md`;
- `02-TARGET-ARCHITECTURE.md`;
- `04-MODULE-CONTRACTS.md`;
- `05-TRACEABILITY-MATRIX.md`。
## 6. ADR 治理
必须新增或更新 ADR 的场景:
- AIOS、OpenClaw、Hermes、Agent Router 的控制边界变化;
- 多租户隔离模型变化;
- 持久化、消息、审计、密钥或部署出现跨模块变化;
- 不兼容 API、字段或状态机变化;
- GitOps、文档门禁或发布策略变化;
- 替换核心基础设施或引入新的权威事实源。
ADR 状态:
- `已提出`:尚未正式采纳;
- `已采纳`:当前有效;
- `已替代`:被新 ADR 替代;
- `已废弃`:保留历史但禁止新实现采用。
任何 ADR 替代必须说明:
- 被替代的 ADR;
- 迁移影响;
- 兼容性和回滚方案;
- 受影响代码、测试和文档。
## 7. 文档—代码匹配门禁
验收必须执行:
```text
python3 tools/aios_v2_docs_code_check.py
```
门禁至少检查:
1. 必需文档存在且非空;
2. 文档语言以中文为主;
3. 文档引用的关键代码、测试和部署路径存在;
4. 文档列出的核心 API 能在代码中找到;
5. 文档列出的核心模型和字段能在代码中找到;
6. Traceability Matrix 覆盖所有 `UR-*` 需求;
7. ADR 索引引用的文件存在;
8. 架构或功能代码变化时,当前交付范围包含 `docs/aios-v2/` 变化;
9. 文档中不存在未解决 Git 冲突标记;
10. 正式文档中不存在摘要占位符或空章节。
门禁失败时:
- Candidate 不得标记为 `Test-verified`;
- OpenClaw 不得继续生产部署;
- Codex 必须登记 blocker 并给出修复建议;
- 修复必须进入 Git 分支并统一审视。
## 8. 在线任务文档要求
OpenClaw 和 Codex 任务必须使用中文说明,并保留可执行字段名。
每个任务至少包含:
- 任务目标;
- 责任范围;
- 禁止事项;
- 输入和输出;
- 分支;
- 执行状态;
- 验收命令;
- claim、heartbeat、progress、blocker、commit、test_status、result 证据要求;
- 阻塞升级路径。
## 9. 归档包要求
正式归档包必须包含完整正文:
```text
docs/aios-v2/
tasks/openclaw-production-deploy-task.yaml
tasks/codex-production-supervisor-task.yaml
deploy/aios-v2/
AIOS-V2R1-PRODUCTION-CANDIDATE.md
```
归档包必须同时生成清单:
- 文件路径;
- 文件大小;
- SHA-256;
- Candidate branch;
- Candidate commit;
- 生成时间。
禁止只打包摘要副本。
## 10. 审查职责
### OpenClaw
- 部署和运行修复时同步相关部署、运维和已知问题文档;
- 不得修改架构边界;
- 发现架构影响时升级给 Codex。
### Codex
- 监督需求、架构、契约、追踪和 ADR 一致性;
- 解决阻塞;
- 拒绝缺少文档或证据的补丁;
- 输出统一发布建议。
### AIOS Owner
- 审批合并、Tag 和生产部署;
- 对重大架构决策和风险接受做最终确认。
## 11. 验收状态
文档完成状态只有在以下条件全部满足时才标记为“完整”:
- 所有必需文档和 ADR 已提交;
- 文档—代码匹配检查通过;
- 归档包包含仓库完整正文;
- 任务合同为中文完整版本;
- Candidate commit、清单和 SHA-256 已记录;
- Codex 监督结论不存在未解决阻塞。
+218
View File
@@ -0,0 +1,218 @@
# AIOS V2R1 监督状态与阻塞处置清单
- 文档版本:`V2R1C0-candidate`
- 快照时间:`2026-07-20 17:03:53 CST`
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
- 候选 OID:`634f6b164f5b20372ab9ec8144bbf76df97de335`
- 当前决策:`STOP_PREPRODUCTION`
- 当前状态:`BLOCKED`
## 1. 执行摘要
AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和 GitOps 证据机制已经形成。OpenClaw 与 Codex 已经领取任务并向 Gitea 提交监督证据,三个关键分支的本地 OID 与 Gitea OID 已一致。
当前仍不允许进入预发布,原因不是主体功能缺失,而是安全、签名材料、Candidate 合并、执行来源证明和 Controller 健康检查五项高风险门禁未通过。
当前禁止:
- 进入 Gate A–D;
- 执行在线 Runtime 联调;
- 执行重启恢复和回滚演练;
- 合并 `main`;
- 创建 Tag;
- 正式部署或切换流量。
## 2. 权威仓库与执行状态
| 项目 | 当前值 |
|---|---|
| 权威 Bare Repo | `/data/aios-gitops/repo.git` |
| OpenClaw Worktree | 独立且 clean |
| Codex Worktree | 独立且 clean |
| Gitea Remote | `https://g.cnnetmkt.top/git/aios/aios.git` |
| Candidate OID | `634f6b164f5b20372ab9ec8144bbf76df97de335` |
| OpenClaw Task OID | `bd2784015d40c328d158cd52ebd8683b6e56b3ad` |
| Codex Task OID | `e28245f411dd85ff9384ed595dacc53aab8a91ba` |
| 三分支 Gitea 同步 | `local_oid == gitea_oid` |
| OpenClaw 状态 | `blocked` |
| OpenClaw Heartbeat | 有记录,但 `valid_for_running=false` |
| Codex 状态 | `blocked` |
| Codex Heartbeat | 新鲜 |
| Codex 发布建议 | `STOP_PREPRODUCTION` |
## 3. 已完成的安全与运行修复
### 3.1 Gitea 凭据轮换
已完成:
- 旧 `owner/openclaw-deploy-cli`、`scope=all` 凭据 live count 为 `0`;
- 新凭据:`gitops-bot/openclaw-deploy-cli-v2-20260720`;
- 新凭据作用域:`write:repository`;
- 凭据文件权限:`root:gitops 0640`;
- 令牌值未进入 Git、日志、Evidence 或报告。
### 3.2 Gitea 与执行服务
已完成:
- 验证 Gitea SSH 指纹;
- 备份并修复 `known_hosts`;
- 恢复 `aios-agent-command-watcher`;
- 恢复 `aios-autonomous-controller`;
- 两项服务均为 `active`;
- OpenClaw/One API 真实模型调用返回 HTTP 200;
- OpenClaw/Codex claim、heartbeat、progress、blockers、result、status 已提交并同步 Gitea。
## 4. 当前开放阻塞
### 4.1 BLK-OPENCLAW-PLAINTEXT-SECRETS
- 严重级别:高
- 状态:开放
- 现象:OpenClaw 受管配置仍发现 3 处 `PLAINTEXT_FOUND`。
- 风险:明文 Secret 可能进入配置、日志、命令参数或执行证据。
- 解除条件:
1. 三处明文内容全部迁移为 SecretRef、systemd Credential、只读 Secret Volume 或仓库外受保护文件;
2. 旧值完成撤销和轮换;
3. Secret Scanner 复验通过;
4. Git 历史、日志和 Evidence 不包含明文。
### 4.2 BLK-AIOS-V2-SIGNING-MATERIAL-MISSING
- 严重级别:高
- 状态:开放
- 现象:`/etc/aios-v2/aios-v2.env` 已存在且权限合规,但 `/etc/aios-v2/openclaw-signing-material.bin` 不存在。
- 风险:无法验证 OpenClaw 签名命令、Key ID 与真实 Runtime 身份链。
- 外部受信输入:批准的签名材料。
- 解除条件:
1. 签名材料由批准的 Secret/Key 交付链安装到仓库外;
2. 权限为 `0600` 或更严格;
3. 运行用户可读;
4. Key ID 与材料匹配;
5. execute/status 路径完成真实签名验证;
6. 不输出材料内容、Hash 或长度特征。
### 4.3 BLK-CANDIDATE-NOT-MERGED
- 严重级别:高
- 状态:开放
- 现象:OpenClaw Task 尚未包含当前 Candidate,`merge-base --is-ancestor` 未通过。
- 风险:后续测试和部署不能证明当前 Candidate 可用。
- 解除条件:
1. 保护已有 `.agent/` Evidence;
2. 普通 Merge 当前 Candidate 到 OpenClaw Task;
3. 解决冲突并由 Codex 审查;
4. 更新所有 Evidence 中的 `candidate_commit`;
5. 本地与 Gitea OID 再次一致。
### 4.4 BLK-OPENCLAW-RUN-PROVENANCE-MISSING
- 严重级别:高
- 状态:已解决
- 根因:旧插件只有进程内 `runId -> sender` Map,没有 Owner-signed Schema、持久 Store 和限定 Git Writer。
- 修复:部署 `aios-tenant-boundary` rc2,增加 Ed25519 issuer/store、Run/Sender/OID/期限绑定、一次性 permit 与专用 `aios_evidence_write`。
- 验证:Provenance `openclaw-v2r1-20260721T031523Z` 完成 Heartbeat 写入、Commit、普通 Push 和审计;`README.md` 越权请求被拒绝;Node 测试 9/9。
### 4.5 BLK-CONTROLLER-LEGACY-GITEA-CHECK
- 严重级别:高
- 状态:已解决
- 现象:Controller 健康检查仍硬编码旧 `aios-v23`,并返回 `gitea=false`、`branch_sync=false`。
- 风险:健康状态与当前 AIOS V2R1 仓库不一致,可能错误阻止或错误放行发布。
- 解除条件:
1. 移除旧仓库硬编码;
2. 从配置读取权威仓库、Remote 和目标分支;
3. 健康检查分别报告 `local_sync`、`gitea_sync` 和 `oid_match`;
4. 不再把本地 Bare Repo 更新当作完全同步;
5. 增加回归测试;
6. 更新中文文档和追踪矩阵。
## 5. 模型审查策略修正
`gpt-5.3-codex-spark` 是子 Agent 审查的优先推荐模型,不是强制依赖。
执行规则:
1. Spark 可用时优先使用;
2. Spark 不可用时,使用模型注册表中已批准、已启用且优先级最高的合适模型;
3. 必须记录实际 `model_id`、模型名称和审查结果;
4. 禁止冒充使用不存在的模型;
5. 只有完全没有可用审查模型时,模型能力才构成阻塞。
当前五个 Spark 角色的独立结论一致为“禁止生产放行”,该结论作为已有审查证据保留;后续复验可使用任何满足上述规则的批准模型。
## 6. Gate 状态
| Gate | 当前状态 | 说明 |
|---|---|---|
| Gate A:文档与设计 | 允许启动 | 五项高风险 Blocker 已解除,等待正式执行 |
| Gate B:代码与测试 | 等待 Gate A | 等待正式执行候选测试 |
| Gate C:预发布 | 禁止 | 等待 Gate A、Gate B、离线全量验收和在线联调 |
| Gate D:发布审查 | `STOP_PREPRODUCTION` | Codex 明确停止预发布 |
| Gate E:生产发布 | 禁止 | 未满足任何生产 Gate |
## 7. 阻塞解除顺序
必须按以下顺序处理,避免重复测试和错误放行:
```text
1. 清除 3 处明文 Secret
↓
2. 安装批准的签名材料
↓
3. 签发最小范围 Run Provenance
↓
4. 修复 Controller 旧仓库健康检查
↓
5. Merge 当前 Candidate 到 OpenClaw Task
↓
6. 校验 local_oid == gitea_oid
↓
7. 执行 Gate A:文档—代码检查
↓
8. 执行 Gate B:单元与集成测试
↓
9. 执行离线全量验收
↓
10. 执行在线 Runtime 联调
↓
11. 执行重启恢复与回滚
↓
12. Codex 重新审查并给出发布建议
```
## 8. 外部受信输入
五项高风险 Blocker 当前均不再需要新的外部受信输入。签名材料继续留在受保护边界,Provenance 仍须按 Run 由 Owner 限时签发,不得转化为长期通用权限。
## 9. 监督结论
当前状态保持:
```text
PARTIAL
STOP_PREPRODUCTION
```
三分支已同步 Gitea,五项高风险 Blocker 已解除,允许执行 Gate A、Gate B 和离线全量验收;在三项正式结果与后续在线验收形成证据前,不进入预发布。
任何后续状态更新必须同步:
- OpenClaw `.agent/status.json`、`blockers.json`、`progress.json`、`result.json`;
- Codex 对应监督 Evidence;
- 本文档;
- `05-TRACEABILITY-MATRIX.md`;
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md`;
- 如涉及架构或发布语义,更新 ADR。
## 10. 2026-07-20 至 2026-07-21 Owner 修复实况
- Worktree:OpenClaw 与 Codex 独立、目标分支唯一检出、无 lock、无 prunable;现有 Evidence 已先备份。
- Candidate:`ba7669c3b49f98cf3500af6ea966ba0db472ff3b` 已普通 Push 到 Gitea,并以 `--no-ff` 合入 OpenClaw 任务分支。
- Secrets:OpenClaw 原生 audit 从 3 项明文降为 `plaintext=0`、`unresolved=0`、`shadowed=0`、`legacy=0`;Gateway Token 已轮换,OneAPI 实际 agent 调用通过。
- Signing:命令合同已从名实不符的 HMAC 改为真实 Ed25519;`openclaw-v2r1-candidate-ed25519` 材料由 Owner 在受保护边界生成,运行用户权限检查、sign/verify 和篡改拒绝通过。
- Controller:修复分支 `patch/bugfix/controller-gitea-health-v2r1-20260720` 的 OID `6188da26453505dacde9d6d298191437164c8d27` 已部署;12 项测试通过,active worktree detached + clean,`gitea=true`、`branch_sync=true`、`oid_match=true`、`sync_status=fully_synced`。
- Provenance:插件 rc2 已声明原生工具合同并通过 runtime doctor;Owner-signed Store、一次性 permit、七路径 Writer、普通 Push、审计与越权拒绝均通过。首次部署检查脚本曾两次因读取错误 JSON 层级触发自动回滚,旧插件与配置均成功恢复,修正断言后部署成功。
因此当前结论仍为 `STOP_PREPRODUCTION`,但 Gate A 已允许启动;后续结论必须基于 Gate A、Gate B、离线 Acceptance、在线 Runtime、重启恢复和正式回滚演练的最新证据。
+129
View File
@@ -0,0 +1,129 @@
# AIOS V2 架构与交付文档索引
- 版本线:`V2R1C0-candidate`
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
- 架构负责人:AIOS V2 CTO
- 当前状态:候选代码闭环已完成,运行时、预发布和生产验证尚待执行
## 1. 文档目的
本目录是 AIOS V2 用户需求、目标架构、模块边界、迁移策略、发布门禁和架构决策的唯一事实源。
所有后续架构、功能、接口、数据模型、安全策略、部署方式和验收流程变化,都必须在同一交付流程中同步更新本目录。
AIOS V2 遵循以下不可协商原则:
1. 不推倒重写现有平台。
2. 优先复用 OpenClaw、Hermes 和 Agent Router 能力。
3. OpenClaw 与 Hermes 从控制面职责中逐步解耦。
4. AIOS 是唯一控制面。
5. 所有面向用户和运维的能力必须通过 Web 或 API 访问。
6. 所有模块必须强制多租户隔离。
7. 所有修改必须通过 Git 分支、提交、测试、审查和受控提升完成。
8. 代码变化必须同步更新需求、架构、契约、追踪矩阵或 ADR。
## 2. 文档地图
| 文档 | 用途 |
|---|---|
| [01-USER-REQUIREMENTS.md](01-USER-REQUIREMENTS.md) | 产品目标、用户角色、功能需求、非功能需求和验收标准 |
| [02-TARGET-ARCHITECTURE.md](02-TARGET-ARCHITECTURE.md) | 目标逻辑架构、控制边界、数据流、安全模型和多租户模型 |
| [03-MIGRATION-AND-DELIVERY.md](03-MIGRATION-AND-DELIVERY.md) | 增量迁移、能力复用、GitOps 流程、发布门禁和回滚策略 |
| [04-MODULE-CONTRACTS.md](04-MODULE-CONTRACTS.md) | 租户上下文、任务生命周期、OpenClaw、Hermes、审计和执行证据契约 |
| [05-TRACEABILITY-MATRIX.md](05-TRACEABILITY-MATRIX.md) | 需求、设计、代码、测试、部署和验收之间的追踪关系 |
| [06-DOCUMENT-GOVERNANCE.md](06-DOCUMENT-GOVERNANCE.md) | 文档语言、完整性、同步门禁、版本与审查要求 |
| [adr/README.md](adr/README.md) | 架构决策记录索引与治理规则 |
## 3. 当前实现映射
| 架构能力 | 候选实现 |
|---|---|
| Tenant、Agent、Task、Policy 和 Audit 控制面 | `active-runtime/app/aios_v2/control_plane.py` |
| 控制面持久化 | `active-runtime/app/aios_v2/persistence.py` |
| Web/API Gateway | `active-runtime/app/aios_v2/gateway.py` |
| 统一应用入口 | `active-runtime/app/aios_v2/app.py` |
| Runtime 调度与结果回写 | `active-runtime/app/aios_v2/orchestrator.py` |
| Agent 执行证据 | `active-runtime/app/aios_v2/execution_tracking.py` |
| GitOps 状态可视化 | `active-runtime/app/aios_v2/gitops_status.py` |
| Web Console | `active-runtime/app/aios_v2/console.py` |
| OpenClaw 执行边界 | `active-runtime/app/aios_openclaw/` |
| Hermes Runtime 边界 | `active-runtime/app/aios_hermes/` |
| 候选测试 | `tests/aios_v2_candidate/` |
| 部署、健康检查与回滚 | `deploy/aios-v2/` |
| OpenClaw 生产部署任务 | `tasks/openclaw-production-deploy-task.yaml` |
| Codex 生产监督任务 | `tasks/codex-production-supervisor-task.yaml` |
## 4. 状态术语
项目必须严格区分“代码存在”和“可生产发布”:
- **已设计**:文档中存在明确边界和验收标准。
- **已实现**:代码已经提交到候选分支。
- **已定义测试**:Git 中存在自动化测试代码。
- **已验证测试**:已对精确 commit 执行测试并保存证据。
- **已验证预发布**:已在隔离环境完成部署、健康检查、重启和回滚演练。
- **已生产发布**:已取得 Owner 批准并完成合并、Tag 和生产部署。
分支、文件、测试脚本或部署脚本存在,并不自动代表能力已通过生产验收。
## 5. 设计摘要
```text
浏览器 / Web Console / Public API
|
v
AIOS 唯一控制面
Tenant | Agent | Task | Policy | Audit
|
v
Execution Orchestrator / Agent Router
/ \
v v
OpenClaw Adapter Hermes Boundary
| |
v v
OpenClaw Runtime Hermes Workers
\
v
结果回写 + 审计 + 执行证据
```
控制面拥有权威身份、策略、审批、任务生命周期和审计状态。OpenClaw 与 Hermes 只接收带租户上下文、已经批准的执行工作,并返回运行结果。
## 6. 发布规则
候选版本在满足以下条件前不得提升:
- 需求到代码的追踪关系完整;
- 文档—代码匹配检查通过;
- 多租户负向测试通过;
- GitOps Controller worktree 保持 detached 且 clean;
- 本地 Git 与 Gitea 目标分支 OID 一致;
- 持久化重启恢复测试通过;
- 预发布健康检查和回滚演练通过;
- Git 中不存在明文密钥;
- OpenClaw 与 Hermes 真实 Runtime 联调通过;
- Owner 对合并、Tag 和生产部署做出明确批准。
## 7. 文档完整性要求
每份正式文档必须包含:
- 文档目的与适用范围;
- 当前版本和状态;
- 关键设计或需求正文;
- 代码、测试或部署映射;
- 验收与更新规则;
- 已知限制和后续演进方向。
禁止在正式归档包中以摘要、占位符或“后续补充”代替正文。
## 8. 中文输出要求
所有面向用户、产品、架构、运维和审计的正式文档统一使用中文。以下技术标识保留原始英文,避免与代码不一致:
- 类名、函数名、字段名;
- API 路径和 HTTP Header;
- Git 分支、Commit、Tag;
- OpenClaw、Hermes、Agent Router、GitOps 等专有名称;
- systemd、Docker、SQLite、HMAC、HTTP 等标准技术术语。
@@ -0,0 +1,108 @@
# ADR-001:AIOS 作为唯一控制面
- 状态:已采纳
- 日期:2026-07-20
- 影响范围:Control Plane、Agent Router、OpenClaw、Hermes、Web Console、GitOps、审计
## 1. 背景
现有系统已经包含多个执行、调度和治理能力。如果 OpenClaw、Hermes、Agent Router 或 Web 层分别保存自己的租户、策略、审批和任务状态,就会形成多个权威事实源,带来以下问题:
- 同一任务在不同模块中状态不一致;
- 审计链不完整;
- 多租户隔离难以统一验证;
- Web、Runtime 和运维入口可能绕过策略;
- Bug 修复和发布无法判断应该修改哪个事实源。
## 2. 决策
AIOS Control Plane 是唯一权威控制面,负责:
- Tenant Registry;
- Identity 与角色上下文;
- Agent Registry;
- Task Lifecycle;
- Policy Decision;
- Approval;
- Audit Event;
- Runtime Inventory;
- Execution Evidence;
- GitOps 与发布状态可视化。
OpenClaw、Hermes 和 Agent Router 只能执行、路由或调度已经由 AIOS 批准的工作,不得拥有第二套租户、策略、审批或任务生命周期。
## 3. 目标边界
```text
Web / API
|
v
AIOS Control Plane
|
v
Execution Orchestrator / Agent Router
|
+--> OpenClaw Adapter --> OpenClaw Runtime
|
+--> Hermes Boundary --> Hermes Workers
```
## 4. 备选方案
### 方案 A:保持多个控制面
拒绝原因:状态会持续分裂,无法形成统一审计和租户隔离。
### 方案 B:由 OpenClaw 或 Hermes 成为主控制面
拒绝原因:二者主要价值是执行与 Runtime,不适合作为统一产品、租户、审批和发布控制面。
### 方案 C:推倒重写全部能力
拒绝原因:会丢失现有执行能力、运行经验和迁移可回滚性。
## 5. 正面影响
- 任务、策略和租户状态唯一;
- Web/API、OpenClaw、Hermes 和 GitOps 使用同一权威模型;
- 审计、执行证据和发布判断可以统一;
- 新 Runtime 可以通过稳定 Adapter 接入;
- 多租户和权限模型可集中验证。
## 6. 负面影响与风险
- 所有旧执行入口需要迁移到 Orchestrator;
- Runtime 需要稳定 Contract;
- Control Plane 可靠性要求提高;
- 在迁移完成前可能短期存在旧旁路,需要明确冻结和监控。
## 7. 迁移与回滚
迁移顺序:
1. 建立 AIOS Control Plane 权威模型;
2. 引入 Orchestrator;
3. OpenClaw/Hermes 通过 Adapter 接入;
4. 将 Web 和自动任务切换到 AIOS API;
5. 关闭旧控制旁路。
回滚只允许回滚 Runtime 或应用版本,不允许恢复第二套权威控制状态。
## 8. 合规检查
任何新功能必须回答:
- 权威状态是否存放在 AIOS?
- Runtime 是否只接收已批准工作?
- Web 是否只调用 AIOS API?
- 是否新增了第二套状态机或租户事实源?
若最后一项为“是”,变更必须拒绝或新增替代 ADR。
## 9. 实现与测试映射
- 实现:`active-runtime/app/aios_v2/control_plane.py`
- 编排:`active-runtime/app/aios_v2/orchestrator.py`
- 应用入口:`active-runtime/app/aios_v2/app.py`
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
- 追踪:`docs/aios-v2/05-TRACEABILITY-MATRIX.md`

Some files were not shown because too many files have changed in this diff Show More