Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c7740739 | ||
|
|
8bbb049c39 | ||
|
|
1707cc4953 | ||
|
|
e397594200 | ||
|
|
68b7f2c1d2 | ||
|
|
fda98e61f5 | ||
|
|
ea9f599522 | ||
|
|
1aabcd8b62 | ||
|
|
4d68af9524 | ||
|
|
467b127804 | ||
|
|
50a73b211b | ||
|
|
f14d4b6e14 | ||
|
|
fc8bd8c8d4 | ||
|
|
382fe662a3 | ||
|
|
4754e5762a | ||
|
|
ce7d35a811 | ||
|
|
60ac7d9d0b | ||
|
|
2a10d1cdd9 | ||
|
|
677ea738e7 | ||
|
|
7154d62cca | ||
|
|
4e154c8baf | ||
|
|
b848361079 | ||
|
|
74224cfbd5 | ||
|
|
a7c09ba646 | ||
|
|
f055cb74bb | ||
|
|
071a378de6 | ||
|
|
a492b15df6 | ||
|
|
a9f0a4359b | ||
|
|
6a9fab894a | ||
|
|
e9111dea47 | ||
|
|
d25fbfa61e | ||
|
|
6b03c47625 | ||
|
|
770fe1ef66 | ||
|
|
7a025430f9 | ||
|
|
d079da4029 | ||
|
|
b8f7dc4112 | ||
|
|
947cdaff42 | ||
|
|
83b0c8bd74 | ||
|
|
fbd880cf8c | ||
|
|
819ac94c9f | ||
|
|
b02401e60d | ||
|
|
cbb2e232f5 | ||
|
|
9e46ac0504 | ||
|
|
ba7669c3b4 | ||
|
|
634f6b164f | ||
|
|
c9ce12d102 | ||
|
|
db50d2fa5a | ||
|
|
63fd23a464 | ||
|
|
2e03ed74f6 | ||
|
|
b86f51c9ab | ||
|
|
09448a5d3d | ||
|
|
18c719b2c6 | ||
|
|
b0a78fa9a8 | ||
|
|
0aca7dbcfc | ||
|
|
3f84d8991e | ||
|
|
5ac8f3ea6f | ||
|
|
bfb1c2fd52 | ||
|
|
cbfe1211c1 | ||
|
|
efd99e620c | ||
|
|
f23b694ad3 | ||
|
|
f97dea55ac | ||
|
|
efd08d7771 | ||
|
|
ff6cb2e217 | ||
|
|
d774620fea | ||
|
|
b26ff010fc | ||
|
|
4b49f2ba4b | ||
|
|
b4b8fe8828 | ||
|
|
4e8ab051a0 | ||
|
|
f61e35b218 | ||
|
|
b2f90f24a3 | ||
|
|
6721eb4752 | ||
|
|
a9e4fd1e40 | ||
|
|
621750976a | ||
|
|
5359e88806 | ||
|
|
76636931ba | ||
|
|
180c1fc66e | ||
|
|
c0ff052dab | ||
|
|
94cffb6ba3 | ||
|
|
9ad5628e69 | ||
|
|
6d397a7894 | ||
|
|
165fea6ba9 | ||
|
|
d079e2087c | ||
|
|
e3b36ebdcc | ||
|
|
5e791b11c0 | ||
|
|
50be65b76e | ||
|
|
7bb86e30bf | ||
|
|
ea56b45b7e | ||
|
|
f53682da7a | ||
|
|
71884ac088 | ||
|
|
969c278cb0 | ||
|
|
6fe2a1e036 | ||
|
|
9a60e0c68e | ||
|
|
46f553ca11 | ||
|
|
4f0a0c2184 | ||
|
|
c107b193d9 | ||
|
|
2db7ced7a4 | ||
|
|
6361d9a334 | ||
|
|
2cf54f04ef | ||
|
|
f840ce5c94 | ||
|
|
35e7b55490 | ||
|
|
1f8d1f23b3 | ||
|
|
e93011791e | ||
|
|
4460564a48 | ||
|
|
4d6658136e | ||
|
|
5767e665c2 |
-35
@@ -1,35 +0,0 @@
|
|||||||
# RESULT: V2R1 Personal-Weixin Foundation Implement
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
status: BLOCKED_SCOPE_CONFLICT
|
|
||||||
foundation_branch: patch/v2r1-personal-weixin-foundation-codex-20260715T183951UTC8
|
|
||||||
foundation_commit: null
|
|
||||||
foundation_parent: d5b51e1342541bf51887782f530c3d2971155996
|
|
||||||
files_changed: []
|
|
||||||
runtime_wheel: not_run_scope_conflict
|
|
||||||
portal_check: not_run_scope_conflict
|
|
||||||
migration_dry_run: not_run_scope_conflict
|
|
||||||
temporary_migration_apply: not_run_scope_conflict
|
|
||||||
model_security: not_run_scope_conflict
|
|
||||||
image_id: null
|
|
||||||
image_digest: null
|
|
||||||
production_changed: false
|
|
||||||
baseline_after: V1R4C6
|
|
||||||
release_status: paused
|
|
||||||
remaining_risks: Existing package-identity regression test requires legacy static package metadata and has no Personal-Weixin coverage.
|
|
||||||
next_step: Owner must explicitly authorize a test-contract update outside the exact 17 implementation paths, or revise the recursive-discovery requirement.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Blocking evidence
|
|
||||||
|
|
||||||
The Foundation worktree was created directly from `d5b51e1342541bf51887782f530c3d2971155996` on `patch/v2r1-personal-weixin-foundation-codex-20260715T183951UTC8`.
|
|
||||||
|
|
||||||
TDD was performed exclusively under `/tmp` before implementation work:
|
|
||||||
|
|
||||||
1. RED: `/tmp/pwx_runtime_discovery_red.py` asserted recursive setuptools discovery (`where = ["app"]`, `include = ["aios_v2r1*"]`) and a Personal-Weixin package. It failed against the base metadata because the base contains the static list `packages = ["aios_v2r1"]`.
|
|
||||||
2. Candidate GREEN: only the authorized `active-runtime/pyproject.toml` and `active-runtime/app/aios_v2r1/personal_weixin/__init__.py` were changed; the temporary discovery test passed.
|
|
||||||
3. Required existing regression test: `PYTHONPATH=active-runtime/app python3 -m pytest tests/test_v2r1_package_identity.py` failed exactly once, at `tests/test_v2r1_package_identity.py:27`, because it asserts the literal legacy metadata `packages = ["aios_v2r1"]`. The run result was `1 failed, 3 passed`.
|
|
||||||
|
|
||||||
Recursive discovery required by this task is incompatible with that literal assertion. Updating `tests/test_v2r1_package_identity.py` would add an eighteenth path, which is explicitly forbidden. The candidate source changes were restored to `d5`; the implementation branch/worktree was then removed with no implementation commit.
|
|
||||||
|
|
||||||
No Portal, migration, Docker image, production system, production database, B6/B7, binding, QR, OAuth/token, secret, or release change was made.
|
|
||||||
@@ -11,7 +11,3 @@ runtime/tmp/
|
|||||||
runtime/cache/
|
runtime/cache/
|
||||||
logs/
|
logs/
|
||||||
config/test_write_perms
|
config/test_write_perms
|
||||||
/data/webops.sqlite3
|
|
||||||
/data/webops.sqlite3-wal
|
|
||||||
/data/webops.sqlite3-shm
|
|
||||||
/data/webops.sqlite3-journal
|
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
# 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 决策。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""AIOS-managed Hermes runtime boundary."""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
HermesAdapter,
|
||||||
|
HermesConflict,
|
||||||
|
HermesNotFound,
|
||||||
|
HermesRuntimeError,
|
||||||
|
InMemoryHermesStore,
|
||||||
|
)
|
||||||
|
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
|
||||||
|
from .worker import HermesWorker, WorkerRunResult
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HermesAdapter",
|
||||||
|
"HermesConflict",
|
||||||
|
"HermesHealth",
|
||||||
|
"HermesJob",
|
||||||
|
"HermesJobState",
|
||||||
|
"HermesNotFound",
|
||||||
|
"HermesResult",
|
||||||
|
"HermesRuntimeError",
|
||||||
|
"HermesWorker",
|
||||||
|
"InMemoryHermesStore",
|
||||||
|
"WorkerRunResult",
|
||||||
|
]
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""Tenant-partitioned Hermes queue and worker lease boundary."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Iterable, Mapping
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
ALLOWED_TRANSITIONS,
|
||||||
|
TERMINAL_STATES,
|
||||||
|
HermesHealth,
|
||||||
|
HermesJob,
|
||||||
|
HermesJobState,
|
||||||
|
HermesResult,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HermesRuntimeError(RuntimeError):
|
||||||
|
"""Base Hermes runtime-boundary failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class HermesNotFound(HermesRuntimeError):
|
||||||
|
"""A tenant-scoped job was not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class HermesConflict(HermesRuntimeError):
|
||||||
|
"""A state, lease, idempotency, or revision conflict occurred."""
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryHermesStore:
|
||||||
|
"""Thread-safe reference store; production backends implement this contract."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.jobs: dict[str, HermesJob] = {}
|
||||||
|
self.results: dict[str, HermesResult] = {}
|
||||||
|
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class HermesAdapter:
|
||||||
|
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: InMemoryHermesStore | None = None,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "hermes-runtime",
|
||||||
|
runtime_version: str = "v1",
|
||||||
|
default_lease_seconds: int = 60,
|
||||||
|
) -> None:
|
||||||
|
if default_lease_seconds < 5:
|
||||||
|
raise ValueError("default_lease_seconds must be >= 5")
|
||||||
|
self.store = store or InMemoryHermesStore()
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.runtime_version = runtime_version
|
||||||
|
self.default_lease_seconds = int(default_lease_seconds)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _digest(job: HermesJob) -> str:
|
||||||
|
raw = json.dumps(
|
||||||
|
{
|
||||||
|
"tenant_id": job.tenant_id,
|
||||||
|
"cell_id": job.cell_id,
|
||||||
|
"profile_id": job.profile_id,
|
||||||
|
"task_id": job.task_id,
|
||||||
|
"trace_id": job.trace_id,
|
||||||
|
"action": job.action,
|
||||||
|
"payload": dict(job.payload),
|
||||||
|
"priority": job.priority,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse(value: str | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format(value: datetime) -> str:
|
||||||
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_tenant(job: HermesJob, tenant_id: str) -> None:
|
||||||
|
if job.tenant_id != tenant_id:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
|
||||||
|
def enqueue(self, job: HermesJob) -> HermesJob:
|
||||||
|
if job.state != HermesJobState.QUEUED or job.revision != 1:
|
||||||
|
raise HermesConflict("new job must be QUEUED at revision 1")
|
||||||
|
digest = self._digest(job)
|
||||||
|
idem_ref = (job.tenant_id, job.idempotency_key)
|
||||||
|
with self.store.lock:
|
||||||
|
existing = self.store.jobs.get(job.job_id)
|
||||||
|
if existing is not None:
|
||||||
|
if self._digest(existing) != digest:
|
||||||
|
raise HermesConflict("job_id reused with different content")
|
||||||
|
return existing
|
||||||
|
prior = self.store.idempotency.get(idem_ref)
|
||||||
|
if prior is not None:
|
||||||
|
prior_id, prior_digest = prior
|
||||||
|
if prior_digest != digest:
|
||||||
|
raise HermesConflict("idempotency key reused with different content")
|
||||||
|
return self.store.jobs[prior_id]
|
||||||
|
self.store.jobs[job.job_id] = job
|
||||||
|
self.store.idempotency[idem_ref] = (job.job_id, digest)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
|
||||||
|
with self.store.lock:
|
||||||
|
job = self.store.jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(job, tenant_id)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def list_jobs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str | None = None,
|
||||||
|
profile_id: str | None = None,
|
||||||
|
states: Iterable[HermesJobState | str] | None = None,
|
||||||
|
) -> list[HermesJob]:
|
||||||
|
state_filter = {HermesJobState(item) for item in states} if states else None
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
jobs = [
|
||||||
|
job
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
if job.tenant_id == tenant_id
|
||||||
|
and (cell_id is None or job.cell_id == cell_id)
|
||||||
|
and (profile_id is None or job.profile_id == profile_id)
|
||||||
|
and (state_filter is None or job.state in state_filter)
|
||||||
|
]
|
||||||
|
return sorted(jobs, key=lambda item: (item.priority, item.created_at, item.job_id))
|
||||||
|
|
||||||
|
def claim(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int | None = None,
|
||||||
|
) -> HermesJob | None:
|
||||||
|
worker_id = str(worker_id or "").strip()
|
||||||
|
if not worker_id:
|
||||||
|
raise ValueError("worker_id is required")
|
||||||
|
duration = int(lease_seconds or self.default_lease_seconds)
|
||||||
|
if duration < 5:
|
||||||
|
raise ValueError("lease_seconds must be >= 5")
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
candidates = [
|
||||||
|
job
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
if job.tenant_id == tenant_id
|
||||||
|
and job.cell_id == cell_id
|
||||||
|
and job.profile_id == profile_id
|
||||||
|
and job.state == HermesJobState.QUEUED
|
||||||
|
]
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
current = sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda item: (item.priority, item.created_at, item.job_id),
|
||||||
|
)[0]
|
||||||
|
claimed = self._transition_locked(
|
||||||
|
current,
|
||||||
|
HermesJobState.CLAIMED,
|
||||||
|
assigned_worker_id=worker_id,
|
||||||
|
lease_expires_at=self._format(
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
return self._transition_locked(current, HermesJobState.RUNNING)
|
||||||
|
|
||||||
|
def heartbeat(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
duration = int(lease_seconds or self.default_lease_seconds)
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
|
||||||
|
raise HermesConflict("job does not accept heartbeat")
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
lease_expires_at=self._format(
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||||
|
),
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.jobs[job_id] = updated
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
output: Mapping[str, object],
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
return self._finish(
|
||||||
|
job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
state=HermesJobState.COMPLETED,
|
||||||
|
output=output,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
error: Mapping[str, object],
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
return self._finish(
|
||||||
|
job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
state=HermesJobState.FAILED,
|
||||||
|
error=error,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self.store.jobs.get(job_id)
|
||||||
|
if current is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(current, tenant_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
if current.state in TERMINAL_STATES:
|
||||||
|
result = self.store.results.get(job_id)
|
||||||
|
if result is None:
|
||||||
|
raise HermesConflict("terminal result missing")
|
||||||
|
return result
|
||||||
|
updated = self._transition_locked(current, HermesJobState.CANCELLED)
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=updated.job_id,
|
||||||
|
tenant_id=updated.tenant_id,
|
||||||
|
task_id=updated.task_id,
|
||||||
|
trace_id=updated.trace_id,
|
||||||
|
state=HermesJobState.CANCELLED,
|
||||||
|
worker_id=updated.assigned_worker_id,
|
||||||
|
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.results[job_id] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
job = self.store.jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(job, tenant_id)
|
||||||
|
result = self.store.results.get(job_id)
|
||||||
|
if result is None:
|
||||||
|
raise HermesConflict("job has no terminal result")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def health(self) -> HermesHealth:
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
active_jobs = sum(
|
||||||
|
1 for job in self.store.jobs.values()
|
||||||
|
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||||
|
)
|
||||||
|
queued_jobs = sum(
|
||||||
|
1 for job in self.store.jobs.values()
|
||||||
|
if job.state == HermesJobState.QUEUED
|
||||||
|
)
|
||||||
|
partitions = {
|
||||||
|
(job.tenant_id, job.cell_id, job.profile_id)
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
}
|
||||||
|
active_workers = sum(
|
||||||
|
1 for heartbeat, _ in self.store.worker_heartbeats.values()
|
||||||
|
if self._parse(heartbeat)
|
||||||
|
and self._parse(heartbeat) >= datetime.now(timezone.utc) - timedelta(seconds=120)
|
||||||
|
)
|
||||||
|
return HermesHealth(
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
status="healthy",
|
||||||
|
version=self.runtime_version,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
active_workers=active_workers,
|
||||||
|
active_jobs=active_jobs,
|
||||||
|
queued_jobs=queued_jobs,
|
||||||
|
tenant_partitions=len(partitions),
|
||||||
|
profile_isolation_enforced=True,
|
||||||
|
details={"store": type(self.store).__name__},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
state: HermesJobState,
|
||||||
|
output: Mapping[str, object] | None = None,
|
||||||
|
error: Mapping[str, object] | None = None,
|
||||||
|
expected_revision: int | None,
|
||||||
|
) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
updated = self._transition_locked(current, state)
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=updated.job_id,
|
||||||
|
tenant_id=updated.tenant_id,
|
||||||
|
task_id=updated.task_id,
|
||||||
|
trace_id=updated.trace_id,
|
||||||
|
state=state,
|
||||||
|
worker_id=worker_id,
|
||||||
|
output=output,
|
||||||
|
error=error,
|
||||||
|
started_at=current.updated_at,
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.results[job_id] = result
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
|
||||||
|
current = self.store.jobs.get(job_id)
|
||||||
|
if current is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(current, tenant_id)
|
||||||
|
if current.assigned_worker_id != worker_id:
|
||||||
|
raise HermesConflict("worker does not own job lease")
|
||||||
|
return current
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_revision(job: HermesJob, expected: int | None) -> None:
|
||||||
|
if expected is not None and job.revision != expected:
|
||||||
|
raise HermesConflict("job revision changed")
|
||||||
|
|
||||||
|
def _transition_locked(
|
||||||
|
self,
|
||||||
|
current: HermesJob,
|
||||||
|
target: HermesJobState,
|
||||||
|
*,
|
||||||
|
assigned_worker_id: str | None = None,
|
||||||
|
lease_expires_at: str | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
if target not in ALLOWED_TRANSITIONS[current.state]:
|
||||||
|
raise HermesConflict(
|
||||||
|
f"invalid transition {current.state.value} -> {target.value}"
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
state=target,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
assigned_worker_id=(
|
||||||
|
assigned_worker_id
|
||||||
|
if assigned_worker_id is not None
|
||||||
|
else current.assigned_worker_id
|
||||||
|
),
|
||||||
|
lease_expires_at=(
|
||||||
|
lease_expires_at
|
||||||
|
if lease_expires_at is not None
|
||||||
|
else (None if target in TERMINAL_STATES else current.lease_expires_at)
|
||||||
|
),
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.jobs[current.job_id] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def _requeue_expired_locked(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for current in list(self.store.jobs.values()):
|
||||||
|
expiry = self._parse(current.lease_expires_at)
|
||||||
|
if (
|
||||||
|
current.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||||
|
and expiry is not None
|
||||||
|
and expiry <= now
|
||||||
|
):
|
||||||
|
self.store.jobs[current.job_id] = replace(
|
||||||
|
current,
|
||||||
|
state=HermesJobState.QUEUED,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
assigned_worker_id=None,
|
||||||
|
lease_expires_at=None,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Tenant-scoped Hermes runtime contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
class HermesJobState(str, Enum):
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
CLAIMED = "CLAIMED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
|
||||||
|
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
|
||||||
|
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
|
||||||
|
HermesJobState.RUNNING: frozenset(
|
||||||
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||||
|
),
|
||||||
|
HermesJobState.COMPLETED: frozenset(),
|
||||||
|
HermesJobState.FAILED: frozenset(),
|
||||||
|
HermesJobState.CANCELLED: frozenset(),
|
||||||
|
}
|
||||||
|
TERMINAL_STATES = frozenset(
|
||||||
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesJob:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
profile_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
idempotency_key: str = ""
|
||||||
|
priority: int = 100
|
||||||
|
state: HermesJobState = HermesJobState.QUEUED
|
||||||
|
revision: int = 1
|
||||||
|
assigned_worker_id: str | None = None
|
||||||
|
lease_expires_at: str | None = None
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
updated_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
|
||||||
|
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||||
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||||
|
if not 0 <= int(self.priority) <= 1000:
|
||||||
|
raise ValueError("priority must be between 0 and 1000")
|
||||||
|
if self.revision < 1:
|
||||||
|
raise ValueError("revision must be >= 1")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
priority: int = 100,
|
||||||
|
) -> "HermesJob":
|
||||||
|
return cls(
|
||||||
|
job_id=f"hermes_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
cell_id=cell_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
task_id=task_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=action,
|
||||||
|
payload=_safe_mapping(payload),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesResult:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
state: HermesJobState
|
||||||
|
worker_id: str | None = None
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||||
|
if self.output is not None:
|
||||||
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||||
|
if self.error is not None:
|
||||||
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesHealth:
|
||||||
|
runtime_id: str
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
heartbeat_at: str
|
||||||
|
active_workers: int = 0
|
||||||
|
active_jobs: int = 0
|
||||||
|
queued_jobs: int = 0
|
||||||
|
tenant_partitions: int = 0
|
||||||
|
profile_isolation_enforced: bool = True
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("runtime_id", "status", "version", "heartbeat_at"):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
|
||||||
|
if int(getattr(self, name)) < 0:
|
||||||
|
raise ValueError(f"{name} must be >= 0")
|
||||||
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Hermes worker loop with lease renewal and bounded recovery."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
from .adapter import HermesAdapter, HermesConflict
|
||||||
|
from .contract import HermesJob, HermesJobState
|
||||||
|
|
||||||
|
|
||||||
|
JobHandler = Callable[[HermesJob], Mapping[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerRunResult:
|
||||||
|
claimed: bool
|
||||||
|
job_id: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HermesWorker:
|
||||||
|
"""Poll one tenant/cell/profile partition and execute jobs safely."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: HermesAdapter,
|
||||||
|
handler: JobHandler,
|
||||||
|
*,
|
||||||
|
worker_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
poll_seconds: float = 1.0,
|
||||||
|
heartbeat_seconds: float = 10.0,
|
||||||
|
) -> None:
|
||||||
|
if poll_seconds <= 0 or heartbeat_seconds <= 0:
|
||||||
|
raise ValueError("poll_seconds and heartbeat_seconds must be positive")
|
||||||
|
for name, value in {
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"cell_id": cell_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
}.items():
|
||||||
|
if not str(value or "").strip():
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
self.adapter = adapter
|
||||||
|
self.handler = handler
|
||||||
|
self.worker_id = worker_id
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.cell_id = cell_id
|
||||||
|
self.profile_id = profile_id
|
||||||
|
self.poll_seconds = float(poll_seconds)
|
||||||
|
self.heartbeat_seconds = float(heartbeat_seconds)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def run_once(self) -> WorkerRunResult:
|
||||||
|
job = self.adapter.claim(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
cell_id=self.cell_id,
|
||||||
|
profile_id=self.profile_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
)
|
||||||
|
if job is None:
|
||||||
|
return WorkerRunResult(claimed=False)
|
||||||
|
|
||||||
|
running = self.adapter.start(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
expected_revision=job.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
heartbeat_thread = threading.Thread(
|
||||||
|
target=self._heartbeat_loop,
|
||||||
|
args=(running.job_id, heartbeat_stop),
|
||||||
|
name=f"hermes-heartbeat-{self.worker_id}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
heartbeat_thread.start()
|
||||||
|
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
execution_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
output = dict(self.handler(running))
|
||||||
|
except Exception as exc: # runtime boundary: normalize below
|
||||||
|
execution_error = exc
|
||||||
|
finally:
|
||||||
|
# Stop the lease heartbeat before reading the final revision. This
|
||||||
|
# prevents a revision increment racing with complete/fail.
|
||||||
|
heartbeat_stop.set()
|
||||||
|
heartbeat_thread.join(timeout=max(self.heartbeat_seconds * 2, 1.0))
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = self.adapter.get(running.job_id, tenant_id=self.tenant_id)
|
||||||
|
if execution_error is None:
|
||||||
|
result = self.adapter.complete(
|
||||||
|
running.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
output=dict(output or {}),
|
||||||
|
expected_revision=current.revision,
|
||||||
|
)
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=result.job_id,
|
||||||
|
state=result.state.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.adapter.fail(
|
||||||
|
running.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
error={
|
||||||
|
"code": "worker_error",
|
||||||
|
"message": str(execution_error),
|
||||||
|
},
|
||||||
|
expected_revision=current.revision,
|
||||||
|
)
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=result.job_id,
|
||||||
|
state=result.state.value,
|
||||||
|
error=str(execution_error),
|
||||||
|
)
|
||||||
|
except HermesConflict as exc:
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=running.job_id,
|
||||||
|
state=HermesJobState.FAILED.value,
|
||||||
|
error=f"reconciliation_conflict: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_forever(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
result = self.run_once()
|
||||||
|
if not result.claimed:
|
||||||
|
self._stop.wait(self.poll_seconds)
|
||||||
|
|
||||||
|
def _heartbeat_loop(self, job_id: str, stop_event: threading.Event) -> None:
|
||||||
|
while not stop_event.wait(self.heartbeat_seconds):
|
||||||
|
try:
|
||||||
|
self.adapter.heartbeat(
|
||||||
|
job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
stop_event.set()
|
||||||
|
return
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""AIOS-managed OpenClaw Ed25519 execution adapter."""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
AdapterError,
|
||||||
|
CommandConflict,
|
||||||
|
CommandNotFound,
|
||||||
|
CommandRejected,
|
||||||
|
InMemoryCallbackSink,
|
||||||
|
OpenClawAdapter,
|
||||||
|
)
|
||||||
|
from .contract import (
|
||||||
|
CONTRACT_VERSION,
|
||||||
|
SIGNATURE_ALGORITHM,
|
||||||
|
CommandState,
|
||||||
|
Ed25519CommandSigner,
|
||||||
|
Ed25519CommandVerifier,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
sign_command,
|
||||||
|
verify_command_signature,
|
||||||
|
)
|
||||||
|
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AdapterError",
|
||||||
|
"CONTRACT_VERSION",
|
||||||
|
"CommandConflict",
|
||||||
|
"CommandNotFound",
|
||||||
|
"CommandRejected",
|
||||||
|
"CommandState",
|
||||||
|
"Ed25519CommandSigner",
|
||||||
|
"Ed25519CommandVerifier",
|
||||||
|
"HTTPRuntimeTransport",
|
||||||
|
"InMemoryCallbackSink",
|
||||||
|
"InProcessRuntimeTransport",
|
||||||
|
"OpenClawAdapter",
|
||||||
|
"OpenClawCommand",
|
||||||
|
"OpenClawResult",
|
||||||
|
"RuntimeHealth",
|
||||||
|
"RuntimeTransport",
|
||||||
|
"SIGNATURE_ALGORITHM",
|
||||||
|
"sign_command",
|
||||||
|
"verify_command_signature",
|
||||||
|
]
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Signed, tenant-isolated execution adapter for OpenClaw runtimes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Callable, Mapping, Protocol
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
utc_now,
|
||||||
|
verify_command_signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterError(RuntimeError):
|
||||||
|
"""Base adapter failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRejected(AdapterError):
|
||||||
|
"""A command failed signature or contract validation."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandNotFound(AdapterError):
|
||||||
|
"""A tenant-scoped command is not visible."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandConflict(AdapterError):
|
||||||
|
"""A command or idempotency key conflicts with an existing execution."""
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeTransport(Protocol):
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
||||||
|
def cancel(self, command_id: str) -> 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,383 @@
|
|||||||
|
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
CONTRACT_VERSION = "aios.openclaw.command.v2"
|
||||||
|
SIGNATURE_ALGORITHM = "Ed25519"
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||||
|
Ed25519PrivateKey,
|
||||||
|
Ed25519PublicKey,
|
||||||
|
)
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("cryptography with Ed25519 support is required") from exc
|
||||||
|
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
|
|
||||||
|
class CommandSigner(Protocol):
|
||||||
|
key_id: str
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
def sign(self, payload: bytes) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CommandVerifier(Protocol):
|
||||||
|
key_id: str
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
def verify(self, payload: bytes, signature: bytes) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Ed25519CommandSigner:
|
||||||
|
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||||
|
serialization, private_cls, _ = _load_crypto()
|
||||||
|
if not isinstance(private_key, private_cls):
|
||||||
|
raise ValueError("private key is not Ed25519")
|
||||||
|
self.private_key = private_key
|
||||||
|
self.key_id = _required(key_id, "key_id")
|
||||||
|
raw = private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandSigner":
|
||||||
|
serialization, private_cls, _ = _load_crypto()
|
||||||
|
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||||
|
if raw.startswith(b"-----BEGIN"):
|
||||||
|
key = serialization.load_pem_private_key(raw, password=None)
|
||||||
|
elif len(raw) == 32:
|
||||||
|
key = private_cls.from_private_bytes(raw)
|
||||||
|
else:
|
||||||
|
raise ValueError("Ed25519 private key must be PEM or a raw 32-byte seed")
|
||||||
|
return cls(key, key_id=key_id)
|
||||||
|
|
||||||
|
def sign(self, payload: bytes) -> bytes:
|
||||||
|
return self.private_key.sign(bytes(payload))
|
||||||
|
|
||||||
|
def verifier(self) -> "Ed25519CommandVerifier":
|
||||||
|
return Ed25519CommandVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
class Ed25519CommandVerifier:
|
||||||
|
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||||
|
serialization, _, public_cls = _load_crypto()
|
||||||
|
if not isinstance(public_key, public_cls):
|
||||||
|
raise ValueError("public key is not Ed25519")
|
||||||
|
self.public_key = public_key
|
||||||
|
self.key_id = _required(key_id, "key_id")
|
||||||
|
raw = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandVerifier":
|
||||||
|
serialization, private_cls, public_cls = _load_crypto()
|
||||||
|
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||||
|
if raw.startswith(b"-----BEGIN"):
|
||||||
|
try:
|
||||||
|
key = serialization.load_pem_public_key(raw)
|
||||||
|
except ValueError:
|
||||||
|
private = serialization.load_pem_private_key(raw, password=None)
|
||||||
|
if not isinstance(private, private_cls):
|
||||||
|
raise ValueError("key is not Ed25519")
|
||||||
|
key = private.public_key()
|
||||||
|
elif len(raw) == 32:
|
||||||
|
key = public_cls.from_public_bytes(raw)
|
||||||
|
else:
|
||||||
|
raise ValueError("Ed25519 public key must be PEM or raw 32-byte key")
|
||||||
|
return cls(key, key_id=key_id)
|
||||||
|
|
||||||
|
def verify(self, payload: bytes, signature: bytes) -> bool:
|
||||||
|
try:
|
||||||
|
self.public_key.verify(bytes(signature), bytes(payload))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CommandState(str, Enum):
|
||||||
|
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
|
||||||
|
signature_algorithm: str = SIGNATURE_ALGORITHM
|
||||||
|
key_id: str = "default"
|
||||||
|
public_key_fingerprint: str = ""
|
||||||
|
signature: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in (
|
||||||
|
"command_id",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"trace_id",
|
||||||
|
"action",
|
||||||
|
"idempotency_key",
|
||||||
|
"created_at",
|
||||||
|
"contract_version",
|
||||||
|
"signature_algorithm",
|
||||||
|
"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}")
|
||||||
|
if self.signature_algorithm != SIGNATURE_ALGORITHM:
|
||||||
|
raise ValueError(
|
||||||
|
f"unsupported signature_algorithm: {self.signature_algorithm}"
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||||
|
object.__setattr__(
|
||||||
|
self, "public_key_fingerprint", str(self.public_key_fingerprint or "").strip()
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||||
|
_parse_timestamp(self.created_at)
|
||||||
|
if self.expires_at is not None:
|
||||||
|
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
||||||
|
if _parse_timestamp(self.expires_at) <= _parse_timestamp(self.created_at):
|
||||||
|
raise ValueError("expires_at must be later than created_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,
|
||||||
|
"signature_algorithm": self.signature_algorithm,
|
||||||
|
"key_id": self.key_id,
|
||||||
|
"public_key_fingerprint": self.public_key_fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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 _as_signer(command: OpenClawCommand, value: Any) -> CommandSigner:
|
||||||
|
if hasattr(value, "sign") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||||
|
return value
|
||||||
|
return Ed25519CommandSigner.from_bytes(value, key_id=command.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_verifier(command: OpenClawCommand, value: Any) -> CommandVerifier:
|
||||||
|
if isinstance(value, Ed25519CommandSigner):
|
||||||
|
return value.verifier()
|
||||||
|
if hasattr(value, "verify") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||||
|
return value
|
||||||
|
return Ed25519CommandVerifier.from_bytes(value, key_id=command.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_command(command: OpenClawCommand, signing_key: Any) -> OpenClawCommand:
|
||||||
|
signer = _as_signer(command, signing_key)
|
||||||
|
if signer.key_id != command.key_id:
|
||||||
|
raise ValueError("command key_id does not match signer")
|
||||||
|
prepared = replace(
|
||||||
|
command,
|
||||||
|
signature_algorithm=SIGNATURE_ALGORITHM,
|
||||||
|
public_key_fingerprint=signer.fingerprint,
|
||||||
|
signature="",
|
||||||
|
)
|
||||||
|
signature = base64.b64encode(
|
||||||
|
signer.sign(canonical_command_bytes(prepared))
|
||||||
|
).decode("ascii")
|
||||||
|
return replace(prepared, signature=signature)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_command_signature(
|
||||||
|
command: OpenClawCommand,
|
||||||
|
verification_key: Any,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
if (
|
||||||
|
not command.signature
|
||||||
|
or command.contract_version != CONTRACT_VERSION
|
||||||
|
or command.signature_algorithm != SIGNATURE_ALGORITHM
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
verifier = _as_verifier(command, verification_key)
|
||||||
|
signature = base64.b64decode(command.signature, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if verifier.key_id != command.key_id:
|
||||||
|
return False
|
||||||
|
if not command.public_key_fingerprint or not hmac.compare_digest(
|
||||||
|
command.public_key_fingerprint, verifier.fingerprint
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if not verifier.verify(canonical_command_bytes(command), signature):
|
||||||
|
return False
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
created = _parse_timestamp(command.created_at)
|
||||||
|
if created.timestamp() - current.timestamp() > clock_skew_seconds:
|
||||||
|
return False
|
||||||
|
if command.expires_at is not None:
|
||||||
|
expires = _parse_timestamp(command.expires_at)
|
||||||
|
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Health policy and self-healing decisions for OpenClaw runtimes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .contract import RuntimeHealth
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(value: str) -> datetime:
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("heartbeat timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthPolicy:
|
||||||
|
max_heartbeat_age_seconds: int = 90
|
||||||
|
require_workspace_isolation: bool = True
|
||||||
|
require_credential_ref: bool = True
|
||||||
|
accepted_statuses: frozenset[str] = field(
|
||||||
|
default_factory=lambda: frozenset({"healthy", "ready"})
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.max_heartbeat_age_seconds < 5:
|
||||||
|
raise ValueError("max_heartbeat_age_seconds must be >= 5")
|
||||||
|
if not self.accepted_statuses:
|
||||||
|
raise ValueError("accepted_statuses must not be empty")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthAssessment:
|
||||||
|
healthy: bool
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
recoverable: bool
|
||||||
|
recommended_action: str
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHealthMonitor:
|
||||||
|
"""Evaluate runtime health and recommend bounded recovery actions."""
|
||||||
|
|
||||||
|
def __init__(self, policy: HealthPolicy | None = None) -> None:
|
||||||
|
self.policy = policy or HealthPolicy()
|
||||||
|
|
||||||
|
def assess(
|
||||||
|
self,
|
||||||
|
health: RuntimeHealth,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> HealthAssessment:
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
heartbeat = _parse(health.heartbeat_at)
|
||||||
|
age = max(0.0, current.timestamp() - heartbeat.timestamp())
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if health.status.lower() not in self.policy.accepted_statuses:
|
||||||
|
reasons.append("runtime_status_unhealthy")
|
||||||
|
if age > self.policy.max_heartbeat_age_seconds:
|
||||||
|
reasons.append("heartbeat_stale")
|
||||||
|
if self.policy.require_workspace_isolation and not health.workspace_isolated:
|
||||||
|
reasons.append("workspace_not_isolated")
|
||||||
|
if self.policy.require_credential_ref and not health.credential_ref_configured:
|
||||||
|
reasons.append("credential_reference_missing")
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
return HealthAssessment(
|
||||||
|
healthy=True,
|
||||||
|
reasons=(),
|
||||||
|
recoverable=True,
|
||||||
|
recommended_action="none",
|
||||||
|
details={"heartbeat_age_seconds": age},
|
||||||
|
)
|
||||||
|
|
||||||
|
non_recoverable = {
|
||||||
|
"workspace_not_isolated",
|
||||||
|
"credential_reference_missing",
|
||||||
|
}.intersection(reasons)
|
||||||
|
if non_recoverable:
|
||||||
|
action = "quarantine_runtime"
|
||||||
|
recoverable = False
|
||||||
|
elif "heartbeat_stale" in reasons:
|
||||||
|
action = "restart_runtime"
|
||||||
|
recoverable = True
|
||||||
|
else:
|
||||||
|
action = "drain_and_restart"
|
||||||
|
recoverable = True
|
||||||
|
|
||||||
|
return HealthAssessment(
|
||||||
|
healthy=False,
|
||||||
|
reasons=tuple(reasons),
|
||||||
|
recoverable=recoverable,
|
||||||
|
recommended_action=action,
|
||||||
|
details={
|
||||||
|
"heartbeat_age_seconds": age,
|
||||||
|
"runtime_id": health.runtime_id,
|
||||||
|
"active_tasks": health.active_tasks,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,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 {},
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""AIOS V2 控制面、执行编排与可信运行来源证明。"""
|
||||||
|
|
||||||
|
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
|
||||||
|
from .provenance import (
|
||||||
|
Ed25519PrivateKeySigner,
|
||||||
|
Ed25519PublicKeyVerifier,
|
||||||
|
ExecutionPath,
|
||||||
|
GitOperation,
|
||||||
|
PRODUCTION_POLICY_VERSION,
|
||||||
|
ProvenanceAuthorization,
|
||||||
|
ProvenanceControlledWriter,
|
||||||
|
ProvenanceDenied,
|
||||||
|
ProvenanceError,
|
||||||
|
ProvenanceExpired,
|
||||||
|
ProvenanceIssuer,
|
||||||
|
ProvenanceNotFound,
|
||||||
|
ProvenanceReplay,
|
||||||
|
ProvenanceRevoked,
|
||||||
|
ProvenanceSignatureError,
|
||||||
|
ProvenanceStatus,
|
||||||
|
ProvenanceVerifier,
|
||||||
|
RunProvenance,
|
||||||
|
SQLiteProvenanceStore,
|
||||||
|
digest_payload,
|
||||||
|
)
|
||||||
|
from .provenance_git import (
|
||||||
|
GitCommandResult,
|
||||||
|
GitExecutionContext,
|
||||||
|
ProvenanceGitError,
|
||||||
|
ProvenanceGitWriter,
|
||||||
|
)
|
||||||
|
from .provenance_operations import (
|
||||||
|
OperationContext,
|
||||||
|
OperationReceipt,
|
||||||
|
ProvenanceOperationGate,
|
||||||
|
build_provenance_writer_from_env,
|
||||||
|
context_from_environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentRecord",
|
||||||
|
"AuditEvent",
|
||||||
|
"ControlPlaneError",
|
||||||
|
"ControlPlaneService",
|
||||||
|
"DispatchRecord",
|
||||||
|
"Ed25519PrivateKeySigner",
|
||||||
|
"Ed25519PublicKeyVerifier",
|
||||||
|
"ExecutionEvidence",
|
||||||
|
"ExecutionOrchestrator",
|
||||||
|
"ExecutionPath",
|
||||||
|
"ExecutionTracker",
|
||||||
|
"GitCommandResult",
|
||||||
|
"GitExecutionContext",
|
||||||
|
"GitOperation",
|
||||||
|
"IdempotencyConflict",
|
||||||
|
"InMemoryControlPlaneStore",
|
||||||
|
"InvalidTransition",
|
||||||
|
"NotFound",
|
||||||
|
"OperationContext",
|
||||||
|
"OperationReceipt",
|
||||||
|
"OrchestrationError",
|
||||||
|
"PRODUCTION_POLICY_VERSION",
|
||||||
|
"PolicyDenied",
|
||||||
|
"ProvenanceAuthorization",
|
||||||
|
"ProvenanceControlledWriter",
|
||||||
|
"ProvenanceDenied",
|
||||||
|
"ProvenanceError",
|
||||||
|
"ProvenanceExpired",
|
||||||
|
"ProvenanceGitError",
|
||||||
|
"ProvenanceGitWriter",
|
||||||
|
"ProvenanceIssuer",
|
||||||
|
"ProvenanceNotFound",
|
||||||
|
"ProvenanceOperationGate",
|
||||||
|
"ProvenanceReplay",
|
||||||
|
"ProvenanceRevoked",
|
||||||
|
"ProvenanceSignatureError",
|
||||||
|
"ProvenanceStatus",
|
||||||
|
"ProvenanceVerifier",
|
||||||
|
"RequestContext",
|
||||||
|
"RevisionConflict",
|
||||||
|
"RunProvenance",
|
||||||
|
"SQLiteProvenanceStore",
|
||||||
|
"TaskRecord",
|
||||||
|
"TaskState",
|
||||||
|
"TenantIsolationError",
|
||||||
|
"TenantRecord",
|
||||||
|
"Unauthorized",
|
||||||
|
"build_provenance_writer_from_env",
|
||||||
|
"context_from_environment",
|
||||||
|
"digest_payload",
|
||||||
|
]
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
"""AIOS V2 production-candidate application entrypoint."""
|
||||||
|
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 (
|
||||||
|
Ed25519CommandSigner,
|
||||||
|
HTTPRuntimeTransport,
|
||||||
|
OpenClawAdapter,
|
||||||
|
)
|
||||||
|
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
||||||
|
|
||||||
|
from .console import CONSOLE_HTML
|
||||||
|
from .control_plane import AuditEvent, RequestContext, Unauthorized
|
||||||
|
from .dispatch_store import SQLiteDispatchRepository
|
||||||
|
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
|
||||||
|
from .provenance_operations import build_provenance_writer_from_env
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(value: str | None) -> bool:
|
||||||
|
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
||||||
|
target = name.lower()
|
||||||
|
for key, value in headers.items():
|
||||||
|
if str(key).lower() == target:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
return normalized or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _provenance_context(
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
header_provenance = _header(headers, "X-AIOS-Provenance-ID")
|
||||||
|
header_run = _header(headers, "X-AIOS-Run-ID")
|
||||||
|
body_provenance = str(payload.get("provenance_id") or "").strip() or None
|
||||||
|
body_run = str(payload.get("run_id") or "").strip() or None
|
||||||
|
if header_provenance and body_provenance and header_provenance != body_provenance:
|
||||||
|
raise ValueError("provenance_id header/body mismatch")
|
||||||
|
if header_run and body_run and header_run != body_run:
|
||||||
|
raise ValueError("run_id header/body mismatch")
|
||||||
|
return header_provenance or body_provenance, header_run or body_run
|
||||||
|
|
||||||
|
|
||||||
|
def _read_protected_file(path: Path, *, private: bool) -> bytes:
|
||||||
|
flags = os.O_RDONLY
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(str(path), flags)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError("key reference is not a regular file")
|
||||||
|
denied = 0o077 if private else 0o022
|
||||||
|
if metadata.st_mode & denied:
|
||||||
|
raise RuntimeError(
|
||||||
|
"private key file permissions must be 0600 or stricter"
|
||||||
|
if private
|
||||||
|
else "public key file must not be group/world writable"
|
||||||
|
)
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
while True:
|
||||||
|
chunk = os.read(descriptor, 65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
value = b"".join(chunks)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError("key file is empty")
|
||||||
|
return value
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
class FileCommandSignerResolver:
|
||||||
|
"""Resolve tenant/key-id scoped OpenClaw Ed25519 signing material."""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path, *, configured_key_id: str) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.configured_key_id = str(configured_key_id or "").strip()
|
||||||
|
if not self.configured_key_id:
|
||||||
|
raise ValueError("configured_key_id is required")
|
||||||
|
|
||||||
|
def __call__(self, tenant_id: str, key_id: str) -> Ed25519CommandSigner:
|
||||||
|
if not str(tenant_id or "").strip():
|
||||||
|
raise RuntimeError("tenant_id is required for command signing")
|
||||||
|
if key_id != self.configured_key_id:
|
||||||
|
raise RuntimeError("OpenClaw signing key_id mismatch")
|
||||||
|
return Ed25519CommandSigner.from_bytes(
|
||||||
|
_read_protected_file(self.path, private=True),
|
||||||
|
key_id=key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneEventPublisher(EventPublisher):
|
||||||
|
"""Append Runtime callback events to the tenant audit stream.
|
||||||
|
|
||||||
|
Callback audit remains informational. Authoritative task reconciliation is
|
||||||
|
performed only by the orchestrator after dispatch/result validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, service: DurableControlPlaneService) -> None:
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
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-v2",
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_runtime_role(context: RequestContext) -> None:
|
||||||
|
if not ({"runtime", "platform_admin"} & set(context.roles)):
|
||||||
|
raise Unauthorized("runtime reconciliation role is required")
|
||||||
|
|
||||||
|
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")
|
||||||
|
provenance_id, run_id = _provenance_context(headers, payload)
|
||||||
|
record = self.orchestrator.dispatch(
|
||||||
|
context,
|
||||||
|
task_id=task_id,
|
||||||
|
timeout_seconds=float(timeout) if timeout is not None else None,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
|
||||||
|
context = self.context(headers)
|
||||||
|
self._require_runtime_role(context)
|
||||||
|
if not isinstance(body, Mapping):
|
||||||
|
raise ValueError("JSON body must be an object")
|
||||||
|
provenance_id, run_id = _provenance_context(headers, body)
|
||||||
|
if not provenance_id or not run_id:
|
||||||
|
raise ValueError("provenance_id and run_id are required")
|
||||||
|
dispatch_id = str(body.get("dispatch_id") or "").strip()
|
||||||
|
if not dispatch_id:
|
||||||
|
raise ValueError("dispatch_id is required")
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=str(body["job_id"]),
|
||||||
|
tenant_id=str(body["tenant_id"]),
|
||||||
|
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,
|
||||||
|
dispatch_id=dispatch_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||||
|
return HTTPStatus.GONE, {
|
||||||
|
"error": "RuntimeReconcileEndpointMoved",
|
||||||
|
"message": "use /api/v2/internal/runtime/hermes/reconcile",
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.gateway.handle(method, raw_path, headers, body)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "AIOSV2Candidate/2.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 Exception 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")
|
||||||
|
dispatch_repository = SQLiteDispatchRepository(state_dir / "dispatches.sqlite3")
|
||||||
|
service = DurableControlPlaneService(repository)
|
||||||
|
require_provenance = _enabled(os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0"))
|
||||||
|
provenance_writer = build_provenance_writer_from_env(
|
||||||
|
state_dir=state_dir, required=require_provenance
|
||||||
|
)
|
||||||
|
tracker = ExecutionTracker(
|
||||||
|
state_dir / "executions",
|
||||||
|
provenance_writer=provenance_writer,
|
||||||
|
require_provenance=require_provenance,
|
||||||
|
)
|
||||||
|
hermes = HermesAdapter()
|
||||||
|
|
||||||
|
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
||||||
|
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||||
|
signing_key_id = os.getenv(
|
||||||
|
"AIOS_OPENCLAW_SIGNING_KEY_ID", "openclaw-v2r1-candidate-ed25519"
|
||||||
|
).strip()
|
||||||
|
openclaw = None
|
||||||
|
resolver = None
|
||||||
|
if openclaw_url and signing_file:
|
||||||
|
resolver = FileCommandSignerResolver(
|
||||||
|
signing_file,
|
||||||
|
configured_key_id=signing_key_id,
|
||||||
|
)
|
||||||
|
transport = HTTPRuntimeTransport(
|
||||||
|
openclaw_url,
|
||||||
|
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
||||||
|
)
|
||||||
|
openclaw = OpenClawAdapter(
|
||||||
|
transport,
|
||||||
|
resolver,
|
||||||
|
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway = GatewayApplication(service)
|
||||||
|
orchestrator = ExecutionOrchestrator(
|
||||||
|
service,
|
||||||
|
openclaw=openclaw,
|
||||||
|
hermes=hermes,
|
||||||
|
openclaw_signing_material_resolver=resolver,
|
||||||
|
openclaw_key_id=signing_key_id,
|
||||||
|
provenance_writer=provenance_writer,
|
||||||
|
require_provenance=require_provenance,
|
||||||
|
dispatch_repository=dispatch_repository,
|
||||||
|
)
|
||||||
|
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("AIOS_V2_PORT", "18920"))
|
||||||
|
server = CandidateHTTPServer((host, port), build_application())
|
||||||
|
print(f"AIOS V2 candidate listening on {host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dependency-free AIOS V2 web console assets.
|
||||||
|
|
||||||
|
Authentication and tenant identity are injected by the trusted reverse proxy.
|
||||||
|
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONSOLE_HTML = r'''<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>AIOS V2 Console</title>
|
||||||
|
<style>
|
||||||
|
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
|
||||||
|
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
|
||||||
|
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
|
||||||
|
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||||
|
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
|
||||||
|
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
|
||||||
|
<main>
|
||||||
|
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
|
||||||
|
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
|
||||||
|
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
|
||||||
|
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
|
||||||
|
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
|
||||||
|
</section>
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
|
||||||
|
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
|
||||||
|
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
|
||||||
|
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
|
||||||
|
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||||
|
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
|
||||||
|
async function refresh(){
|
||||||
|
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
|
||||||
|
try{
|
||||||
|
const [agents,tasks,audit,runtime,executions]=await Promise.all([
|
||||||
|
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
|
||||||
|
]);
|
||||||
|
document.getElementById('agentCount').textContent=agents.agents.length;
|
||||||
|
document.getElementById('taskCount').textContent=tasks.tasks.length;
|
||||||
|
document.getElementById('auditCount').textContent=audit.events.length;
|
||||||
|
document.getElementById('executionCount').textContent=executions.executions.length;
|
||||||
|
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
|
||||||
|
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
|
||||||
|
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
|
||||||
|
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
|
||||||
|
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
|
||||||
|
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
|
||||||
|
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
|
||||||
|
}
|
||||||
|
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
"""Tenant-isolated AIOS V2 control-plane domain service.
|
||||||
|
|
||||||
|
This module deliberately contains no OpenClaw or Hermes implementation details.
|
||||||
|
It owns authoritative tenant, agent, task-lifecycle, idempotency and audit state.
|
||||||
|
Runtime adapters consume approved tasks through a separate boundary.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _new_id(prefix: str) -> str:
|
||||||
|
return f"{prefix}_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_digest(value: Mapping[str, Any]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneError(RuntimeError):
|
||||||
|
"""Base error returned by the control-plane domain."""
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(ControlPlaneError):
|
||||||
|
"""The caller is not authenticated or lacks the required role."""
|
||||||
|
|
||||||
|
|
||||||
|
class TenantIsolationError(ControlPlaneError):
|
||||||
|
"""A request attempted to cross a tenant boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(ControlPlaneError):
|
||||||
|
"""A tenant-scoped resource does not exist or is not visible."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransition(ControlPlaneError):
|
||||||
|
"""A task state transition violates the lifecycle contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyConflict(ControlPlaneError):
|
||||||
|
"""An idempotency key was reused for a different command."""
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionConflict(ControlPlaneError):
|
||||||
|
"""Optimistic task revision does not match current state."""
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyDenied(ControlPlaneError):
|
||||||
|
"""The policy gate denied a control-plane operation."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RequestContext:
|
||||||
|
"""Authenticated request identity propagated across all AIOS layers."""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
principal_id: str
|
||||||
|
trace_id: str
|
||||||
|
roles: frozenset[str] = field(default_factory=frozenset)
|
||||||
|
session_id: str | None = None
|
||||||
|
policy_version: str = "v1"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("tenant_id", "principal_id", "trace_id"):
|
||||||
|
if not str(getattr(self, name, "")).strip():
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
roles: Iterable[str] = (),
|
||||||
|
trace_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
policy_version: str = "v1",
|
||||||
|
) -> "RequestContext":
|
||||||
|
return cls(
|
||||||
|
tenant_id=tenant_id.strip(),
|
||||||
|
principal_id=principal_id.strip(),
|
||||||
|
trace_id=trace_id or _new_id("trace"),
|
||||||
|
roles=frozenset(str(role).strip() for role in roles if str(role).strip()),
|
||||||
|
session_id=session_id,
|
||||||
|
policy_version=policy_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TenantRecord:
|
||||||
|
tenant_id: str
|
||||||
|
display_name: str
|
||||||
|
status: str = "active"
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentRecord:
|
||||||
|
agent_id: str
|
||||||
|
tenant_id: str
|
||||||
|
display_name: str
|
||||||
|
runtime: str
|
||||||
|
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
enabled: bool = True
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskState(str, Enum):
|
||||||
|
CREATED = "CREATED"
|
||||||
|
APPROVED = "APPROVED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_TASK_STATES = frozenset(
|
||||||
|
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
ALLOWED_TASK_TRANSITIONS: Mapping[TaskState, frozenset[TaskState]] = {
|
||||||
|
TaskState.CREATED: frozenset({TaskState.APPROVED, TaskState.CANCELLED}),
|
||||||
|
TaskState.APPROVED: frozenset({TaskState.RUNNING, TaskState.CANCELLED}),
|
||||||
|
TaskState.RUNNING: frozenset(
|
||||||
|
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||||
|
),
|
||||||
|
TaskState.COMPLETED: frozenset(),
|
||||||
|
TaskState.FAILED: frozenset(),
|
||||||
|
TaskState.CANCELLED: frozenset(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskRecord:
|
||||||
|
task_id: str
|
||||||
|
tenant_id: str
|
||||||
|
agent_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
state: TaskState = TaskState.CREATED
|
||||||
|
revision: int = 1
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
request_digest: str = ""
|
||||||
|
result: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
created_by: str = ""
|
||||||
|
approved_by: str | None = None
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuditEvent:
|
||||||
|
event_id: str
|
||||||
|
tenant_id: str
|
||||||
|
principal_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
outcome: str
|
||||||
|
policy_version: str
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PolicyDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
policy_version: str
|
||||||
|
|
||||||
|
|
||||||
|
PolicyEvaluator = Callable[[RequestContext, str, Mapping[str, Any]], PolicyDecision]
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryControlPlaneStore:
|
||||||
|
"""Thread-safe reference store used by tests and the first gateway slice.
|
||||||
|
|
||||||
|
Production backends must preserve the same tenant-scoped interface and add
|
||||||
|
database row-level security, migrations, backup and HA evidence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.tenants: dict[str, TenantRecord] = {}
|
||||||
|
self.agents: dict[str, AgentRecord] = {}
|
||||||
|
self.tasks: dict[str, TaskRecord] = {}
|
||||||
|
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
self.audit_events: list[AuditEvent] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneService:
|
||||||
|
"""Authoritative tenant, agent and task lifecycle service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: InMemoryControlPlaneStore | None = None,
|
||||||
|
*,
|
||||||
|
policy_evaluator: PolicyEvaluator | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.store = store or InMemoryControlPlaneStore()
|
||||||
|
self.policy_evaluator = policy_evaluator or self._default_policy
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_policy(
|
||||||
|
context: RequestContext,
|
||||||
|
action: str,
|
||||||
|
resource: Mapping[str, Any],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
del resource
|
||||||
|
role_requirements = {
|
||||||
|
"tenant.register": {"platform_admin"},
|
||||||
|
"tenant.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"agent.register": {"tenant_admin", "operator", "platform_admin"},
|
||||||
|
"agent.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"task.create": {"tenant_admin", "operator", "platform_admin"},
|
||||||
|
"task.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"task.approve": {"tenant_admin", "approver", "platform_admin"},
|
||||||
|
"task.start": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.complete": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.fail": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.cancel": {"tenant_admin", "operator", "approver", "platform_admin"},
|
||||||
|
"audit.read": {"tenant_admin", "auditor", "platform_admin"},
|
||||||
|
}
|
||||||
|
required = role_requirements.get(action, {"platform_admin"})
|
||||||
|
allowed = bool(context.roles.intersection(required))
|
||||||
|
return PolicyDecision(
|
||||||
|
allowed=allowed,
|
||||||
|
reason="role_allowed" if allowed else "required_role_missing",
|
||||||
|
policy_version=context.policy_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_context(self, context: RequestContext) -> None:
|
||||||
|
if not context.principal_id or not context.tenant_id or not context.trace_id:
|
||||||
|
raise Unauthorized("authenticated tenant context is required")
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
action: str,
|
||||||
|
resource: Mapping[str, Any],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
self._require_context(context)
|
||||||
|
decision = self.policy_evaluator(context, action, resource)
|
||||||
|
if not decision.allowed:
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action=action,
|
||||||
|
resource_type=str(resource.get("resource_type", "policy")),
|
||||||
|
resource_id=str(resource.get("resource_id", "unknown")),
|
||||||
|
outcome="denied",
|
||||||
|
details={"reason": decision.reason},
|
||||||
|
)
|
||||||
|
raise PolicyDenied(f"{action} denied: {decision.reason}")
|
||||||
|
return decision
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_same_tenant(context: RequestContext, tenant_id: str) -> None:
|
||||||
|
if context.tenant_id != tenant_id and "platform_admin" not in context.roles:
|
||||||
|
raise TenantIsolationError("cross-tenant access denied")
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
outcome: str,
|
||||||
|
details: Mapping[str, Any] | None = None,
|
||||||
|
) -> AuditEvent:
|
||||||
|
event = AuditEvent(
|
||||||
|
event_id=_new_id("audit"),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
outcome=outcome,
|
||||||
|
policy_version=context.policy_version,
|
||||||
|
details=copy.deepcopy(dict(details or {})),
|
||||||
|
)
|
||||||
|
self.store.audit_events.append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def register_tenant(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
display_name: str,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> TenantRecord:
|
||||||
|
tenant_id = tenant_id.strip()
|
||||||
|
if not tenant_id or not display_name.strip():
|
||||||
|
raise ValueError("tenant_id and display_name are required")
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"tenant.register",
|
||||||
|
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||||
|
)
|
||||||
|
self._assert_same_tenant(context, tenant_id)
|
||||||
|
with self.store.lock:
|
||||||
|
if tenant_id in self.store.tenants:
|
||||||
|
raise ControlPlaneError(f"tenant already exists: {tenant_id}")
|
||||||
|
tenant = TenantRecord(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
metadata=copy.deepcopy(dict(metadata or {})),
|
||||||
|
)
|
||||||
|
self.store.tenants[tenant_id] = tenant
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="tenant.register",
|
||||||
|
resource_type="tenant",
|
||||||
|
resource_id=tenant_id,
|
||||||
|
outcome="allowed",
|
||||||
|
)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
def get_tenant(self, context: RequestContext, tenant_id: str | None = None) -> TenantRecord:
|
||||||
|
tenant_id = tenant_id or context.tenant_id
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"tenant.read",
|
||||||
|
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||||
|
)
|
||||||
|
self._assert_same_tenant(context, tenant_id)
|
||||||
|
with self.store.lock:
|
||||||
|
tenant = self.store.tenants.get(tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise NotFound("tenant not found")
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
def register_agent(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
display_name: str,
|
||||||
|
runtime: str,
|
||||||
|
capabilities: Iterable[str] = (),
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> AgentRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.register",
|
||||||
|
{"resource_type": "agent", "resource_id": agent_id},
|
||||||
|
)
|
||||||
|
tenant = self.get_tenant(context)
|
||||||
|
if tenant.status != "active":
|
||||||
|
raise ControlPlaneError("tenant is not active")
|
||||||
|
normalized_capabilities = tuple(
|
||||||
|
sorted({str(item).strip() for item in capabilities if str(item).strip()})
|
||||||
|
)
|
||||||
|
if not agent_id.strip() or not display_name.strip() or not runtime.strip():
|
||||||
|
raise ValueError("agent_id, display_name and runtime are required")
|
||||||
|
with self.store.lock:
|
||||||
|
existing = self.store.agents.get(agent_id)
|
||||||
|
if existing is not None and existing.tenant_id != context.tenant_id:
|
||||||
|
raise TenantIsolationError("agent id belongs to another tenant")
|
||||||
|
if existing is not None:
|
||||||
|
raise ControlPlaneError(f"agent already exists: {agent_id}")
|
||||||
|
agent = AgentRecord(
|
||||||
|
agent_id=agent_id.strip(),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
runtime=runtime.strip(),
|
||||||
|
capabilities=normalized_capabilities,
|
||||||
|
metadata=copy.deepcopy(dict(metadata or {})),
|
||||||
|
)
|
||||||
|
self.store.agents[agent.agent_id] = agent
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="agent.register",
|
||||||
|
resource_type="agent",
|
||||||
|
resource_id=agent.agent_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={"runtime": agent.runtime},
|
||||||
|
)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def get_agent(self, context: RequestContext, agent_id: str) -> AgentRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.read",
|
||||||
|
{"resource_type": "agent", "resource_id": agent_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
agent = self.store.agents.get(agent_id)
|
||||||
|
if agent is None:
|
||||||
|
raise NotFound("agent not found")
|
||||||
|
self._assert_same_tenant(context, agent.tenant_id)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def list_agents(self, context: RequestContext) -> list[AgentRecord]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.read",
|
||||||
|
{"resource_type": "agent_collection", "resource_id": context.tenant_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
agent
|
||||||
|
for agent in self.store.agents.values()
|
||||||
|
if agent.tenant_id == context.tenant_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.create",
|
||||||
|
{"resource_type": "task", "resource_id": idempotency_key or "new"},
|
||||||
|
)
|
||||||
|
agent = self.get_agent(context, agent_id)
|
||||||
|
if not agent.enabled:
|
||||||
|
raise ControlPlaneError("agent is disabled")
|
||||||
|
if action not in agent.capabilities and "*" not in agent.capabilities:
|
||||||
|
raise PolicyDenied(f"agent does not advertise capability: {action}")
|
||||||
|
normalized_payload = copy.deepcopy(dict(payload or {}))
|
||||||
|
request_digest = _canonical_digest(
|
||||||
|
{"agent_id": agent_id, "action": action, "payload": normalized_payload}
|
||||||
|
)
|
||||||
|
normalized_key = idempotency_key.strip() if idempotency_key else None
|
||||||
|
with self.store.lock:
|
||||||
|
if normalized_key:
|
||||||
|
idempotency_ref = (context.tenant_id, normalized_key)
|
||||||
|
existing = self.store.idempotency.get(idempotency_ref)
|
||||||
|
if existing is not None:
|
||||||
|
existing_task_id, existing_digest = existing
|
||||||
|
if existing_digest != request_digest:
|
||||||
|
raise IdempotencyConflict(
|
||||||
|
"idempotency key was reused with a different request"
|
||||||
|
)
|
||||||
|
return self.store.tasks[existing_task_id]
|
||||||
|
task = TaskRecord(
|
||||||
|
task_id=_new_id("task"),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
action=action,
|
||||||
|
payload=normalized_payload,
|
||||||
|
idempotency_key=normalized_key,
|
||||||
|
request_digest=request_digest,
|
||||||
|
created_by=context.principal_id,
|
||||||
|
)
|
||||||
|
self.store.tasks[task.task_id] = task
|
||||||
|
if normalized_key:
|
||||||
|
self.store.idempotency[(context.tenant_id, normalized_key)] = (
|
||||||
|
task.task_id,
|
||||||
|
request_digest,
|
||||||
|
)
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="task.create",
|
||||||
|
resource_type="task",
|
||||||
|
resource_id=task.task_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={"agent_id": agent_id, "action": action},
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def get_task(self, context: RequestContext, task_id: str) -> TaskRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.read",
|
||||||
|
{"resource_type": "task", "resource_id": task_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
task = self.store.tasks.get(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise NotFound("task not found")
|
||||||
|
self._assert_same_tenant(context, task.tenant_id)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
states: Iterable[TaskState | str] | None = None,
|
||||||
|
) -> list[TaskRecord]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.read",
|
||||||
|
{"resource_type": "task_collection", "resource_id": context.tenant_id},
|
||||||
|
)
|
||||||
|
state_filter = (
|
||||||
|
{TaskState(item) for item in states}
|
||||||
|
if states is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
task
|
||||||
|
for task in self.store.tasks.values()
|
||||||
|
if task.tenant_id == context.tenant_id
|
||||||
|
and (state_filter is None or task.state in state_filter)
|
||||||
|
]
|
||||||
|
|
||||||
|
def transition_task(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
target_state: TaskState | str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
result: Mapping[str, Any] | None = None,
|
||||||
|
error: Mapping[str, Any] | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
target = TaskState(target_state)
|
||||||
|
action_name = {
|
||||||
|
TaskState.APPROVED: "task.approve",
|
||||||
|
TaskState.RUNNING: "task.start",
|
||||||
|
TaskState.COMPLETED: "task.complete",
|
||||||
|
TaskState.FAILED: "task.fail",
|
||||||
|
TaskState.CANCELLED: "task.cancel",
|
||||||
|
}.get(target)
|
||||||
|
if action_name is None:
|
||||||
|
raise InvalidTransition(f"cannot transition to {target.value}")
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
action_name,
|
||||||
|
{"resource_type": "task", "resource_id": task_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
current = self.store.tasks.get(task_id)
|
||||||
|
if current is None:
|
||||||
|
raise NotFound("task not found")
|
||||||
|
self._assert_same_tenant(context, current.tenant_id)
|
||||||
|
if expected_revision is not None and current.revision != expected_revision:
|
||||||
|
raise RevisionConflict(
|
||||||
|
f"expected revision {expected_revision}, current {current.revision}"
|
||||||
|
)
|
||||||
|
if target not in ALLOWED_TASK_TRANSITIONS[current.state]:
|
||||||
|
raise InvalidTransition(
|
||||||
|
f"invalid transition {current.state.value} -> {target.value}"
|
||||||
|
)
|
||||||
|
if target == TaskState.COMPLETED and result is None:
|
||||||
|
raise ValueError("completed task requires result")
|
||||||
|
if target == TaskState.FAILED and error is None:
|
||||||
|
raise ValueError("failed task requires error")
|
||||||
|
updated = TaskRecord(
|
||||||
|
task_id=current.task_id,
|
||||||
|
tenant_id=current.tenant_id,
|
||||||
|
agent_id=current.agent_id,
|
||||||
|
action=current.action,
|
||||||
|
payload=current.payload,
|
||||||
|
state=target,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
idempotency_key=current.idempotency_key,
|
||||||
|
request_digest=current.request_digest,
|
||||||
|
result=copy.deepcopy(dict(result)) if result is not None else current.result,
|
||||||
|
error=copy.deepcopy(dict(error)) if error is not None else current.error,
|
||||||
|
created_by=current.created_by,
|
||||||
|
approved_by=(
|
||||||
|
context.principal_id
|
||||||
|
if target == TaskState.APPROVED
|
||||||
|
else current.approved_by
|
||||||
|
),
|
||||||
|
created_at=current.created_at,
|
||||||
|
updated_at=_utc_now(),
|
||||||
|
)
|
||||||
|
self.store.tasks[task_id] = updated
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action=action_name,
|
||||||
|
resource_type="task",
|
||||||
|
resource_id=task_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={
|
||||||
|
"from": current.state.value,
|
||||||
|
"to": target.value,
|
||||||
|
"revision": updated.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list_audit_events(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
) -> list[AuditEvent]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"audit.read",
|
||||||
|
{"resource_type": "audit", "resource_id": resource_id or context.tenant_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
event
|
||||||
|
for event in self.store.audit_events
|
||||||
|
if event.tenant_id == context.tenant_id
|
||||||
|
and (resource_id is None or event.resource_id == resource_id)
|
||||||
|
]
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""AIOS V2 持久化 Dispatch 记录。
|
||||||
|
|
||||||
|
Dispatch 是 AIOS Task 与 Runtime command/job 之间的唯一可信绑定。所有结果回写
|
||||||
|
必须匹配 tenant/task/trace/runtime_execution/provenance/run/principal。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchState(str, Enum):
|
||||||
|
PREPARED = "PREPARED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_DISPATCH_STATES = frozenset(
|
||||||
|
{DispatchState.COMPLETED, DispatchState.FAILED, DispatchState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DispatchRecord:
|
||||||
|
dispatch_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
runtime: str
|
||||||
|
runtime_execution_id: str
|
||||||
|
trace_id: str
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
authorization_id: str
|
||||||
|
state: DispatchState = DispatchState.PREPARED
|
||||||
|
expected_worker_id: str | None = None
|
||||||
|
result_digest: str | None = None
|
||||||
|
created_at: str = ""
|
||||||
|
updated_at: str = ""
|
||||||
|
completed_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"dispatch_id",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"principal_id",
|
||||||
|
"runtime",
|
||||||
|
"runtime_execution_id",
|
||||||
|
"trace_id",
|
||||||
|
"provenance_id",
|
||||||
|
"run_id",
|
||||||
|
"authorization_id",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "state", DispatchState(self.state))
|
||||||
|
now = utc_now()
|
||||||
|
object.__setattr__(self, "created_at", self.created_at or now)
|
||||||
|
object.__setattr__(self, "updated_at", self.updated_at or now)
|
||||||
|
if self.expected_worker_id is not None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"expected_worker_id",
|
||||||
|
_required(self.expected_worker_id, "expected_worker_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
runtime: str,
|
||||||
|
runtime_execution_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
authorization_id: str,
|
||||||
|
) -> "DispatchRecord":
|
||||||
|
return cls(
|
||||||
|
dispatch_id=f"dispatch_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
runtime=runtime,
|
||||||
|
runtime_execution_id=runtime_execution_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
value = asdict(self)
|
||||||
|
value["state"] = self.state.value
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: Mapping[str, Any]) -> "DispatchRecord":
|
||||||
|
return cls(**dict(value))
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchConflict(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchNotFound(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchRepository(Protocol):
|
||||||
|
def create(self, record: DispatchRecord) -> DispatchRecord: ...
|
||||||
|
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord: ...
|
||||||
|
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None: ...
|
||||||
|
def get_by_runtime_execution(
|
||||||
|
self, runtime_execution_id: str, *, tenant_id: str
|
||||||
|
) -> DispatchRecord | None: ...
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
dispatch_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
state: DispatchState | str | None = None,
|
||||||
|
expected_worker_id: str | None = None,
|
||||||
|
result_digest: str | None = None,
|
||||||
|
) -> DispatchRecord: ...
|
||||||
|
def list(self, *, tenant_id: str) -> list[DispatchRecord]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryDispatchRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._records: dict[str, DispatchRecord] = {}
|
||||||
|
self._by_task: dict[tuple[str, str], str] = {}
|
||||||
|
self._by_runtime: dict[tuple[str, str], str] = {}
|
||||||
|
|
||||||
|
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||||
|
with self._lock:
|
||||||
|
task_ref = (record.tenant_id, record.task_id)
|
||||||
|
runtime_ref = (record.tenant_id, record.runtime_execution_id)
|
||||||
|
if task_ref in self._by_task or runtime_ref in self._by_runtime:
|
||||||
|
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||||
|
if existing == record:
|
||||||
|
return record
|
||||||
|
raise DispatchConflict("task or runtime execution already has a dispatch")
|
||||||
|
self._records[record.dispatch_id] = record
|
||||||
|
self._by_task[task_ref] = record.dispatch_id
|
||||||
|
self._by_runtime[runtime_ref] = record.dispatch_id
|
||||||
|
return record
|
||||||
|
|
||||||
|
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||||
|
with self._lock:
|
||||||
|
record = self._records.get(dispatch_id)
|
||||||
|
if record is None or record.tenant_id != tenant_id:
|
||||||
|
raise DispatchNotFound("dispatch not found")
|
||||||
|
return record
|
||||||
|
|
||||||
|
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||||
|
with self._lock:
|
||||||
|
dispatch_id = self._by_task.get((tenant_id, task_id))
|
||||||
|
return self._records.get(dispatch_id) if dispatch_id else None
|
||||||
|
|
||||||
|
def get_by_runtime_execution(
|
||||||
|
self, runtime_execution_id: str, *, tenant_id: str
|
||||||
|
) -> DispatchRecord | None:
|
||||||
|
with self._lock:
|
||||||
|
dispatch_id = self._by_runtime.get((tenant_id, runtime_execution_id))
|
||||||
|
return self._records.get(dispatch_id) if dispatch_id else None
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
dispatch_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
state: DispatchState | str | None = None,
|
||||||
|
expected_worker_id: str | None = None,
|
||||||
|
result_digest: str | None = None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
with self._lock:
|
||||||
|
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||||
|
target = DispatchState(state) if state is not None else current.state
|
||||||
|
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||||
|
raise DispatchConflict("terminal dispatch cannot transition")
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
state=target,
|
||||||
|
expected_worker_id=(
|
||||||
|
expected_worker_id
|
||||||
|
if expected_worker_id is not None
|
||||||
|
else current.expected_worker_id
|
||||||
|
),
|
||||||
|
result_digest=result_digest or current.result_digest,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||||
|
)
|
||||||
|
self._records[dispatch_id] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||||
|
with self._lock:
|
||||||
|
return sorted(
|
||||||
|
[item for item in self._records.values() if item.tenant_id == tenant_id],
|
||||||
|
key=lambda item: (item.created_at, item.dispatch_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteDispatchRepository:
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS dispatch_records (
|
||||||
|
dispatch_id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
runtime_execution_id TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(tenant_id, task_id),
|
||||||
|
UNIQUE(tenant_id, runtime_execution_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dispatch_tenant_state
|
||||||
|
ON dispatch_records(tenant_id, state, updated_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode(record: DispatchRecord) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
record.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode(row: sqlite3.Row) -> DispatchRecord:
|
||||||
|
value = json.loads(row["payload_json"])
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise RuntimeError("invalid dispatch record")
|
||||||
|
return DispatchRecord.from_dict(value)
|
||||||
|
|
||||||
|
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO dispatch_records VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
record.dispatch_id,
|
||||||
|
record.tenant_id,
|
||||||
|
record.task_id,
|
||||||
|
record.runtime_execution_id,
|
||||||
|
self._encode(record),
|
||||||
|
record.state.value,
|
||||||
|
record.updated_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return record
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
connection.rollback()
|
||||||
|
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||||
|
if existing is not None and existing == record:
|
||||||
|
return existing
|
||||||
|
raise DispatchConflict("task or runtime execution already has a dispatch") from exc
|
||||||
|
|
||||||
|
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload_json FROM dispatch_records WHERE dispatch_id = ? AND tenant_id = ?",
|
||||||
|
(_required(dispatch_id, "dispatch_id"), _required(tenant_id, "tenant_id")),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise DispatchNotFound("dispatch not found")
|
||||||
|
return self._decode(row)
|
||||||
|
|
||||||
|
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload_json FROM dispatch_records WHERE task_id = ? AND tenant_id = ?",
|
||||||
|
(_required(task_id, "task_id"), _required(tenant_id, "tenant_id")),
|
||||||
|
).fetchone()
|
||||||
|
return self._decode(row) if row is not None else None
|
||||||
|
|
||||||
|
def get_by_runtime_execution(
|
||||||
|
self, runtime_execution_id: str, *, tenant_id: str
|
||||||
|
) -> DispatchRecord | None:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload_json FROM dispatch_records WHERE runtime_execution_id = ? AND tenant_id = ?",
|
||||||
|
(
|
||||||
|
_required(runtime_execution_id, "runtime_execution_id"),
|
||||||
|
_required(tenant_id, "tenant_id"),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return self._decode(row) if row is not None else None
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
dispatch_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
state: DispatchState | str | None = None,
|
||||||
|
expected_worker_id: str | None = None,
|
||||||
|
result_digest: str | None = None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||||
|
target = DispatchState(state) if state is not None else current.state
|
||||||
|
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||||
|
raise DispatchConflict("terminal dispatch cannot transition")
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
state=target,
|
||||||
|
expected_worker_id=(
|
||||||
|
expected_worker_id if expected_worker_id is not None else current.expected_worker_id
|
||||||
|
),
|
||||||
|
result_digest=result_digest or current.result_digest,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||||
|
)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE dispatch_records SET payload_json = ?, state = ?, updated_at = ? WHERE dispatch_id = ? AND tenant_id = ?",
|
||||||
|
(
|
||||||
|
self._encode(updated),
|
||||||
|
updated.state.value,
|
||||||
|
updated.updated_at,
|
||||||
|
updated.dispatch_id,
|
||||||
|
updated.tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT payload_json FROM dispatch_records WHERE tenant_id = ? ORDER BY updated_at, dispatch_id",
|
||||||
|
(_required(tenant_id, "tenant_id"),),
|
||||||
|
).fetchall()
|
||||||
|
return [self._decode(row) for row in rows]
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""AIOS V2 Agent 执行证据。
|
||||||
|
|
||||||
|
Evidence 按租户隔离并使用原子 JSON 写入。生产模式下,claim、heartbeat
|
||||||
|
和 complete 在落盘前必须通过最小权限 Evidence Grant 授权。
|
||||||
|
"""
|
||||||
|
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, Protocol
|
||||||
|
|
||||||
|
from .provenance import ProvenanceDenied, digest_payload
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceAuthorizationWriter(Protocol):
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
provenance_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
last_authorization_id: 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))
|
||||||
|
for optional in ("provenance_id", "run_id", "last_authorization_id"):
|
||||||
|
value = getattr(self, optional)
|
||||||
|
object.__setattr__(self, optional, str(value).strip() if value else None)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionTracker:
|
||||||
|
"""每个租户、每次执行持久保存一份证据记录。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root: str | Path,
|
||||||
|
*,
|
||||||
|
provenance_writer: EvidenceAuthorizationWriter | None = None,
|
||||||
|
require_provenance: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.root = Path(root).resolve()
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.provenance_writer = provenance_writer
|
||||||
|
self.require_provenance = bool(require_provenance)
|
||||||
|
if self.require_provenance and self.provenance_writer is None:
|
||||||
|
raise ValueError("production evidence tracking requires a provenance writer")
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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 _authorize(
|
||||||
|
self,
|
||||||
|
evidence: ExecutionEvidence,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
principal_id: str | None,
|
||||||
|
provenance_id: str | None,
|
||||||
|
run_id: str | None,
|
||||||
|
changes: Mapping[str, Any],
|
||||||
|
) -> str | None:
|
||||||
|
if not self.require_provenance:
|
||||||
|
return None
|
||||||
|
if self.provenance_writer is None:
|
||||||
|
raise RuntimeError("Run Provenance writer is not configured")
|
||||||
|
effective_provenance = provenance_id or evidence.provenance_id
|
||||||
|
effective_run = run_id or evidence.run_id
|
||||||
|
if not effective_provenance or not effective_run or not principal_id:
|
||||||
|
raise RuntimeError(
|
||||||
|
"production evidence write requires provenance_id, run_id and principal_id"
|
||||||
|
)
|
||||||
|
request_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"operation": operation,
|
||||||
|
"execution_id": evidence.execution_id,
|
||||||
|
"tenant_id": evidence.tenant_id,
|
||||||
|
"task_id": evidence.task_id,
|
||||||
|
"agent_id": evidence.agent_id,
|
||||||
|
"executor": evidence.executor,
|
||||||
|
"branch": evidence.branch,
|
||||||
|
"changes": dict(changes),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
authorization = self.provenance_writer.authorize_evidence_write(
|
||||||
|
provenance_id=effective_provenance,
|
||||||
|
run_id=effective_run,
|
||||||
|
tenant_id=evidence.tenant_id,
|
||||||
|
task_id=evidence.task_id,
|
||||||
|
agent_id=evidence.agent_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
request_digest=request_digest,
|
||||||
|
)
|
||||||
|
except ProvenanceDenied as exc:
|
||||||
|
raise RuntimeError(f"Run Provenance denied evidence write: {exc}") from exc
|
||||||
|
return authorization.authorization_id
|
||||||
|
|
||||||
|
def claim(
|
||||||
|
self,
|
||||||
|
evidence: ExecutionEvidence,
|
||||||
|
*,
|
||||||
|
principal_id: str | None = None,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(evidence.tenant_id, evidence.execution_id)
|
||||||
|
with self._lock:
|
||||||
|
if path.exists():
|
||||||
|
current = self._read(path)
|
||||||
|
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
|
||||||
|
raise RuntimeError("execution_id already belongs to another task")
|
||||||
|
if self.require_provenance and (
|
||||||
|
current.provenance_id != (provenance_id or evidence.provenance_id)
|
||||||
|
or current.run_id != (run_id or evidence.run_id)
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"execution_id already belongs to another provenance context"
|
||||||
|
)
|
||||||
|
return current
|
||||||
|
effective_provenance = provenance_id or evidence.provenance_id
|
||||||
|
effective_run = run_id or evidence.run_id
|
||||||
|
authorization_id = self._authorize(
|
||||||
|
evidence,
|
||||||
|
operation="claim",
|
||||||
|
principal_id=principal_id,
|
||||||
|
provenance_id=effective_provenance,
|
||||||
|
run_id=effective_run,
|
||||||
|
changes={"status": evidence.status, "current_step": evidence.current_step},
|
||||||
|
)
|
||||||
|
persisted = replace(
|
||||||
|
evidence,
|
||||||
|
provenance_id=effective_provenance,
|
||||||
|
run_id=effective_run,
|
||||||
|
last_authorization_id=authorization_id,
|
||||||
|
)
|
||||||
|
self._write(path, persisted)
|
||||||
|
return persisted
|
||||||
|
|
||||||
|
def heartbeat(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
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,
|
||||||
|
principal_id: str | None = None,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(tenant_id, execution_id)
|
||||||
|
with self._lock:
|
||||||
|
current = self._read(path)
|
||||||
|
changes = {
|
||||||
|
"status": 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,
|
||||||
|
}
|
||||||
|
authorization_id = self._authorize(
|
||||||
|
current,
|
||||||
|
operation="heartbeat",
|
||||||
|
principal_id=principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
**changes,
|
||||||
|
last_authorization_id=authorization_id or current.last_authorization_id,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._write(path, updated)
|
||||||
|
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,
|
||||||
|
principal_id: str | None = None,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(tenant_id, execution_id)
|
||||||
|
with self._lock:
|
||||||
|
current = self._read(path)
|
||||||
|
changes = {
|
||||||
|
"status": _required(status, "status"),
|
||||||
|
"current_step": "complete",
|
||||||
|
"progress_completed": current.progress_total,
|
||||||
|
"last_commit": last_commit or current.last_commit,
|
||||||
|
"test_status": _required(test_status, "test_status"),
|
||||||
|
"test_summary": _safe(test_summary),
|
||||||
|
"result_summary": _safe(result_summary),
|
||||||
|
}
|
||||||
|
authorization_id = self._authorize(
|
||||||
|
current,
|
||||||
|
operation="complete",
|
||||||
|
principal_id=principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
**changes,
|
||||||
|
last_authorization_id=authorization_id or current.last_authorization_id,
|
||||||
|
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.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, path)
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""HTTP/JSON gateway for the AIOS V2 control plane.
|
||||||
|
|
||||||
|
The public gateway owns user-facing control operations only. Runtime lifecycle
|
||||||
|
transitions are deliberately absent: external callers cannot directly move a
|
||||||
|
task to RUNNING, COMPLETED or FAILED. Those transitions are available only
|
||||||
|
through the provenance-protected orchestrator and internal reconciliation API.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
from .dispatch_store import DispatchConflict, DispatchNotFound
|
||||||
|
from .orchestrator import OrchestrationError
|
||||||
|
from .provenance import ProvenanceDenied, ProvenanceReplay
|
||||||
|
|
||||||
|
TENANT_HEADER = "X-AIOS-Tenant-ID"
|
||||||
|
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
|
||||||
|
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 user-facing request router that can be tested without a socket."""
|
||||||
|
|
||||||
|
def __init__(self, service: ControlPlaneService | None = None) -> None:
|
||||||
|
self.service = service or ControlPlaneService()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
|
||||||
|
authenticated = str(headers.get(AUTHENTICATED_HEADER, "")).strip().lower()
|
||||||
|
if authenticated not in {"1", "true", "yes"}:
|
||||||
|
raise Unauthorized("trusted authentication assertion is required")
|
||||||
|
roles = [
|
||||||
|
role.strip()
|
||||||
|
for role in str(headers.get(ROLES_HEADER, "")).split(",")
|
||||||
|
if role.strip()
|
||||||
|
]
|
||||||
|
return RequestContext.create(
|
||||||
|
tenant_id=str(headers.get(TENANT_HEADER, "")),
|
||||||
|
principal_id=str(headers.get(PRINCIPAL_HEADER, "")),
|
||||||
|
trace_id=str(headers.get(TRACE_HEADER, "")).strip() or None,
|
||||||
|
session_id=str(headers.get(SESSION_HEADER, "")).strip() or None,
|
||||||
|
policy_version=str(headers.get(POLICY_HEADER, "v1")).strip() or "v1",
|
||||||
|
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":
|
||||||
|
return HTTPStatus.OK, {"tenant": _json_value(self.service.get_tenant(context))}
|
||||||
|
|
||||||
|
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":
|
||||||
|
tasks = self.service.list_tasks(context, states=query.get("state"))
|
||||||
|
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
|
||||||
|
|
||||||
|
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
bypass_match = re.fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/(start|complete|fail)", path
|
||||||
|
)
|
||||||
|
if method == "POST" and bypass_match:
|
||||||
|
return HTTPStatus.FORBIDDEN, {
|
||||||
|
"error": "RuntimeLifecycleBypassDenied",
|
||||||
|
"message": (
|
||||||
|
"RUNNING, COMPLETED and FAILED transitions are internal; "
|
||||||
|
"use the provenance-protected dispatch/reconcile flow"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
transition_match = re.fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/(approve|cancel)", path
|
||||||
|
)
|
||||||
|
if method == "POST" and transition_match:
|
||||||
|
task_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(transition_match.group(1)), label="task_id"
|
||||||
|
)
|
||||||
|
target = (
|
||||||
|
TaskState.APPROVED
|
||||||
|
if transition_match.group(2) == "approve"
|
||||||
|
else TaskState.CANCELLED
|
||||||
|
)
|
||||||
|
expected_revision = payload.get("expected_revision")
|
||||||
|
task = self.service.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task_id,
|
||||||
|
target_state=target,
|
||||||
|
expected_revision=(
|
||||||
|
int(expected_revision) if expected_revision 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),
|
||||||
|
(ProvenanceDenied, HTTPStatus.FORBIDDEN),
|
||||||
|
(TenantIsolationError, HTTPStatus.NOT_FOUND),
|
||||||
|
(NotFound, HTTPStatus.NOT_FOUND),
|
||||||
|
(DispatchNotFound, HTTPStatus.NOT_FOUND),
|
||||||
|
(ProvenanceReplay, HTTPStatus.CONFLICT),
|
||||||
|
(IdempotencyConflict, HTTPStatus.CONFLICT),
|
||||||
|
(RevisionConflict, HTTPStatus.CONFLICT),
|
||||||
|
(InvalidTransition, HTTPStatus.CONFLICT),
|
||||||
|
(DispatchConflict, HTTPStatus.CONFLICT),
|
||||||
|
(OrchestrationError, 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")
|
||||||
|
return json.loads(self.rfile.read(length).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 Exception as exc:
|
||||||
|
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:
|
||||||
|
super().log_message(format, *args)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_address: tuple[str, int],
|
||||||
|
application: GatewayApplication,
|
||||||
|
) -> None:
|
||||||
|
self.application = application
|
||||||
|
super().__init__(server_address, GatewayRequestHandler)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
|
||||||
|
server = GatewayHTTPServer((host, port), GatewayApplication())
|
||||||
|
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Sanitized GitOps runtime status for the AIOS Web control plane."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitOpsStatus:
|
||||||
|
available: bool
|
||||||
|
service_status: str
|
||||||
|
workspace_mode: str
|
||||||
|
workspace_branch: str | None
|
||||||
|
workspace_ref: str | None
|
||||||
|
workspace_dirty: bool | None
|
||||||
|
candidate_branch: str
|
||||||
|
release_channel: str
|
||||||
|
release_version: str
|
||||||
|
sync_status: str
|
||||||
|
details: Mapping[str, Any]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(worktree: Path, *args: str) -> tuple[int, str, str]:
|
||||||
|
process = subprocess.run(
|
||||||
|
["git", "-C", str(worktree), *args],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return process.returncode, process.stdout.strip(), process.stderr.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_gitops_status() -> GitOpsStatus:
|
||||||
|
"""Return only non-sensitive Git/workspace metadata.
|
||||||
|
|
||||||
|
Remote URLs, environment values, credentials, filesystem contents and
|
||||||
|
command stderr are deliberately excluded from the Web response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
candidate_branch = os.getenv(
|
||||||
|
"AIOS_CANDIDATE_BRANCH",
|
||||||
|
"integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
)
|
||||||
|
release_channel = os.getenv("AIOS_RELEASE_CHANNEL", "candidate")
|
||||||
|
release_version = os.getenv("AIOS_RELEASE_VERSION", "V2R1C0-candidate")
|
||||||
|
sync_status = os.getenv("AIOS_GITOPS_SYNC_STATUS", "unknown")
|
||||||
|
worktree = Path(
|
||||||
|
os.getenv(
|
||||||
|
"AIOS_GITOPS_CONTROLLER_WORKTREE",
|
||||||
|
"/data/aios-gitops/worktrees/controller",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not worktree.is_dir():
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=False,
|
||||||
|
service_status="unknown",
|
||||||
|
workspace_mode="unavailable",
|
||||||
|
workspace_branch=None,
|
||||||
|
workspace_ref=None,
|
||||||
|
workspace_dirty=None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={"reason": "controller_worktree_unavailable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
inside_code, inside, _ = _run_git(worktree, "rev-parse", "--is-inside-work-tree")
|
||||||
|
if inside_code != 0 or inside != "true":
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=False,
|
||||||
|
service_status="unknown",
|
||||||
|
workspace_mode="invalid",
|
||||||
|
workspace_branch=None,
|
||||||
|
workspace_ref=None,
|
||||||
|
workspace_dirty=None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={"reason": "controller_worktree_invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
branch_code, branch, _ = _run_git(worktree, "branch", "--show-current")
|
||||||
|
ref_code, ref_value, _ = _run_git(worktree, "rev-parse", "HEAD")
|
||||||
|
dirty_code, dirty_value, _ = _run_git(worktree, "status", "--porcelain=v1")
|
||||||
|
|
||||||
|
service_status = "unknown"
|
||||||
|
try:
|
||||||
|
process = subprocess.run(
|
||||||
|
["systemctl", "is-active", "aios-gitops-model-api"],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
service_status = process.stdout.strip() or "inactive"
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
service_status = "unavailable"
|
||||||
|
|
||||||
|
branch_value = branch if branch_code == 0 and branch else None
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=True,
|
||||||
|
service_status=service_status,
|
||||||
|
workspace_mode="branch" if branch_value else "detached",
|
||||||
|
workspace_branch=branch_value,
|
||||||
|
workspace_ref=ref_value if ref_code == 0 else None,
|
||||||
|
workspace_dirty=bool(dirty_value) if dirty_code == 0 else None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={
|
||||||
|
"controller_clean": dirty_code == 0 and not dirty_value,
|
||||||
|
"controller_detached": branch_code == 0 and not branch,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
"""AIOS V2 execution orchestrator.
|
||||||
|
|
||||||
|
The orchestrator is the only component allowed to translate an approved AIOS
|
||||||
|
Task into an OpenClaw command or Hermes job. Every dispatch is persisted before
|
||||||
|
Runtime side effects. Runtime results must bind to the exact dispatch,
|
||||||
|
command/job, tenant, task, trace, principal and provenance context.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
from aios_hermes import HermesAdapter, HermesJob, HermesJobState, HermesResult
|
||||||
|
from aios_openclaw import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawAdapter,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
sign_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
ControlPlaneService,
|
||||||
|
RequestContext,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
)
|
||||||
|
from .dispatch_store import (
|
||||||
|
DispatchConflict,
|
||||||
|
DispatchRecord,
|
||||||
|
DispatchRepository,
|
||||||
|
DispatchState,
|
||||||
|
InMemoryDispatchRepository,
|
||||||
|
TERMINAL_DISPATCH_STATES,
|
||||||
|
)
|
||||||
|
from .provenance import ProvenanceDenied, digest_payload
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeAuthorizationWriter(Protocol):
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestrationError(RuntimeError):
|
||||||
|
"""AIOS failed to dispatch or reconcile a runtime execution."""
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionOrchestrator:
|
||||||
|
"""Dispatch approved AIOS tasks and strictly reconcile Runtime 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",
|
||||||
|
provenance_writer: RuntimeAuthorizationWriter | None = None,
|
||||||
|
require_provenance: bool = False,
|
||||||
|
dispatch_repository: DispatchRepository | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.control_plane = control_plane
|
||||||
|
self.openclaw = openclaw
|
||||||
|
self.hermes = hermes
|
||||||
|
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
|
||||||
|
self.openclaw_key_id = str(openclaw_key_id or "").strip() or "default"
|
||||||
|
self.default_cell_id = default_cell_id
|
||||||
|
self.default_profile_id = default_profile_id
|
||||||
|
self.provenance_writer = provenance_writer
|
||||||
|
self.require_provenance = bool(require_provenance)
|
||||||
|
self.dispatch_repository = dispatch_repository or InMemoryDispatchRepository()
|
||||||
|
if self.require_provenance and self.provenance_writer is None:
|
||||||
|
raise ValueError("production orchestration requires a provenance writer")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record_provenance(value: str | None, *, required: bool, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if required and not normalized:
|
||||||
|
raise OrchestrationError(f"production dispatch requires {label}")
|
||||||
|
return normalized or f"{label}-not-required"
|
||||||
|
|
||||||
|
def _authorize_dispatch(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task: TaskRecord,
|
||||||
|
agent: AgentRecord,
|
||||||
|
provenance_id: str | None,
|
||||||
|
run_id: str | None,
|
||||||
|
timeout_seconds: float | None,
|
||||||
|
) -> str:
|
||||||
|
if not self.require_provenance:
|
||||||
|
return "authorization-not-required"
|
||||||
|
if self.provenance_writer is None:
|
||||||
|
raise OrchestrationError("Run Provenance writer is not configured")
|
||||||
|
if not provenance_id or not run_id:
|
||||||
|
raise OrchestrationError(
|
||||||
|
"production dispatch requires provenance_id and run_id"
|
||||||
|
)
|
||||||
|
request_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"operation": "dispatch",
|
||||||
|
"tenant_id": task.tenant_id,
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"agent_id": agent.agent_id,
|
||||||
|
"principal_id": context.principal_id,
|
||||||
|
"runtime": agent.runtime.strip().lower(),
|
||||||
|
"action": task.action,
|
||||||
|
"payload": task.payload,
|
||||||
|
"trace_id": context.trace_id,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
"task_revision": task.revision,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
authorization = self.provenance_writer.authorize_runtime_execute(
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
request_digest=request_digest,
|
||||||
|
)
|
||||||
|
return authorization.authorization_id
|
||||||
|
except ProvenanceDenied as exc:
|
||||||
|
raise OrchestrationError(f"Run Provenance denied dispatch: {exc}") from exc
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
task = self.control_plane.get_task(context, task_id)
|
||||||
|
agent = self.control_plane.get_agent(context, task.agent_id)
|
||||||
|
persisted_provenance = self._record_provenance(
|
||||||
|
provenance_id, required=self.require_provenance, label="provenance_id"
|
||||||
|
)
|
||||||
|
persisted_run = self._record_provenance(
|
||||||
|
run_id, required=self.require_provenance, label="run_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = self.dispatch_repository.get_by_task(
|
||||||
|
task.task_id, tenant_id=task.tenant_id
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if (
|
||||||
|
existing.provenance_id != persisted_provenance
|
||||||
|
or existing.run_id != persisted_run
|
||||||
|
or existing.principal_id != context.principal_id
|
||||||
|
):
|
||||||
|
raise OrchestrationError(
|
||||||
|
"existing dispatch belongs to another provenance or principal context"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
if task.state != TaskState.APPROVED:
|
||||||
|
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||||
|
|
||||||
|
authorization_id = self._authorize_dispatch(
|
||||||
|
context,
|
||||||
|
task=task,
|
||||||
|
agent=agent,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
runtime = agent.runtime.strip().lower()
|
||||||
|
prepared: DispatchRecord | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if runtime == "openclaw":
|
||||||
|
command = self._create_openclaw_command(context, task)
|
||||||
|
prepared = self.dispatch_repository.create(
|
||||||
|
DispatchRecord.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_execution_id=command.command_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
provenance_id=persisted_provenance,
|
||||||
|
run_id=persisted_run,
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.RUNNING,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
prepared.dispatch_id,
|
||||||
|
tenant_id=prepared.tenant_id,
|
||||||
|
state=DispatchState.RUNNING,
|
||||||
|
)
|
||||||
|
if self.openclaw is None:
|
||||||
|
raise OrchestrationError("OpenClaw runtime is not configured")
|
||||||
|
result = self.openclaw.execute(
|
||||||
|
command, timeout_seconds=timeout_seconds
|
||||||
|
)
|
||||||
|
self.reconcile_openclaw(
|
||||||
|
context,
|
||||||
|
result,
|
||||||
|
dispatch_id=prepared.dispatch_id,
|
||||||
|
provenance_id=persisted_provenance,
|
||||||
|
run_id=persisted_run,
|
||||||
|
)
|
||||||
|
return self.dispatch_repository.get(
|
||||||
|
prepared.dispatch_id, tenant_id=prepared.tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if runtime == "hermes":
|
||||||
|
if self.hermes is None:
|
||||||
|
raise OrchestrationError("Hermes runtime is not configured")
|
||||||
|
job = self._create_hermes_job(context, agent, task)
|
||||||
|
prepared = self.dispatch_repository.create(
|
||||||
|
DispatchRecord.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
runtime="hermes",
|
||||||
|
runtime_execution_id=job.job_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
provenance_id=persisted_provenance,
|
||||||
|
run_id=persisted_run,
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.RUNNING,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
queued = self.hermes.enqueue(job)
|
||||||
|
return self.dispatch_repository.update(
|
||||||
|
prepared.dispatch_id,
|
||||||
|
tenant_id=prepared.tenant_id,
|
||||||
|
state=DispatchState.QUEUED,
|
||||||
|
result_digest=digest_payload(
|
||||||
|
{
|
||||||
|
"queued_job_id": queued.job_id,
|
||||||
|
"state": queued.state.value,
|
||||||
|
"revision": queued.revision,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
|
||||||
|
except Exception as exc:
|
||||||
|
if prepared is not None:
|
||||||
|
try:
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
prepared.dispatch_id,
|
||||||
|
tenant_id=prepared.tenant_id,
|
||||||
|
state=DispatchState.FAILED,
|
||||||
|
result_digest=digest_payload(
|
||||||
|
{
|
||||||
|
"code": "dispatch_failed",
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
current = self.control_plane.get_task(context, task.task_id)
|
||||||
|
if current.state == TaskState.RUNNING:
|
||||||
|
self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.FAILED,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
error={"code": "dispatch_failed", "message": str(exc)},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _create_openclaw_command(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
task: TaskRecord,
|
||||||
|
) -> OpenClawCommand:
|
||||||
|
if self.openclaw_signing_material_resolver is None:
|
||||||
|
raise OrchestrationError("OpenClaw signing material is not configured")
|
||||||
|
key_material = self.openclaw_signing_material_resolver(
|
||||||
|
task.tenant_id,
|
||||||
|
self.openclaw_key_id,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return sign_command(command, key_material)
|
||||||
|
|
||||||
|
def _create_hermes_job(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
agent: AgentRecord,
|
||||||
|
task: TaskRecord,
|
||||||
|
) -> HermesJob:
|
||||||
|
metadata = dict(agent.metadata)
|
||||||
|
return HermesJob.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
|
||||||
|
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_dispatch_context(
|
||||||
|
dispatch: DispatchRecord,
|
||||||
|
*,
|
||||||
|
runtime: str,
|
||||||
|
runtime_execution_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
) -> None:
|
||||||
|
expected = {
|
||||||
|
"runtime": runtime,
|
||||||
|
"runtime_execution_id": runtime_execution_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"principal_id": principal_id,
|
||||||
|
"provenance_id": provenance_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
}
|
||||||
|
for field_name, expected_value in expected.items():
|
||||||
|
if getattr(dispatch, field_name) != expected_value:
|
||||||
|
raise OrchestrationError(f"dispatch {field_name} mismatch")
|
||||||
|
|
||||||
|
def reconcile_openclaw(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
result: OpenClawResult,
|
||||||
|
*,
|
||||||
|
dispatch_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
) -> TaskRecord:
|
||||||
|
dispatch = self.dispatch_repository.get(
|
||||||
|
dispatch_id, tenant_id=context.tenant_id
|
||||||
|
)
|
||||||
|
self._assert_dispatch_context(
|
||||||
|
dispatch,
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_execution_id=result.command_id,
|
||||||
|
tenant_id=result.tenant_id,
|
||||||
|
task_id=result.task_id,
|
||||||
|
trace_id=result.trace_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
result_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"command_id": result.command_id,
|
||||||
|
"state": result.state.value,
|
||||||
|
"output": result.output,
|
||||||
|
"error": result.error,
|
||||||
|
"runtime_id": result.runtime_id,
|
||||||
|
"attempts": result.attempts,
|
||||||
|
"finished_at": result.finished_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if dispatch.state in TERMINAL_DISPATCH_STATES:
|
||||||
|
if dispatch.result_digest != result_digest:
|
||||||
|
raise DispatchConflict("terminal dispatch received a different result")
|
||||||
|
return self.control_plane.get_task(context, dispatch.task_id)
|
||||||
|
|
||||||
|
task = self.control_plane.get_task(context, dispatch.task_id)
|
||||||
|
if task.state != TaskState.RUNNING:
|
||||||
|
raise OrchestrationError("task is not RUNNING for active dispatch")
|
||||||
|
if result.state == CommandState.COMPLETED:
|
||||||
|
target = TaskState.COMPLETED
|
||||||
|
values = {"result": result.output or {}}
|
||||||
|
dispatch_state = DispatchState.COMPLETED
|
||||||
|
elif result.state in {
|
||||||
|
CommandState.FAILED,
|
||||||
|
CommandState.TIMED_OUT,
|
||||||
|
CommandState.REJECTED,
|
||||||
|
}:
|
||||||
|
target = TaskState.FAILED
|
||||||
|
values = {
|
||||||
|
"error": result.error or {"code": result.state.value.lower()}
|
||||||
|
}
|
||||||
|
dispatch_state = DispatchState.FAILED
|
||||||
|
elif result.state == CommandState.CANCELLED:
|
||||||
|
target = TaskState.CANCELLED
|
||||||
|
values = {}
|
||||||
|
dispatch_state = DispatchState.CANCELLED
|
||||||
|
else:
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
dispatch.dispatch_id,
|
||||||
|
tenant_id=dispatch.tenant_id,
|
||||||
|
state=DispatchState.RUNNING,
|
||||||
|
result_digest=result_digest,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
updated_task = self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=target,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
dispatch.dispatch_id,
|
||||||
|
tenant_id=dispatch.tenant_id,
|
||||||
|
state=dispatch_state,
|
||||||
|
result_digest=result_digest,
|
||||||
|
)
|
||||||
|
return updated_task
|
||||||
|
|
||||||
|
def reconcile_hermes(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
result: HermesResult,
|
||||||
|
*,
|
||||||
|
dispatch_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
) -> TaskRecord:
|
||||||
|
dispatch = self.dispatch_repository.get(
|
||||||
|
dispatch_id, tenant_id=context.tenant_id
|
||||||
|
)
|
||||||
|
self._assert_dispatch_context(
|
||||||
|
dispatch,
|
||||||
|
runtime="hermes",
|
||||||
|
runtime_execution_id=result.job_id,
|
||||||
|
tenant_id=result.tenant_id,
|
||||||
|
task_id=result.task_id,
|
||||||
|
trace_id=result.trace_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
if self.hermes is None:
|
||||||
|
raise OrchestrationError("Hermes runtime is not configured")
|
||||||
|
job = self.hermes.get(result.job_id, tenant_id=result.tenant_id)
|
||||||
|
if result.state in {HermesJobState.COMPLETED, HermesJobState.FAILED}:
|
||||||
|
if not result.worker_id:
|
||||||
|
raise OrchestrationError("terminal Hermes result requires worker_id")
|
||||||
|
if job.assigned_worker_id != result.worker_id:
|
||||||
|
raise OrchestrationError("Hermes result worker does not own job lease")
|
||||||
|
if (
|
||||||
|
dispatch.expected_worker_id is not None
|
||||||
|
and dispatch.expected_worker_id != result.worker_id
|
||||||
|
):
|
||||||
|
raise OrchestrationError("Hermes result worker mismatch")
|
||||||
|
result_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"job_id": result.job_id,
|
||||||
|
"worker_id": result.worker_id,
|
||||||
|
"state": result.state.value,
|
||||||
|
"output": result.output,
|
||||||
|
"error": result.error,
|
||||||
|
"finished_at": result.finished_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if dispatch.state in TERMINAL_DISPATCH_STATES:
|
||||||
|
if dispatch.result_digest != result_digest:
|
||||||
|
raise DispatchConflict("terminal dispatch received a different result")
|
||||||
|
return self.control_plane.get_task(context, dispatch.task_id)
|
||||||
|
|
||||||
|
task = self.control_plane.get_task(context, dispatch.task_id)
|
||||||
|
if task.state != TaskState.RUNNING:
|
||||||
|
raise OrchestrationError("task is not RUNNING for active dispatch")
|
||||||
|
if result.state == HermesJobState.COMPLETED:
|
||||||
|
target = TaskState.COMPLETED
|
||||||
|
values = {"result": result.output or {}}
|
||||||
|
dispatch_state = DispatchState.COMPLETED
|
||||||
|
elif result.state == HermesJobState.FAILED:
|
||||||
|
target = TaskState.FAILED
|
||||||
|
values = {"error": result.error or {"code": "hermes_failed"}}
|
||||||
|
dispatch_state = DispatchState.FAILED
|
||||||
|
elif result.state == HermesJobState.CANCELLED:
|
||||||
|
target = TaskState.CANCELLED
|
||||||
|
values = {}
|
||||||
|
dispatch_state = DispatchState.CANCELLED
|
||||||
|
else:
|
||||||
|
raise OrchestrationError("Hermes reconcile only accepts terminal results")
|
||||||
|
|
||||||
|
updated_task = self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=target,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
dispatch.dispatch_id,
|
||||||
|
tenant_id=dispatch.tenant_id,
|
||||||
|
state=dispatch_state,
|
||||||
|
expected_worker_id=result.worker_id,
|
||||||
|
result_digest=result_digest,
|
||||||
|
)
|
||||||
|
return updated_task
|
||||||
|
|
||||||
|
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
|
||||||
|
return {
|
||||||
|
item.task_id: item
|
||||||
|
for item in self.dispatch_repository.list(tenant_id=tenant_id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Durable single-process persistence for the AIOS V2 control plane.
|
||||||
|
|
||||||
|
The candidate uses an atomic SQLite snapshot so a gateway restart does not lose
|
||||||
|
tenants, agents, task state, idempotency records, or audit events. The domain
|
||||||
|
service remains storage-agnostic; a later HA backend can replace this module
|
||||||
|
without changing API or runtime-adapter contracts.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
AuditEvent,
|
||||||
|
ControlPlaneService,
|
||||||
|
InMemoryControlPlaneStore,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
TenantRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteSnapshotRepository:
|
||||||
|
"""Store one versioned control-plane snapshot in SQLite."""
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS control_plane_snapshot (
|
||||||
|
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
|
||||||
|
schema_version INTEGER NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def load(self) -> dict[str, Any] | None:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT schema_version, payload_json FROM control_plane_snapshot WHERE singleton_id = 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
schema_version, payload_json = row
|
||||||
|
if int(schema_version) != self.SCHEMA_VERSION:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unsupported control-plane snapshot version: {schema_version}"
|
||||||
|
)
|
||||||
|
decoded = json.loads(str(payload_json))
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
raise RuntimeError("invalid control-plane snapshot")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
def save(self, payload: dict[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO control_plane_snapshot (
|
||||||
|
singleton_id, schema_version, payload_json, updated_at
|
||||||
|
) VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(singleton_id) DO UPDATE SET
|
||||||
|
schema_version = excluded.schema_version,
|
||||||
|
payload_json = excluded.payload_json,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(self.SCHEMA_VERSION, encoded),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class DurableControlPlaneService(ControlPlaneService):
|
||||||
|
"""Control-plane service that persists after every successful mutation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: SQLiteSnapshotRepository,
|
||||||
|
*,
|
||||||
|
policy_evaluator=None,
|
||||||
|
) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
store = InMemoryControlPlaneStore()
|
||||||
|
self._restore(store, repository.load())
|
||||||
|
super().__init__(store, policy_evaluator=policy_evaluator)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore(
|
||||||
|
store: InMemoryControlPlaneStore,
|
||||||
|
snapshot: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
if not snapshot:
|
||||||
|
return
|
||||||
|
for item in snapshot.get("tenants", []):
|
||||||
|
record = TenantRecord(**item)
|
||||||
|
store.tenants[record.tenant_id] = record
|
||||||
|
for item in snapshot.get("agents", []):
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["capabilities"] = tuple(normalized.get("capabilities", ()))
|
||||||
|
record = AgentRecord(**normalized)
|
||||||
|
store.agents[record.agent_id] = record
|
||||||
|
for item in snapshot.get("tasks", []):
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["state"] = TaskState(normalized["state"])
|
||||||
|
record = TaskRecord(**normalized)
|
||||||
|
store.tasks[record.task_id] = record
|
||||||
|
store.audit_events.extend(AuditEvent(**item) for item in snapshot.get("audit", []))
|
||||||
|
for item in snapshot.get("idempotency", []):
|
||||||
|
store.idempotency[(item["tenant_id"], item["key"])] = (
|
||||||
|
item["task_id"],
|
||||||
|
item["digest"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _snapshot(self) -> dict[str, Any]:
|
||||||
|
with self.store.lock:
|
||||||
|
return {
|
||||||
|
"schema_version": self.repository.SCHEMA_VERSION,
|
||||||
|
"tenants": [asdict(item) for item in self.store.tenants.values()],
|
||||||
|
"agents": [asdict(item) for item in self.store.agents.values()],
|
||||||
|
"tasks": [
|
||||||
|
{**asdict(item), "state": item.state.value}
|
||||||
|
for item in self.store.tasks.values()
|
||||||
|
],
|
||||||
|
"audit": [asdict(item) for item in self.store.audit_events],
|
||||||
|
"idempotency": [
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"key": key,
|
||||||
|
"task_id": task_id,
|
||||||
|
"digest": digest,
|
||||||
|
}
|
||||||
|
for (tenant_id, key), (task_id, digest)
|
||||||
|
in self.store.idempotency.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _mutate(self, operation: Callable[[], Any]) -> Any:
|
||||||
|
with self.store.lock:
|
||||||
|
result = operation()
|
||||||
|
self.repository.save(self._snapshot())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def register_tenant(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).register_tenant(*args, **kwargs))
|
||||||
|
|
||||||
|
def register_agent(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).register_agent(*args, **kwargs))
|
||||||
|
|
||||||
|
def create_task(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).create_task(*args, **kwargs))
|
||||||
|
|
||||||
|
def transition_task(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).transition_task(*args, **kwargs))
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
"""AIOS V2 可信 Run Provenance 签发、持久化与受控写入。
|
||||||
|
|
||||||
|
生产策略要求 Provenance 同时覆盖七类路径:代码变更、Git 操作、部署、
|
||||||
|
Runtime 执行、Evidence 写入、回滚和审计。任何缺失、篡改、过期、撤销、
|
||||||
|
重放或上下文不匹配都默认拒绝。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
PROVENANCE_VERSION = "aios.v2.run-provenance.v1"
|
||||||
|
PRODUCTION_POLICY_VERSION = "aios.v2.production.v1"
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(value: str) -> datetime:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("timestamp is required")
|
||||||
|
if normalized.endswith("Z"):
|
||||||
|
normalized = normalized[:-1] + "+00:00"
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
dict(value),
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def digest_payload(value: Mapping[str, Any]) -> str:
|
||||||
|
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_digest(value: str) -> str:
|
||||||
|
normalized = required(value, "request_digest").lower()
|
||||||
|
if len(normalized) != 64 or any(ch not in "0123456789abcdef" for ch in normalized):
|
||||||
|
raise ValueError("request_digest must be a SHA-256 hexadecimal digest")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPath(str, Enum):
|
||||||
|
CODE_CHANGE = "code_change"
|
||||||
|
GIT_OPERATION = "git_operation"
|
||||||
|
DEPLOYMENT = "deployment"
|
||||||
|
RUNTIME_EXECUTE = "runtime_execute"
|
||||||
|
EVIDENCE_WRITE = "evidence_write"
|
||||||
|
ROLLBACK = "rollback"
|
||||||
|
AUDIT = "audit"
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_PRODUCTION_PATHS = frozenset(item.value for item in ExecutionPath)
|
||||||
|
|
||||||
|
|
||||||
|
class GitOperation(str, Enum):
|
||||||
|
READ_REF = "read_ref"
|
||||||
|
FETCH = "fetch"
|
||||||
|
CREATE_BRANCH = "create_branch"
|
||||||
|
COMMIT = "commit"
|
||||||
|
MERGE = "merge"
|
||||||
|
PUSH = "push"
|
||||||
|
UPDATE_EVIDENCE = "update_evidence"
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceStatus(str, Enum):
|
||||||
|
ISSUED = "issued"
|
||||||
|
VERIFIED = "verified"
|
||||||
|
USED = "used"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceError(RuntimeError):
|
||||||
|
"""Run Provenance 基础错误。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceDenied(ProvenanceError):
|
||||||
|
"""策略拒绝当前操作。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceNotFound(ProvenanceDenied):
|
||||||
|
"""未找到 Provenance。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceSignatureError(ProvenanceDenied):
|
||||||
|
"""签名无效。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceExpired(ProvenanceDenied):
|
||||||
|
"""Provenance 已过期。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceRevoked(ProvenanceDenied):
|
||||||
|
"""Provenance 已撤销。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceReplay(ProvenanceDenied):
|
||||||
|
"""检测到相同授权摘要重放。"""
|
||||||
|
|
||||||
|
|
||||||
|
class Signer(Protocol):
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
def sign(self, payload: bytes) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureVerifier(Protocol):
|
||||||
|
key_id: str
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
def verify(self, payload: bytes, signature: str) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||||
|
Ed25519PrivateKey,
|
||||||
|
Ed25519PublicKey,
|
||||||
|
)
|
||||||
|
except ImportError as exc: # pragma: no cover - depends on deployment image
|
||||||
|
raise RuntimeError(
|
||||||
|
"cryptography package with Ed25519 support is required"
|
||||||
|
) from exc
|
||||||
|
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
|
|
||||||
|
def _check_private_key_file(path: Path) -> bytes:
|
||||||
|
resolved = path.resolve()
|
||||||
|
metadata = resolved.stat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError("private key reference is not a regular file")
|
||||||
|
if metadata.st_mode & 0o077:
|
||||||
|
raise RuntimeError("private key file permissions must be 0600 or stricter")
|
||||||
|
return resolved.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_public_key_file(path: Path) -> bytes:
|
||||||
|
resolved = path.resolve()
|
||||||
|
metadata = resolved.stat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError("public key reference is not a regular file")
|
||||||
|
if metadata.st_mode & 0o022:
|
||||||
|
raise RuntimeError("public key file must not be group/world writable")
|
||||||
|
return resolved.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
class Ed25519PrivateKeySigner:
|
||||||
|
"""Ed25519 Owner/Control Plane 签发器。"""
|
||||||
|
|
||||||
|
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||||
|
self.private_key = private_key
|
||||||
|
self.key_id = required(key_id, "key_id")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PrivateKeySigner":
|
||||||
|
serialization, private_cls, _ = _load_crypto()
|
||||||
|
value = _check_private_key_file(Path(path))
|
||||||
|
if value.startswith(b"-----BEGIN"):
|
||||||
|
key = serialization.load_pem_private_key(value, password=None)
|
||||||
|
elif len(value) == 32:
|
||||||
|
key = private_cls.from_private_bytes(value)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("private key must be PEM or a raw 32-byte Ed25519 seed")
|
||||||
|
if not isinstance(key, private_cls):
|
||||||
|
raise RuntimeError("private key is not Ed25519")
|
||||||
|
return cls(key, key_id=key_id)
|
||||||
|
|
||||||
|
def sign(self, payload: bytes) -> str:
|
||||||
|
return base64.b64encode(self.private_key.sign(bytes(payload))).decode("ascii")
|
||||||
|
|
||||||
|
def public_verifier(self) -> "Ed25519PublicKeyVerifier":
|
||||||
|
return Ed25519PublicKeyVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
class Ed25519PublicKeyVerifier:
|
||||||
|
"""Ed25519 签名验证器。"""
|
||||||
|
|
||||||
|
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||||
|
serialization, _, public_cls = _load_crypto()
|
||||||
|
if not isinstance(public_key, public_cls):
|
||||||
|
raise RuntimeError("public key is not Ed25519")
|
||||||
|
self.public_key = public_key
|
||||||
|
self.key_id = required(key_id, "key_id")
|
||||||
|
raw = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PublicKeyVerifier":
|
||||||
|
serialization, _, public_cls = _load_crypto()
|
||||||
|
value = _check_public_key_file(Path(path))
|
||||||
|
if value.startswith(b"-----BEGIN"):
|
||||||
|
key = serialization.load_pem_public_key(value)
|
||||||
|
elif len(value) == 32:
|
||||||
|
key = public_cls.from_public_bytes(value)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("public key must be PEM or raw 32-byte Ed25519 key")
|
||||||
|
if not isinstance(key, public_cls):
|
||||||
|
raise RuntimeError("public key is not Ed25519")
|
||||||
|
return cls(key, key_id=key_id)
|
||||||
|
|
||||||
|
def verify(self, payload: bytes, signature: str) -> bool:
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(required(signature, "signature"), validate=True)
|
||||||
|
self.public_key.verify(decoded, bytes(payload))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunProvenance:
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
issuer: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
allowed_paths: Sequence[str]
|
||||||
|
allowed_git_operations: Sequence[str]
|
||||||
|
issued_at: str
|
||||||
|
expires_at: str
|
||||||
|
policy_version: str
|
||||||
|
key_id: str
|
||||||
|
nonce: str
|
||||||
|
signature: str = ""
|
||||||
|
schema_version: str = PROVENANCE_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"provenance_id",
|
||||||
|
"run_id",
|
||||||
|
"issuer",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"principal_id",
|
||||||
|
"issued_at",
|
||||||
|
"expires_at",
|
||||||
|
"policy_version",
|
||||||
|
"key_id",
|
||||||
|
"nonce",
|
||||||
|
"schema_version",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||||
|
if self.schema_version != PROVENANCE_VERSION:
|
||||||
|
raise ValueError(f"unsupported schema_version: {self.schema_version}")
|
||||||
|
issued = parse_timestamp(self.issued_at)
|
||||||
|
expires = parse_timestamp(self.expires_at)
|
||||||
|
if expires <= issued:
|
||||||
|
raise ValueError("expires_at must be later than issued_at")
|
||||||
|
paths = tuple(sorted({ExecutionPath(item).value for item in self.allowed_paths}))
|
||||||
|
operations = tuple(
|
||||||
|
sorted({GitOperation(item).value for item in self.allowed_git_operations})
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "allowed_paths", paths)
|
||||||
|
object.__setattr__(self, "allowed_git_operations", operations)
|
||||||
|
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||||
|
|
||||||
|
def unsigned_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": self.schema_version,
|
||||||
|
"provenance_id": self.provenance_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"issuer": self.issuer,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"principal_id": self.principal_id,
|
||||||
|
"allowed_paths": list(self.allowed_paths),
|
||||||
|
"allowed_git_operations": list(self.allowed_git_operations),
|
||||||
|
"issued_at": self.issued_at,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
"policy_version": self.policy_version,
|
||||||
|
"key_id": self.key_id,
|
||||||
|
"nonce": self.nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
def canonical_bytes(self) -> bytes:
|
||||||
|
return canonical_json_bytes(self.unsigned_dict())
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
value = self.unsigned_dict()
|
||||||
|
value["signature"] = self.signature
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: Mapping[str, Any]) -> "RunProvenance":
|
||||||
|
return cls(**dict(value))
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceIssuer:
|
||||||
|
"""只由 Owner 或 AIOS Control Plane 持有的签发服务。"""
|
||||||
|
|
||||||
|
def __init__(self, signer: Signer, store: "SQLiteProvenanceStore") -> None:
|
||||||
|
self.signer = signer
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def issue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
issuer: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
allowed_paths: Sequence[str] = tuple(REQUIRED_PRODUCTION_PATHS),
|
||||||
|
allowed_git_operations: Sequence[str] = tuple(item.value for item in GitOperation),
|
||||||
|
ttl_seconds: int = 3600,
|
||||||
|
policy_version: str = PRODUCTION_POLICY_VERSION,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
nonce: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RunProvenance:
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
raise ValueError("ttl_seconds must be positive")
|
||||||
|
normalized_paths = frozenset(ExecutionPath(item).value for item in allowed_paths)
|
||||||
|
if policy_version == PRODUCTION_POLICY_VERSION and normalized_paths != REQUIRED_PRODUCTION_PATHS:
|
||||||
|
missing = sorted(REQUIRED_PRODUCTION_PATHS - normalized_paths)
|
||||||
|
extra = sorted(normalized_paths - REQUIRED_PRODUCTION_PATHS)
|
||||||
|
raise ProvenanceDenied(
|
||||||
|
f"production provenance must cover exactly seven paths; missing={missing}, extra={extra}"
|
||||||
|
)
|
||||||
|
normalized_operations = tuple(
|
||||||
|
sorted({GitOperation(item).value for item in allowed_git_operations})
|
||||||
|
)
|
||||||
|
if ExecutionPath.GIT_OPERATION.value in normalized_paths and not normalized_operations:
|
||||||
|
raise ProvenanceDenied("git_operation path requires explicit allowed Git operations")
|
||||||
|
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
unsigned = RunProvenance(
|
||||||
|
provenance_id=provenance_id or f"prov_{uuid.uuid4().hex}",
|
||||||
|
run_id=run_id,
|
||||||
|
issuer=issuer,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
allowed_paths=tuple(normalized_paths),
|
||||||
|
allowed_git_operations=normalized_operations,
|
||||||
|
issued_at=issued.isoformat().replace("+00:00", "Z"),
|
||||||
|
expires_at=(issued + timedelta(seconds=ttl_seconds))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
policy_version=policy_version,
|
||||||
|
key_id=self.signer.key_id,
|
||||||
|
nonce=nonce or uuid.uuid4().hex,
|
||||||
|
)
|
||||||
|
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
|
||||||
|
self.store.save_issued(signed)
|
||||||
|
return signed
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProvenanceAuthorization:
|
||||||
|
authorization_id: str
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
execution_path: str
|
||||||
|
git_operation: str | None
|
||||||
|
request_digest: str
|
||||||
|
policy_version: str
|
||||||
|
key_id: str
|
||||||
|
public_key_fingerprint: str
|
||||||
|
authorized_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteProvenanceStore:
|
||||||
|
"""持久化 Provenance、状态、授权使用和不可变事件。"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._connection = sqlite3.connect(
|
||||||
|
str(self.path), timeout=30.0, check_same_thread=False
|
||||||
|
)
|
||||||
|
self._connection.row_factory = sqlite3.Row
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
self._connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_records (
|
||||||
|
provenance_id TEXT PRIMARY KEY,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
signature_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_usage (
|
||||||
|
authorization_id TEXT PRIMARY KEY,
|
||||||
|
provenance_id TEXT NOT NULL,
|
||||||
|
execution_path TEXT NOT NULL,
|
||||||
|
git_operation TEXT NOT NULL DEFAULT '',
|
||||||
|
request_digest TEXT NOT NULL,
|
||||||
|
authorized_at TEXT NOT NULL,
|
||||||
|
receipt_json TEXT NOT NULL,
|
||||||
|
UNIQUE(provenance_id, execution_path, git_operation, request_digest),
|
||||||
|
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
provenance_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_provenance_usage_id
|
||||||
|
ON provenance_usage(provenance_id, authorized_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_provenance_events_id
|
||||||
|
ON provenance_events(provenance_id, created_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.close()
|
||||||
|
|
||||||
|
def _event(
|
||||||
|
self,
|
||||||
|
provenance_id: str,
|
||||||
|
event_type: str,
|
||||||
|
details: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_events VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
f"pevt_{uuid.uuid4().hex}",
|
||||||
|
provenance_id,
|
||||||
|
event_type,
|
||||||
|
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
|
||||||
|
utc_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_issued(self, provenance: RunProvenance) -> None:
|
||||||
|
payload = json.dumps(
|
||||||
|
provenance.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
prior = self._connection.execute(
|
||||||
|
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
|
||||||
|
(provenance.provenance_id,),
|
||||||
|
).fetchone()
|
||||||
|
if prior is not None:
|
||||||
|
if prior["payload_json"] != payload:
|
||||||
|
raise ProvenanceDenied("provenance_id reused with different payload")
|
||||||
|
self._connection.rollback()
|
||||||
|
return
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_records VALUES (?, ?, ?, 0, ?, ?)",
|
||||||
|
(
|
||||||
|
provenance.provenance_id,
|
||||||
|
payload,
|
||||||
|
ProvenanceStatus.ISSUED.value,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._event(provenance.provenance_id, "issued")
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get(self, provenance_id: str) -> RunProvenance:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
|
||||||
|
(required(provenance_id, "provenance_id"),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ProvenanceNotFound("provenance not found")
|
||||||
|
value = json.loads(row["payload_json"])
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ProvenanceError("stored provenance payload is invalid")
|
||||||
|
return RunProvenance.from_dict(value)
|
||||||
|
|
||||||
|
def status(self, provenance_id: str) -> ProvenanceStatus:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT status FROM provenance_records WHERE provenance_id = ?",
|
||||||
|
(required(provenance_id, "provenance_id"),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ProvenanceNotFound("provenance not found")
|
||||||
|
return ProvenanceStatus(row["status"])
|
||||||
|
|
||||||
|
def mark_verified(self, provenance_id: str, *, fingerprint: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(provenance_id)
|
||||||
|
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
|
||||||
|
raise ProvenanceDenied(f"cannot verify provenance in state {current.value}")
|
||||||
|
next_status = (
|
||||||
|
ProvenanceStatus.USED.value
|
||||||
|
if current == ProvenanceStatus.USED
|
||||||
|
else ProvenanceStatus.VERIFIED.value
|
||||||
|
)
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_records SET status = ?, signature_verified = 1, updated_at = ? WHERE provenance_id = ?",
|
||||||
|
(next_status, utc_now(), provenance_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
provenance_id,
|
||||||
|
"verified",
|
||||||
|
{"public_key_fingerprint": fingerprint},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def mark_expired(self, provenance_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(provenance_id)
|
||||||
|
if current == ProvenanceStatus.REVOKED:
|
||||||
|
self._connection.rollback()
|
||||||
|
return
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||||
|
(ProvenanceStatus.EXPIRED.value, utc_now(), provenance_id),
|
||||||
|
)
|
||||||
|
self._event(provenance_id, "expired")
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def revoke(self, provenance_id: str, *, reason: str, principal_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
self.get(provenance_id)
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||||
|
(ProvenanceStatus.REVOKED.value, utc_now(), provenance_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
provenance_id,
|
||||||
|
"revoked",
|
||||||
|
{
|
||||||
|
"reason": required(reason, "reason"),
|
||||||
|
"principal_id": required(principal_id, "principal_id"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def record_authorization(self, authorization: ProvenanceAuthorization) -> None:
|
||||||
|
receipt = json.dumps(
|
||||||
|
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(authorization.provenance_id)
|
||||||
|
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
|
||||||
|
raise ProvenanceDenied(f"provenance state is {current.value}")
|
||||||
|
try:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
authorization.authorization_id,
|
||||||
|
authorization.provenance_id,
|
||||||
|
authorization.execution_path,
|
||||||
|
authorization.git_operation or "",
|
||||||
|
authorization.request_digest,
|
||||||
|
authorization.authorized_at,
|
||||||
|
receipt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise ProvenanceReplay(
|
||||||
|
"identical provenance path/operation/request digest already used"
|
||||||
|
) from exc
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||||
|
(
|
||||||
|
ProvenanceStatus.USED.value,
|
||||||
|
authorization.authorized_at,
|
||||||
|
authorization.provenance_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
authorization.provenance_id,
|
||||||
|
"used",
|
||||||
|
{
|
||||||
|
"authorization_id": authorization.authorization_id,
|
||||||
|
"execution_path": authorization.execution_path,
|
||||||
|
"git_operation": authorization.git_operation,
|
||||||
|
"request_digest": authorization.request_digest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def authorization_count(self, provenance_id: str) -> int:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT COUNT(*) AS total FROM provenance_usage WHERE provenance_id = ?",
|
||||||
|
(required(provenance_id, "provenance_id"),),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["total"])
|
||||||
|
|
||||||
|
def events(self, provenance_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self._connection.execute(
|
||||||
|
"SELECT event_type, details_json, created_at FROM provenance_events WHERE provenance_id = ? ORDER BY created_at, event_id",
|
||||||
|
(required(provenance_id, "provenance_id"),),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"event_type": row["event_type"],
|
||||||
|
"details": json.loads(row["details_json"]),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceVerifier:
|
||||||
|
"""验证 Owner 签名、上下文、路径、Git 操作、时效和重放。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: SQLiteProvenanceStore,
|
||||||
|
verifiers: Mapping[str, SignatureVerifier],
|
||||||
|
) -> None:
|
||||||
|
if not verifiers:
|
||||||
|
raise ValueError("at least one public key verifier is required")
|
||||||
|
self.store = store
|
||||||
|
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
execution_path: str,
|
||||||
|
request_digest: str,
|
||||||
|
git_operation: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ProvenanceAuthorization:
|
||||||
|
provenance = self.store.get(provenance_id)
|
||||||
|
status = self.store.status(provenance.provenance_id)
|
||||||
|
if status == ProvenanceStatus.REVOKED:
|
||||||
|
raise ProvenanceRevoked("provenance is revoked")
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
if current >= parse_timestamp(provenance.expires_at):
|
||||||
|
self.store.mark_expired(provenance.provenance_id)
|
||||||
|
raise ProvenanceExpired("provenance is expired")
|
||||||
|
verifier = self.verifiers.get(provenance.key_id)
|
||||||
|
if verifier is None:
|
||||||
|
raise ProvenanceSignatureError("no trusted public key for provenance key_id")
|
||||||
|
if not provenance.signature or not verifier.verify(
|
||||||
|
provenance.canonical_bytes(), provenance.signature
|
||||||
|
):
|
||||||
|
raise ProvenanceSignatureError("invalid provenance signature")
|
||||||
|
expected = {
|
||||||
|
"run_id": required(run_id, "run_id"),
|
||||||
|
"tenant_id": required(tenant_id, "tenant_id"),
|
||||||
|
"task_id": required(task_id, "task_id"),
|
||||||
|
"agent_id": required(agent_id, "agent_id"),
|
||||||
|
"principal_id": required(principal_id, "principal_id"),
|
||||||
|
}
|
||||||
|
for field_name, expected_value in expected.items():
|
||||||
|
if getattr(provenance, field_name) != expected_value:
|
||||||
|
raise ProvenanceDenied(f"provenance {field_name} mismatch")
|
||||||
|
path = ExecutionPath(execution_path).value
|
||||||
|
if path not in provenance.allowed_paths:
|
||||||
|
raise ProvenanceDenied(f"execution path is not allowed: {path}")
|
||||||
|
operation: str | None = None
|
||||||
|
if path == ExecutionPath.GIT_OPERATION.value:
|
||||||
|
if git_operation is None:
|
||||||
|
raise ProvenanceDenied("git operation is required for git_operation path")
|
||||||
|
operation = GitOperation(git_operation).value
|
||||||
|
if operation not in provenance.allowed_git_operations:
|
||||||
|
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
|
||||||
|
elif git_operation is not None:
|
||||||
|
raise ProvenanceDenied("git_operation is only valid for git_operation path")
|
||||||
|
digest = _validate_digest(request_digest)
|
||||||
|
self.store.mark_verified(
|
||||||
|
provenance.provenance_id, fingerprint=verifier.fingerprint
|
||||||
|
)
|
||||||
|
authorization = ProvenanceAuthorization(
|
||||||
|
authorization_id=f"pauth_{uuid.uuid4().hex}",
|
||||||
|
provenance_id=provenance.provenance_id,
|
||||||
|
run_id=provenance.run_id,
|
||||||
|
tenant_id=provenance.tenant_id,
|
||||||
|
task_id=provenance.task_id,
|
||||||
|
agent_id=provenance.agent_id,
|
||||||
|
principal_id=provenance.principal_id,
|
||||||
|
execution_path=path,
|
||||||
|
git_operation=operation,
|
||||||
|
request_digest=digest,
|
||||||
|
policy_version=provenance.policy_version,
|
||||||
|
key_id=provenance.key_id,
|
||||||
|
public_key_fingerprint=verifier.fingerprint,
|
||||||
|
)
|
||||||
|
self.store.record_authorization(authorization)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceControlledWriter:
|
||||||
|
"""七路径统一策略入口并持久写入授权回执。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
verifier: ProvenanceVerifier,
|
||||||
|
receipt_root: str | Path,
|
||||||
|
*,
|
||||||
|
writer_id: str = "aios-v2-provenance-writer",
|
||||||
|
) -> None:
|
||||||
|
self.verifier = verifier
|
||||||
|
self.receipt_root = Path(receipt_root).resolve()
|
||||||
|
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.writer_id = required(writer_id, "writer_id")
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ProvenanceDenied("invalid receipt path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def authorize(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
authorization = self.verifier.authorize(**values)
|
||||||
|
tenant = self._component(authorization.tenant_id)
|
||||||
|
provenance_id = self._component(authorization.provenance_id)
|
||||||
|
target_dir = self.receipt_root / tenant / provenance_id
|
||||||
|
target = target_dir / f"{authorization.authorization_id}.json"
|
||||||
|
payload = authorization.as_dict()
|
||||||
|
payload["writer_id"] = self.writer_id
|
||||||
|
with self._lock:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
def authorize_code_change(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
|
||||||
|
|
||||||
|
def authorize_git_operation(
|
||||||
|
self, *, git_operation: str, **values: Any
|
||||||
|
) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||||
|
git_operation=git_operation,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_deployment(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
|
||||||
|
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
|
||||||
|
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
|
||||||
|
|
||||||
|
def authorize_rollback(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
|
||||||
|
|
||||||
|
def authorize_audit(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AIOS V2 Run Provenance 与最小权限 Grant 运维 CLI。
|
||||||
|
|
||||||
|
私钥只用于签发;授权命令只加载一个与 ``--path`` 对应的 Grant。所有输出均为
|
||||||
|
不含 Secret 的 JSON。旧 ``issue`` 仅为候选兼容,生产必须使用
|
||||||
|
``issue-workflow`` 与 ``issue-grant``。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from .provenance import (
|
||||||
|
Ed25519PrivateKeySigner,
|
||||||
|
Ed25519PublicKeyVerifier,
|
||||||
|
ExecutionPath,
|
||||||
|
GitOperation,
|
||||||
|
ProvenanceIssuer,
|
||||||
|
SQLiteProvenanceStore,
|
||||||
|
)
|
||||||
|
from .provenance_grants import (
|
||||||
|
GrantIssuer,
|
||||||
|
GrantType,
|
||||||
|
GrantVerifier,
|
||||||
|
SQLiteGrantStore,
|
||||||
|
WorkflowProvenanceIssuer,
|
||||||
|
)
|
||||||
|
from .provenance_operations import (
|
||||||
|
OperationContext,
|
||||||
|
ProvenanceOperationGate,
|
||||||
|
context_from_environment,
|
||||||
|
load_request_file,
|
||||||
|
)
|
||||||
|
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object(value: str | None, file_path: str | None) -> Mapping[str, Any]:
|
||||||
|
if value and file_path:
|
||||||
|
raise ValueError("request-json and request-file are mutually exclusive")
|
||||||
|
if file_path:
|
||||||
|
return load_request_file(file_path)
|
||||||
|
parsed = json.loads(value or "{}")
|
||||||
|
if not isinstance(parsed, Mapping):
|
||||||
|
raise ValueError("request JSON must be an object")
|
||||||
|
return dict(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv(value: str | None) -> tuple[str, ...]:
|
||||||
|
return tuple(item.strip() for item in str(value or "").split(",") if item.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: str | None, payload: Mapping[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||||
|
if path:
|
||||||
|
target = Path(path).resolve()
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(encoded, encoding="utf-8")
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
print(encoded, end="")
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_store(args: argparse.Namespace) -> SQLiteProvenanceStore:
|
||||||
|
return SQLiteProvenanceStore(args.store_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_store(args: argparse.Namespace) -> SQLiteGrantStore:
|
||||||
|
return SQLiteGrantStore(args.store_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _context(args: argparse.Namespace) -> OperationContext:
|
||||||
|
if args.context_from_env:
|
||||||
|
return context_from_environment()
|
||||||
|
values = {
|
||||||
|
"provenance_id": args.provenance_id,
|
||||||
|
"run_id": args.run_id,
|
||||||
|
"tenant_id": args.tenant_id,
|
||||||
|
"task_id": args.task_id,
|
||||||
|
"agent_id": args.agent_id,
|
||||||
|
"principal_id": args.principal_id,
|
||||||
|
}
|
||||||
|
missing = [name for name, value in values.items() if not str(value or "").strip()]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"missing context arguments: {missing}")
|
||||||
|
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
|
||||||
|
|
||||||
|
|
||||||
|
def command_issue_legacy(args: argparse.Namespace) -> int:
|
||||||
|
store = _parent_store(args)
|
||||||
|
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||||
|
provenance = ProvenanceIssuer(signer, store).issue(
|
||||||
|
run_id=args.run_id,
|
||||||
|
issuer=args.issuer,
|
||||||
|
tenant_id=args.tenant_id,
|
||||||
|
task_id=args.task_id,
|
||||||
|
agent_id=args.agent_id,
|
||||||
|
principal_id=args.principal_id,
|
||||||
|
allowed_paths=_csv(args.allowed_paths) or tuple(item.value for item in ExecutionPath),
|
||||||
|
allowed_git_operations=_csv(args.allowed_git_operations)
|
||||||
|
or tuple(item.value for item in GitOperation),
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
policy_version=args.policy_version,
|
||||||
|
)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{
|
||||||
|
"mode": "legacy-full-rights-candidate-only",
|
||||||
|
"provenance": provenance.as_dict(),
|
||||||
|
"status": store.status(provenance.provenance_id).value,
|
||||||
|
"secret_value_stored": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_issue_workflow(args: argparse.Namespace) -> int:
|
||||||
|
store = _parent_store(args)
|
||||||
|
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||||
|
workflow = WorkflowProvenanceIssuer(signer, store).issue(
|
||||||
|
run_id=args.run_id,
|
||||||
|
issuer=args.issuer,
|
||||||
|
tenant_id=args.tenant_id,
|
||||||
|
task_id=args.task_id,
|
||||||
|
agent_id=args.agent_id,
|
||||||
|
principal_id=args.principal_id,
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{
|
||||||
|
"mode": "least-privilege-workflow",
|
||||||
|
"workflow_provenance": workflow.as_dict(),
|
||||||
|
"status": store.status(workflow.provenance_id).value,
|
||||||
|
"secret_value_stored": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_issue_grant(args: argparse.Namespace) -> int:
|
||||||
|
parent_store = _parent_store(args)
|
||||||
|
grant_store = _grant_store(args)
|
||||||
|
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||||
|
grant = GrantIssuer(signer, parent_store, grant_store).issue(
|
||||||
|
parent_provenance_id=args.parent_provenance_id,
|
||||||
|
grant_type=GrantType(args.grant_type),
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
allowed_git_operations=_csv(args.allowed_git_operations),
|
||||||
|
)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{
|
||||||
|
"grant": grant.as_dict(),
|
||||||
|
"status": grant_store.status(grant.grant_id).value,
|
||||||
|
"secret_value_stored": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _single_scope_writer(args: argparse.Namespace) -> ScopedLeastPrivilegeProvenanceWriter:
|
||||||
|
path = ExecutionPath(args.path).value
|
||||||
|
parent_store = _parent_store(args)
|
||||||
|
grant_store = _grant_store(args)
|
||||||
|
verifier = Ed25519PublicKeyVerifier.from_file(args.public_key_file, key_id=args.key_id)
|
||||||
|
trusted = GrantVerifier(parent_store, grant_store, {args.key_id: verifier})
|
||||||
|
return ScopedLeastPrivilegeProvenanceWriter(
|
||||||
|
trusted,
|
||||||
|
args.receipt_dir,
|
||||||
|
{path: args.grant_id},
|
||||||
|
required_paths=(path,),
|
||||||
|
writer_id=f"aios-v2-cli-{path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command_authorize(args: argparse.Namespace) -> int:
|
||||||
|
writer = _single_scope_writer(args)
|
||||||
|
context = _context(args)
|
||||||
|
receipt = ProvenanceOperationGate(writer).authorize(
|
||||||
|
context,
|
||||||
|
execution_path=args.path,
|
||||||
|
operation=args.operation,
|
||||||
|
request=_json_object(args.request_json, args.request_file),
|
||||||
|
)
|
||||||
|
_write_json(args.output, {"authorization": receipt.as_dict()})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_revoke(args: argparse.Namespace) -> int:
|
||||||
|
store = _parent_store(args)
|
||||||
|
store.revoke(args.provenance_id, reason=args.reason, principal_id=args.principal_id)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{"provenance_id": args.provenance_id, "status": store.status(args.provenance_id).value},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_status(args: argparse.Namespace) -> int:
|
||||||
|
store = _parent_store(args)
|
||||||
|
provenance = store.get(args.provenance_id)
|
||||||
|
grant_store = _grant_store(args)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{
|
||||||
|
"provenance": provenance.as_dict(),
|
||||||
|
"status": store.status(args.provenance_id).value,
|
||||||
|
"authorization_count": store.authorization_count(args.provenance_id),
|
||||||
|
"events": store.events(args.provenance_id),
|
||||||
|
"grants": [item.as_dict() for item in grant_store.list_for_parent(args.provenance_id)],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_grant_revoke(args: argparse.Namespace) -> int:
|
||||||
|
store = _grant_store(args)
|
||||||
|
store.revoke(args.grant_id, reason=args.reason, principal_id=args.principal_id)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{"grant_id": args.grant_id, "status": store.status(args.grant_id).value},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_grant_status(args: argparse.Namespace) -> int:
|
||||||
|
store = _grant_store(args)
|
||||||
|
grant = store.get(args.grant_id)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{"grant": grant.as_dict(), "status": store.status(args.grant_id).value},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def add_common_store(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--store-file",
|
||||||
|
default=os.getenv(
|
||||||
|
"AIOS_PROVENANCE_STORE_FILE",
|
||||||
|
"/var/lib/aios-v2/run-provenance.sqlite3",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def add_signing_context(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("--private-key-file", required=True)
|
||||||
|
parser.add_argument("--key-id", required=True)
|
||||||
|
|
||||||
|
|
||||||
|
def add_workflow_identity(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("--run-id", required=True)
|
||||||
|
parser.add_argument("--issuer", required=True)
|
||||||
|
parser.add_argument("--tenant-id", required=True)
|
||||||
|
parser.add_argument("--task-id", required=True)
|
||||||
|
parser.add_argument("--agent-id", required=True)
|
||||||
|
parser.add_argument("--principal-id", required=True)
|
||||||
|
|
||||||
|
|
||||||
|
def add_context(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("--context-from-env", action="store_true")
|
||||||
|
parser.add_argument("--provenance-id")
|
||||||
|
parser.add_argument("--run-id")
|
||||||
|
parser.add_argument("--tenant-id")
|
||||||
|
parser.add_argument("--task-id")
|
||||||
|
parser.add_argument("--agent-id")
|
||||||
|
parser.add_argument("--principal-id")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="AIOS V2 Run Provenance CLI")
|
||||||
|
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
issue = subcommands.add_parser("issue", help="候选兼容:签发全权限 Provenance;生产禁止")
|
||||||
|
add_common_store(issue)
|
||||||
|
add_signing_context(issue)
|
||||||
|
add_workflow_identity(issue)
|
||||||
|
issue.add_argument("--allowed-paths")
|
||||||
|
issue.add_argument("--allowed-git-operations")
|
||||||
|
issue.add_argument("--ttl-seconds", type=int, default=3600)
|
||||||
|
issue.add_argument("--policy-version", default="aios.v2.candidate.legacy")
|
||||||
|
issue.set_defaults(handler=command_issue_legacy)
|
||||||
|
|
||||||
|
workflow = subcommands.add_parser(
|
||||||
|
"issue-workflow", help="签发不含操作权限的父 Workflow Provenance"
|
||||||
|
)
|
||||||
|
add_common_store(workflow)
|
||||||
|
add_signing_context(workflow)
|
||||||
|
add_workflow_identity(workflow)
|
||||||
|
workflow.add_argument("--ttl-seconds", type=int, default=14400)
|
||||||
|
workflow.set_defaults(handler=command_issue_workflow)
|
||||||
|
|
||||||
|
grant = subcommands.add_parser("issue-grant", help="签发单一路径最小权限 Grant")
|
||||||
|
add_common_store(grant)
|
||||||
|
add_signing_context(grant)
|
||||||
|
grant.add_argument("--parent-provenance-id", required=True)
|
||||||
|
grant.add_argument("--grant-type", choices=[item.value for item in GrantType], required=True)
|
||||||
|
grant.add_argument("--allowed-git-operations")
|
||||||
|
grant.add_argument("--ttl-seconds", type=int, default=900)
|
||||||
|
grant.set_defaults(handler=command_issue_grant)
|
||||||
|
|
||||||
|
authorize = subcommands.add_parser("authorize", help="使用单一子 Grant 授权一次运维操作")
|
||||||
|
add_common_store(authorize)
|
||||||
|
add_context(authorize)
|
||||||
|
authorize.add_argument("--public-key-file", required=True)
|
||||||
|
authorize.add_argument("--key-id", required=True)
|
||||||
|
authorize.add_argument("--grant-id", required=True)
|
||||||
|
authorize.add_argument(
|
||||||
|
"--receipt-dir",
|
||||||
|
default=os.getenv(
|
||||||
|
"AIOS_PROVENANCE_RECEIPT_DIR",
|
||||||
|
"/var/lib/aios-v2/provenance-receipts",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
authorize.add_argument(
|
||||||
|
"--path",
|
||||||
|
required=True,
|
||||||
|
choices=[
|
||||||
|
ExecutionPath.CODE_CHANGE.value,
|
||||||
|
ExecutionPath.DEPLOYMENT.value,
|
||||||
|
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||||
|
ExecutionPath.EVIDENCE_WRITE.value,
|
||||||
|
ExecutionPath.ROLLBACK.value,
|
||||||
|
ExecutionPath.AUDIT.value,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
authorize.add_argument("--operation", required=True)
|
||||||
|
request_group = authorize.add_mutually_exclusive_group()
|
||||||
|
request_group.add_argument("--request-json")
|
||||||
|
request_group.add_argument("--request-file")
|
||||||
|
authorize.set_defaults(handler=command_authorize)
|
||||||
|
|
||||||
|
revoke = subcommands.add_parser("revoke", help="撤销父 Workflow Provenance")
|
||||||
|
add_common_store(revoke)
|
||||||
|
revoke.add_argument("--provenance-id", required=True)
|
||||||
|
revoke.add_argument("--reason", required=True)
|
||||||
|
revoke.add_argument("--principal-id", required=True)
|
||||||
|
revoke.set_defaults(handler=command_revoke)
|
||||||
|
|
||||||
|
status = subcommands.add_parser("status", help="查询父 Workflow 与子 Grant")
|
||||||
|
add_common_store(status)
|
||||||
|
status.add_argument("--provenance-id", required=True)
|
||||||
|
status.set_defaults(handler=command_status)
|
||||||
|
|
||||||
|
grant_revoke = subcommands.add_parser("grant-revoke", help="单独撤销子 Grant")
|
||||||
|
add_common_store(grant_revoke)
|
||||||
|
grant_revoke.add_argument("--grant-id", required=True)
|
||||||
|
grant_revoke.add_argument("--reason", required=True)
|
||||||
|
grant_revoke.add_argument("--principal-id", required=True)
|
||||||
|
grant_revoke.set_defaults(handler=command_grant_revoke)
|
||||||
|
|
||||||
|
grant_status = subcommands.add_parser("grant-status", help="查询子 Grant 状态")
|
||||||
|
add_common_store(grant_status)
|
||||||
|
grant_status.add_argument("--grant-id", required=True)
|
||||||
|
grant_status.set_defaults(handler=command_grant_status)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
return int(args.handler(args))
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"status": "denied", "error": type(exc).__name__, "message": str(exc)},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
"""受最小权限 Run Provenance Grant 约束的 Git 写入器。
|
||||||
|
|
||||||
|
所有 Git 操作都使用参数数组执行。对于 Commit 和 Evidence Commit,先计算
|
||||||
|
工作树内容摘要并完成 Provenance 授权,之后才允许执行 ``git add``,避免未授权
|
||||||
|
请求改变 Git Index。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
from .provenance import GitOperation, ProvenanceDenied, digest_payload, utc_now
|
||||||
|
|
||||||
|
_BRANCH_PATTERN = re.compile(r"^(?![-./])(?!.*\.\.)(?!.*//)[A-Za-z0-9._/-]{1,240}$")
|
||||||
|
_SECRET_URL = re.compile(r"(?i)(https?://)([^/@\s]+)@")
|
||||||
|
_TOKENISH = re.compile(r"(?i)(token|password|secret|authorization)([=:]\s*)([^\s]+)")
|
||||||
|
|
||||||
|
|
||||||
|
class GitAuthorizationWriter(Protocol):
|
||||||
|
def authorize_code_change(self, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
def authorize_git_operation(self, *, git_operation: str, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceGitError(RuntimeError):
|
||||||
|
"""Git 操作被拒绝或执行失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitExecutionContext:
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitCommandResult:
|
||||||
|
operation_id: str
|
||||||
|
operation: str
|
||||||
|
authorization_ids: Sequence[str]
|
||||||
|
command: Sequence[str]
|
||||||
|
exit_code: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
head_before: str | None
|
||||||
|
head_after: str | None
|
||||||
|
started_at: str
|
||||||
|
completed_at: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceGitWriter:
|
||||||
|
"""执行经过单路径 Grant 授权的窄范围 Git 操作。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repo: str | Path,
|
||||||
|
writer: GitAuthorizationWriter,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 120.0,
|
||||||
|
output_limit: int = 16000,
|
||||||
|
) -> None:
|
||||||
|
self.repo = Path(repo).resolve()
|
||||||
|
self.writer = writer
|
||||||
|
self.timeout_seconds = float(timeout_seconds)
|
||||||
|
self.output_limit = int(output_limit)
|
||||||
|
if self.timeout_seconds <= 0 or self.output_limit < 1024:
|
||||||
|
raise ValueError("invalid Git writer limits")
|
||||||
|
self._run_raw(["rev-parse", "--is-inside-work-tree"], check=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_branch(branch: str) -> str:
|
||||||
|
normalized = str(branch or "").strip()
|
||||||
|
if not _BRANCH_PATTERN.fullmatch(normalized) or normalized.endswith((".", "/")):
|
||||||
|
raise ProvenanceGitError("invalid Git branch name")
|
||||||
|
if normalized in {"main", "master"}:
|
||||||
|
raise ProvenanceGitError("direct main/master operation is prohibited")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _validate_path(self, value: str | Path) -> str:
|
||||||
|
raw = Path(value)
|
||||||
|
candidate = (self.repo / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
||||||
|
try:
|
||||||
|
relative = candidate.relative_to(self.repo)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ProvenanceGitError("Git path escapes repository") from exc
|
||||||
|
if str(relative) in {"", "."}:
|
||||||
|
raise ProvenanceGitError("repository root is not a valid file path")
|
||||||
|
return relative.as_posix()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redact(value: str, limit: int) -> str:
|
||||||
|
text = _SECRET_URL.sub(r"\1***@", str(value or ""))
|
||||||
|
text = _TOKENISH.sub(r"\1\2***", text)
|
||||||
|
if len(text) > limit:
|
||||||
|
return text[:limit] + "\n...[truncated]"
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _head(self) -> str | None:
|
||||||
|
process = self._run_raw(["rev-parse", "HEAD"], check=False)
|
||||||
|
return process.stdout.strip() if process.returncode == 0 else None
|
||||||
|
|
||||||
|
def _run_raw(
|
||||||
|
self,
|
||||||
|
args: Sequence[str],
|
||||||
|
*,
|
||||||
|
check: bool,
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
command = ["git", "-C", str(self.repo), *[str(item) for item in args]]
|
||||||
|
safe_env = {
|
||||||
|
**os.environ,
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"GIT_CONFIG_NOSYSTEM": os.environ.get("GIT_CONFIG_NOSYSTEM", "1"),
|
||||||
|
}
|
||||||
|
if env:
|
||||||
|
safe_env.update({str(key): str(value) for key, value in env.items()})
|
||||||
|
process = subprocess.run(
|
||||||
|
command,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
env=safe_env,
|
||||||
|
)
|
||||||
|
if check and process.returncode != 0:
|
||||||
|
raise ProvenanceGitError(
|
||||||
|
self._redact(process.stderr or process.stdout, self.output_limit)
|
||||||
|
or f"Git command failed with exit code {process.returncode}"
|
||||||
|
)
|
||||||
|
return process
|
||||||
|
|
||||||
|
def _path_digest(self, relative: str) -> str:
|
||||||
|
target = self.repo / relative
|
||||||
|
if target.exists():
|
||||||
|
if not target.is_file():
|
||||||
|
raise ProvenanceGitError("Git writer accepts file paths only")
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
with target.open("rb") as handle:
|
||||||
|
while True:
|
||||||
|
block = handle.read(65536)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
hasher.update(block)
|
||||||
|
return hasher.hexdigest()
|
||||||
|
tracked = self._run_raw(["ls-files", "--error-unmatch", "--", relative], check=False)
|
||||||
|
if tracked.returncode != 0:
|
||||||
|
raise ProvenanceGitError(f"path does not exist and is not tracked: {relative}")
|
||||||
|
return "deleted"
|
||||||
|
|
||||||
|
def _content_manifest(self, paths: Sequence[str]) -> Mapping[str, str]:
|
||||||
|
return {path: self._path_digest(path) for path in sorted(set(paths))}
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
operation: GitOperation,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
code_change: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
request_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"operation": operation.value,
|
||||||
|
"repo": self.repo.name,
|
||||||
|
"request": dict(request),
|
||||||
|
"head": self._head(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ids: list[str] = []
|
||||||
|
try:
|
||||||
|
if code_change:
|
||||||
|
code_authorization = self.writer.authorize_code_change(
|
||||||
|
provenance_id=context.provenance_id,
|
||||||
|
run_id=context.run_id,
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
task_id=context.task_id,
|
||||||
|
agent_id=context.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
request_digest=digest_payload(
|
||||||
|
{
|
||||||
|
"git_operation": operation.value,
|
||||||
|
"request_digest": request_digest,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ids.append(code_authorization.authorization_id)
|
||||||
|
git_authorization = self.writer.authorize_git_operation(
|
||||||
|
provenance_id=context.provenance_id,
|
||||||
|
run_id=context.run_id,
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
task_id=context.task_id,
|
||||||
|
agent_id=context.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
git_operation=operation.value,
|
||||||
|
request_digest=request_digest,
|
||||||
|
)
|
||||||
|
ids.append(git_authorization.authorization_id)
|
||||||
|
return ids
|
||||||
|
except ProvenanceDenied as exc:
|
||||||
|
raise ProvenanceGitError(f"Run Provenance denied Git operation: {exc}") from exc
|
||||||
|
|
||||||
|
def _run_authorized(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation: GitOperation,
|
||||||
|
args: Sequence[str],
|
||||||
|
authorization_ids: Sequence[str],
|
||||||
|
) -> GitCommandResult:
|
||||||
|
started = utc_now()
|
||||||
|
before = self._head()
|
||||||
|
process = self._run_raw(args, check=False)
|
||||||
|
after = self._head()
|
||||||
|
result = GitCommandResult(
|
||||||
|
operation_id=f"gitop_{uuid.uuid4().hex}",
|
||||||
|
operation=operation.value,
|
||||||
|
authorization_ids=tuple(authorization_ids),
|
||||||
|
command=tuple(["git", "-C", "<repo>", *[str(item) for item in args]]),
|
||||||
|
exit_code=int(process.returncode),
|
||||||
|
stdout=self._redact(process.stdout, self.output_limit),
|
||||||
|
stderr=self._redact(process.stderr, self.output_limit),
|
||||||
|
head_before=before,
|
||||||
|
head_after=after,
|
||||||
|
started_at=started,
|
||||||
|
completed_at=utc_now(),
|
||||||
|
)
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise ProvenanceGitError(
|
||||||
|
result.stderr or result.stdout or f"Git {operation.value} failed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _execute(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
operation: GitOperation,
|
||||||
|
args: Sequence[str],
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
code_change: bool,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
authorization_ids = self._authorize(
|
||||||
|
context,
|
||||||
|
operation=operation,
|
||||||
|
request=request,
|
||||||
|
code_change=code_change,
|
||||||
|
)
|
||||||
|
return self._run_authorized(
|
||||||
|
operation=operation,
|
||||||
|
args=args,
|
||||||
|
authorization_ids=authorization_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_ref(self, context: GitExecutionContext, ref: str) -> GitCommandResult:
|
||||||
|
normalized = str(ref or "").strip()
|
||||||
|
if not normalized or any(ch.isspace() for ch in normalized):
|
||||||
|
raise ProvenanceGitError("invalid Git ref")
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.READ_REF,
|
||||||
|
args=["rev-parse", "--verify", normalized],
|
||||||
|
request={"ref": normalized},
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
refspecs: Sequence[str],
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized_remote = str(remote or "").strip()
|
||||||
|
normalized_refspecs = tuple(str(item).strip() for item in refspecs if str(item).strip())
|
||||||
|
if not normalized_remote or not normalized_refspecs:
|
||||||
|
raise ProvenanceGitError("remote and refspecs are required")
|
||||||
|
if any(item.startswith("+") and ":refs/heads/" in item for item in normalized_refspecs):
|
||||||
|
raise ProvenanceGitError(
|
||||||
|
"fetching directly into local branch refs is prohibited; use remote-tracking refs"
|
||||||
|
)
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.FETCH,
|
||||||
|
args=["fetch", "--prune", normalized_remote, *normalized_refspecs],
|
||||||
|
request={"remote": normalized_remote, "refspecs": normalized_refspecs},
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_branch(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
branch: str,
|
||||||
|
start_point: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized = self._validate_branch(branch)
|
||||||
|
start = str(start_point or "").strip()
|
||||||
|
if not start:
|
||||||
|
raise ProvenanceGitError("start_point is required")
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.CREATE_BRANCH,
|
||||||
|
args=["branch", normalized, start],
|
||||||
|
request={"branch": normalized, "start_point": start},
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def commit(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
paths: Sequence[str | Path],
|
||||||
|
message: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized_paths = tuple(self._validate_path(item) for item in paths)
|
||||||
|
normalized_message = str(message or "").strip()
|
||||||
|
if not normalized_paths or not normalized_message:
|
||||||
|
raise ProvenanceGitError("commit paths and message are required")
|
||||||
|
if len(normalized_message) > 500:
|
||||||
|
raise ProvenanceGitError("commit message is too long")
|
||||||
|
request = {
|
||||||
|
"paths": normalized_paths,
|
||||||
|
"message": normalized_message,
|
||||||
|
"content_manifest": self._content_manifest(normalized_paths),
|
||||||
|
}
|
||||||
|
authorization_ids = self._authorize(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.COMMIT,
|
||||||
|
request=request,
|
||||||
|
code_change=True,
|
||||||
|
)
|
||||||
|
self._run_raw(["add", "--", *normalized_paths], check=True)
|
||||||
|
return self._run_authorized(
|
||||||
|
operation=GitOperation.COMMIT,
|
||||||
|
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
|
||||||
|
authorization_ids=authorization_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def merge(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
source_ref: str,
|
||||||
|
message: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
source = str(source_ref or "").strip()
|
||||||
|
normalized_message = str(message or "").strip()
|
||||||
|
if not source or not normalized_message:
|
||||||
|
raise ProvenanceGitError("source_ref and message are required")
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.MERGE,
|
||||||
|
args=["merge", "--no-ff", "--no-edit", "-m", normalized_message, source],
|
||||||
|
request={"source_ref": source, "message": normalized_message},
|
||||||
|
code_change=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def push(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
branch: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized_remote = str(remote or "").strip()
|
||||||
|
normalized_branch = self._validate_branch(branch)
|
||||||
|
if not normalized_remote:
|
||||||
|
raise ProvenanceGitError("remote is required")
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.PUSH,
|
||||||
|
args=["push", normalized_remote, f"refs/heads/{normalized_branch}:refs/heads/{normalized_branch}"],
|
||||||
|
request={"remote": normalized_remote, "branch": normalized_branch},
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_evidence(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
paths: Sequence[str | Path],
|
||||||
|
message: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized_paths = tuple(self._validate_path(item) for item in paths)
|
||||||
|
normalized_message = str(message or "").strip()
|
||||||
|
if not normalized_paths or not normalized_message:
|
||||||
|
raise ProvenanceGitError("evidence paths and message are required")
|
||||||
|
if any(not path.startswith(".agent/") for path in normalized_paths):
|
||||||
|
raise ProvenanceGitError("UPDATE_EVIDENCE may only commit .agent/ paths")
|
||||||
|
request = {
|
||||||
|
"paths": normalized_paths,
|
||||||
|
"message": normalized_message,
|
||||||
|
"content_manifest": self._content_manifest(normalized_paths),
|
||||||
|
}
|
||||||
|
authorization_ids = self._authorize(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.UPDATE_EVIDENCE,
|
||||||
|
request=request,
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
self._run_raw(["add", "--", *normalized_paths], check=True)
|
||||||
|
return self._run_authorized(
|
||||||
|
operation=GitOperation.UPDATE_EVIDENCE,
|
||||||
|
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
|
||||||
|
authorization_ids=authorization_ids,
|
||||||
|
)
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
"""AIOS V2 最小权限 Run Provenance 子 Grant。
|
||||||
|
|
||||||
|
父 Workflow Provenance 只绑定 run/tenant/task/agent/principal 身份,不直接授予
|
||||||
|
操作权限。每个子 Grant 只允许一个 ExecutionPath;Git Grant 还必须限定具体
|
||||||
|
GitOperation。子 Grant 具备独立 TTL、nonce、状态、撤销和重放防护。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from .provenance import (
|
||||||
|
ExecutionPath,
|
||||||
|
GitOperation,
|
||||||
|
ProvenanceDenied,
|
||||||
|
ProvenanceExpired,
|
||||||
|
ProvenanceIssuer,
|
||||||
|
ProvenanceNotFound,
|
||||||
|
ProvenanceReplay,
|
||||||
|
ProvenanceRevoked,
|
||||||
|
ProvenanceSignatureError,
|
||||||
|
ProvenanceStatus,
|
||||||
|
ProvenanceVerifier,
|
||||||
|
RunProvenance,
|
||||||
|
SQLiteProvenanceStore,
|
||||||
|
Signer,
|
||||||
|
SignatureVerifier,
|
||||||
|
_validate_digest,
|
||||||
|
canonical_json_bytes,
|
||||||
|
parse_timestamp,
|
||||||
|
required,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
WORKFLOW_POLICY_VERSION = "aios.v2.workflow.v1"
|
||||||
|
GRANT_SCHEMA_VERSION = "aios.v2.provenance-grant.v1"
|
||||||
|
|
||||||
|
|
||||||
|
class GrantType(str, Enum):
|
||||||
|
CODE_CHANGE = "code_change"
|
||||||
|
GIT = "git"
|
||||||
|
DEPLOYMENT = "deployment"
|
||||||
|
RUNTIME = "runtime"
|
||||||
|
EVIDENCE = "evidence"
|
||||||
|
ROLLBACK = "rollback"
|
||||||
|
AUDIT = "audit"
|
||||||
|
|
||||||
|
|
||||||
|
GRANT_PATHS: Mapping[GrantType, ExecutionPath] = {
|
||||||
|
GrantType.CODE_CHANGE: ExecutionPath.CODE_CHANGE,
|
||||||
|
GrantType.GIT: ExecutionPath.GIT_OPERATION,
|
||||||
|
GrantType.DEPLOYMENT: ExecutionPath.DEPLOYMENT,
|
||||||
|
GrantType.RUNTIME: ExecutionPath.RUNTIME_EXECUTE,
|
||||||
|
GrantType.EVIDENCE: ExecutionPath.EVIDENCE_WRITE,
|
||||||
|
GrantType.ROLLBACK: ExecutionPath.ROLLBACK,
|
||||||
|
GrantType.AUDIT: ExecutionPath.AUDIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GrantStatus(str, Enum):
|
||||||
|
ISSUED = "issued"
|
||||||
|
VERIFIED = "verified"
|
||||||
|
USED = "used"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProvenanceGrant:
|
||||||
|
grant_id: str
|
||||||
|
parent_provenance_id: str
|
||||||
|
grant_type: GrantType
|
||||||
|
execution_path: ExecutionPath
|
||||||
|
run_id: str
|
||||||
|
issuer: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
allowed_git_operations: Sequence[str]
|
||||||
|
issued_at: str
|
||||||
|
expires_at: str
|
||||||
|
policy_version: str
|
||||||
|
key_id: str
|
||||||
|
nonce: str
|
||||||
|
signature: str = ""
|
||||||
|
schema_version: str = GRANT_SCHEMA_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"grant_id",
|
||||||
|
"parent_provenance_id",
|
||||||
|
"run_id",
|
||||||
|
"issuer",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"principal_id",
|
||||||
|
"issued_at",
|
||||||
|
"expires_at",
|
||||||
|
"policy_version",
|
||||||
|
"key_id",
|
||||||
|
"nonce",
|
||||||
|
"schema_version",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||||
|
if self.schema_version != GRANT_SCHEMA_VERSION:
|
||||||
|
raise ValueError(f"unsupported grant schema: {self.schema_version}")
|
||||||
|
grant_type = GrantType(self.grant_type)
|
||||||
|
path = ExecutionPath(self.execution_path)
|
||||||
|
if GRANT_PATHS[grant_type] != path:
|
||||||
|
raise ValueError("grant_type does not match execution_path")
|
||||||
|
object.__setattr__(self, "grant_type", grant_type)
|
||||||
|
object.__setattr__(self, "execution_path", path)
|
||||||
|
operations = tuple(
|
||||||
|
sorted({GitOperation(item).value for item in self.allowed_git_operations})
|
||||||
|
)
|
||||||
|
if grant_type == GrantType.GIT and not operations:
|
||||||
|
raise ValueError("Git Grant requires explicit Git operations")
|
||||||
|
if grant_type != GrantType.GIT and operations:
|
||||||
|
raise ValueError("only Git Grant may contain Git operations")
|
||||||
|
object.__setattr__(self, "allowed_git_operations", operations)
|
||||||
|
issued = parse_timestamp(self.issued_at)
|
||||||
|
expires = parse_timestamp(self.expires_at)
|
||||||
|
if expires <= issued:
|
||||||
|
raise ValueError("grant expires_at must be later than issued_at")
|
||||||
|
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||||
|
|
||||||
|
def unsigned_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": self.schema_version,
|
||||||
|
"grant_id": self.grant_id,
|
||||||
|
"parent_provenance_id": self.parent_provenance_id,
|
||||||
|
"grant_type": self.grant_type.value,
|
||||||
|
"execution_path": self.execution_path.value,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"issuer": self.issuer,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"principal_id": self.principal_id,
|
||||||
|
"allowed_git_operations": list(self.allowed_git_operations),
|
||||||
|
"issued_at": self.issued_at,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
"policy_version": self.policy_version,
|
||||||
|
"key_id": self.key_id,
|
||||||
|
"nonce": self.nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
def canonical_bytes(self) -> bytes:
|
||||||
|
return canonical_json_bytes(self.unsigned_dict())
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
value = self.unsigned_dict()
|
||||||
|
value["signature"] = self.signature
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: Mapping[str, Any]) -> "ProvenanceGrant":
|
||||||
|
return cls(**dict(value))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GrantAuthorization:
|
||||||
|
authorization_id: str
|
||||||
|
grant_id: str
|
||||||
|
parent_provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
execution_path: str
|
||||||
|
git_operation: str | None
|
||||||
|
request_digest: str
|
||||||
|
policy_version: str
|
||||||
|
key_id: str
|
||||||
|
public_key_fingerprint: str
|
||||||
|
authorized_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provenance_id(self) -> str:
|
||||||
|
return self.parent_provenance_id
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowProvenanceIssuer:
|
||||||
|
"""签发不携带操作权限的父 Workflow Provenance。"""
|
||||||
|
|
||||||
|
def __init__(self, signer: Signer, store: SQLiteProvenanceStore) -> None:
|
||||||
|
self._issuer = ProvenanceIssuer(signer, store)
|
||||||
|
|
||||||
|
def issue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
issuer: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
ttl_seconds: int = 14400,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
nonce: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RunProvenance:
|
||||||
|
return self._issuer.issue(
|
||||||
|
run_id=run_id,
|
||||||
|
issuer=issuer,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
allowed_paths=(),
|
||||||
|
allowed_git_operations=(),
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
policy_version=WORKFLOW_POLICY_VERSION,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
nonce=nonce,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteGrantStore:
|
||||||
|
"""在 Provenance SQLite 数据库中持久化子 Grant 与使用记录。"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._connection = sqlite3.connect(
|
||||||
|
str(self.path), timeout=30.0, check_same_thread=False
|
||||||
|
)
|
||||||
|
self._connection.row_factory = sqlite3.Row
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
self._connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_grants (
|
||||||
|
grant_id TEXT PRIMARY KEY,
|
||||||
|
parent_provenance_id TEXT NOT NULL,
|
||||||
|
grant_type TEXT NOT NULL,
|
||||||
|
execution_path TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
signature_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grants_parent
|
||||||
|
ON provenance_grants(parent_provenance_id, grant_type, status);
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_grant_usage (
|
||||||
|
authorization_id TEXT PRIMARY KEY,
|
||||||
|
grant_id TEXT NOT NULL,
|
||||||
|
execution_path TEXT NOT NULL,
|
||||||
|
git_operation TEXT NOT NULL DEFAULT '',
|
||||||
|
request_digest TEXT NOT NULL,
|
||||||
|
authorized_at TEXT NOT NULL,
|
||||||
|
receipt_json TEXT NOT NULL,
|
||||||
|
UNIQUE(grant_id, execution_path, git_operation, request_digest),
|
||||||
|
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_grant_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
grant_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.close()
|
||||||
|
|
||||||
|
def _event(
|
||||||
|
self,
|
||||||
|
grant_id: str,
|
||||||
|
event_type: str,
|
||||||
|
details: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_grant_events VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
f"gevt_{uuid.uuid4().hex}",
|
||||||
|
grant_id,
|
||||||
|
event_type,
|
||||||
|
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
|
||||||
|
utc_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_issued(self, grant: ProvenanceGrant) -> None:
|
||||||
|
payload = json.dumps(
|
||||||
|
grant.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_grants VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||||
|
(
|
||||||
|
grant.grant_id,
|
||||||
|
grant.parent_provenance_id,
|
||||||
|
grant.grant_type.value,
|
||||||
|
grant.execution_path.value,
|
||||||
|
payload,
|
||||||
|
GrantStatus.ISSUED.value,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._event(grant.grant_id, "issued")
|
||||||
|
self._connection.commit()
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise ProvenanceDenied("grant_id already exists") from exc
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get(self, grant_id: str) -> ProvenanceGrant:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT payload_json FROM provenance_grants WHERE grant_id = ?",
|
||||||
|
(required(grant_id, "grant_id"),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ProvenanceNotFound("provenance grant not found")
|
||||||
|
value = json.loads(row["payload_json"])
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ProvenanceDenied("stored grant is invalid")
|
||||||
|
return ProvenanceGrant.from_dict(value)
|
||||||
|
|
||||||
|
def status(self, grant_id: str) -> GrantStatus:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT status FROM provenance_grants WHERE grant_id = ?",
|
||||||
|
(required(grant_id, "grant_id"),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ProvenanceNotFound("provenance grant not found")
|
||||||
|
return GrantStatus(row["status"])
|
||||||
|
|
||||||
|
def mark_verified(self, grant_id: str, *, fingerprint: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(grant_id)
|
||||||
|
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
|
||||||
|
raise ProvenanceDenied(f"cannot verify grant in state {current.value}")
|
||||||
|
next_status = (
|
||||||
|
GrantStatus.USED.value
|
||||||
|
if current == GrantStatus.USED
|
||||||
|
else GrantStatus.VERIFIED.value
|
||||||
|
)
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_grants SET status = ?, signature_verified = 1, updated_at = ? WHERE grant_id = ?",
|
||||||
|
(next_status, utc_now(), grant_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
grant_id,
|
||||||
|
"verified",
|
||||||
|
{"public_key_fingerprint": fingerprint},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def mark_expired(self, grant_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
if self.status(grant_id) == GrantStatus.REVOKED:
|
||||||
|
self._connection.rollback()
|
||||||
|
return
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||||
|
(GrantStatus.EXPIRED.value, utc_now(), grant_id),
|
||||||
|
)
|
||||||
|
self._event(grant_id, "expired")
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def revoke(self, grant_id: str, *, reason: str, principal_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
self.get(grant_id)
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||||
|
(GrantStatus.REVOKED.value, utc_now(), grant_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
grant_id,
|
||||||
|
"revoked",
|
||||||
|
{
|
||||||
|
"reason": required(reason, "reason"),
|
||||||
|
"principal_id": required(principal_id, "principal_id"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def record_authorization(self, authorization: GrantAuthorization) -> None:
|
||||||
|
receipt = json.dumps(
|
||||||
|
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(authorization.grant_id)
|
||||||
|
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
|
||||||
|
raise ProvenanceDenied(f"grant state is {current.value}")
|
||||||
|
try:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_grant_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
authorization.authorization_id,
|
||||||
|
authorization.grant_id,
|
||||||
|
authorization.execution_path,
|
||||||
|
authorization.git_operation or "",
|
||||||
|
authorization.request_digest,
|
||||||
|
authorization.authorized_at,
|
||||||
|
receipt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise ProvenanceReplay(
|
||||||
|
"identical grant path/operation/request digest already used"
|
||||||
|
) from exc
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||||
|
(GrantStatus.USED.value, authorization.authorized_at, authorization.grant_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
authorization.grant_id,
|
||||||
|
"used",
|
||||||
|
{
|
||||||
|
"authorization_id": authorization.authorization_id,
|
||||||
|
"execution_path": authorization.execution_path,
|
||||||
|
"git_operation": authorization.git_operation,
|
||||||
|
"request_digest": authorization.request_digest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list_for_parent(self, parent_provenance_id: str) -> list[ProvenanceGrant]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self._connection.execute(
|
||||||
|
"SELECT payload_json FROM provenance_grants WHERE parent_provenance_id = ? ORDER BY grant_type, grant_id",
|
||||||
|
(required(parent_provenance_id, "parent_provenance_id"),),
|
||||||
|
).fetchall()
|
||||||
|
return [ProvenanceGrant.from_dict(json.loads(row["payload_json"])) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
class GrantIssuer:
|
||||||
|
"""从父 Workflow Provenance 派生单一路径最小权限 Grant。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
signer: Signer,
|
||||||
|
parent_store: SQLiteProvenanceStore,
|
||||||
|
grant_store: SQLiteGrantStore,
|
||||||
|
) -> None:
|
||||||
|
self.signer = signer
|
||||||
|
self.parent_store = parent_store
|
||||||
|
self.grant_store = grant_store
|
||||||
|
|
||||||
|
def issue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
parent_provenance_id: str,
|
||||||
|
grant_type: GrantType | str,
|
||||||
|
ttl_seconds: int = 900,
|
||||||
|
allowed_git_operations: Sequence[str] = (),
|
||||||
|
grant_id: str | None = None,
|
||||||
|
nonce: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ProvenanceGrant:
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
raise ValueError("grant ttl_seconds must be positive")
|
||||||
|
parent = self.parent_store.get(parent_provenance_id)
|
||||||
|
if parent.policy_version != WORKFLOW_POLICY_VERSION:
|
||||||
|
raise ProvenanceDenied("child grants require a workflow provenance parent")
|
||||||
|
if parent.allowed_paths or parent.allowed_git_operations:
|
||||||
|
raise ProvenanceDenied("workflow provenance parent must not contain operation rights")
|
||||||
|
parent_status = self.parent_store.status(parent.provenance_id)
|
||||||
|
if parent_status == ProvenanceStatus.REVOKED:
|
||||||
|
raise ProvenanceRevoked("workflow provenance is revoked")
|
||||||
|
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
parent_expiry = parse_timestamp(parent.expires_at)
|
||||||
|
expires = min(issued + timedelta(seconds=ttl_seconds), parent_expiry)
|
||||||
|
if expires <= issued:
|
||||||
|
raise ProvenanceExpired("workflow provenance has expired")
|
||||||
|
normalized_type = GrantType(grant_type)
|
||||||
|
operations = tuple(allowed_git_operations)
|
||||||
|
unsigned = ProvenanceGrant(
|
||||||
|
grant_id=grant_id or f"grant_{uuid.uuid4().hex}",
|
||||||
|
parent_provenance_id=parent.provenance_id,
|
||||||
|
grant_type=normalized_type,
|
||||||
|
execution_path=GRANT_PATHS[normalized_type],
|
||||||
|
run_id=parent.run_id,
|
||||||
|
issuer=parent.issuer,
|
||||||
|
tenant_id=parent.tenant_id,
|
||||||
|
task_id=parent.task_id,
|
||||||
|
agent_id=parent.agent_id,
|
||||||
|
principal_id=parent.principal_id,
|
||||||
|
allowed_git_operations=operations,
|
||||||
|
issued_at=issued.isoformat().replace("+00:00", "Z"),
|
||||||
|
expires_at=expires.isoformat().replace("+00:00", "Z"),
|
||||||
|
policy_version=f"{WORKFLOW_POLICY_VERSION}.{normalized_type.value}",
|
||||||
|
key_id=self.signer.key_id,
|
||||||
|
nonce=nonce or uuid.uuid4().hex,
|
||||||
|
)
|
||||||
|
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
|
||||||
|
self.grant_store.save_issued(signed)
|
||||||
|
return signed
|
||||||
|
|
||||||
|
|
||||||
|
class GrantVerifier:
|
||||||
|
"""验证父 Workflow 与子 Grant,确保单一路径最小权限。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent_store: SQLiteProvenanceStore,
|
||||||
|
grant_store: SQLiteGrantStore,
|
||||||
|
verifiers: Mapping[str, SignatureVerifier],
|
||||||
|
) -> None:
|
||||||
|
if not verifiers:
|
||||||
|
raise ValueError("at least one trusted verifier is required")
|
||||||
|
self.parent_store = parent_store
|
||||||
|
self.grant_store = grant_store
|
||||||
|
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
|
||||||
|
|
||||||
|
def _verify_parent(self, parent: RunProvenance, *, now: datetime) -> SignatureVerifier:
|
||||||
|
status = self.parent_store.status(parent.provenance_id)
|
||||||
|
if status == ProvenanceStatus.REVOKED:
|
||||||
|
raise ProvenanceRevoked("workflow provenance is revoked")
|
||||||
|
if now >= parse_timestamp(parent.expires_at):
|
||||||
|
self.parent_store.mark_expired(parent.provenance_id)
|
||||||
|
raise ProvenanceExpired("workflow provenance is expired")
|
||||||
|
verifier = self.verifiers.get(parent.key_id)
|
||||||
|
if verifier is None or not parent.signature or not verifier.verify(
|
||||||
|
parent.canonical_bytes(), parent.signature
|
||||||
|
):
|
||||||
|
raise ProvenanceSignatureError("invalid workflow provenance signature")
|
||||||
|
if parent.policy_version != WORKFLOW_POLICY_VERSION:
|
||||||
|
raise ProvenanceDenied("parent is not a workflow provenance")
|
||||||
|
if parent.allowed_paths or parent.allowed_git_operations:
|
||||||
|
raise ProvenanceDenied("workflow provenance must not contain operation rights")
|
||||||
|
return verifier
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
grant_id: str,
|
||||||
|
parent_provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
execution_path: str,
|
||||||
|
request_digest: str,
|
||||||
|
git_operation: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> GrantAuthorization:
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
parent = self.parent_store.get(parent_provenance_id)
|
||||||
|
self._verify_parent(parent, now=current)
|
||||||
|
grant = self.grant_store.get(grant_id)
|
||||||
|
grant_status = self.grant_store.status(grant.grant_id)
|
||||||
|
if grant_status == GrantStatus.REVOKED:
|
||||||
|
raise ProvenanceRevoked("provenance grant is revoked")
|
||||||
|
if current >= parse_timestamp(grant.expires_at):
|
||||||
|
self.grant_store.mark_expired(grant.grant_id)
|
||||||
|
raise ProvenanceExpired("provenance grant is expired")
|
||||||
|
verifier = self.verifiers.get(grant.key_id)
|
||||||
|
if verifier is None or not grant.signature or not verifier.verify(
|
||||||
|
grant.canonical_bytes(), grant.signature
|
||||||
|
):
|
||||||
|
raise ProvenanceSignatureError("invalid provenance grant signature")
|
||||||
|
expected = {
|
||||||
|
"parent_provenance_id": parent.provenance_id,
|
||||||
|
"run_id": required(run_id, "run_id"),
|
||||||
|
"tenant_id": required(tenant_id, "tenant_id"),
|
||||||
|
"task_id": required(task_id, "task_id"),
|
||||||
|
"agent_id": required(agent_id, "agent_id"),
|
||||||
|
"principal_id": required(principal_id, "principal_id"),
|
||||||
|
}
|
||||||
|
for field_name, expected_value in expected.items():
|
||||||
|
if getattr(grant, field_name) != expected_value:
|
||||||
|
raise ProvenanceDenied(f"grant {field_name} mismatch")
|
||||||
|
if field_name != "parent_provenance_id" and getattr(parent, field_name) != expected_value:
|
||||||
|
raise ProvenanceDenied(f"workflow {field_name} mismatch")
|
||||||
|
path = ExecutionPath(execution_path)
|
||||||
|
if grant.execution_path != path:
|
||||||
|
raise ProvenanceDenied(
|
||||||
|
f"grant permits {grant.execution_path.value}, not {path.value}"
|
||||||
|
)
|
||||||
|
operation: str | None = None
|
||||||
|
if path == ExecutionPath.GIT_OPERATION:
|
||||||
|
if git_operation is None:
|
||||||
|
raise ProvenanceDenied("Git operation is required")
|
||||||
|
operation = GitOperation(git_operation).value
|
||||||
|
if operation not in grant.allowed_git_operations:
|
||||||
|
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
|
||||||
|
elif git_operation is not None:
|
||||||
|
raise ProvenanceDenied("git_operation is only valid for a Git Grant")
|
||||||
|
digest = _validate_digest(request_digest)
|
||||||
|
self.grant_store.mark_verified(grant.grant_id, fingerprint=verifier.fingerprint)
|
||||||
|
authorization = GrantAuthorization(
|
||||||
|
authorization_id=f"gauth_{uuid.uuid4().hex}",
|
||||||
|
grant_id=grant.grant_id,
|
||||||
|
parent_provenance_id=parent.provenance_id,
|
||||||
|
run_id=grant.run_id,
|
||||||
|
tenant_id=grant.tenant_id,
|
||||||
|
task_id=grant.task_id,
|
||||||
|
agent_id=grant.agent_id,
|
||||||
|
principal_id=grant.principal_id,
|
||||||
|
execution_path=path.value,
|
||||||
|
git_operation=operation,
|
||||||
|
request_digest=digest,
|
||||||
|
policy_version=grant.policy_version,
|
||||||
|
key_id=grant.key_id,
|
||||||
|
public_key_fingerprint=verifier.fingerprint,
|
||||||
|
)
|
||||||
|
self.grant_store.record_authorization(authorization)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
|
||||||
|
class LeastPrivilegeProvenanceWriter:
|
||||||
|
"""按路径选择独立子 Grant 的生产 Writer。
|
||||||
|
|
||||||
|
``provenance_id`` 参数始终指父 Workflow Provenance;Grant ID 来自受保护
|
||||||
|
配置映射,调用方无法通过请求体临时扩大权限。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
verifier: GrantVerifier,
|
||||||
|
receipt_root: str | Path,
|
||||||
|
grant_ids: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
writer_id: str = "aios-v2-least-privilege-writer",
|
||||||
|
) -> None:
|
||||||
|
self.verifier = verifier
|
||||||
|
self.receipt_root = Path(receipt_root).resolve()
|
||||||
|
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.grant_ids = {
|
||||||
|
ExecutionPath(path).value: required(grant_id, f"grant_id[{path}]")
|
||||||
|
for path, grant_id in grant_ids.items()
|
||||||
|
}
|
||||||
|
missing = sorted(path.value for path in ExecutionPath if path.value not in self.grant_ids)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"missing least-privilege Grant mappings: {missing}")
|
||||||
|
self.writer_id = required(writer_id, "writer_id")
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ProvenanceDenied("invalid receipt path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def authorize(self, **values: Any) -> GrantAuthorization:
|
||||||
|
path = ExecutionPath(values["execution_path"]).value
|
||||||
|
grant_id = self.grant_ids[path]
|
||||||
|
parent_provenance_id = values.pop("provenance_id")
|
||||||
|
authorization = self.verifier.authorize(
|
||||||
|
grant_id=grant_id,
|
||||||
|
parent_provenance_id=parent_provenance_id,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
tenant = self._component(authorization.tenant_id)
|
||||||
|
parent = self._component(authorization.parent_provenance_id)
|
||||||
|
grant = self._component(authorization.grant_id)
|
||||||
|
target_dir = self.receipt_root / tenant / parent / grant
|
||||||
|
target = target_dir / f"{authorization.authorization_id}.json"
|
||||||
|
payload = authorization.as_dict()
|
||||||
|
payload["writer_id"] = self.writer_id
|
||||||
|
with self._lock:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
|
||||||
|
|
||||||
|
def authorize_git_operation(
|
||||||
|
self, *, git_operation: str, **values: Any
|
||||||
|
) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||||
|
git_operation=git_operation,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
|
||||||
|
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
|
||||||
|
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
|
||||||
|
|
||||||
|
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
|
||||||
|
|
||||||
|
def authorize_audit(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""AIOS V2 运维操作 Provenance Gate 与服务级 Scoped Writer 工厂。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
from .provenance import (
|
||||||
|
Ed25519PublicKeyVerifier,
|
||||||
|
ExecutionPath,
|
||||||
|
ProvenanceAuthorization,
|
||||||
|
ProvenanceControlledWriter,
|
||||||
|
ProvenanceDenied,
|
||||||
|
ProvenanceVerifier,
|
||||||
|
SQLiteProvenanceStore,
|
||||||
|
digest_payload,
|
||||||
|
)
|
||||||
|
from .provenance_grants import GrantAuthorization, GrantVerifier, SQLiteGrantStore
|
||||||
|
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||||
|
|
||||||
|
|
||||||
|
APPLICATION_PROVENANCE_SCOPE = (
|
||||||
|
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||||
|
ExecutionPath.EVIDENCE_WRITE.value,
|
||||||
|
ExecutionPath.AUDIT.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationWriter(Protocol):
|
||||||
|
def authorize_deployment(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_rollback(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_audit(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_code_change(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OperationContext:
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OperationReceipt:
|
||||||
|
operation: str
|
||||||
|
request_digest: str
|
||||||
|
authorization_id: str
|
||||||
|
provenance_id: str
|
||||||
|
grant_id: str | None
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
execution_path: str
|
||||||
|
authorized_at: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceOperationGate:
|
||||||
|
"""部署、回滚、审计和运行操作的统一授权入口。"""
|
||||||
|
|
||||||
|
def __init__(self, writer: AuthorizationWriter) -> None:
|
||||||
|
self.writer = writer
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
context: OperationContext,
|
||||||
|
*,
|
||||||
|
execution_path: str,
|
||||||
|
operation: str,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
) -> OperationReceipt:
|
||||||
|
path = ExecutionPath(execution_path).value
|
||||||
|
normalized_operation = str(operation or "").strip()
|
||||||
|
if not normalized_operation:
|
||||||
|
raise ValueError("operation is required")
|
||||||
|
digest = digest_payload(
|
||||||
|
{
|
||||||
|
"operation": normalized_operation,
|
||||||
|
"execution_path": path,
|
||||||
|
"request": dict(request),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
values = {
|
||||||
|
"provenance_id": context.provenance_id,
|
||||||
|
"run_id": context.run_id,
|
||||||
|
"tenant_id": context.tenant_id,
|
||||||
|
"task_id": context.task_id,
|
||||||
|
"agent_id": context.agent_id,
|
||||||
|
"principal_id": context.principal_id,
|
||||||
|
"request_digest": digest,
|
||||||
|
}
|
||||||
|
if path == ExecutionPath.DEPLOYMENT.value:
|
||||||
|
authorization = self.writer.authorize_deployment(**values)
|
||||||
|
elif path == ExecutionPath.ROLLBACK.value:
|
||||||
|
authorization = self.writer.authorize_rollback(**values)
|
||||||
|
elif path == ExecutionPath.AUDIT.value:
|
||||||
|
authorization = self.writer.authorize_audit(**values)
|
||||||
|
elif path == ExecutionPath.CODE_CHANGE.value:
|
||||||
|
authorization = self.writer.authorize_code_change(**values)
|
||||||
|
elif path == ExecutionPath.EVIDENCE_WRITE.value:
|
||||||
|
authorization = self.writer.authorize_evidence_write(**values)
|
||||||
|
elif path == ExecutionPath.RUNTIME_EXECUTE.value:
|
||||||
|
authorization = self.writer.authorize_runtime_execute(**values)
|
||||||
|
else:
|
||||||
|
raise ProvenanceDenied(
|
||||||
|
"generic operation gate does not authorize git_operation; use ProvenanceGitWriter"
|
||||||
|
)
|
||||||
|
return self._receipt(normalized_operation, digest, authorization)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _receipt(
|
||||||
|
operation: str,
|
||||||
|
request_digest: str,
|
||||||
|
authorization: ProvenanceAuthorization | GrantAuthorization | Any,
|
||||||
|
) -> OperationReceipt:
|
||||||
|
return OperationReceipt(
|
||||||
|
operation=operation,
|
||||||
|
request_digest=request_digest,
|
||||||
|
authorization_id=authorization.authorization_id,
|
||||||
|
provenance_id=authorization.provenance_id,
|
||||||
|
grant_id=getattr(authorization, "grant_id", None),
|
||||||
|
run_id=authorization.run_id,
|
||||||
|
tenant_id=authorization.tenant_id,
|
||||||
|
task_id=authorization.task_id,
|
||||||
|
agent_id=authorization.agent_id,
|
||||||
|
principal_id=authorization.principal_id,
|
||||||
|
execution_path=authorization.execution_path,
|
||||||
|
authorized_at=authorization.authorized_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_GRANT_ENV_BY_PATH: Mapping[str, str] = {
|
||||||
|
ExecutionPath.CODE_CHANGE.value: "AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID",
|
||||||
|
ExecutionPath.GIT_OPERATION.value: "AIOS_PROVENANCE_GRANT_GIT_ID",
|
||||||
|
ExecutionPath.DEPLOYMENT.value: "AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID",
|
||||||
|
ExecutionPath.RUNTIME_EXECUTE.value: "AIOS_PROVENANCE_GRANT_RUNTIME_ID",
|
||||||
|
ExecutionPath.EVIDENCE_WRITE.value: "AIOS_PROVENANCE_GRANT_EVIDENCE_ID",
|
||||||
|
ExecutionPath.ROLLBACK.value: "AIOS_PROVENANCE_GRANT_ROLLBACK_ID",
|
||||||
|
ExecutionPath.AUDIT.value: "AIOS_PROVENANCE_GRANT_AUDIT_ID",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def grant_ids_from_environment(
|
||||||
|
*,
|
||||||
|
required_paths: Sequence[str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
paths = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
ExecutionPath(path).value
|
||||||
|
for path in (required_paths or tuple(item.value for item in ExecutionPath))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
values = {path: os.getenv(_GRANT_ENV_BY_PATH[path], "").strip() for path in paths}
|
||||||
|
missing = [_GRANT_ENV_BY_PATH[path] for path, value in values.items() if not value]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"missing scoped Provenance Grant configuration: {missing}")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def build_provenance_writer_from_env(
|
||||||
|
*,
|
||||||
|
state_dir: str | Path | None = None,
|
||||||
|
required: bool | None = None,
|
||||||
|
required_paths: Sequence[str] | None = None,
|
||||||
|
writer_id: str = "aios-v2-application-grant-writer",
|
||||||
|
) -> AuthorizationWriter | None:
|
||||||
|
"""从环境构建服务级最小权限 Writer。
|
||||||
|
|
||||||
|
生产应用默认只加载 Runtime、Evidence、Audit Grant。Git、部署和回滚进程
|
||||||
|
必须显式传入自己的 ``required_paths``,不能继承应用默认范围。旧全权限 Writer
|
||||||
|
只允许在显式关闭强制模式的候选/测试环境中使用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
require_value = (
|
||||||
|
bool(required)
|
||||||
|
if required is not None
|
||||||
|
else os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0").strip().lower()
|
||||||
|
in {"1", "true", "yes", "on"}
|
||||||
|
)
|
||||||
|
public_key_file = os.getenv("AIOS_PROVENANCE_PUBLIC_KEY_FILE", "").strip()
|
||||||
|
key_id = os.getenv("AIOS_PROVENANCE_KEY_ID", "").strip()
|
||||||
|
base = Path(state_dir or os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||||
|
store_file = os.getenv(
|
||||||
|
"AIOS_PROVENANCE_STORE_FILE", str(base / "run-provenance.sqlite3")
|
||||||
|
).strip()
|
||||||
|
receipt_root = os.getenv(
|
||||||
|
"AIOS_PROVENANCE_RECEIPT_DIR", str(base / "provenance-receipts")
|
||||||
|
).strip()
|
||||||
|
configured = bool(public_key_file and key_id)
|
||||||
|
if not configured:
|
||||||
|
if require_value:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Run Provenance is required but AIOS_PROVENANCE_PUBLIC_KEY_FILE or AIOS_PROVENANCE_KEY_ID is missing"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
verifier = Ed25519PublicKeyVerifier.from_file(public_key_file, key_id=key_id)
|
||||||
|
parent_store = SQLiteProvenanceStore(store_file)
|
||||||
|
normalized_paths = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
ExecutionPath(path).value
|
||||||
|
for path in (
|
||||||
|
required_paths
|
||||||
|
if required_paths is not None
|
||||||
|
else APPLICATION_PROVENANCE_SCOPE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if require_value:
|
||||||
|
if not normalized_paths:
|
||||||
|
raise RuntimeError("production Run Provenance requires a non-empty service scope")
|
||||||
|
grant_ids = grant_ids_from_environment(required_paths=normalized_paths)
|
||||||
|
grant_store = SQLiteGrantStore(store_file)
|
||||||
|
trusted_grants = GrantVerifier(parent_store, grant_store, {key_id: verifier})
|
||||||
|
return ScopedLeastPrivilegeProvenanceWriter(
|
||||||
|
trusted_grants,
|
||||||
|
receipt_root,
|
||||||
|
grant_ids,
|
||||||
|
required_paths=normalized_paths,
|
||||||
|
writer_id=writer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy = ProvenanceVerifier(parent_store, {key_id: verifier})
|
||||||
|
return ProvenanceControlledWriter(legacy, receipt_root)
|
||||||
|
|
||||||
|
|
||||||
|
def context_from_environment() -> OperationContext:
|
||||||
|
values = {
|
||||||
|
"provenance_id": os.getenv("AIOS_PROVENANCE_ID", ""),
|
||||||
|
"run_id": os.getenv("AIOS_RUN_ID", ""),
|
||||||
|
"tenant_id": os.getenv("AIOS_TENANT_ID", ""),
|
||||||
|
"task_id": os.getenv("AIOS_TASK_ID", ""),
|
||||||
|
"agent_id": os.getenv("AIOS_AGENT_ID", ""),
|
||||||
|
"principal_id": os.getenv("AIOS_PRINCIPAL_ID", ""),
|
||||||
|
}
|
||||||
|
missing = [name for name, value in values.items() if not str(value).strip()]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"missing Provenance context environment variables: {missing}")
|
||||||
|
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
|
||||||
|
|
||||||
|
|
||||||
|
def load_request_file(path: str | Path) -> Mapping[str, Any]:
|
||||||
|
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError("request file must contain a JSON object")
|
||||||
|
return dict(value)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""按服务范围加载的最小权限 Provenance Writer。
|
||||||
|
|
||||||
|
该 Writer 只持有调用进程需要的 Grant ID。未配置路径的方法即使被调用也会
|
||||||
|
默认拒绝,避免应用进程获得 Git、部署或回滚权限。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from .provenance import ExecutionPath, ProvenanceDenied, required
|
||||||
|
from .provenance_grants import GrantAuthorization, GrantVerifier
|
||||||
|
|
||||||
|
|
||||||
|
class ScopedLeastPrivilegeProvenanceWriter:
|
||||||
|
"""只暴露指定 ExecutionPath 的最小权限 Writer。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
verifier: GrantVerifier,
|
||||||
|
receipt_root: str | Path,
|
||||||
|
grant_ids: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
required_paths: Sequence[str],
|
||||||
|
writer_id: str = "aios-v2-scoped-grant-writer",
|
||||||
|
) -> None:
|
||||||
|
self.verifier = verifier
|
||||||
|
self.receipt_root = Path(receipt_root).resolve()
|
||||||
|
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
normalized_required = tuple(
|
||||||
|
sorted({ExecutionPath(path).value for path in required_paths})
|
||||||
|
)
|
||||||
|
if not normalized_required:
|
||||||
|
raise ValueError("required_paths must not be empty")
|
||||||
|
self.required_paths = frozenset(normalized_required)
|
||||||
|
self.grant_ids = {
|
||||||
|
path: required(grant_ids.get(path, ""), f"grant_id[{path}]")
|
||||||
|
for path in normalized_required
|
||||||
|
}
|
||||||
|
unexpected = sorted(set(grant_ids) - self.required_paths)
|
||||||
|
if unexpected:
|
||||||
|
raise ValueError(
|
||||||
|
f"scoped writer received unnecessary Grant mappings: {unexpected}"
|
||||||
|
)
|
||||||
|
self.writer_id = required(writer_id, "writer_id")
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ProvenanceDenied("invalid receipt path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _grant_for(self, path: str) -> str:
|
||||||
|
normalized = ExecutionPath(path).value
|
||||||
|
grant_id = self.grant_ids.get(normalized)
|
||||||
|
if grant_id is None:
|
||||||
|
raise ProvenanceDenied(
|
||||||
|
f"this process is not allowed to authorize {normalized}"
|
||||||
|
)
|
||||||
|
return grant_id
|
||||||
|
|
||||||
|
def authorize(self, **values: Any) -> GrantAuthorization:
|
||||||
|
path = ExecutionPath(values["execution_path"]).value
|
||||||
|
grant_id = self._grant_for(path)
|
||||||
|
parent_provenance_id = values.pop("provenance_id")
|
||||||
|
authorization = self.verifier.authorize(
|
||||||
|
grant_id=grant_id,
|
||||||
|
parent_provenance_id=parent_provenance_id,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
tenant = self._component(authorization.tenant_id)
|
||||||
|
parent = self._component(authorization.parent_provenance_id)
|
||||||
|
grant = self._component(authorization.grant_id)
|
||||||
|
target_dir = self.receipt_root / tenant / parent / grant
|
||||||
|
target = target_dir / f"{authorization.authorization_id}.json"
|
||||||
|
payload = authorization.as_dict()
|
||||||
|
payload["writer_id"] = self.writer_id
|
||||||
|
payload["scope"] = sorted(self.required_paths)
|
||||||
|
with self._lock:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.CODE_CHANGE.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_git_operation(
|
||||||
|
self, *, git_operation: str, **values: Any
|
||||||
|
) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||||
|
git_operation=git_operation,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.DEPLOYMENT.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.ROLLBACK.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_audit(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
"""Canonical AIOS V2R1 runtime package."""
|
|
||||||
|
|
||||||
__version__ = "2.1.0-rc.1"
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
"""Append-only, tamper-evident audit chain for AIOS V2R1."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import closing
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, TypeVar
|
|
||||||
|
|
||||||
from .tenant_context import ConflictError, canonical_json, isoformat, require_ident
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLog:
|
|
||||||
def __init__(self, database: str | Path) -> None:
|
|
||||||
self.database = Path(database)
|
|
||||||
self.database.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._initialize()
|
|
||||||
os.chmod(self.database, 0o600)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
db = sqlite3.connect(str(self.database), timeout=30)
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
db.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return db
|
|
||||||
|
|
||||||
def _storage(self, operation: Callable[[], T]) -> T:
|
|
||||||
failure: ConflictError | None = None
|
|
||||||
try:
|
|
||||||
return operation()
|
|
||||||
except sqlite3.Error:
|
|
||||||
failure = ConflictError("audit storage unavailable")
|
|
||||||
raise failure
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
|
||||||
def initialize() -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_events (
|
|
||||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
trace_id TEXT NOT NULL,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
payload TEXT NOT NULL,
|
|
||||||
previous_hash TEXT NOT NULL,
|
|
||||||
event_hash TEXT NOT NULL UNIQUE,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self._storage(initialize)
|
|
||||||
|
|
||||||
def append(
|
|
||||||
self, *, tenant_id: str, trace_id: str, event_type: str,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> str:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
trace_id = require_ident("trace_id", trace_id)
|
|
||||||
event_type = require_ident("event_type", event_type)
|
|
||||||
|
|
||||||
def write() -> str:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
previous = db.execute(
|
|
||||||
"SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1"
|
|
||||||
).fetchone()
|
|
||||||
previous_hash = previous[0] if previous else "GENESIS"
|
|
||||||
body = {
|
|
||||||
"tenant_id": tenant_id, "trace_id": trace_id,
|
|
||||||
"event_type": event_type, "payload": payload,
|
|
||||||
"previous_hash": previous_hash, "created_at": isoformat(),
|
|
||||||
}
|
|
||||||
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO audit_events VALUES(NULL,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
tenant_id, trace_id, event_type,
|
|
||||||
json.dumps(payload, ensure_ascii=False),
|
|
||||||
previous_hash, digest, body["created_at"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return digest
|
|
||||||
|
|
||||||
return self._storage(write)
|
|
||||||
|
|
||||||
def verify(self) -> bool:
|
|
||||||
def check() -> bool:
|
|
||||||
previous = "GENESIS"
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"SELECT tenant_id,trace_id,event_type,payload,previous_hash,"
|
|
||||||
"event_hash,created_at FROM audit_events ORDER BY seq"
|
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
try:
|
|
||||||
payload = json.loads(row[3])
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return False
|
|
||||||
body = {
|
|
||||||
"tenant_id": row[0], "trace_id": row[1],
|
|
||||||
"event_type": row[2], "payload": payload,
|
|
||||||
"previous_hash": row[4], "created_at": row[6],
|
|
||||||
}
|
|
||||||
if row[4] != previous:
|
|
||||||
return False
|
|
||||||
digest = hashlib.sha256(canonical_json(body)).hexdigest()
|
|
||||||
if digest != row[5]:
|
|
||||||
return False
|
|
||||||
previous = digest
|
|
||||||
return True
|
|
||||||
|
|
||||||
return self._storage(check)
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
"""AIOS V2R1 Tenant Cell lifecycle and atomic registry primitives."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import fcntl
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
from .tenant_context import ValidationError, require_ident
|
|
||||||
|
|
||||||
_THREAD_LOCKS_GUARD = threading.Lock()
|
|
||||||
_THREAD_LOCKS: dict[str, threading.RLock] = {}
|
|
||||||
_STATES = {"created", "starting", "healthy", "upgrading", "rollback", "failed"}
|
|
||||||
_TRANSITIONS = {
|
|
||||||
"created": {"starting", "failed"},
|
|
||||||
"starting": {"healthy", "failed"},
|
|
||||||
"healthy": {"upgrading", "failed"},
|
|
||||||
"upgrading": {"healthy", "rollback", "failed"},
|
|
||||||
"rollback": {"healthy", "failed"},
|
|
||||||
"failed": {"starting"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _lock_for(path: Path) -> threading.RLock:
|
|
||||||
key = str(path.resolve())
|
|
||||||
with _THREAD_LOCKS_GUARD:
|
|
||||||
return _THREAD_LOCKS.setdefault(key, threading.RLock())
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CellRecord:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
version: str
|
|
||||||
state: str
|
|
||||||
config_digest: str
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class CellManager:
|
|
||||||
def __init__(self, registry: str | Path) -> None:
|
|
||||||
self.registry = Path(registry)
|
|
||||||
self.registry.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._lock_file = self.registry.with_name(self.registry.name + ".lock")
|
|
||||||
self._thread_lock = _lock_for(self.registry)
|
|
||||||
with self._exclusive():
|
|
||||||
if not self.registry.exists():
|
|
||||||
self._save({"cells": {}})
|
|
||||||
else:
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _exclusive(self) -> Iterator[None]:
|
|
||||||
with self._thread_lock:
|
|
||||||
with self._lock_file.open("a+", encoding="utf-8") as lock:
|
|
||||||
os.chmod(self._lock_file, 0o600)
|
|
||||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
||||||
|
|
||||||
def _load(self) -> dict[str, dict[str, dict[str, str]]]:
|
|
||||||
failure: ValidationError | None = None
|
|
||||||
try:
|
|
||||||
value = json.loads(self.registry.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
||||||
failure = ValidationError("cell registry unavailable")
|
|
||||||
if failure is not None:
|
|
||||||
raise failure
|
|
||||||
if not isinstance(value, dict) or set(value) != {"cells"}:
|
|
||||||
raise ValidationError("cell registry unavailable")
|
|
||||||
cells = value["cells"]
|
|
||||||
if not isinstance(cells, dict):
|
|
||||||
raise ValidationError("cell registry unavailable")
|
|
||||||
expected = {
|
|
||||||
"tenant_id", "cell_id", "version", "state",
|
|
||||||
"config_digest", "created_at",
|
|
||||||
}
|
|
||||||
for tenant_id, item in cells.items():
|
|
||||||
if (
|
|
||||||
not isinstance(item, dict) or set(item) != expected
|
|
||||||
or item.get("tenant_id") != tenant_id
|
|
||||||
or item.get("state") not in _STATES
|
|
||||||
or any(not isinstance(item.get(name), str) for name in expected)
|
|
||||||
):
|
|
||||||
raise ValidationError("cell registry unavailable")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _save(self, value: dict[str, object]) -> None:
|
|
||||||
descriptor, temporary_name = tempfile.mkstemp(
|
|
||||||
prefix=f".{self.registry.name}.", dir=self.registry.parent, text=True
|
|
||||||
)
|
|
||||||
temporary = Path(temporary_name)
|
|
||||||
try:
|
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
||||||
json.dump(value, output, ensure_ascii=False, indent=2)
|
|
||||||
output.write("\n")
|
|
||||||
output.flush()
|
|
||||||
os.fsync(output.fileno())
|
|
||||||
os.chmod(temporary, 0o600)
|
|
||||||
os.replace(temporary, self.registry)
|
|
||||||
directory = os.open(self.registry.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
||||||
try:
|
|
||||||
os.fsync(directory)
|
|
||||||
finally:
|
|
||||||
os.close(directory)
|
|
||||||
finally:
|
|
||||||
if temporary.exists():
|
|
||||||
temporary.unlink()
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self, tenant_id: str, cell_id: str, version: str, digest: str,
|
|
||||||
) -> CellRecord:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
|
||||||
version = require_ident("version", version)
|
|
||||||
digest = require_ident("digest", digest)
|
|
||||||
with self._exclusive():
|
|
||||||
data = self._load()
|
|
||||||
if tenant_id in data["cells"]:
|
|
||||||
raise ValidationError("tenant already has a cell")
|
|
||||||
record = CellRecord(
|
|
||||||
tenant_id, cell_id, version, "created", digest,
|
|
||||||
datetime.now(timezone.utc).isoformat(),
|
|
||||||
)
|
|
||||||
data["cells"][tenant_id] = record.__dict__
|
|
||||||
self._save(data)
|
|
||||||
return record
|
|
||||||
|
|
||||||
def status(self, tenant_id: str) -> CellRecord:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
with self._exclusive():
|
|
||||||
data = self._load()
|
|
||||||
item = data["cells"].get(tenant_id)
|
|
||||||
if not item:
|
|
||||||
raise ValidationError("unknown tenant")
|
|
||||||
return CellRecord(**item)
|
|
||||||
|
|
||||||
def transition(self, tenant_id: str, state: str) -> CellRecord:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
if state not in _STATES:
|
|
||||||
raise ValidationError("invalid cell state")
|
|
||||||
with self._exclusive():
|
|
||||||
data = self._load()
|
|
||||||
item = data["cells"].get(tenant_id)
|
|
||||||
if not item:
|
|
||||||
raise ValidationError("unknown tenant")
|
|
||||||
current = item["state"]
|
|
||||||
if state == current:
|
|
||||||
return CellRecord(**item)
|
|
||||||
if state not in _TRANSITIONS[current]:
|
|
||||||
raise ValidationError(f"invalid cell transition: {current}->{state}")
|
|
||||||
item["state"] = state
|
|
||||||
self._save(data)
|
|
||||||
return CellRecord(**item)
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""The only V2R1 ingress adapter: verified context to routed ledger record."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from threading import RLock
|
|
||||||
from typing import Callable, Mapping, Any
|
|
||||||
|
|
||||||
from .message_ledger import LedgerRecord, MessageLedger
|
|
||||||
from .router import Route, TenantRouter
|
|
||||||
from .session_pool import SessionLease, SessionPool
|
|
||||||
from .tenant_context import CanonicalMessage, TenantContext, utc_now
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IngressResult:
|
|
||||||
record: LedgerRecord
|
|
||||||
route: Route
|
|
||||||
session: SessionLease
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelAdapter:
|
|
||||||
"""Fail-closed runtime ingress composition.
|
|
||||||
|
|
||||||
The adapter intentionally accepts no tenant or Cell authority outside the
|
|
||||||
signed context and TenantRouter. A process lock keeps rollback handling
|
|
||||||
deterministic for a single runtime while SQLite protects cross-process
|
|
||||||
ledger writes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
router: TenantRouter,
|
|
||||||
ledger: MessageLedger,
|
|
||||||
session_pool: SessionPool,
|
|
||||||
signing_key: bytes,
|
|
||||||
clock: Callable[[], datetime] = utc_now,
|
|
||||||
) -> None:
|
|
||||||
self._router = router
|
|
||||||
self._ledger = ledger
|
|
||||||
self._session_pool = session_pool
|
|
||||||
self._signing_key = signing_key
|
|
||||||
self._clock = clock
|
|
||||||
self._lock = RLock()
|
|
||||||
|
|
||||||
def ingest(self, *, token: str, message: Mapping[str, Any]) -> IngressResult:
|
|
||||||
with self._lock:
|
|
||||||
context = TenantContext.verify(token, self._signing_key, now=self._clock())
|
|
||||||
canonical = CanonicalMessage.from_mapping(message)
|
|
||||||
canonical.assert_context(context)
|
|
||||||
route = self._router.assert_context_route(context)
|
|
||||||
self._router.assert_message_route(canonical)
|
|
||||||
self._session_pool.assert_available(context, route)
|
|
||||||
|
|
||||||
existed = self._ledger.matches(canonical)
|
|
||||||
record = self._ledger.append(canonical)
|
|
||||||
try:
|
|
||||||
lease = self._session_pool.acquire(context, route)
|
|
||||||
except Exception:
|
|
||||||
if not existed:
|
|
||||||
self._ledger.remove(record)
|
|
||||||
raise
|
|
||||||
return IngressResult(record=record, route=route, session=lease)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""aios-cell command implementation for V2R1 Cell lifecycle."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .cell_manager import CellManager
|
|
||||||
from .ingress_service import serve
|
|
||||||
from .tenant_context import V2R1Error, ValidationError
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(prog="aios-cell")
|
|
||||||
parser.add_argument("registry")
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
create = sub.add_parser("create")
|
|
||||||
create.add_argument("tenant_id")
|
|
||||||
create.add_argument("cell_id")
|
|
||||||
create.add_argument("version")
|
|
||||||
create.add_argument("digest")
|
|
||||||
|
|
||||||
status = sub.add_parser("status")
|
|
||||||
status.add_argument("tenant_id")
|
|
||||||
|
|
||||||
transition = sub.add_parser("transition")
|
|
||||||
transition.add_argument("tenant_id")
|
|
||||||
transition.add_argument("state")
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def build_serve_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(prog="aios-cell serve")
|
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
|
||||||
parser.add_argument("--port", type=int, default=18792)
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def _domain_failure(error: V2R1Error) -> int:
|
|
||||||
code = "VALIDATION_ERROR" if isinstance(error, ValidationError) else "RUNTIME_ERROR"
|
|
||||||
print(
|
|
||||||
json.dumps({"code": code, "error": str(error)}, ensure_ascii=False, sort_keys=True),
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 3
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
values = list(sys.argv[1:] if argv is None else argv)
|
|
||||||
service_mode = (
|
|
||||||
bool(values) and values[0] == "serve"
|
|
||||||
and (len(values) == 1 or values[1].startswith("-"))
|
|
||||||
)
|
|
||||||
if service_mode:
|
|
||||||
serve_args = build_serve_parser().parse_args(values[1:])
|
|
||||||
serve(serve_args.host, serve_args.port)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
args = build_parser().parse_args(values)
|
|
||||||
try:
|
|
||||||
manager = CellManager(args.registry)
|
|
||||||
if args.command == "create":
|
|
||||||
result = manager.create(
|
|
||||||
args.tenant_id, args.cell_id, args.version, args.digest
|
|
||||||
)
|
|
||||||
elif args.command == "status":
|
|
||||||
result = manager.status(args.tenant_id)
|
|
||||||
elif args.command == "transition":
|
|
||||||
result = manager.transition(args.tenant_id, args.state)
|
|
||||||
else:
|
|
||||||
return 2
|
|
||||||
except V2R1Error as error:
|
|
||||||
return _domain_failure(error)
|
|
||||||
|
|
||||||
print(json.dumps(result.__dict__, ensure_ascii=False, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""OpenClaw Cell lifecycle adapter for AIOS V2R1."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from .tenant_context import ValidationError, V2R1Error, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CellSpec:
|
|
||||||
tenant_id: str
|
|
||||||
image: str
|
|
||||||
runtime: str = "docker"
|
|
||||||
port: int | None = None
|
|
||||||
memory: str = "2g"
|
|
||||||
cpus: str = "2"
|
|
||||||
pids_limit: int = 512
|
|
||||||
network: str = "bridge"
|
|
||||||
env: tuple[str, ...] = field(default_factory=tuple)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
if self.runtime not in {"docker", "podman"}:
|
|
||||||
raise ValidationError("unsupported runtime")
|
|
||||||
if not self.image:
|
|
||||||
raise ValidationError("image required")
|
|
||||||
|
|
||||||
|
|
||||||
class OpenClawFleet:
|
|
||||||
def __init__(self, binary: str = "openclaw") -> None:
|
|
||||||
self.binary = binary
|
|
||||||
|
|
||||||
def _run(self, args: list[str]) -> dict[str, Any]:
|
|
||||||
proc = subprocess.run(
|
|
||||||
[self.binary, *args],
|
|
||||||
check=False,
|
|
||||||
text=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
if proc.returncode:
|
|
||||||
raise V2R1Error(proc.stderr.strip() or "openclaw command failed")
|
|
||||||
data = json.loads(proc.stdout or "{}")
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
return {"result": data}
|
|
||||||
|
|
||||||
def create(self, spec: CellSpec) -> dict[str, Any]:
|
|
||||||
return self._run([
|
|
||||||
"fleet", "create", spec.tenant_id,
|
|
||||||
"--image", spec.image,
|
|
||||||
"--runtime", spec.runtime,
|
|
||||||
"--json",
|
|
||||||
])
|
|
||||||
|
|
||||||
def status(self, tenant_id: str) -> dict[str, Any]:
|
|
||||||
require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
return self._run(["fleet", "status", tenant_id, "--json"])
|
|
||||||
|
|
||||||
def doctor(self, tenant_id: str | None = None) -> dict[str, Any]:
|
|
||||||
args = ["fleet", "doctor"]
|
|
||||||
if tenant_id:
|
|
||||||
require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
args.append(tenant_id)
|
|
||||||
args.append("--json")
|
|
||||||
return self._run(args)
|
|
||||||
|
|
||||||
def backup(self, tenant_id: str, output: str | Path) -> dict[str, Any]:
|
|
||||||
return self._run(["fleet", "backup", tenant_id, "--out", str(output), "--json"])
|
|
||||||
|
|
||||||
def restore(self, tenant_id: str, archive: str | Path) -> dict[str, Any]:
|
|
||||||
return self._run(["fleet", "restore", tenant_id, "--from", str(archive), "--json"])
|
|
||||||
|
|
||||||
def upgrade(self, tenant_id: str, image: str) -> dict[str, Any]:
|
|
||||||
return self._run(["fleet", "upgrade", tenant_id, "--image", image, "--json"])
|
|
||||||
|
|
||||||
|
|
||||||
def load_inventory(path: str | Path) -> list[CellSpec]:
|
|
||||||
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
||||||
if raw.get("schema_version") != "aios.cells.v1":
|
|
||||||
raise ValidationError("invalid inventory schema")
|
|
||||||
result: list[CellSpec] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for item in raw.get("cells", []):
|
|
||||||
spec = CellSpec(
|
|
||||||
tenant_id=item["tenant_id"],
|
|
||||||
image=item["image"],
|
|
||||||
runtime=item.get("runtime", "docker"),
|
|
||||||
port=item.get("port"),
|
|
||||||
env=tuple(item.get("env", [])),
|
|
||||||
)
|
|
||||||
if spec.tenant_id in seen:
|
|
||||||
raise ValidationError("duplicate tenant")
|
|
||||||
seen.add(spec.tenant_id)
|
|
||||||
result.append(spec)
|
|
||||||
if not result:
|
|
||||||
raise ValidationError("no cells")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def inventory_as_dict(items: list[CellSpec]) -> dict[str, Any]:
|
|
||||||
return {"schema_version": "aios.cells.v1", "cells": [asdict(x) for x in items]}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
"""Tenant-partitioned Hermes shared agent fabric primitives."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, TypeVar
|
|
||||||
|
|
||||||
from .tenant_context import (
|
|
||||||
CanonicalMessage, ConflictError, ScopeError, ValidationError,
|
|
||||||
isoformat, require_ident,
|
|
||||||
)
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FabricJob:
|
|
||||||
job_id: str
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
profile_id: str
|
|
||||||
session_id: str
|
|
||||||
event_id: str
|
|
||||||
status: str = "queued"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for name in ("job_id", "cell_id", "profile_id", "session_id", "event_id"):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
if self.status not in {"queued", "running", "completed", "failed", "cancelled"}:
|
|
||||||
raise ValidationError("invalid job status")
|
|
||||||
|
|
||||||
|
|
||||||
class HermesFabric:
|
|
||||||
"""Shared scheduler storage with mandatory tenant partition keys."""
|
|
||||||
|
|
||||||
def __init__(self, database: str | Path) -> None:
|
|
||||||
self.database = str(database)
|
|
||||||
Path(self.database).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._initialize()
|
|
||||||
os.chmod(self.database, 0o600)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
db = sqlite3.connect(self.database, timeout=30)
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
db.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return db
|
|
||||||
|
|
||||||
def _storage(self, operation: Callable[[], T]) -> T:
|
|
||||||
failure: ConflictError | None = None
|
|
||||||
try:
|
|
||||||
return operation()
|
|
||||||
except sqlite3.Error:
|
|
||||||
failure = ConflictError("fabric storage unavailable")
|
|
||||||
raise failure
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
|
||||||
def initialize() -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS fabric_jobs (
|
|
||||||
job_id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
profile_id TEXT NOT NULL,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
event_id TEXT NOT NULL UNIQUE,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_fabric_tenant_queue "
|
|
||||||
"ON fabric_jobs(tenant_id, cell_id, status)"
|
|
||||||
)
|
|
||||||
self._storage(initialize)
|
|
||||||
|
|
||||||
def enqueue(
|
|
||||||
self, *, message: CanonicalMessage, profile_id: str,
|
|
||||||
) -> FabricJob:
|
|
||||||
profile_id = require_ident("profile_id", profile_id)
|
|
||||||
if not message.command_capable:
|
|
||||||
raise ScopeError("non-command events cannot create execution jobs")
|
|
||||||
if not message.is_authenticated_human_command:
|
|
||||||
raise ScopeError("only authenticated human commands can create jobs")
|
|
||||||
job = FabricJob(
|
|
||||||
job_id=f"job-{message.event_id}", tenant_id=message.tenant_id,
|
|
||||||
cell_id=message.cell_id, profile_id=profile_id,
|
|
||||||
session_id=message.session_id, event_id=message.event_id,
|
|
||||||
)
|
|
||||||
now = isoformat()
|
|
||||||
failure: ConflictError | None = None
|
|
||||||
try:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO fabric_jobs VALUES(?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
job.job_id, job.tenant_id, job.cell_id, job.profile_id,
|
|
||||||
job.session_id, job.event_id, job.status, now, now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
failure = ConflictError(
|
|
||||||
"fabric event already exists or violates queue uniqueness"
|
|
||||||
)
|
|
||||||
except sqlite3.Error:
|
|
||||||
failure = ConflictError("fabric storage unavailable")
|
|
||||||
if failure is not None:
|
|
||||||
raise failure
|
|
||||||
return job
|
|
||||||
|
|
||||||
def claim(
|
|
||||||
self, tenant_id: str, cell_id: str, profile_id: str,
|
|
||||||
) -> FabricJob | None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
|
||||||
profile_id = require_ident("profile_id", profile_id)
|
|
||||||
|
|
||||||
def claim_one() -> FabricJob | None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT * FROM fabric_jobs WHERE tenant_id=? AND cell_id=? "
|
|
||||||
"AND profile_id=? AND status='queued' ORDER BY created_at LIMIT 1",
|
|
||||||
(tenant_id, cell_id, profile_id),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
db.commit()
|
|
||||||
return None
|
|
||||||
updated = db.execute(
|
|
||||||
"UPDATE fabric_jobs SET status='running', updated_at=? "
|
|
||||||
"WHERE job_id=? AND status='queued'",
|
|
||||||
(isoformat(), row[0]),
|
|
||||||
)
|
|
||||||
if updated.rowcount != 1:
|
|
||||||
db.rollback()
|
|
||||||
return None
|
|
||||||
db.commit()
|
|
||||||
return FabricJob(
|
|
||||||
job_id=row[0], tenant_id=row[1], cell_id=row[2],
|
|
||||||
profile_id=row[3], session_id=row[4], event_id=row[5],
|
|
||||||
status="running",
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._storage(claim_one)
|
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
|
||||||
def query() -> int:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
return int(db.execute("SELECT count(*) FROM fabric_jobs").fetchone()[0])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"component": "hermes_fabric", "jobs": self._storage(query),
|
|
||||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""Hermes shared-fabric profile isolation primitives."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .tenant_context import ValidationError, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HermesProfile:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
profile_id: str
|
|
||||||
hermes_home: Path
|
|
||||||
workspace: Path
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for name in ("cell_id", "profile_id"):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
if (
|
|
||||||
not isinstance(self.hermes_home, Path)
|
|
||||||
or not isinstance(self.workspace, Path)
|
|
||||||
or not self.hermes_home.is_absolute()
|
|
||||||
or not self.workspace.is_absolute()
|
|
||||||
):
|
|
||||||
raise ValidationError("profile paths must be absolute")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def environment(self) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
"HERMES_HOME": str(self.hermes_home),
|
|
||||||
"HOME": str(self.hermes_home),
|
|
||||||
"AIOS_TENANT_ID": self.tenant_id,
|
|
||||||
"AIOS_CELL_ID": self.cell_id,
|
|
||||||
"AIOS_PROFILE_ID": self.profile_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _reject_symlink_components(root: Path) -> None:
|
|
||||||
current = Path(root.anchor)
|
|
||||||
for part in root.parts[1:]:
|
|
||||||
current = current / part
|
|
||||||
if current.is_symlink():
|
|
||||||
raise ValidationError("profile root contains symlink")
|
|
||||||
|
|
||||||
def _validate_roots(self) -> None:
|
|
||||||
for root in (self.hermes_home, self.workspace):
|
|
||||||
self._reject_symlink_components(root)
|
|
||||||
|
|
||||||
def prepare(self) -> None:
|
|
||||||
self._validate_roots()
|
|
||||||
for root in (self.hermes_home, self.workspace):
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._validate_roots()
|
|
||||||
for root in (self.hermes_home, self.workspace):
|
|
||||||
os.chmod(root, 0o700)
|
|
||||||
|
|
||||||
def assert_path_allowed(self, path: str | Path) -> None:
|
|
||||||
self._validate_roots()
|
|
||||||
target = Path(path).resolve()
|
|
||||||
for root in (self.hermes_home.resolve(), self.workspace.resolve()):
|
|
||||||
if target == root or root in target.parents:
|
|
||||||
return
|
|
||||||
raise ValidationError("path escapes profile isolation boundary")
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"""Fail-closed identity binding for authenticated external ingress.
|
|
||||||
|
|
||||||
External payload claims are not authority. An HMAC-authenticated envelope is
|
|
||||||
the sole factory for :class:`AuthenticatedPrincipal`; the resolver then checks
|
|
||||||
every claim against the authoritative ``TenantRouter`` before the runtime signs
|
|
||||||
an internal ``TenantContext``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import re
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from .router import Route, TenantRouter
|
|
||||||
from .tenant_context import ScopeError, ValidationError, canonical_json, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
_PROVENANCE = object()
|
|
||||||
_SIGNATURE_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
_SCOPE_FIELDS = (
|
|
||||||
"principal_id", "tenant_id", "cell_id", "channel",
|
|
||||||
"channel_account_id", "session_id", "message",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationError(ValidationError):
|
|
||||||
"""Authentication or identity resolution failed without disclosing scope."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, init=False)
|
|
||||||
class AuthenticatedPrincipal:
|
|
||||||
"""A principal which only the verified envelope factory can create."""
|
|
||||||
|
|
||||||
principal_id: str
|
|
||||||
|
|
||||||
def __init__(self, principal_id: str, *, _provenance: object) -> None:
|
|
||||||
if _provenance is not _PROVENANCE:
|
|
||||||
raise TypeError("AuthenticatedPrincipal is created by authenticated ingress only")
|
|
||||||
object.__setattr__(self, "principal_id", require_ident("principal_id", principal_id))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _verified(cls, principal_id: str) -> "AuthenticatedPrincipal":
|
|
||||||
return cls(principal_id, _provenance=_PROVENANCE)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IdentityClaims:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
session_id: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for field in ("cell_id", "channel", "channel_account_id", "session_id"):
|
|
||||||
require_ident(field, getattr(self, field))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthenticatedIngress:
|
|
||||||
principal: AuthenticatedPrincipal
|
|
||||||
claims: IdentityClaims
|
|
||||||
message: Mapping[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RuntimeIdentity:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
principal_id: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
session_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class IngressAuthenticator:
|
|
||||||
"""Verify a canonical HMAC envelope before any identity lookup occurs."""
|
|
||||||
|
|
||||||
def __init__(self, hmac_key: bytes) -> None:
|
|
||||||
if not isinstance(hmac_key, bytes) or len(hmac_key) < 32:
|
|
||||||
raise ValidationError("ingress HMAC key must be at least 32 bytes")
|
|
||||||
self._hmac_key = hmac_key
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _signed_payload(request: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
if not isinstance(request, Mapping):
|
|
||||||
raise ValidationError("ingress request must be an object")
|
|
||||||
if set(request) != {*_SCOPE_FIELDS, "signature"}:
|
|
||||||
raise ValidationError("ingress request fields are invalid")
|
|
||||||
values = {field: request[field] for field in _SCOPE_FIELDS}
|
|
||||||
if not isinstance(values["message"], Mapping):
|
|
||||||
raise ValidationError("ingress message must be an object")
|
|
||||||
return values
|
|
||||||
|
|
||||||
def authenticate(self, request: Mapping[str, Any]) -> AuthenticatedIngress:
|
|
||||||
values = self._signed_payload(request)
|
|
||||||
signature = request["signature"]
|
|
||||||
if not isinstance(signature, str) or not _SIGNATURE_RE.fullmatch(signature):
|
|
||||||
raise AuthenticationError("invalid ingress signature")
|
|
||||||
expected = hmac.new(
|
|
||||||
self._hmac_key, canonical_json(values), hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
if not hmac.compare_digest(expected, signature):
|
|
||||||
raise AuthenticationError("invalid ingress signature")
|
|
||||||
return AuthenticatedIngress(
|
|
||||||
principal=AuthenticatedPrincipal._verified(values["principal_id"]),
|
|
||||||
claims=IdentityClaims(
|
|
||||||
tenant_id=values["tenant_id"],
|
|
||||||
cell_id=values["cell_id"],
|
|
||||||
channel=values["channel"],
|
|
||||||
channel_account_id=values["channel_account_id"],
|
|
||||||
session_id=values["session_id"],
|
|
||||||
),
|
|
||||||
message=dict(values["message"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeIdentityResolver:
|
|
||||||
"""Resolve authenticated ingress identity through ``TenantRouter`` only."""
|
|
||||||
|
|
||||||
def __init__(self, router: TenantRouter) -> None:
|
|
||||||
self._router = router
|
|
||||||
|
|
||||||
def resolve(
|
|
||||||
self, principal: AuthenticatedPrincipal, claims: IdentityClaims
|
|
||||||
) -> RuntimeIdentity:
|
|
||||||
if not isinstance(principal, AuthenticatedPrincipal):
|
|
||||||
raise AuthenticationError("untrusted principal type")
|
|
||||||
try:
|
|
||||||
route: Route = self._router.resolve(
|
|
||||||
channel=claims.channel,
|
|
||||||
channel_account_id=claims.channel_account_id,
|
|
||||||
principal_id=principal.principal_id,
|
|
||||||
session_id=claims.session_id,
|
|
||||||
)
|
|
||||||
except (ScopeError, ValidationError) as exc:
|
|
||||||
raise AuthenticationError("identity is not registered") from None
|
|
||||||
expected = (
|
|
||||||
claims.tenant_id, claims.cell_id, claims.channel,
|
|
||||||
claims.channel_account_id, principal.principal_id, claims.session_id,
|
|
||||||
)
|
|
||||||
actual = (
|
|
||||||
route.tenant_id, route.cell_id, route.channel,
|
|
||||||
route.channel_account_id, route.principal_id, route.session_id,
|
|
||||||
)
|
|
||||||
if actual != expected:
|
|
||||||
raise AuthenticationError("identity claims do not match authoritative route")
|
|
||||||
return RuntimeIdentity(
|
|
||||||
tenant_id=route.tenant_id,
|
|
||||||
cell_id=route.cell_id,
|
|
||||||
principal_id=route.principal_id,
|
|
||||||
channel=route.channel,
|
|
||||||
channel_account_id=route.channel_account_id,
|
|
||||||
session_id=route.session_id or "",
|
|
||||||
)
|
|
||||||
@@ -1,512 +0,0 @@
|
|||||||
"""Fail-closed loopback HTTP shell for V2R1 ingress processors."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Mapping
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
|
|
||||||
MAX_REQUEST_BYTES = 1024 * 1024
|
|
||||||
MAX_RESPONSE_BYTES = 4096
|
|
||||||
REQUEST_IO_TIMEOUT_SECONDS = 0.5
|
|
||||||
REQUEST_NETWORK_DEADLINE_SECONDS = 1.0
|
|
||||||
HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
|
||||||
HEADER_NAME_BYTES = frozenset(
|
|
||||||
b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
||||||
)
|
|
||||||
INTERNAL_ERROR_BODY = b'{"status":"INTERNAL_ERROR"}'
|
|
||||||
MUTATION_ROUTES = frozenset(
|
|
||||||
{"/v1/ingress", "/v1/receipts/status", "/v1/outbound-receipts"}
|
|
||||||
)
|
|
||||||
Processor = Callable[[str, bytes], tuple[int, Mapping[str, Any]]]
|
|
||||||
|
|
||||||
|
|
||||||
class RequestRejected(Exception):
|
|
||||||
def __init__(self, status: int, payload_status: str) -> None:
|
|
||||||
super().__init__(payload_status)
|
|
||||||
self.status = status
|
|
||||||
self.payload_status = payload_status
|
|
||||||
|
|
||||||
|
|
||||||
class IngressHTTPServer(ThreadingHTTPServer):
|
|
||||||
"""Threaded loopback server with an optional externally supplied processor."""
|
|
||||||
|
|
||||||
daemon_threads = False
|
|
||||||
block_on_close = True
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
address: tuple[str, int],
|
|
||||||
handler: type[BaseHTTPRequestHandler],
|
|
||||||
processor: Processor | None,
|
|
||||||
) -> None:
|
|
||||||
self._closing = threading.Event()
|
|
||||||
self._lifecycle_lock = threading.RLock()
|
|
||||||
self._connections_lock = threading.Lock()
|
|
||||||
self._connections: set[socket.socket] = set()
|
|
||||||
self._server_close_lock = threading.Lock()
|
|
||||||
self.processor = processor
|
|
||||||
super().__init__(address, handler)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_closing(self) -> bool:
|
|
||||||
return self._closing.is_set()
|
|
||||||
|
|
||||||
def get_request(self) -> tuple[socket.socket, tuple[str, int]]:
|
|
||||||
"""Register each accepted socket before handler scheduling can begin."""
|
|
||||||
connection, client_address = super().get_request()
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
if self._closing.is_set():
|
|
||||||
reject = True
|
|
||||||
else:
|
|
||||||
with self._connections_lock:
|
|
||||||
self._connections.add(connection)
|
|
||||||
reject = False
|
|
||||||
if reject:
|
|
||||||
self._shutdown_and_close_connection(connection)
|
|
||||||
raise OSError("server is closing")
|
|
||||||
return connection, client_address
|
|
||||||
|
|
||||||
def unregister_connection(self, connection: socket.socket) -> None:
|
|
||||||
with self._connections_lock:
|
|
||||||
self._connections.discard(connection)
|
|
||||||
|
|
||||||
def _shutdown_and_close_connection(self, connection: socket.socket) -> None:
|
|
||||||
try:
|
|
||||||
connection.shutdown(socket.SHUT_RDWR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
connection.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self.unregister_connection(connection)
|
|
||||||
|
|
||||||
def _close_accepted_connections(self) -> None:
|
|
||||||
with self._connections_lock:
|
|
||||||
connections = tuple(self._connections)
|
|
||||||
for connection in connections:
|
|
||||||
self._shutdown_and_close_connection(connection)
|
|
||||||
|
|
||||||
def process_request(
|
|
||||||
self,
|
|
||||||
request: socket.socket,
|
|
||||||
client_address: tuple[str, int],
|
|
||||||
) -> None:
|
|
||||||
"""Linearize request admission with the transition to closing."""
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
if self._closing.is_set():
|
|
||||||
reject = True
|
|
||||||
else:
|
|
||||||
reject = False
|
|
||||||
try:
|
|
||||||
super().process_request(request, client_address)
|
|
||||||
except Exception:
|
|
||||||
self._shutdown_and_close_connection(request)
|
|
||||||
raise
|
|
||||||
if reject:
|
|
||||||
self._shutdown_and_close_connection(request)
|
|
||||||
|
|
||||||
def shutdown_request(self, request: socket.socket) -> None:
|
|
||||||
try:
|
|
||||||
request.shutdown(socket.SHUT_RDWR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
super().shutdown_request(request)
|
|
||||||
finally:
|
|
||||||
self.unregister_connection(request)
|
|
||||||
|
|
||||||
def call_processor(self, path: str, body: bytes) -> tuple[bool, Any]:
|
|
||||||
"""Start processing atomically with respect to server shutdown."""
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
if self._closing.is_set() or self.processor is None:
|
|
||||||
return False, None
|
|
||||||
return True, self.processor(path, body)
|
|
||||||
|
|
||||||
def _begin_closing(self) -> None:
|
|
||||||
with self._lifecycle_lock:
|
|
||||||
self._closing.set()
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
self._begin_closing()
|
|
||||||
self._close_accepted_connections()
|
|
||||||
super().shutdown()
|
|
||||||
|
|
||||||
def server_close(self) -> None:
|
|
||||||
"""Interrupt accepted sockets before the base class joins handlers."""
|
|
||||||
with self._server_close_lock:
|
|
||||||
self._begin_closing()
|
|
||||||
self._close_accepted_connections()
|
|
||||||
super().server_close()
|
|
||||||
|
|
||||||
def handle_error(
|
|
||||||
self,
|
|
||||||
request: socket.socket,
|
|
||||||
client_address: tuple[str, int],
|
|
||||||
) -> None:
|
|
||||||
"""Close unexpected handler failures without printing request data."""
|
|
||||||
try:
|
|
||||||
request.shutdown(socket.SHUT_RDWR)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _ValidatingHeaderReader:
|
|
||||||
"""Validate raw header lines before the standard library normalizes them."""
|
|
||||||
|
|
||||||
def __init__(self, handler: "IngressRequestHandler", reader: Any) -> None:
|
|
||||||
self._handler = handler
|
|
||||||
self._reader = reader
|
|
||||||
|
|
||||||
def readline(self, limit: int = -1) -> bytes:
|
|
||||||
try:
|
|
||||||
line = self._reader.readline(limit)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
|
|
||||||
self._handler._ensure_network_read_active()
|
|
||||||
self._validate(line)
|
|
||||||
return line
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate(line: bytes) -> None:
|
|
||||||
if line == b"\r\n":
|
|
||||||
return
|
|
||||||
if not line or not line.endswith(b"\r\n"):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
raw = line[:-2]
|
|
||||||
if not raw or raw[:1] in {b" ", b"\t"}:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
separator = raw.find(b":")
|
|
||||||
if separator <= 0:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
name = raw[:separator]
|
|
||||||
value = raw[separator + 1 :]
|
|
||||||
if any(byte not in HEADER_NAME_BYTES for byte in name):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
if any((byte < 0x20 and byte != 0x09) or byte == 0x7F for byte in value):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
if name.lower() == b"content-length":
|
|
||||||
numeric = value[1:] if value.startswith(b" ") else value
|
|
||||||
if not numeric or any(byte < 0x30 or byte > 0x39 for byte in numeric):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
|
|
||||||
|
|
||||||
class IngressRequestHandler(BaseHTTPRequestHandler):
|
|
||||||
"""Bounded JSON transport; application processing is injected later."""
|
|
||||||
|
|
||||||
server: IngressHTTPServer
|
|
||||||
|
|
||||||
def setup(self) -> None:
|
|
||||||
super().setup()
|
|
||||||
self.connection.settimeout(REQUEST_IO_TIMEOUT_SECONDS)
|
|
||||||
self._network_read_lock = threading.Lock()
|
|
||||||
self._network_read_expired = threading.Event()
|
|
||||||
self._network_read_deadline: float | None = None
|
|
||||||
self._network_read_timer: threading.Timer | None = None
|
|
||||||
self._network_read_active = False
|
|
||||||
|
|
||||||
def finish(self) -> None:
|
|
||||||
self._complete_network_read()
|
|
||||||
try:
|
|
||||||
super().finish()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _start_network_read(self) -> None:
|
|
||||||
self._complete_network_read()
|
|
||||||
timer = threading.Timer(
|
|
||||||
REQUEST_NETWORK_DEADLINE_SECONDS,
|
|
||||||
self._expire_network_read,
|
|
||||||
)
|
|
||||||
timer.name = f"aios-ingress-read-deadline-{id(self)}"
|
|
||||||
timer.daemon = True
|
|
||||||
with self._network_read_lock:
|
|
||||||
self._network_read_expired.clear()
|
|
||||||
self._network_read_deadline = (
|
|
||||||
time.monotonic() + REQUEST_NETWORK_DEADLINE_SECONDS
|
|
||||||
)
|
|
||||||
self._network_read_active = True
|
|
||||||
self._network_read_timer = timer
|
|
||||||
timer.start()
|
|
||||||
|
|
||||||
def _expire_network_read(self) -> None:
|
|
||||||
with self._network_read_lock:
|
|
||||||
if not self._network_read_active:
|
|
||||||
return
|
|
||||||
self._network_read_expired.set()
|
|
||||||
self.close_connection = True
|
|
||||||
try:
|
|
||||||
self.connection.shutdown(socket.SHUT_RD)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _complete_network_read(self) -> None:
|
|
||||||
timer: threading.Timer | None
|
|
||||||
with getattr(self, "_network_read_lock", threading.Lock()):
|
|
||||||
self._network_read_active = False
|
|
||||||
self._network_read_deadline = None
|
|
||||||
timer = self._network_read_timer
|
|
||||||
self._network_read_timer = None
|
|
||||||
if timer is not None:
|
|
||||||
timer.cancel()
|
|
||||||
if timer is not threading.current_thread():
|
|
||||||
timer.join(timeout=REQUEST_IO_TIMEOUT_SECONDS)
|
|
||||||
|
|
||||||
def _ensure_network_read_active(self) -> None:
|
|
||||||
if self.server.is_closing:
|
|
||||||
raise RequestRejected(503, "SERVICE_UNAVAILABLE")
|
|
||||||
deadline = self._network_read_deadline
|
|
||||||
if self._network_read_expired.is_set() or (
|
|
||||||
deadline is not None and time.monotonic() >= deadline
|
|
||||||
):
|
|
||||||
self._network_read_expired.set()
|
|
||||||
raise RequestRejected(408, "REQUEST_TIMEOUT")
|
|
||||||
|
|
||||||
def parse_request(self) -> bool:
|
|
||||||
original_reader = self.rfile
|
|
||||||
self.rfile = _ValidatingHeaderReader(self, original_reader) # type: ignore[assignment]
|
|
||||||
try:
|
|
||||||
return super().parse_request()
|
|
||||||
finally:
|
|
||||||
self.rfile = original_reader
|
|
||||||
|
|
||||||
def handle_one_request(self) -> None:
|
|
||||||
self._start_network_read()
|
|
||||||
try:
|
|
||||||
super().handle_one_request()
|
|
||||||
except RequestRejected as rejection:
|
|
||||||
self.close_connection = True
|
|
||||||
self._send_safely(rejection.status, {"status": rejection.payload_status})
|
|
||||||
except TimeoutError:
|
|
||||||
self.close_connection = True
|
|
||||||
self._send_safely(408, {"status": "REQUEST_TIMEOUT"})
|
|
||||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
||||||
self.close_connection = True
|
|
||||||
except Exception:
|
|
||||||
self.close_connection = True
|
|
||||||
self._send_safely(500, {"status": "INTERNAL_ERROR"})
|
|
||||||
finally:
|
|
||||||
self._complete_network_read()
|
|
||||||
|
|
||||||
def send_error(
|
|
||||||
self,
|
|
||||||
code: int,
|
|
||||||
message: str | None = None,
|
|
||||||
explain: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Replace BaseHTTPRequestHandler HTML errors with bounded JSON."""
|
|
||||||
self.close_connection = True
|
|
||||||
if code == 501:
|
|
||||||
self._method_not_allowed()
|
|
||||||
return
|
|
||||||
self._send(int(code), {"status": "HTTP_ERROR"})
|
|
||||||
|
|
||||||
def log_message(self, format: str, *args: object) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
def _send_safely(self, status: int, payload: Mapping[str, Any]) -> None:
|
|
||||||
try:
|
|
||||||
self._send(status, payload)
|
|
||||||
except OSError:
|
|
||||||
self.close_connection = True
|
|
||||||
|
|
||||||
def _path(self) -> str:
|
|
||||||
target = self.path
|
|
||||||
if not target or any(
|
|
||||||
ord(character) < 0x20 or ord(character) == 0x7F for character in target
|
|
||||||
):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
for index, character in enumerate(target):
|
|
||||||
if character == "%" and (
|
|
||||||
index + 2 >= len(target)
|
|
||||||
or any(value not in HEX_DIGITS for value in target[index + 1 : index + 3])
|
|
||||||
):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
try:
|
|
||||||
parts = urlsplit(target)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
|
||||||
if parts.fragment:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
if target.startswith("/"):
|
|
||||||
if parts.scheme or parts.netloc:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
elif parts.scheme in {"http", "https"} and parts.netloc:
|
|
||||||
if parts.hostname is None:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
try:
|
|
||||||
_ = parts.port
|
|
||||||
except ValueError as exc:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
|
||||||
else:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
return parts.path or "/"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _encoded_response(
|
|
||||||
status: object,
|
|
||||||
payload: object,
|
|
||||||
) -> tuple[int, bytes]:
|
|
||||||
if (
|
|
||||||
type(status) is not int
|
|
||||||
or not 200 <= status <= 599
|
|
||||||
or status in {204, 205, 304}
|
|
||||||
or not isinstance(payload, Mapping)
|
|
||||||
):
|
|
||||||
return 500, INTERNAL_ERROR_BODY
|
|
||||||
try:
|
|
||||||
body = json.dumps(
|
|
||||||
dict(payload),
|
|
||||||
ensure_ascii=False,
|
|
||||||
separators=(",", ":"),
|
|
||||||
sort_keys=True,
|
|
||||||
allow_nan=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
except Exception:
|
|
||||||
return 500, INTERNAL_ERROR_BODY
|
|
||||||
if len(body) > MAX_RESPONSE_BYTES:
|
|
||||||
return 500, INTERNAL_ERROR_BODY
|
|
||||||
return status, body
|
|
||||||
|
|
||||||
def _send(self, status: int, payload: Mapping[str, Any]) -> None:
|
|
||||||
self._complete_network_read()
|
|
||||||
status, body = self._encoded_response(status, payload)
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.end_headers()
|
|
||||||
if getattr(self, "command", None) != "HEAD":
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def _method_not_allowed(self) -> None:
|
|
||||||
self._send(405, {"status": "METHOD_NOT_ALLOWED"})
|
|
||||||
|
|
||||||
def _reject(self, status: int, payload_status: str) -> None:
|
|
||||||
self.close_connection = True
|
|
||||||
self._send(status, {"status": payload_status})
|
|
||||||
|
|
||||||
def _content_length(self) -> int:
|
|
||||||
if self.headers.get_all("Transfer-Encoding", []):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
values = self.headers.get_all("Content-Length", [])
|
|
||||||
if len(values) > 1:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
if not values:
|
|
||||||
return 0
|
|
||||||
raw_length = values[0]
|
|
||||||
if (
|
|
||||||
not raw_length
|
|
||||||
or not raw_length.isascii()
|
|
||||||
or not raw_length.isdigit()
|
|
||||||
):
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
try:
|
|
||||||
length = int(raw_length)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST") from exc
|
|
||||||
if length > MAX_REQUEST_BYTES:
|
|
||||||
raise RequestRejected(413, "REQUEST_TOO_LARGE")
|
|
||||||
return length
|
|
||||||
|
|
||||||
def _read_exact_body(self, length: int) -> bytes:
|
|
||||||
try:
|
|
||||||
body = self.rfile.read(length)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise RequestRejected(408, "REQUEST_TIMEOUT") from exc
|
|
||||||
self._ensure_network_read_active()
|
|
||||||
if len(body) != length:
|
|
||||||
raise RequestRejected(400, "BAD_REQUEST")
|
|
||||||
return body
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
path = self._path()
|
|
||||||
if path == "/health/live":
|
|
||||||
self._send(200, {"status": "LIVE"})
|
|
||||||
elif path == "/health/ready":
|
|
||||||
if self.server.processor is None:
|
|
||||||
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
|
||||||
else:
|
|
||||||
self._send(200, {"status": "READY"})
|
|
||||||
elif path in MUTATION_ROUTES:
|
|
||||||
self._method_not_allowed()
|
|
||||||
else:
|
|
||||||
self._send(404, {"status": "NOT_FOUND"})
|
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
path = self._path()
|
|
||||||
if path not in MUTATION_ROUTES:
|
|
||||||
if path in {"/health/live", "/health/ready"}:
|
|
||||||
self._method_not_allowed()
|
|
||||||
else:
|
|
||||||
self._send(404, {"status": "NOT_FOUND"})
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
length = self._content_length()
|
|
||||||
except RequestRejected as rejection:
|
|
||||||
self._reject(rejection.status, rejection.payload_status)
|
|
||||||
return
|
|
||||||
processor = self.server.processor
|
|
||||||
if processor is None:
|
|
||||||
self._send(503, {"status": "SERVICE_NOT_CONFIGURED"})
|
|
||||||
return
|
|
||||||
if self.server.is_closing:
|
|
||||||
self._reject(503, "SERVICE_UNAVAILABLE")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
body = self._read_exact_body(length)
|
|
||||||
except RequestRejected as rejection:
|
|
||||||
self._reject(rejection.status, rejection.payload_status)
|
|
||||||
return
|
|
||||||
self._complete_network_read()
|
|
||||||
try:
|
|
||||||
started, result = self.server.call_processor(path, body)
|
|
||||||
if not started:
|
|
||||||
self._reject(503, "SERVICE_UNAVAILABLE")
|
|
||||||
return
|
|
||||||
status, payload = result
|
|
||||||
except Exception:
|
|
||||||
self._send(500, {"status": "INTERNAL_ERROR"})
|
|
||||||
return
|
|
||||||
self._send(status, payload)
|
|
||||||
|
|
||||||
do_DELETE = _method_not_allowed
|
|
||||||
do_HEAD = _method_not_allowed
|
|
||||||
do_OPTIONS = _method_not_allowed
|
|
||||||
do_PATCH = _method_not_allowed
|
|
||||||
do_PUT = _method_not_allowed
|
|
||||||
|
|
||||||
|
|
||||||
def build_server(
|
|
||||||
host: str = "127.0.0.1",
|
|
||||||
port: int = 18792,
|
|
||||||
*,
|
|
||||||
processor: Processor | None = None,
|
|
||||||
) -> IngressHTTPServer:
|
|
||||||
"""Build a loopback-only HTTP server without starting it."""
|
|
||||||
try:
|
|
||||||
address = ipaddress.ip_address(host)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("host must be a loopback IP address") from exc
|
|
||||||
if address.version != 4 or not address.is_loopback:
|
|
||||||
raise ValueError("host must be an IPv4 loopback address")
|
|
||||||
return IngressHTTPServer((host, port), IngressRequestHandler, processor)
|
|
||||||
|
|
||||||
|
|
||||||
def serve(host: str = "127.0.0.1", port: int = 18792) -> None:
|
|
||||||
"""Run the unconfigured loopback shell until interrupted."""
|
|
||||||
server = build_server(host, port)
|
|
||||||
try:
|
|
||||||
server.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
server.server_close()
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Tenant-scoped memory namespace and access policy for AIOS V2R1."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from .tenant_context import ScopeError, ValidationError, require_ident
|
|
||||||
|
|
||||||
MemoryKind = Literal["session", "profile", "cell", "tenant", "shared_artifact"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MemoryScope:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
profile_id: str
|
|
||||||
session_id: str | None
|
|
||||||
kind: MemoryKind
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for name in ("cell_id", "profile_id"):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
if self.session_id is not None:
|
|
||||||
require_ident("session_id", self.session_id)
|
|
||||||
if self.kind not in {"session", "profile", "cell", "tenant", "shared_artifact"}:
|
|
||||||
raise ValidationError("invalid memory kind")
|
|
||||||
if self.kind == "session" and self.session_id is None:
|
|
||||||
raise ValidationError("session memory requires session_id")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def namespace(self) -> str:
|
|
||||||
parts = ["tenant", self.tenant_id, "cell", self.cell_id]
|
|
||||||
if self.kind in {"profile", "session"}:
|
|
||||||
parts.extend(["profile", self.profile_id])
|
|
||||||
if self.kind == "session":
|
|
||||||
parts.extend(["session", str(self.session_id)])
|
|
||||||
parts.extend(["memory", self.kind])
|
|
||||||
return str(PurePosixPath(*parts))
|
|
||||||
|
|
||||||
def assert_access(self, requested: "MemoryScope", *, write: bool = False) -> None:
|
|
||||||
if self.tenant_id != requested.tenant_id:
|
|
||||||
raise ScopeError("cross-tenant memory access denied")
|
|
||||||
if self.cell_id != requested.cell_id:
|
|
||||||
raise ScopeError("cross-cell memory access denied")
|
|
||||||
if requested.kind == "shared_artifact":
|
|
||||||
if write:
|
|
||||||
raise ScopeError("shared artifacts are immutable")
|
|
||||||
return
|
|
||||||
if self.kind != requested.kind:
|
|
||||||
raise ScopeError("cross-kind memory access denied")
|
|
||||||
if self.profile_id != requested.profile_id and requested.kind in {"profile", "session"}:
|
|
||||||
raise ScopeError("cross-profile memory access denied")
|
|
||||||
if requested.kind == "session" and self.session_id != requested.session_id:
|
|
||||||
raise ScopeError("cross-session memory access denied")
|
|
||||||
|
|
||||||
|
|
||||||
def qualify_memory_key(scope: MemoryScope, key: str) -> str:
|
|
||||||
normalized = require_ident("memory_key", key)
|
|
||||||
if normalized != key:
|
|
||||||
raise ValidationError("memory_key must be canonical")
|
|
||||||
return f"{scope.namespace}/{normalized}"
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
"""Tenant-scoped append-only message ledger and exact quote resolver."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
from typing import TypeVar
|
|
||||||
|
|
||||||
from .tenant_context import (
|
|
||||||
CanonicalMessage, ConflictError, ScopeError, ValidationError,
|
|
||||||
isoformat, normalize_message_id, utc_now,
|
|
||||||
)
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class LedgerRecord:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
peer_id: str
|
|
||||||
direction: str
|
|
||||||
message_id: str
|
|
||||||
event_id: str
|
|
||||||
body: str
|
|
||||||
occurred_at: str
|
|
||||||
expires_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class MessageLedger:
|
|
||||||
"""Exact-ID ledger with no fuzzy, prefix, or time-nearest fallback."""
|
|
||||||
|
|
||||||
def __init__(self, database: str | Path, *, ttl_days: int = 7) -> None:
|
|
||||||
if not 1 <= ttl_days <= 365:
|
|
||||||
raise ValidationError("ttl_days must be between 1 and 365")
|
|
||||||
self.database = Path(database)
|
|
||||||
self.ttl_days = ttl_days
|
|
||||||
self.database.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._initialize()
|
|
||||||
try:
|
|
||||||
os.chmod(self.database, 0o600)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
db = sqlite3.connect(str(self.database), timeout=30)
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
db.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return db
|
|
||||||
|
|
||||||
def _storage(self, operation: Callable[[], T]) -> T:
|
|
||||||
failure: ConflictError | None = None
|
|
||||||
try:
|
|
||||||
return operation()
|
|
||||||
except sqlite3.Error:
|
|
||||||
failure = ConflictError("ledger storage unavailable")
|
|
||||||
# Raise after the driver exception handler, keeping SQLite details out of
|
|
||||||
# both the public exception and its implicit context.
|
|
||||||
raise failure
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
|
||||||
def initialize() -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_ledger (
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
channel_account_id TEXT NOT NULL,
|
|
||||||
peer_id TEXT NOT NULL,
|
|
||||||
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound','internal')),
|
|
||||||
message_id TEXT NOT NULL,
|
|
||||||
event_id TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
occurred_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(
|
|
||||||
tenant_id, cell_id, channel, channel_account_id,
|
|
||||||
peer_id, direction, message_id
|
|
||||||
),
|
|
||||||
UNIQUE(tenant_id, cell_id, event_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_reply_lookup ON message_ledger(
|
|
||||||
tenant_id, cell_id, channel, channel_account_id,
|
|
||||||
peer_id, message_id, expires_at
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self._storage(initialize)
|
|
||||||
|
|
||||||
def append(self, message: CanonicalMessage) -> LedgerRecord:
|
|
||||||
message_id = normalize_message_id(message.message_id)
|
|
||||||
now = utc_now()
|
|
||||||
expires = isoformat(now + timedelta(days=self.ttl_days))
|
|
||||||
key = (
|
|
||||||
message.tenant_id, message.cell_id, message.channel,
|
|
||||||
message.channel_account_id, message.peer_id, message.direction, message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def write() -> LedgerRecord:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT event_id, body, occurred_at, expires_at FROM message_ledger "
|
|
||||||
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
|
||||||
"AND peer_id=? AND direction=? AND message_id=?", key,
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
if existing["body"] != message.body:
|
|
||||||
db.rollback()
|
|
||||||
raise ConflictError("message ID collision with different content")
|
|
||||||
db.commit()
|
|
||||||
return LedgerRecord(
|
|
||||||
*key, existing["event_id"], existing["body"],
|
|
||||||
existing["occurred_at"], existing["expires_at"],
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO message_ledger VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(*key, message.event_id, message.body, message.occurred_at,
|
|
||||||
expires, isoformat(now)),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return LedgerRecord(
|
|
||||||
*key, message.event_id, message.body, message.occurred_at, expires
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._storage(write)
|
|
||||||
|
|
||||||
def remember(self, message: CanonicalMessage) -> LedgerRecord:
|
|
||||||
"""Backward-compatible API wrapper outside the runtime write path."""
|
|
||||||
return self.append(message)
|
|
||||||
|
|
||||||
def matches(self, message: CanonicalMessage) -> bool:
|
|
||||||
message_id = normalize_message_id(message.message_id)
|
|
||||||
|
|
||||||
def query() -> bool:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT event_id, body FROM message_ledger WHERE tenant_id=? AND "
|
|
||||||
"cell_id=? AND channel=? AND channel_account_id=? AND peer_id=? "
|
|
||||||
"AND direction=? AND message_id=?",
|
|
||||||
(
|
|
||||||
message.tenant_id, message.cell_id, message.channel,
|
|
||||||
message.channel_account_id, message.peer_id, message.direction,
|
|
||||||
message_id,
|
|
||||||
),
|
|
||||||
).fetchone()
|
|
||||||
return bool(
|
|
||||||
row and row["event_id"] == message.event_id
|
|
||||||
and row["body"] == message.body
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._storage(query)
|
|
||||||
|
|
||||||
def remove(self, record: LedgerRecord) -> None:
|
|
||||||
def delete() -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.execute(
|
|
||||||
"DELETE FROM message_ledger WHERE tenant_id=? AND cell_id=? AND "
|
|
||||||
"channel=? AND channel_account_id=? AND peer_id=? AND direction=? "
|
|
||||||
"AND message_id=? AND event_id=?",
|
|
||||||
(
|
|
||||||
record.tenant_id, record.cell_id, record.channel,
|
|
||||||
record.channel_account_id, record.peer_id, record.direction,
|
|
||||||
record.message_id, record.event_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self._storage(delete)
|
|
||||||
|
|
||||||
def resolve_quote(self, message: CanonicalMessage) -> str:
|
|
||||||
if message.quoted_body is not None:
|
|
||||||
return message.quoted_body
|
|
||||||
if message.reply_to_message_id is None:
|
|
||||||
raise ScopeError("quoted_text_unavailable")
|
|
||||||
reply_id = normalize_message_id(
|
|
||||||
message.reply_to_message_id, field_name="reply_to_message_id"
|
|
||||||
)
|
|
||||||
|
|
||||||
def query() -> list[sqlite3.Row]:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
return db.execute(
|
|
||||||
"SELECT direction, body, event_id FROM message_ledger "
|
|
||||||
"WHERE tenant_id=? AND cell_id=? AND channel=? AND channel_account_id=? "
|
|
||||||
"AND peer_id=? AND message_id=? AND expires_at>?",
|
|
||||||
(
|
|
||||||
message.tenant_id, message.cell_id, message.channel,
|
|
||||||
message.channel_account_id, message.peer_id, reply_id, isoformat(),
|
|
||||||
),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
rows = self._storage(query)
|
|
||||||
if len(rows) == 1:
|
|
||||||
return str(rows[0]["body"])
|
|
||||||
if len(rows) > 1:
|
|
||||||
raise ConflictError("ambiguous reply ID across message directions")
|
|
||||||
raise ScopeError("quoted_text_unavailable")
|
|
||||||
|
|
||||||
def purge_expired(self) -> int:
|
|
||||||
def purge() -> int:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
cursor = db.execute(
|
|
||||||
"DELETE FROM message_ledger WHERE expires_at<=?", (isoformat(),)
|
|
||||||
)
|
|
||||||
return cursor.rowcount
|
|
||||||
return self._storage(purge)
|
|
||||||
|
|
||||||
def integrity_check(self) -> str:
|
|
||||||
def check() -> str:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
return str(db.execute("PRAGMA integrity_check").fetchone()[0])
|
|
||||||
return self._storage(check)
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
def query() -> int:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
return int(db.execute("SELECT count(*) FROM message_ledger").fetchone()[0])
|
|
||||||
return self._storage(query)
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
"""Authoritative tenant registry and fail-closed message router."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Iterator
|
|
||||||
|
|
||||||
from .tenant_context import (
|
|
||||||
CanonicalMessage,
|
|
||||||
ConflictError,
|
|
||||||
ScopeError,
|
|
||||||
TenantContext,
|
|
||||||
ValidationError,
|
|
||||||
isoformat,
|
|
||||||
parse_time,
|
|
||||||
require_ident,
|
|
||||||
utc_now,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Route:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
principal_id: str
|
|
||||||
session_id: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class TenantRouter:
|
|
||||||
"""SQLite-backed routing registry.
|
|
||||||
|
|
||||||
Channel accounts are authoritative tenant ownership boundaries. Principal and
|
|
||||||
session bindings may narrow a route, but can never move traffic to another
|
|
||||||
tenant or Cell.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, database: str | Path, *, clock: Callable[[], datetime] = utc_now
|
|
||||||
) -> None:
|
|
||||||
self.database = str(database)
|
|
||||||
self._clock = clock
|
|
||||||
self._initialize()
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
connection = sqlite3.connect(self.database, timeout=30, isolation_level=None)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
|
||||||
connection.execute("PRAGMA journal_mode=WAL")
|
|
||||||
connection.execute("PRAGMA synchronous=FULL")
|
|
||||||
connection.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return connection
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _transaction(self) -> Iterator[sqlite3.Connection]:
|
|
||||||
connection = self._connect()
|
|
||||||
try:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
yield connection
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
except Exception:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
db.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
|
||||||
tenant_id TEXT PRIMARY KEY,
|
|
||||||
cell_id TEXT NOT NULL UNIQUE,
|
|
||||||
status TEXT NOT NULL CHECK(status IN ('active','suspended','retired')),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_routes (
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
channel_account_id TEXT NOT NULL,
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(channel, channel_account_id),
|
|
||||||
FOREIGN KEY(tenant_id) REFERENCES tenants(tenant_id)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS principal_routes (
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
channel_account_id TEXT NOT NULL,
|
|
||||||
principal_id TEXT NOT NULL,
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(channel, channel_account_id, principal_id),
|
|
||||||
FOREIGN KEY(channel, channel_account_id)
|
|
||||||
REFERENCES channel_routes(channel, channel_account_id)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS session_bindings (
|
|
||||||
tenant_id TEXT NOT NULL,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
cell_id TEXT NOT NULL,
|
|
||||||
principal_id TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(tenant_id, session_id),
|
|
||||||
FOREIGN KEY(tenant_id) REFERENCES tenants(tenant_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_expiry
|
|
||||||
ON session_bindings(expires_at);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_tenant(self, tenant_id: str, cell_id: str) -> None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
|
||||||
now = isoformat(self._clock())
|
|
||||||
with self._transaction() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
if row["cell_id"] != cell_id:
|
|
||||||
raise ConflictError("tenant already bound to another cell")
|
|
||||||
db.execute(
|
|
||||||
"UPDATE tenants SET status='active', updated_at=? WHERE tenant_id=?",
|
|
||||||
(now, tenant_id),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
conflict = db.execute(
|
|
||||||
"SELECT tenant_id FROM tenants WHERE cell_id=?", (cell_id,)
|
|
||||||
).fetchone()
|
|
||||||
if conflict:
|
|
||||||
raise ConflictError("cell already belongs to another tenant")
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO tenants VALUES(?,?,?,?,?)",
|
|
||||||
(tenant_id, cell_id, "active", now, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_tenant_status(self, tenant_id: str, status: str) -> None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
if status not in {"active", "suspended", "retired"}:
|
|
||||||
raise ValidationError("invalid tenant status")
|
|
||||||
with self._transaction() as db:
|
|
||||||
cursor = db.execute(
|
|
||||||
"UPDATE tenants SET status=?, updated_at=? WHERE tenant_id=?",
|
|
||||||
(status, isoformat(self._clock()), tenant_id),
|
|
||||||
)
|
|
||||||
if cursor.rowcount != 1:
|
|
||||||
raise ValidationError("unknown tenant")
|
|
||||||
|
|
||||||
def bind_channel(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
channel: str,
|
|
||||||
channel_account_id: str,
|
|
||||||
) -> None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
channel = require_ident("channel", channel)
|
|
||||||
channel_account_id = require_ident("channel_account_id", channel_account_id)
|
|
||||||
with self._transaction() as db:
|
|
||||||
tenant = db.execute(
|
|
||||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
|
||||||
).fetchone()
|
|
||||||
if not tenant or tenant["status"] != "active":
|
|
||||||
raise ValidationError("tenant is not active")
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT tenant_id, cell_id FROM channel_routes "
|
|
||||||
"WHERE channel=? AND channel_account_id=?",
|
|
||||||
(channel, channel_account_id),
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
if existing["tenant_id"] != tenant_id or existing["cell_id"] != tenant["cell_id"]:
|
|
||||||
raise ConflictError("channel account already belongs to another tenant")
|
|
||||||
return
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO channel_routes VALUES(?,?,?,?,?)",
|
|
||||||
(channel, channel_account_id, tenant_id, tenant["cell_id"], isoformat(self._clock())),
|
|
||||||
)
|
|
||||||
|
|
||||||
def bind_principal(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
channel: str,
|
|
||||||
channel_account_id: str,
|
|
||||||
principal_id: str,
|
|
||||||
) -> None:
|
|
||||||
principal_id = require_ident("principal_id", principal_id)
|
|
||||||
route = self.resolve_channel(channel, channel_account_id)
|
|
||||||
if route.tenant_id != tenant_id:
|
|
||||||
raise ScopeError("principal cannot cross tenant boundary")
|
|
||||||
with self._transaction() as db:
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT tenant_id, cell_id FROM principal_routes WHERE "
|
|
||||||
"channel=? AND channel_account_id=? AND principal_id=?",
|
|
||||||
(channel, channel_account_id, principal_id),
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
if existing["tenant_id"] != tenant_id or existing["cell_id"] != route.cell_id:
|
|
||||||
raise ConflictError("principal route conflict")
|
|
||||||
return
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO principal_routes VALUES(?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
channel,
|
|
||||||
channel_account_id,
|
|
||||||
principal_id,
|
|
||||||
tenant_id,
|
|
||||||
route.cell_id,
|
|
||||||
isoformat(self._clock()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def bind_session(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
session_id: str,
|
|
||||||
cell_id: str,
|
|
||||||
principal_id: str,
|
|
||||||
ttl_seconds: int = 86400,
|
|
||||||
) -> None:
|
|
||||||
tenant_id = require_ident("tenant_id", tenant_id, tenant=True)
|
|
||||||
session_id = require_ident("session_id", session_id)
|
|
||||||
cell_id = require_ident("cell_id", cell_id)
|
|
||||||
principal_id = require_ident("principal_id", principal_id)
|
|
||||||
if not 60 <= ttl_seconds <= 31 * 86400:
|
|
||||||
raise ValidationError("invalid session TTL")
|
|
||||||
now = self._clock()
|
|
||||||
expires = isoformat(now + timedelta(seconds=ttl_seconds))
|
|
||||||
with self._transaction() as db:
|
|
||||||
tenant = db.execute(
|
|
||||||
"SELECT cell_id, status FROM tenants WHERE tenant_id=?", (tenant_id,)
|
|
||||||
).fetchone()
|
|
||||||
if not tenant or tenant["status"] != "active" or tenant["cell_id"] != cell_id:
|
|
||||||
raise ScopeError("session cell does not match tenant")
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT cell_id, principal_id FROM session_bindings "
|
|
||||||
"WHERE tenant_id=? AND session_id=?",
|
|
||||||
(tenant_id, session_id),
|
|
||||||
).fetchone()
|
|
||||||
if existing and (
|
|
||||||
existing["cell_id"] != cell_id or existing["principal_id"] != principal_id
|
|
||||||
):
|
|
||||||
raise ConflictError("session is already bound to another principal or cell")
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO session_bindings VALUES(?,?,?,?,?,?) "
|
|
||||||
"ON CONFLICT(tenant_id,session_id) DO UPDATE SET expires_at=excluded.expires_at",
|
|
||||||
(tenant_id, session_id, cell_id, principal_id, expires, isoformat(now)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_channel(self, channel: str, channel_account_id: str) -> Route:
|
|
||||||
channel = require_ident("channel", channel)
|
|
||||||
channel_account_id = require_ident("channel_account_id", channel_account_id)
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT r.tenant_id, r.cell_id, t.status FROM channel_routes r "
|
|
||||||
"JOIN tenants t ON t.tenant_id=r.tenant_id "
|
|
||||||
"WHERE r.channel=? AND r.channel_account_id=?",
|
|
||||||
(channel, channel_account_id),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise ScopeError("unknown channel account")
|
|
||||||
if row["status"] != "active":
|
|
||||||
raise ScopeError("tenant is not active")
|
|
||||||
return Route(row["tenant_id"], row["cell_id"], channel, channel_account_id, "", None)
|
|
||||||
|
|
||||||
def resolve(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
channel: str,
|
|
||||||
channel_account_id: str,
|
|
||||||
principal_id: str,
|
|
||||||
session_id: str | None = None,
|
|
||||||
) -> Route:
|
|
||||||
principal_id = require_ident("principal_id", principal_id)
|
|
||||||
base = self.resolve_channel(channel, channel_account_id)
|
|
||||||
with closing(self._connect()) as connection, connection as db:
|
|
||||||
principal = db.execute(
|
|
||||||
"SELECT tenant_id, cell_id FROM principal_routes WHERE "
|
|
||||||
"channel=? AND channel_account_id=? AND principal_id=?",
|
|
||||||
(channel, channel_account_id, principal_id),
|
|
||||||
).fetchone()
|
|
||||||
if principal and (
|
|
||||||
principal["tenant_id"] != base.tenant_id or principal["cell_id"] != base.cell_id
|
|
||||||
):
|
|
||||||
raise ScopeError("principal route violates channel ownership")
|
|
||||||
if session_id is not None:
|
|
||||||
session_id = require_ident("session_id", session_id)
|
|
||||||
session = db.execute(
|
|
||||||
"SELECT cell_id, principal_id, expires_at FROM session_bindings "
|
|
||||||
"WHERE tenant_id=? AND session_id=?",
|
|
||||||
(base.tenant_id, session_id),
|
|
||||||
).fetchone()
|
|
||||||
if not session:
|
|
||||||
raise ScopeError("unknown session")
|
|
||||||
if parse_time(session["expires_at"]) <= self._clock():
|
|
||||||
raise ScopeError("session expired")
|
|
||||||
if session["cell_id"] != base.cell_id or session["principal_id"] != principal_id:
|
|
||||||
raise ScopeError("session scope mismatch")
|
|
||||||
return Route(
|
|
||||||
base.tenant_id,
|
|
||||||
base.cell_id,
|
|
||||||
channel,
|
|
||||||
channel_account_id,
|
|
||||||
principal_id,
|
|
||||||
session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def assert_message_route(self, message: CanonicalMessage) -> Route:
|
|
||||||
route = self.resolve(
|
|
||||||
channel=message.channel,
|
|
||||||
channel_account_id=message.channel_account_id,
|
|
||||||
principal_id=message.principal_id,
|
|
||||||
session_id=message.session_id,
|
|
||||||
)
|
|
||||||
if message.tenant_id != route.tenant_id or message.cell_id != route.cell_id:
|
|
||||||
raise ScopeError("message claims do not match authoritative route")
|
|
||||||
return route
|
|
||||||
|
|
||||||
def assert_context_route(self, context: TenantContext) -> Route:
|
|
||||||
route = self.resolve(
|
|
||||||
channel=context.channel,
|
|
||||||
channel_account_id=context.channel_account_id,
|
|
||||||
principal_id=context.principal_id,
|
|
||||||
session_id=context.session_id,
|
|
||||||
)
|
|
||||||
if context.tenant_id != route.tenant_id or context.cell_id != route.cell_id:
|
|
||||||
raise ScopeError("context claims do not match authoritative route")
|
|
||||||
return route
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""V2R1 runtime composition root for authenticated channel ingress."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Mapping
|
|
||||||
|
|
||||||
from .channel_adapter import ChannelAdapter, IngressResult
|
|
||||||
from .identity import (
|
|
||||||
AuthenticationError,
|
|
||||||
IngressAuthenticator,
|
|
||||||
RuntimeIdentity,
|
|
||||||
RuntimeIdentityResolver,
|
|
||||||
)
|
|
||||||
from .message_ledger import MessageLedger
|
|
||||||
from .router import TenantRouter
|
|
||||||
from .session_pool import SessionPool
|
|
||||||
from .tenant_context import ConflictError, ScopeError, TenantContext, ValidationError, utc_now
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RuntimeIngressResponse:
|
|
||||||
"""Sanitised boundary response; no exception text crosses external ingress."""
|
|
||||||
|
|
||||||
status: str
|
|
||||||
result: IngressResult | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeWiring:
|
|
||||||
"""Own the only external-to-runtime identity binding and message path.
|
|
||||||
|
|
||||||
The request accepted by :meth:`ingest` is an HMAC-authenticated envelope;
|
|
||||||
tenant, cell, channel, account, session, principal, and message claims are
|
|
||||||
signed together. The envelope is then resolved against ``TenantRouter``.
|
|
||||||
Only that resolved identity can mint the short-lived internal context passed
|
|
||||||
to ``ChannelAdapter``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
router: TenantRouter,
|
|
||||||
ledger: MessageLedger,
|
|
||||||
session_pool: SessionPool,
|
|
||||||
channel_adapter: ChannelAdapter,
|
|
||||||
identity_resolver: RuntimeIdentityResolver,
|
|
||||||
ingress_authenticator: IngressAuthenticator,
|
|
||||||
internal_signing_key: bytes,
|
|
||||||
clock: Callable[[], datetime],
|
|
||||||
) -> None:
|
|
||||||
self.router = router
|
|
||||||
self.ledger = ledger
|
|
||||||
self.session_pool = session_pool
|
|
||||||
self.channel_adapter = channel_adapter
|
|
||||||
self.identity_resolver = identity_resolver
|
|
||||||
self._ingress_authenticator = ingress_authenticator
|
|
||||||
self._internal_signing_key = internal_signing_key
|
|
||||||
self._clock = clock
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(
|
|
||||||
cls,
|
|
||||||
root: str | Path,
|
|
||||||
*,
|
|
||||||
signing_key: bytes,
|
|
||||||
ingress_hmac_key: bytes | None = None,
|
|
||||||
clock: Callable[[], datetime] = utc_now,
|
|
||||||
) -> "RuntimeWiring":
|
|
||||||
if not isinstance(signing_key, bytes) or len(signing_key) < 32:
|
|
||||||
raise ValidationError("internal signing key must be at least 32 bytes")
|
|
||||||
# Kept optional for one controlled migration release; the external
|
|
||||||
# request path still cannot supply a TenantContext token.
|
|
||||||
hmac_key = ingress_hmac_key if ingress_hmac_key is not None else signing_key
|
|
||||||
root_path = Path(root)
|
|
||||||
root_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
router = TenantRouter(root_path / "router.sqlite3", clock=clock)
|
|
||||||
ledger = MessageLedger(root_path / "message_ledger.sqlite3")
|
|
||||||
session_pool = SessionPool(router)
|
|
||||||
adapter = ChannelAdapter(
|
|
||||||
router=router,
|
|
||||||
ledger=ledger,
|
|
||||||
session_pool=session_pool,
|
|
||||||
signing_key=signing_key,
|
|
||||||
clock=clock,
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
router=router,
|
|
||||||
ledger=ledger,
|
|
||||||
session_pool=session_pool,
|
|
||||||
channel_adapter=adapter,
|
|
||||||
identity_resolver=RuntimeIdentityResolver(router),
|
|
||||||
ingress_authenticator=IngressAuthenticator(hmac_key),
|
|
||||||
internal_signing_key=signing_key,
|
|
||||||
clock=clock,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _issue_internal_context(self, identity: RuntimeIdentity) -> TenantContext:
|
|
||||||
"""Mint internal scope only after all claims matched the router route."""
|
|
||||||
return TenantContext.issue(
|
|
||||||
lifetime_seconds=300,
|
|
||||||
now=self._clock(),
|
|
||||||
tenant_id=identity.tenant_id,
|
|
||||||
cell_id=identity.cell_id,
|
|
||||||
principal_id=identity.principal_id,
|
|
||||||
principal_type="authenticated_user",
|
|
||||||
session_id=identity.session_id,
|
|
||||||
task_id=None,
|
|
||||||
channel=identity.channel,
|
|
||||||
channel_account_id=identity.channel_account_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def ingest(self, *, request: Mapping[str, Any]) -> RuntimeIngressResponse:
|
|
||||||
"""Authenticate, resolve, issue internal context, then append and lease.
|
|
||||||
|
|
||||||
The only successful route is ``IngressAuthenticator`` ->
|
|
||||||
``RuntimeIdentityResolver`` -> ``ChannelAdapter`` ->
|
|
||||||
``MessageLedger.append`` + ``SessionPool``. The legacy caller-supplied
|
|
||||||
TenantContext token interface is intentionally not accepted.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
ingress = self._ingress_authenticator.authenticate(request)
|
|
||||||
identity = self.identity_resolver.resolve(ingress.principal, ingress.claims)
|
|
||||||
context = self._issue_internal_context(identity)
|
|
||||||
result = self.channel_adapter.ingest(
|
|
||||||
token=context.sign(self._internal_signing_key), message=ingress.message
|
|
||||||
)
|
|
||||||
return RuntimeIngressResponse(status="ACCEPTED", result=result)
|
|
||||||
except (AuthenticationError, ScopeError):
|
|
||||||
return RuntimeIngressResponse(status="AUTH_FAILED")
|
|
||||||
except ValidationError:
|
|
||||||
return RuntimeIngressResponse(status="VALIDATION_FAILED")
|
|
||||||
except ConflictError:
|
|
||||||
return RuntimeIngressResponse(status="CONFLICT")
|
|
||||||
except Exception:
|
|
||||||
return RuntimeIngressResponse(status="INTERNAL_ERROR")
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""Tenant and Cell scoped, value-free secret reference model."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from .tenant_context import ScopeError, ValidationError, require_ident
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SecretRef:
|
|
||||||
secret_id: str
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
kind: Literal["channel", "model", "tool", "runtime"]
|
|
||||||
version: str
|
|
||||||
broker_path: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for name in ("secret_id", "cell_id", "version"):
|
|
||||||
value = getattr(self, name)
|
|
||||||
if require_ident(name, value) != value:
|
|
||||||
raise ValidationError(f"{name} must be canonical")
|
|
||||||
if require_ident("tenant_id", self.tenant_id, tenant=True) != self.tenant_id:
|
|
||||||
raise ValidationError("tenant_id must be canonical")
|
|
||||||
if self.kind not in {"channel", "model", "tool", "runtime"}:
|
|
||||||
raise ValidationError("invalid secret kind")
|
|
||||||
self._validate_broker_path()
|
|
||||||
|
|
||||||
def _validate_broker_path(self) -> None:
|
|
||||||
if not isinstance(self.broker_path, str) or not self.broker_path.startswith("secret://"):
|
|
||||||
raise ValidationError("secret broker path required")
|
|
||||||
suffix = self.broker_path[len("secret://"):]
|
|
||||||
if (
|
|
||||||
not suffix or "?" in suffix or "#" in suffix or chr(92) in suffix
|
|
||||||
or any(part in {"", ".", ".."} for part in suffix.split("/"))
|
|
||||||
):
|
|
||||||
raise ValidationError("invalid secret broker path")
|
|
||||||
for part in suffix.split("/"):
|
|
||||||
if require_ident("secret broker path segment", part) != part:
|
|
||||||
raise ValidationError("invalid secret broker path")
|
|
||||||
|
|
||||||
def can_be_used_by(self, *, tenant_id: str, cell_id: str) -> bool:
|
|
||||||
return self.tenant_id == tenant_id and self.cell_id == cell_id
|
|
||||||
|
|
||||||
def resolve(self, broker: Any) -> str:
|
|
||||||
"""Resolve through one explicit broker boundary without leaking provider errors."""
|
|
||||||
failure: ScopeError | None = None
|
|
||||||
value: object = None
|
|
||||||
try:
|
|
||||||
reader = getattr(broker, "read_secret")
|
|
||||||
if not callable(reader):
|
|
||||||
raise TypeError("invalid broker")
|
|
||||||
value = reader(self)
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise ValueError("invalid broker result")
|
|
||||||
except Exception:
|
|
||||||
failure = ScopeError("secret broker unavailable")
|
|
||||||
if failure is not None:
|
|
||||||
raise failure
|
|
||||||
return value
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Process-local session lease pool backed by TenantRouter authority."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from threading import RLock
|
|
||||||
|
|
||||||
from .router import Route, TenantRouter
|
|
||||||
from .tenant_context import ScopeError, TenantContext
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SessionLease:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
principal_id: str
|
|
||||||
session_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class SessionPool:
|
|
||||||
"""Tracks active leases without creating a second session authority store."""
|
|
||||||
|
|
||||||
def __init__(self, router: TenantRouter) -> None:
|
|
||||||
self._router = router
|
|
||||||
self._leases: dict[tuple[str, str], SessionLease] = {}
|
|
||||||
self._lock = RLock()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _assert_route(context: TenantContext, route: Route) -> None:
|
|
||||||
if (
|
|
||||||
route.tenant_id != context.tenant_id
|
|
||||||
or route.cell_id != context.cell_id
|
|
||||||
or route.principal_id != context.principal_id
|
|
||||||
or route.session_id != context.session_id
|
|
||||||
):
|
|
||||||
raise ScopeError("session lease does not match authoritative route")
|
|
||||||
|
|
||||||
def assert_available(self, context: TenantContext, route: Route) -> None:
|
|
||||||
"""Re-resolve the context from the router before a ledger mutation."""
|
|
||||||
self._assert_route(context, route)
|
|
||||||
self._assert_route(context, self._router.assert_context_route(context))
|
|
||||||
|
|
||||||
def acquire(self, context: TenantContext, route: Route) -> SessionLease:
|
|
||||||
self._assert_route(context, route)
|
|
||||||
lease = SessionLease(
|
|
||||||
tenant_id=context.tenant_id,
|
|
||||||
cell_id=context.cell_id,
|
|
||||||
principal_id=context.principal_id,
|
|
||||||
session_id=context.session_id,
|
|
||||||
)
|
|
||||||
with self._lock:
|
|
||||||
return self._leases.setdefault((lease.tenant_id, lease.session_id), lease)
|
|
||||||
|
|
||||||
def release(self, lease: SessionLease) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._leases.pop((lease.tenant_id, lease.session_id), None)
|
|
||||||
|
|
||||||
def active_count(self) -> int:
|
|
||||||
with self._lock:
|
|
||||||
return len(self._leases)
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
"""Signed tenant context and canonical message contract for AIOS V2R1."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
VERSION = "V2R1C0"
|
|
||||||
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
||||||
TENANT_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
|
|
||||||
IDENT_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._:@-]{0,126}[A-Za-z0-9])?$")
|
|
||||||
|
|
||||||
|
|
||||||
class V2R1Error(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(V2R1Error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ScopeError(V2R1Error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ConflictError(V2R1Error):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def isoformat(value: datetime | None = None) -> str:
|
|
||||||
return (value or utc_now()).astimezone(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_time(value: str) -> datetime:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise ValidationError("timestamp must be a non-empty string")
|
|
||||||
text = value.strip()
|
|
||||||
if text.endswith("Z"):
|
|
||||||
text = text[:-1] + "+00:00"
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(text)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError(f"invalid timestamp: {value!r}") from exc
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
raise ValidationError("timestamp must include timezone")
|
|
||||||
return parsed.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def require_ident(name: str, value: Any, *, tenant: bool = False) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ValidationError(f"{name} must be a string")
|
|
||||||
value = value.strip()
|
|
||||||
pattern = TENANT_RE if tenant else IDENT_RE
|
|
||||||
if not pattern.fullmatch(value):
|
|
||||||
raise ValidationError(f"invalid {name}: {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_message_id(value: Any, *, field_name: str = "message_id") -> str:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
raise ValidationError(f"{field_name} cannot be boolean")
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = value.strip()
|
|
||||||
if not value or len(value) > 256 or any(ord(ch) < 32 for ch in value):
|
|
||||||
raise ValidationError(f"invalid {field_name}")
|
|
||||||
return value
|
|
||||||
if isinstance(value, int):
|
|
||||||
if abs(value) > MAX_SAFE_INTEGER:
|
|
||||||
raise ValidationError(f"{field_name} is an unsafe numeric ID")
|
|
||||||
return str(value)
|
|
||||||
raise ValidationError(f"{field_name} must be a lossless string or safe integer")
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: Any) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _b64(data: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def _unb64(value: str) -> bytes:
|
|
||||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TenantContext:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
principal_id: str
|
|
||||||
principal_type: str
|
|
||||||
session_id: str
|
|
||||||
task_id: str | None
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
issued_at: str
|
|
||||||
expires_at: str
|
|
||||||
nonce: str
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for name in (
|
|
||||||
"cell_id", "principal_id", "principal_type", "session_id",
|
|
||||||
"channel", "channel_account_id", "nonce",
|
|
||||||
):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
if self.task_id is not None:
|
|
||||||
require_ident("task_id", self.task_id)
|
|
||||||
issued = parse_time(self.issued_at)
|
|
||||||
expires = parse_time(self.expires_at)
|
|
||||||
if expires <= issued or expires - issued > timedelta(hours=1):
|
|
||||||
raise ValidationError("invalid tenant context lifetime")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def issue(cls, *, lifetime_seconds: int = 300, now: datetime | None = None, **scope: Any) -> "TenantContext":
|
|
||||||
if not 1 <= lifetime_seconds <= 3600:
|
|
||||||
raise ValidationError("lifetime_seconds must be between 1 and 3600")
|
|
||||||
current = (now or utc_now()).astimezone(timezone.utc)
|
|
||||||
return cls(
|
|
||||||
**scope,
|
|
||||||
issued_at=isoformat(current),
|
|
||||||
expires_at=isoformat(current + timedelta(seconds=lifetime_seconds)),
|
|
||||||
nonce=secrets.token_hex(16),
|
|
||||||
)
|
|
||||||
|
|
||||||
def sign(self, key: bytes) -> str:
|
|
||||||
if len(key) < 32:
|
|
||||||
raise ValidationError("signing key must be at least 32 bytes")
|
|
||||||
payload = _b64(canonical_json(asdict(self)))
|
|
||||||
signature = _b64(hmac.new(key, payload.encode("ascii"), hashlib.sha256).digest())
|
|
||||||
return f"{payload}.{signature}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def verify(
|
|
||||||
cls,
|
|
||||||
token: str,
|
|
||||||
key: bytes,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
expected_tenant_id: str | None = None,
|
|
||||||
expected_cell_id: str | None = None,
|
|
||||||
) -> "TenantContext":
|
|
||||||
if len(key) < 32:
|
|
||||||
raise ValidationError("signing key must be at least 32 bytes")
|
|
||||||
try:
|
|
||||||
payload, encoded_signature = token.split(".", 1)
|
|
||||||
actual = _unb64(encoded_signature)
|
|
||||||
if encoded_signature != _b64(actual):
|
|
||||||
raise ValidationError("non-canonical tenant context signature")
|
|
||||||
data = json.loads(_unb64(payload))
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValidationError("malformed tenant context token") from exc
|
|
||||||
expected = hmac.new(key, payload.encode("ascii"), hashlib.sha256).digest()
|
|
||||||
if not hmac.compare_digest(actual, expected):
|
|
||||||
raise ValidationError("tenant context signature mismatch")
|
|
||||||
context = cls(**data)
|
|
||||||
current = (now or utc_now()).astimezone(timezone.utc)
|
|
||||||
if current < parse_time(context.issued_at) - timedelta(seconds=60):
|
|
||||||
raise ValidationError("tenant context issued in future")
|
|
||||||
if current >= parse_time(context.expires_at):
|
|
||||||
raise ValidationError("tenant context expired")
|
|
||||||
if expected_tenant_id and context.tenant_id != expected_tenant_id:
|
|
||||||
raise ScopeError("tenant mismatch")
|
|
||||||
if expected_cell_id and context.cell_id != expected_cell_id:
|
|
||||||
raise ScopeError("cell mismatch")
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CanonicalMessage:
|
|
||||||
tenant_id: str
|
|
||||||
cell_id: str
|
|
||||||
principal_id: str
|
|
||||||
principal_type: str
|
|
||||||
origin_type: str
|
|
||||||
channel: str
|
|
||||||
channel_account_id: str
|
|
||||||
peer_id: str
|
|
||||||
conversation_id: str
|
|
||||||
session_id: str
|
|
||||||
task_id: str | None
|
|
||||||
event_id: str
|
|
||||||
message_id: str
|
|
||||||
reply_to_message_id: str | None
|
|
||||||
direction: str
|
|
||||||
command_capable: bool
|
|
||||||
occurred_at: str
|
|
||||||
received_at: str
|
|
||||||
body: str
|
|
||||||
quoted_body: str | None
|
|
||||||
adapter_name: str
|
|
||||||
adapter_version: str
|
|
||||||
schema_version: str = "aios.message.v1"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.schema_version != "aios.message.v1":
|
|
||||||
raise ValidationError("unsupported message schema version")
|
|
||||||
require_ident("tenant_id", self.tenant_id, tenant=True)
|
|
||||||
for name in (
|
|
||||||
"cell_id", "principal_id", "principal_type", "origin_type", "channel",
|
|
||||||
"channel_account_id", "peer_id", "conversation_id", "session_id",
|
|
||||||
"event_id", "adapter_name", "adapter_version",
|
|
||||||
):
|
|
||||||
require_ident(name, getattr(self, name))
|
|
||||||
if self.task_id is not None:
|
|
||||||
require_ident("task_id", self.task_id)
|
|
||||||
normalize_message_id(self.message_id)
|
|
||||||
if self.reply_to_message_id is not None:
|
|
||||||
normalize_message_id(self.reply_to_message_id, field_name="reply_to_message_id")
|
|
||||||
if self.direction not in {"inbound", "outbound", "internal"}:
|
|
||||||
raise ValidationError("invalid direction")
|
|
||||||
if not isinstance(self.command_capable, bool):
|
|
||||||
raise ValidationError("command_capable must be boolean")
|
|
||||||
parse_time(self.occurred_at)
|
|
||||||
parse_time(self.received_at)
|
|
||||||
if not isinstance(self.body, str):
|
|
||||||
raise ValidationError("body must be a string")
|
|
||||||
if self.quoted_body is not None and not isinstance(self.quoted_body, str):
|
|
||||||
raise ValidationError("quoted_body must be a string or null")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, data: Mapping[str, Any]) -> "CanonicalMessage":
|
|
||||||
content = data.get("content") or {}
|
|
||||||
adapter = data.get("adapter") or {}
|
|
||||||
if not isinstance(content, Mapping) or not isinstance(adapter, Mapping):
|
|
||||||
raise ValidationError("content and adapter must be objects")
|
|
||||||
values = dict(data)
|
|
||||||
return cls(
|
|
||||||
schema_version=values.get("schema_version", "aios.message.v1"),
|
|
||||||
tenant_id=values["tenant_id"],
|
|
||||||
cell_id=values["cell_id"],
|
|
||||||
principal_id=values["principal_id"],
|
|
||||||
principal_type=values["principal_type"],
|
|
||||||
origin_type=values["origin_type"],
|
|
||||||
channel=values["channel"],
|
|
||||||
channel_account_id=values["channel_account_id"],
|
|
||||||
peer_id=values["peer_id"],
|
|
||||||
conversation_id=values["conversation_id"],
|
|
||||||
session_id=values["session_id"],
|
|
||||||
task_id=values.get("task_id"),
|
|
||||||
event_id=values["event_id"],
|
|
||||||
message_id=normalize_message_id(values["message_id"]),
|
|
||||||
reply_to_message_id=(
|
|
||||||
normalize_message_id(values["reply_to_message_id"], field_name="reply_to_message_id")
|
|
||||||
if values.get("reply_to_message_id") is not None else None
|
|
||||||
),
|
|
||||||
direction=values["direction"],
|
|
||||||
command_capable=values["command_capable"],
|
|
||||||
occurred_at=values["occurred_at"],
|
|
||||||
received_at=values["received_at"],
|
|
||||||
body=content.get("body", ""),
|
|
||||||
quoted_body=content.get("quoted_body"),
|
|
||||||
adapter_name=adapter["name"],
|
|
||||||
adapter_version=adapter["version"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def assert_context(self, context: TenantContext) -> None:
|
|
||||||
fields = (
|
|
||||||
"tenant_id", "cell_id", "principal_id", "session_id", "task_id",
|
|
||||||
"channel", "channel_account_id",
|
|
||||||
)
|
|
||||||
mismatches = [name for name in fields if getattr(self, name) != getattr(context, name)]
|
|
||||||
if mismatches:
|
|
||||||
raise ScopeError("message/context mismatch: " + ", ".join(mismatches))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_authenticated_human_command(self) -> bool:
|
|
||||||
return (
|
|
||||||
self.direction == "inbound"
|
|
||||||
and self.command_capable
|
|
||||||
and self.principal_type == "authenticated_user"
|
|
||||||
and self.origin_type == "channel_user"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PolicyDecision:
|
|
||||||
allowed: bool
|
|
||||||
approval_required: bool
|
|
||||||
code: str
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
READ_ONLY_TOOLS = frozenset({
|
|
||||||
"read", "grep", "glob", "find", "ls", "web_search", "web_fetch",
|
|
||||||
"memory_search", "sessions_list", "sessions_history", "session_status",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_tool_call(tool_name: str, message: CanonicalMessage) -> PolicyDecision:
|
|
||||||
normalized = tool_name.strip().lower() if isinstance(tool_name, str) else ""
|
|
||||||
if not normalized:
|
|
||||||
return PolicyDecision(False, False, "invalid_tool", "tool name missing")
|
|
||||||
if normalized in READ_ONLY_TOOLS:
|
|
||||||
return PolicyDecision(True, False, "read_only", "read-only operation")
|
|
||||||
if not message.is_authenticated_human_command:
|
|
||||||
return PolicyDecision(
|
|
||||||
False, False, "origin_denied",
|
|
||||||
"side effects require an authenticated current human command",
|
|
||||||
)
|
|
||||||
return PolicyDecision(
|
|
||||||
True, True, "approval_required",
|
|
||||||
"authenticated human side effect requires explicit approval",
|
|
||||||
)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "aios-v2r1-runtime"
|
|
||||||
version = "2.1.0rc1"
|
|
||||||
description = "AIOS V2R1 canonical runtime package"
|
|
||||||
requires-python = ">=3.11"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
aios-cell = "aios_v2r1.cli:main"
|
|
||||||
|
|
||||||
[tool.setuptools]
|
|
||||||
package-dir = {"" = "app"}
|
|
||||||
packages = ["aios_v2r1"]
|
|
||||||
+29
-18
@@ -1,7 +1,8 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
# AIOS Agent Command Plane Registry
|
# AIOS Agent Command Plane Registry
|
||||||
# Git is the single source of truth for all agent commands.
|
# Git is the single source of truth for all agent commands.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
created_at: "2026-07-08T15:20:00+08:00"
|
created_at: 2026-07-08T15:20:00+08:00
|
||||||
baseline: V1R3C1
|
baseline: V1R3C1
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
@@ -11,22 +12,6 @@ commands:
|
|||||||
completed_path: "agents/commands/completed/"
|
completed_path: "agents/commands/completed/"
|
||||||
failed_path: "agents/commands/failed/"
|
failed_path: "agents/commands/failed/"
|
||||||
|
|
||||||
directories:
|
|
||||||
commands/active: "New commands pending execution — Git is the only entry point"
|
|
||||||
commands/running: "Commands currently being executed by an agent"
|
|
||||||
commands/completed: "Commands that finished successfully — moved here on success"
|
|
||||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
|
||||||
|
|
||||||
lifecycle:
|
|
||||||
new: active/
|
|
||||||
running: running/
|
|
||||||
success: completed/
|
|
||||||
failure: failed/
|
|
||||||
|
|
||||||
# Command lifecycle state machine:
|
|
||||||
# active/ → locked & moved to running/ → execute → success → completed/
|
|
||||||
# → failure → failed/
|
|
||||||
|
|
||||||
policies:
|
policies:
|
||||||
path: "agents/policies/"
|
path: "agents/policies/"
|
||||||
source: "git"
|
source: "git"
|
||||||
@@ -53,4 +38,30 @@ audit:
|
|||||||
agent_runs: "audit/agent-runs/"
|
agent_runs: "audit/agent-runs/"
|
||||||
version_history: "audit/version-history/"
|
version_history: "audit/version-history/"
|
||||||
command_history: "audit/command-history/"
|
command_history: "audit/command-history/"
|
||||||
interaction_history: "audit/interaction-history/"
|
=======
|
||||||
|
# AIOS Agent Command Registry
|
||||||
|
# Git is the single source of truth for all agent commands.
|
||||||
|
|
||||||
|
registry_version: 1.0.0
|
||||||
|
created_at: "2026-07-08T15:44:00+08:00"
|
||||||
|
baseline: V1R3C1
|
||||||
|
|
||||||
|
directories:
|
||||||
|
commands/active: "New commands pending execution — Git is the only entry point"
|
||||||
|
commands/running: "Commands currently being executed by an agent"
|
||||||
|
commands/completed: "Commands that finished successfully — moved here on success"
|
||||||
|
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||||
|
|
||||||
|
state: agents/state/
|
||||||
|
policies: agents/policies/
|
||||||
|
|
||||||
|
lifecycle:
|
||||||
|
new: active/
|
||||||
|
running: running/
|
||||||
|
success: completed/
|
||||||
|
failure: failed/
|
||||||
|
|
||||||
|
# Command lifecycle state machine:
|
||||||
|
# active/ → locked & moved to running/ → execute → success → completed/
|
||||||
|
# → failure → failed/
|
||||||
|
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Personal Weixin v1 security contract
|
|
||||||
|
|
||||||
Status: normative design contract for R10 B6/B7. It authorizes no runtime or production change.
|
|
||||||
|
|
||||||
## Trust boundaries and ownership
|
|
||||||
|
|
||||||
The browser is untrusted. The Portal authenticates the human user and resolves tenant identity from the server session. The Portal adapter is least-privileged: it may request account-scoped operations but must not store provider credentials, tokens, raw iLink state, or OpenClaw protocol state. OpenClaw owns provider credentials and protocol state. The Gateway transport authenticator, not an event-body boolean or identity field, establishes whether an inbound event is authentic.
|
|
||||||
|
|
||||||
Every operation is keyed by server-resolved `(tenantId, accountId)`. Entitlements are allow-only, server-owned records. A default account is display preference, never authorization. The browser-visible `accountId` is an untrusted reference to a server-preallocated account slot: the server MUST resolve it through the authenticated user's tenant and current entitlement on list, readiness, login start, login wait, status, inbound challenge, canary, and rollback checks. Start and every wait poll re-run that resolution. Missing, arbitrary, stale, cross-tenant, or no-longer-entitled values fail closed before account existence or state is disclosed; there is no default/first/configured-account fallback.
|
|
||||||
|
|
||||||
## Login state and secret handling
|
|
||||||
|
|
||||||
`sessionKey` is a required, nonempty opaque value returned by OpenClaw. The adapter MUST forward its exact bytes unchanged. It MUST NOT trim, normalize, case-fold, derive, regenerate, alias, or substitute the value. A missing or empty `sessionKey` fails closed and MUST NOT fall back to `accountId` or any identifier. Durable state stores only a one-way session-key digest where correlation is required; raw `sessionKey` is not persisted.
|
|
||||||
|
|
||||||
`verifyCode` is accepted only as an authenticated, CSRF-protected wait POST test/input field, matches 4-8 decimal digits, is forwarded once, and is discarded. `challengeCode` is accepted only as an authenticated Gateway event test/input field, is compared through a one-way digest, and is discarded. Neither secret may be persisted in raw form or appear in URLs, responses, logs, traces, metrics labels, analytics, audit payloads, exceptions, or background-job arguments. Golden fixtures containing these values are synthetic test vectors only and MUST NOT be copied into production telemetry or responses.
|
|
||||||
|
|
||||||
QR data URLs are transient no-store response data with a 1 MiB bound and MUST NOT be logged or persisted. Canonical login outcomes are `qr_pending`, `need_verifycode`, `invalid_verifycode`, `expired`, `failed`, `already_connected`, and `confirmed`. Any unknown upstream status maps fail-closed to canonical `failed` with `errorCode=unknown_upstream_status`; raw unknown status is bounded evidence only. Upstream `binded_redirect` maps only to `already_connected` with `connected=false`, `alreadyConnected=true`, and `terminal=true`, never to `confirmed`.
|
|
||||||
|
|
||||||
## Canonical readiness
|
|
||||||
|
|
||||||
Readiness is one live, authenticated snapshot from the Gateway/OpenClaw boundary with one unified response shape for `ready`, `not_ready`, and `error`. It MUST include Gateway reachability/error, exact plugin id/version/digest, selected account identity, configured/enabled/running/last-error state, current tenant/account entitlement, reasons, observation time, and freshness deadline. `ready=true` is legal only when every required field is present, the snapshot is still fresh, expected plugin version/digest match, the selected account is entitled/configured/enabled/running with null error, and the Gateway is reachable. Missing fields, an old snapshot, digest mismatch, no entitlement, not-running state, unauthenticated evidence, or any contradiction returns `ready=false` with `code=not_ready` or `code=error` and at least one stable reason. Cached, synthetic, file-only, default-account, or service-up-only evidence cannot produce ready.
|
|
||||||
|
|
||||||
## Authenticated inbound-event challenge
|
|
||||||
|
|
||||||
The event body contains exactly `eventId`, `accountId`, `senderId`, `chatId`, `challengeCode`, and `observedAt`. It does not and cannot assert authentication, tenant id, Portal user id, or session key. Those values are absent by contract. Transport authentication MUST cover HTTP method, path, timestamp header, nonce header, and raw body digest; enforce a bounded clock window; durably reserve nonce/event replay keys for at least that window; compare signatures in constant time; and complete before body values reach logs or mutable business state.
|
|
||||||
|
|
||||||
After transport authentication, the server hashes the ephemeral `challengeCode` and resolves the pending durable challenge. That server-created record already owns tenant id, Portal user id, entitled account id, session-key digest, issued/expiry/consumed times, and state (`issued`, `consumed`, or `expired`). The body `accountId` must exactly match the record and current entitlement; it never supplies tenant/user/session authority. The first valid authenticated event atomically binds its provider `senderId` and `chatId`.
|
|
||||||
|
|
||||||
Issuance and consumption MUST run in a database transaction with row locking or an equivalent compare-and-swap primitive. `issued -> consumed` is single-use and atomic. An exact replay of the same authenticated event is idempotent. A changed event payload, sender, chat, account, durable tenant/user/account/session binding, expired or already-consumed record, duplicate event with different payload, missing entitlement, or unauthenticated transport fails closed with zero mutation. Raw challenge codes and raw session keys are never stored.
|
|
||||||
|
|
||||||
## Unsupported unbind
|
|
||||||
|
|
||||||
End-to-end unbind is unsupported in R10. Every public or internal unbind entry point MUST return the exact schema response with HTTP 501, `ok=false`, `supported=false`, `code=unbind_not_supported`, and `mutationCount=0`. The 501 decision occurs before token deletion, account update, session or challenge mutation, Gateway/OpenClaw call, business-state audit mutation, or background-job enqueue.
|
|
||||||
|
|
||||||
## Web, canary, and rollback controls
|
|
||||||
|
|
||||||
Only exact B6/B7 allowlisted paths in the R10 amendment may change. Portal endpoints require authenticated Django sessions, CSRF protection for mutations, server-scoped object lookup, IDOR tests, rate limits, request-size limits, `Cache-Control: no-store`, and structured redaction of session keys, verification codes, challenge codes, QR data, tokens, and provider payloads. Stable public errors never expose upstream bodies or cross-tenant existence.
|
|
||||||
|
|
||||||
The canary uses one explicitly entitled tenant/account pair and proves restart, concurrent waits, atomic challenge consumption, exact replay, altered replay denial, cross-tenant denial, all canonical statuses/readiness shapes, and zero-mutation 501 behavior. Promotion requires byte-identical artifacts and digests from the verified canary. Rollback restores the immediately preceding byte-identical artifacts/configuration and re-runs canonical readiness; database rollback is forbidden unless an explicitly approved manifest proves backward incompatibility and supplies a separate recovery plan.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"tenant.account.entitlement","tenantId":"tenant-canary","accountId":"account-canary","effect":"allow","policyVersion":"policy-v1","issuedAtMs":1784068000000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":true,"code":"ready","reasons":[],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":null},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"field_missing","field":"plugin.digestSha256"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"snapshot_stale","field":"freshUntilMs"}],"observedAtMs":1784060000000,"freshUntilMs":1784060060000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"plugin_digest_mismatch","field":"plugin.digestSha256"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":true,"lastError":null,"isDefault":false,"entitled":false}],"ready":false,"code":"not_ready","reasons":[{"code":"account_not_entitled","field":"accounts[0].entitled"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":true,"errorCode":null},"plugin":{"id":"openclaw-weixin","loaded":true,"version":"2.4.6","digestSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":false,"lastError":"plugin_not_running","isDefault":false,"entitled":true}],"ready":false,"code":"not_ready","reasons":[{"code":"account_not_running","field":"accounts[0].running"}],"observedAtMs":1784068060000,"freshUntilMs":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"personal.weixin.readiness","tenantId":"tenant-canary","selectedAccountId":"account-canary","gateway":{"reachable":false,"errorCode":"gateway_unreachable"},"plugin":{"id":"openclaw-weixin","loaded":false,"version":null,"digestSha256":null},"accounts":[{"accountId":"account-canary","name":"Canary Personal Weixin","configured":true,"enabled":true,"running":false,"lastError":"gateway_unreachable","isDefault":false,"entitled":true}],"ready":false,"code":"error","reasons":[{"code":"gateway_error","field":"gateway.reachable"}],"observedAtMs":1784068060000,"freshUntilMs":1784068060000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.start.request","accountId":"account-canary","force":false,"timeoutMs":30000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.wait.request","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","verifyCode":"4321","currentQrDataUrl":"data:image/png;base64,iVBORw0KGgo=","timeoutMs":30000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"qr_pending","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":false,"qrDataUrl":"data:image/png;base64,iVBORw0KGgo="}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"need_verifycode","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":true}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"invalid_verifycode","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":false,"needsVerifyCode":true,"errorCode":"invalid_verifycode"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"expired","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"login_expired"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"failed","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"upstream_failed"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"already_connected","upstreamStatus":"binded_redirect","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":true,"terminal":true,"needsVerifyCode":false}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"confirmed","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":true,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"userId":"sender-canary"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.login.result","status":"failed","upstreamStatus":"future_unknown_state","accountId":"account-canary","sessionKey":"weixin-session-20260715-A1","connected":false,"alreadyConnected":false,"terminal":true,"needsVerifyCode":false,"errorCode":"unknown_upstream_status"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"eventId":"event-20260715-A1","accountId":"account-canary","senderId":"sender-canary","chatId":"chat-canary","challengeCode":"A1B2C3D4","observedAt":1784068120000}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"schemaVersion":"aios.personal-weixin.golden.v1","kind":"web.unbind.result","httpStatus":501,"ok":false,"supported":false,"code":"unbind_not_supported","mutationCount":0}
|
|
||||||
@@ -1,986 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://aios.imruyi.top/contracts/personal-weixin/v1/golden-contract.schema.json",
|
|
||||||
"title": "AIOS Personal Weixin Golden Contract v1",
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/entitlement"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/readiness"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/startRequest"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/waitRequest"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/loginResult"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/inboundEvent"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/$defs/unbindResult"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"$defs": {
|
|
||||||
"identifier": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128,
|
|
||||||
"pattern": "^[A-Za-z0-9._~:@-]+$"
|
|
||||||
},
|
|
||||||
"sessionKey": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 256,
|
|
||||||
"pattern": "^[A-Za-z0-9._~:-]+$"
|
|
||||||
},
|
|
||||||
"schemaVersion": {
|
|
||||||
"const": "aios.personal-weixin.golden.v1"
|
|
||||||
},
|
|
||||||
"entitlement": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"tenantId",
|
|
||||||
"accountId",
|
|
||||||
"effect",
|
|
||||||
"policyVersion",
|
|
||||||
"issuedAtMs"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "tenant.account.entitlement"
|
|
||||||
},
|
|
||||||
"tenantId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"effect": {
|
|
||||||
"const": "allow"
|
|
||||||
},
|
|
||||||
"policyVersion": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"issuedAtMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"readinessReason": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"code",
|
|
||||||
"field"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"enum": [
|
|
||||||
"field_missing",
|
|
||||||
"snapshot_stale",
|
|
||||||
"plugin_digest_mismatch",
|
|
||||||
"account_not_entitled",
|
|
||||||
"account_not_running",
|
|
||||||
"gateway_error"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"field": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"readiness": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"tenantId",
|
|
||||||
"selectedAccountId",
|
|
||||||
"gateway",
|
|
||||||
"plugin",
|
|
||||||
"accounts",
|
|
||||||
"ready",
|
|
||||||
"code",
|
|
||||||
"reasons",
|
|
||||||
"observedAtMs",
|
|
||||||
"freshUntilMs"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "personal.weixin.readiness"
|
|
||||||
},
|
|
||||||
"tenantId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"selectedAccountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"gateway": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"reachable",
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"reachable": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
],
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plugin": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"loaded",
|
|
||||||
"version",
|
|
||||||
"digestSha256"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "openclaw-weixin"
|
|
||||||
},
|
|
||||||
"loaded": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
],
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 64
|
|
||||||
},
|
|
||||||
"digestSha256": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[a-f0-9]{64}$"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"accounts": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"accountId",
|
|
||||||
"name",
|
|
||||||
"configured",
|
|
||||||
"enabled",
|
|
||||||
"running",
|
|
||||||
"lastError",
|
|
||||||
"isDefault",
|
|
||||||
"entitled"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
},
|
|
||||||
"configured": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"running": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"lastError": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
],
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 256
|
|
||||||
},
|
|
||||||
"isDefault": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"entitled": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ready": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"code": {
|
|
||||||
"enum": [
|
|
||||||
"ready",
|
|
||||||
"not_ready",
|
|
||||||
"error"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"reasons": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 32,
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/$defs/readinessReason"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"observedAtMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
},
|
|
||||||
"freshUntilMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"ready": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"ready"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"const": "ready"
|
|
||||||
},
|
|
||||||
"reasons": {
|
|
||||||
"maxItems": 0
|
|
||||||
},
|
|
||||||
"gateway": {
|
|
||||||
"properties": {
|
|
||||||
"reachable": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"const": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plugin": {
|
|
||||||
"properties": {
|
|
||||||
"loaded": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"digestSha256": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[a-f0-9]{64}$"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"accounts": {
|
|
||||||
"items": {
|
|
||||||
"properties": {
|
|
||||||
"configured": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"running": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"lastError": {
|
|
||||||
"const": null
|
|
||||||
},
|
|
||||||
"entitled": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"else": {
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"enum": [
|
|
||||||
"not_ready",
|
|
||||||
"error"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"reasons": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"startRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"accountId",
|
|
||||||
"force",
|
|
||||||
"timeoutMs"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "web.login.start.request"
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"force": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"timeoutMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1000,
|
|
||||||
"maximum": 120000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"waitRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"accountId",
|
|
||||||
"sessionKey",
|
|
||||||
"timeoutMs"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "web.login.wait.request"
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"sessionKey": {
|
|
||||||
"$ref": "#/$defs/sessionKey"
|
|
||||||
},
|
|
||||||
"verifyCode": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[0-9]{4,8}$"
|
|
||||||
},
|
|
||||||
"currentQrDataUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 1048576,
|
|
||||||
"pattern": "^data:image/png;base64,"
|
|
||||||
},
|
|
||||||
"timeoutMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1000,
|
|
||||||
"maximum": 120000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"loginResult": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"status",
|
|
||||||
"accountId",
|
|
||||||
"sessionKey",
|
|
||||||
"connected",
|
|
||||||
"alreadyConnected",
|
|
||||||
"terminal",
|
|
||||||
"needsVerifyCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "web.login.result"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"enum": [
|
|
||||||
"qr_pending",
|
|
||||||
"need_verifycode",
|
|
||||||
"invalid_verifycode",
|
|
||||||
"expired",
|
|
||||||
"failed",
|
|
||||||
"already_connected",
|
|
||||||
"confirmed"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"sessionKey": {
|
|
||||||
"$ref": "#/$defs/sessionKey"
|
|
||||||
},
|
|
||||||
"connected": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"qrDataUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 1048576,
|
|
||||||
"pattern": "^data:image/png;base64,"
|
|
||||||
},
|
|
||||||
"userId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"enum": [
|
|
||||||
"invalid_verifycode",
|
|
||||||
"login_expired",
|
|
||||||
"upstream_failed",
|
|
||||||
"unknown_upstream_status"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"upstreamStatus": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "qr_pending"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "need_verifycode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "invalid_verifycode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"errorCode": {
|
|
||||||
"const": "invalid_verifycode"
|
|
||||||
},
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "expired"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"errorCode": {
|
|
||||||
"const": "login_expired"
|
|
||||||
},
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"errorCode": {
|
|
||||||
"const": "unknown_upstream_status"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"required": [
|
|
||||||
"status",
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "failed"
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"const": "unknown_upstream_status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"required": [
|
|
||||||
"status",
|
|
||||||
"errorCode"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "failed"
|
|
||||||
},
|
|
||||||
"errorCode": {
|
|
||||||
"const": "upstream_failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"not": {
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "already_connected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"upstreamStatus": {
|
|
||||||
"const": "binded_redirect"
|
|
||||||
},
|
|
||||||
"connected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"const": "confirmed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"required": [
|
|
||||||
"userId"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"connected": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"alreadyConnected": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"terminal": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"needsVerifyCode": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"not": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"qrDataUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"errorCode"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"upstreamStatus"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"inboundEvent": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"eventId",
|
|
||||||
"accountId",
|
|
||||||
"senderId",
|
|
||||||
"chatId",
|
|
||||||
"challengeCode",
|
|
||||||
"observedAt"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"eventId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"accountId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"senderId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"chatId": {
|
|
||||||
"$ref": "#/$defs/identifier"
|
|
||||||
},
|
|
||||||
"challengeCode": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 6,
|
|
||||||
"maxLength": 64,
|
|
||||||
"pattern": "^[A-Z0-9]+$"
|
|
||||||
},
|
|
||||||
"observedAt": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"unbindResult": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schemaVersion",
|
|
||||||
"kind",
|
|
||||||
"httpStatus",
|
|
||||||
"ok",
|
|
||||||
"supported",
|
|
||||||
"code",
|
|
||||||
"mutationCount"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schemaVersion": {
|
|
||||||
"$ref": "#/$defs/schemaVersion"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"const": "web.unbind.result"
|
|
||||||
},
|
|
||||||
"httpStatus": {
|
|
||||||
"const": 501
|
|
||||||
},
|
|
||||||
"ok": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"supported": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"code": {
|
|
||||||
"const": "unbind_not_supported"
|
|
||||||
},
|
|
||||||
"mutationCount": {
|
|
||||||
"const": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="${AIOS_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
|
||||||
|
BASE_REF="${AIOS_DOCS_BASE_REF:-}"
|
||||||
|
RUN_LIVE_HEALTHCHECK="${AIOS_RUN_LIVE_HEALTHCHECK:-0}"
|
||||||
|
RESULT_DIR="${AIOS_ACCEPTANCE_RESULT_DIR:-$REPO_ROOT/.aios-evidence/acceptance}"
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
RUN_DIR="$RESULT_DIR/$STAMP"
|
||||||
|
SUMMARY="$RUN_DIR/summary.json"
|
||||||
|
|
||||||
|
install -d -m 0750 "$RUN_DIR"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
failed=0
|
||||||
|
warnings=0
|
||||||
|
steps=()
|
||||||
|
|
||||||
|
record() {
|
||||||
|
local name="$1" status="$2" code="$3" log="$4"
|
||||||
|
steps+=("$name|$status|$code|$log")
|
||||||
|
case "$status" in
|
||||||
|
通过) pass=$((pass + 1)) ;;
|
||||||
|
失败) failed=$((failed + 1)) ;;
|
||||||
|
警告) warnings=$((warnings + 1)) ;;
|
||||||
|
esac
|
||||||
|
printf '%s:%s(退出码 %s)\n' "$status" "$name" "$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_step() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local safe_name
|
||||||
|
safe_name="$(printf '%s' "$name" | tr ' /:' '___')"
|
||||||
|
local log="$RUN_DIR/${safe_name}.log"
|
||||||
|
set +e
|
||||||
|
(cd "$REPO_ROOT" && "$@") >"$log" 2>&1
|
||||||
|
local code=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$code" -eq 0 ]]; then
|
||||||
|
record "$name" "通过" "$code" "$log"
|
||||||
|
else
|
||||||
|
record "$name" "失败" "$code" "$log"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_optional_step() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local safe_name
|
||||||
|
safe_name="$(printf '%s' "$name" | tr ' /:' '___')"
|
||||||
|
local log="$RUN_DIR/${safe_name}.log"
|
||||||
|
set +e
|
||||||
|
(cd "$REPO_ROOT" && "$@") >"$log" 2>&1
|
||||||
|
local code=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$code" -eq 0 ]]; then
|
||||||
|
record "$name" "通过" "$code" "$log"
|
||||||
|
else
|
||||||
|
record "$name" "警告" "$code" "$log"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'AIOS V2R1 Candidate 全量验收开始\n'
|
||||||
|
printf '仓库:%s\n' "$REPO_ROOT"
|
||||||
|
printf '结果目录:%s\n' "$RUN_DIR"
|
||||||
|
|
||||||
|
[[ -d "$REPO_ROOT" ]] || { echo "错误:仓库不存在" >&2; exit 2; }
|
||||||
|
|
||||||
|
DOCS_ARGS=(python3 tools/aios_v2_docs_code_check.py --repo "$REPO_ROOT")
|
||||||
|
if [[ -n "$BASE_REF" ]]; then
|
||||||
|
DOCS_ARGS+=(--base-ref "$BASE_REF")
|
||||||
|
fi
|
||||||
|
run_step "文档与代码匹配" "${DOCS_ARGS[@]}"
|
||||||
|
|
||||||
|
run_step "Python 语法编译" python3 -m compileall -q \
|
||||||
|
active-runtime/app/aios_v2 \
|
||||||
|
active-runtime/app/aios_openclaw \
|
||||||
|
active-runtime/app/aios_hermes
|
||||||
|
|
||||||
|
run_step "候选单元与集成测试" python3 -m unittest discover \
|
||||||
|
-s tests/aios_v2_candidate \
|
||||||
|
-p 'test_*.py' \
|
||||||
|
-v
|
||||||
|
|
||||||
|
run_step "Shell 脚本语法" bash -c '
|
||||||
|
set -Eeuo pipefail
|
||||||
|
while IFS= read -r -d "" path; do
|
||||||
|
bash -n "$path"
|
||||||
|
done < <(find deploy/aios-v2 -type f -name "*.sh" -print0)
|
||||||
|
'
|
||||||
|
|
||||||
|
run_step "文档 Git 冲突标记" bash -c '
|
||||||
|
set -Eeuo pipefail
|
||||||
|
if grep -RInE "^(<<<<<<<|=======|>>>>>>>)" docs/aios-v2; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
|
||||||
|
run_step "明文密钥基础扫描" python3 -c '
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
roots = [Path("active-runtime/app"), Path("deploy/aios-v2"), Path("docs/aios-v2"), Path("tasks")]
|
||||||
|
patterns = [
|
||||||
|
re.compile(r"(?i)(api[_-]?key|password|private[_-]?key|access[_-]?token)\s*[:=]\s*[\"\x27][^\"\x27\n]{12,}[\"\x27]"),
|
||||||
|
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||||
|
]
|
||||||
|
violations = []
|
||||||
|
for root in roots:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if not path.is_file() or path.suffix in {".pyc", ".db", ".sqlite3"}:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
if any(pattern.search(text) for pattern in patterns):
|
||||||
|
violations.append(str(path))
|
||||||
|
if violations:
|
||||||
|
print("疑似明文密钥文件:", *violations, sep="\n", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
'
|
||||||
|
|
||||||
|
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||||
|
run_optional_step "systemd 单元验证" systemd-analyze verify \
|
||||||
|
deploy/aios-v2/systemd/aios-v2.service
|
||||||
|
else
|
||||||
|
record "systemd 单元验证" "警告" "127" "未安装 systemd-analyze"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||||
|
run_optional_step "Docker Compose 配置验证" docker compose \
|
||||||
|
-f deploy/aios-v2/docker/compose.yaml config
|
||||||
|
else
|
||||||
|
record "Docker Compose 配置验证" "警告" "127" "未安装 Docker Compose"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RUN_LIVE_HEALTHCHECK" == "1" ]]; then
|
||||||
|
run_step "在线健康检查" bash deploy/aios-v2/healthcheck/check.sh
|
||||||
|
else
|
||||||
|
record "在线健康检查" "警告" "0" "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)"
|
||||||
|
commit="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || true)"
|
||||||
|
dirty="$(git -C "$REPO_ROOT" status --porcelain=v1 2>/dev/null || true)"
|
||||||
|
|
||||||
|
python3 - "$SUMMARY" "$branch" "$commit" "$dirty" "$pass" "$failed" "$warnings" "${steps[@]}" <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
summary_path = pathlib.Path(sys.argv[1])
|
||||||
|
branch, commit, dirty = sys.argv[2:5]
|
||||||
|
passed, failed, warnings = map(int, sys.argv[5:8])
|
||||||
|
steps = []
|
||||||
|
for item in sys.argv[8:]:
|
||||||
|
name, status, code, log = item.split("|", 3)
|
||||||
|
steps.append({"name": name, "status": status, "exit_code": int(code), "log": log})
|
||||||
|
payload = {
|
||||||
|
"schema": "aios.v2.acceptance.v1",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
"branch": branch or None,
|
||||||
|
"commit": commit or None,
|
||||||
|
"workspace_dirty": bool(dirty),
|
||||||
|
"passed": passed,
|
||||||
|
"failed": failed,
|
||||||
|
"warnings": warnings,
|
||||||
|
"steps": steps,
|
||||||
|
"final_status": "passed" if failed == 0 else "failed",
|
||||||
|
}
|
||||||
|
summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
PY
|
||||||
|
|
||||||
|
printf '验收结果:通过 %s,失败 %s,警告 %s\n' "$pass" "$failed" "$warnings"
|
||||||
|
printf '证据:%s\n' "$SUMMARY"
|
||||||
|
|
||||||
|
if [[ "$failed" -ne 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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 \
|
||||||
|
&& python3 -m pip install --no-cache-dir 'cryptography>=42'
|
||||||
|
|
||||||
|
WORKDIR /opt/aios/app
|
||||||
|
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
|
||||||
|
|
||||||
|
USER aios:aios
|
||||||
|
EXPOSE 18920
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
|
||||||
|
|
||||||
|
CMD ["python3", "-m", "aios_v2.app"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
aios-v2:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: deploy/aios-v2/docker/Dockerfile
|
||||||
|
image: aios-v2:V2R1C0-candidate
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${AIOS_V2_PORT:-18920}:18920"
|
||||||
|
environment:
|
||||||
|
AIOS_V2_HOST: 0.0.0.0
|
||||||
|
AIOS_V2_PORT: 18920
|
||||||
|
AIOS_V2_STATE_DIR: /var/lib/aios-v2
|
||||||
|
AIOS_OPENCLAW_URL: ${AIOS_OPENCLAW_URL:-}
|
||||||
|
AIOS_OPENCLAW_CREDENTIAL_REF: ${AIOS_OPENCLAW_CREDENTIAL_REF:-}
|
||||||
|
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE: ${AIOS_OPENCLAW_SIGNING_MATERIAL_FILE:-}
|
||||||
|
AIOS_RELEASE_CHANNEL: candidate
|
||||||
|
AIOS_RELEASE_VERSION: V2R1C0-candidate
|
||||||
|
volumes:
|
||||||
|
- aios-v2-state:/var/lib/aios-v2
|
||||||
|
# Mount runtime signing material explicitly when OpenClaw is enabled.
|
||||||
|
# The host file must be mode 0600; never store it in this repository.
|
||||||
|
# - /etc/aios-v2/openclaw-signing-material:/run/secrets/openclaw-signing-material:ro
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python3
|
||||||
|
- -c
|
||||||
|
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 4
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
aios-v2-state:
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# AIOS V2R1 Candidate 环境模板
|
||||||
|
# 正式环境禁止保存明文 Secret。
|
||||||
|
# Provenance/OpenClaw 私钥只用于签发端,不进入应用容器或 Git。
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
AIOS_V2_HOST=127.0.0.1
|
||||||
|
AIOS_V2_PORT=18920
|
||||||
|
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||||
|
|
||||||
|
# OpenClaw Ed25519 Command v2
|
||||||
|
AIOS_OPENCLAW_URL=
|
||||||
|
AIOS_OPENCLAW_CREDENTIAL_REF=
|
||||||
|
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=/run/credentials/aios-v2.service/openclaw-command-private-key
|
||||||
|
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||||
|
|
||||||
|
# Parent Workflow Provenance
|
||||||
|
AIOS_REQUIRE_RUN_PROVENANCE=true
|
||||||
|
AIOS_PROVENANCE_PUBLIC_KEY_FILE=/etc/aios-v2/provenance/public-key.pem
|
||||||
|
AIOS_PROVENANCE_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||||
|
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2/run-provenance.sqlite3
|
||||||
|
AIOS_PROVENANCE_RECEIPT_DIR=/var/lib/aios-v2/provenance-receipts
|
||||||
|
|
||||||
|
# Least-privilege child Grants: production requires all seven IDs.
|
||||||
|
AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID=
|
||||||
|
AIOS_PROVENANCE_GRANT_GIT_ID=
|
||||||
|
AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID=
|
||||||
|
AIOS_PROVENANCE_GRANT_RUNTIME_ID=
|
||||||
|
AIOS_PROVENANCE_GRANT_EVIDENCE_ID=
|
||||||
|
AIOS_PROVENANCE_GRANT_ROLLBACK_ID=
|
||||||
|
AIOS_PROVENANCE_GRANT_AUDIT_ID=
|
||||||
|
|
||||||
|
# Per-operation context. Deployment/rollback wrappers populate these from the
|
||||||
|
# approved task execution context; do not hard-code shared production values.
|
||||||
|
AIOS_PROVENANCE_ID=
|
||||||
|
AIOS_RUN_ID=
|
||||||
|
AIOS_TENANT_ID=
|
||||||
|
AIOS_TASK_ID=
|
||||||
|
AIOS_AGENT_ID=
|
||||||
|
AIOS_PRINCIPAL_ID=
|
||||||
|
|
||||||
|
# 禁止:
|
||||||
|
# AIOS_PROVENANCE_PRIVATE_KEY_FILE
|
||||||
|
# AIOS_OPENCLAW_TOKEN
|
||||||
|
# API_KEY=value
|
||||||
|
# PASSWORD=value
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
OPERATION_PATH="${1:-}"
|
||||||
|
OPERATION_NAME="${2:-}"
|
||||||
|
REQUEST_FILE="${3:-}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "PROVENANCE_DENIED: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -n "$OPERATION_PATH" ]] || fail "usage: $0 <deployment|rollback|audit|code_change|runtime_execute|evidence_write> <operation> <request-json-file>"
|
||||||
|
[[ -n "$OPERATION_NAME" ]] || fail "operation name is required"
|
||||||
|
[[ -n "$REQUEST_FILE" && -f "$REQUEST_FILE" ]] || fail "request JSON file is required"
|
||||||
|
|
||||||
|
: "${AIOS_PROVENANCE_PUBLIC_KEY_FILE:?missing AIOS_PROVENANCE_PUBLIC_KEY_FILE}"
|
||||||
|
: "${AIOS_PROVENANCE_KEY_ID:?missing AIOS_PROVENANCE_KEY_ID}"
|
||||||
|
: "${AIOS_PROVENANCE_STORE_FILE:?missing AIOS_PROVENANCE_STORE_FILE}"
|
||||||
|
: "${AIOS_PROVENANCE_RECEIPT_DIR:?missing AIOS_PROVENANCE_RECEIPT_DIR}"
|
||||||
|
: "${AIOS_PROVENANCE_ID:?missing AIOS_PROVENANCE_ID}"
|
||||||
|
: "${AIOS_RUN_ID:?missing AIOS_RUN_ID}"
|
||||||
|
: "${AIOS_TENANT_ID:?missing AIOS_TENANT_ID}"
|
||||||
|
: "${AIOS_TASK_ID:?missing AIOS_TASK_ID}"
|
||||||
|
: "${AIOS_AGENT_ID:?missing AIOS_AGENT_ID}"
|
||||||
|
: "${AIOS_PRINCIPAL_ID:?missing AIOS_PRINCIPAL_ID}"
|
||||||
|
|
||||||
|
PYTHONPATH_VALUE="${AIOS_V2_PYTHONPATH:-/opt/aios/current/active-runtime/app}"
|
||||||
|
|
||||||
|
PYTHONPATH="$PYTHONPATH_VALUE${PYTHONPATH:+:$PYTHONPATH}" \
|
||||||
|
python3 -m aios_v2.provenance_cli authorize \
|
||||||
|
--context-from-env \
|
||||||
|
--store-file "$AIOS_PROVENANCE_STORE_FILE" \
|
||||||
|
--public-key-file "$AIOS_PROVENANCE_PUBLIC_KEY_FILE" \
|
||||||
|
--key-id "$AIOS_PROVENANCE_KEY_ID" \
|
||||||
|
--receipt-dir "$AIOS_PROVENANCE_RECEIPT_DIR" \
|
||||||
|
--path "$OPERATION_PATH" \
|
||||||
|
--operation "$OPERATION_NAME" \
|
||||||
|
--request-file "$REQUEST_FILE"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SERVICE="${AIOS_V2_SERVICE:-aios-v2}"
|
||||||
|
RELEASE_ROOT="${AIOS_V2_RELEASE_ROOT:-/opt/aios/releases}"
|
||||||
|
CURRENT_LINK="${AIOS_V2_CURRENT_LINK:-/opt/aios/current}"
|
||||||
|
STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
|
||||||
|
BACKUP_ROOT="${AIOS_V2_BACKUP_ROOT:-/var/backups/aios-v2}"
|
||||||
|
TARGET_RELEASE="${1:-}"
|
||||||
|
RESTORE_STATE="${AIOS_V2_RESTORE_STATE:-0}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
|
||||||
|
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
|
||||||
|
|
||||||
|
if [[ "$TARGET_RELEASE" = /* ]]; then
|
||||||
|
TARGET_PATH="$(readlink -f "$TARGET_RELEASE")"
|
||||||
|
else
|
||||||
|
[[ "$TARGET_RELEASE" != *"/"* && "$TARGET_RELEASE" != "." && "$TARGET_RELEASE" != ".." ]] \
|
||||||
|
|| fail "invalid release name"
|
||||||
|
TARGET_PATH="$(readlink -f "$RELEASE_ROOT/$TARGET_RELEASE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -d "$TARGET_PATH/active-runtime/app" ]] || fail "target release is invalid: $TARGET_PATH"
|
||||||
|
[[ -f "$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh" ]] \
|
||||||
|
|| fail "target release has no healthcheck"
|
||||||
|
|
||||||
|
PREVIOUS_PATH=""
|
||||||
|
if [[ -L "$CURRENT_LINK" ]]; then
|
||||||
|
PREVIOUS_PATH="$(readlink -f "$CURRENT_LINK")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
BACKUP_DIR="$BACKUP_ROOT/rollback-$STAMP"
|
||||||
|
install -d -m 0750 "$BACKUP_DIR"
|
||||||
|
|
||||||
|
if [[ -d "$STATE_DIR" ]]; then
|
||||||
|
tar --xattrs --acls -C "$(dirname "$STATE_DIR")" \
|
||||||
|
-czf "$BACKUP_DIR/state-before-rollback.tar.gz" \
|
||||||
|
"$(basename "$STATE_DIR")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$PREVIOUS_PATH" > "$BACKUP_DIR/previous-release.txt"
|
||||||
|
printf '%s\n' "$TARGET_PATH" > "$BACKUP_DIR/target-release.txt"
|
||||||
|
|
||||||
|
systemctl stop "$SERVICE"
|
||||||
|
ln -sfn "$TARGET_PATH" "${CURRENT_LINK}.next"
|
||||||
|
mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start "$SERVICE"
|
||||||
|
|
||||||
|
if AIOS_V2_SERVICE="$SERVICE" \
|
||||||
|
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||||
|
"$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh"; then
|
||||||
|
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Target release failed healthcheck; restoring previous release." >&2
|
||||||
|
systemctl stop "$SERVICE" || true
|
||||||
|
|
||||||
|
if [[ -n "$PREVIOUS_PATH" && -d "$PREVIOUS_PATH" ]]; then
|
||||||
|
ln -sfn "$PREVIOUS_PATH" "${CURRENT_LINK}.previous"
|
||||||
|
mv -Tf "${CURRENT_LINK}.previous" "$CURRENT_LINK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RESTORE_STATE" == "1" && -f "$BACKUP_DIR/state-before-rollback.tar.gz" ]]; then
|
||||||
|
rm -rf "$STATE_DIR"
|
||||||
|
tar --xattrs --acls -C "$(dirname "$STATE_DIR")" \
|
||||||
|
-xzf "$BACKUP_DIR/state-before-rollback.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start "$SERVICE" || true
|
||||||
|
|
||||||
|
if [[ -n "$PREVIOUS_PATH" && -x "$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" ]]; then
|
||||||
|
AIOS_V2_SERVICE="$SERVICE" \
|
||||||
|
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||||
|
"$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS V2 Control Plane Candidate
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios
|
||||||
|
Group=aios
|
||||||
|
WorkingDirectory=/opt/aios/current/active-runtime/app
|
||||||
|
EnvironmentFile=-/etc/aios-v2/aios-v2.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/bin/python3 -m aios_v2.app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStartSec=30
|
||||||
|
TimeoutStopSec=30
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
ReadWritePaths=/var/lib/aios-v2
|
||||||
|
StateDirectory=aios-v2
|
||||||
|
StateDirectoryMode=0750
|
||||||
|
UMask=0027
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
printf 'AIOS V2R1C0 RC1 health check\n'
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
required = [
|
|
||||||
'active-runtime/app/aios_v2r1/tenant_context.py',
|
|
||||||
'active-runtime/app/aios_v2r1/router.py',
|
|
||||||
'active-runtime/app/aios_v2r1/hermes_fabric.py',
|
|
||||||
'openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json',
|
|
||||||
]
|
|
||||||
missing=[p for p in required if not Path(p).exists()]
|
|
||||||
if missing:
|
|
||||||
raise SystemExit('missing: '+','.join(missing))
|
|
||||||
print('artifact presence PASS')
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo 'runtime health requires OpenClaw environment probe'
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
|
||||||
EXPECTED_BRANCH="release/v2r1c0-tenant-cell-fabric-20260712"
|
|
||||||
|
|
||||||
if [[ "$(git -C "$ROOT" branch --show-current)" != "$EXPECTED_BRANCH" ]]; then
|
|
||||||
echo "wrong release branch"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
COMMIT=$(git -C "$ROOT" rev-parse HEAD)
|
|
||||||
echo "Installing AIOS V2R1C0 RC1 commit=$COMMIT"
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
import compileall
|
|
||||||
if not compileall.compile_dir('active-runtime/app/aios_v2r1', quiet=1):
|
|
||||||
raise SystemExit(1)
|
|
||||||
print('python compile PASS')
|
|
||||||
PY
|
|
||||||
|
|
||||||
if [[ -f openclaw/plugins/aios-tenant-boundary/package.json ]]; then
|
|
||||||
echo "plugin package PASS"
|
|
||||||
else
|
|
||||||
echo "plugin package missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installation preflight PASS"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
MODE=${1:---check}
|
|
||||||
|
|
||||||
case "$MODE" in
|
|
||||||
--check)
|
|
||||||
echo "V2R1C0 RC1 validation mode"
|
|
||||||
;;
|
|
||||||
--install)
|
|
||||||
echo "Install requires approved OpenClaw Cell target"
|
|
||||||
;;
|
|
||||||
--rollback)
|
|
||||||
echo "Rollback requires recorded Cell backup"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 [--check|--install|--rollback]"
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
import compileall
|
|
||||||
raise SystemExit(0 if compileall.compile_dir('active-runtime/app/aios_v2r1', quiet=1) else 1)
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "python runtime syntax: PASS"
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# AIOS V2 用户需求规格
|
||||||
|
|
||||||
|
- 文档版本:`V2R1-hardening-candidate`
|
||||||
|
- 状态:加固候选版本需求基线
|
||||||
|
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、Run Provenance、Dispatch、审计、部署与回滚
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
现有系统已经具备 OpenClaw、Hermes、Agent Router、GitOps、局部 WebUI、审计和多租户候选能力。AIOS V2 的目标不是推倒重写,而是将这些分散能力收敛到统一、可审计、可 Web 化、可多租户隔离的控制面。
|
||||||
|
|
||||||
|
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录、执行证据、Run Provenance 和 Dispatch。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、时间和审计事件。
|
||||||
|
- 公共 API 不允许直接调用 `start`、`complete` 或 `fail` 推进权威状态。
|
||||||
|
|
||||||
|
### 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、Dispatch、Run Provenance、Grant 使用记录和 Execution Evidence 必须在服务重启后恢复。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 候选版支持 SQLite WAL 或等价原子持久化。
|
||||||
|
- 有 Control Plane 与 Dispatch restart recovery 测试。
|
||||||
|
- 生产版需进一步提供迁移、备份、恢复、HA 和容量证据。
|
||||||
|
|
||||||
|
### UR-012 审计与合规
|
||||||
|
|
||||||
|
所有控制与执行动作必须生成不可混租户的审计事件。
|
||||||
|
|
||||||
|
至少记录:
|
||||||
|
|
||||||
|
- tenant、principal、trace、action、resource、outcome、policy version、timestamp。
|
||||||
|
- OpenClaw/Hermes Runtime 生命周期。
|
||||||
|
- GitOps、部署和回滚动作。
|
||||||
|
- Provenance Grant、Scope、Authorization Receipt 和 Dispatch 结果摘要。
|
||||||
|
|
||||||
|
### UR-013 密钥安全
|
||||||
|
|
||||||
|
Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key、密码、Token 或私钥。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Git 只存 `secret_ref`、`credential_ref`、`key_id` 或受保护文件路径。
|
||||||
|
- Secret 文件权限为 `0600` 或更严格。
|
||||||
|
- Secret Scanner 默认拒绝可疑提交。
|
||||||
|
- OpenClaw Command 和 Run Provenance 使用 Ed25519;生产应用只读取公钥。
|
||||||
|
|
||||||
|
### UR-014 部署与回滚
|
||||||
|
|
||||||
|
系统必须提供 systemd、Docker/Compose、健康检查和受保护回滚入口。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 服务以非 root 权限运行。
|
||||||
|
- 状态目录独立持久化。
|
||||||
|
- 回滚前自动备份状态。
|
||||||
|
- 默认只回滚代码,状态恢复需要显式授权。
|
||||||
|
|
||||||
|
### UR-015 文档与代码同步
|
||||||
|
|
||||||
|
任何影响用户行为、架构边界、API、数据模型、状态机、安全策略、部署或运维方式的变更,必须在同一分支同步更新 `docs/aios-v2/`。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 架构/功能代码变化但无文档变化时,文档同步门禁失败。
|
||||||
|
- Traceability Matrix 必须指向当前代码和测试。
|
||||||
|
- 新架构决策必须增加或更新 ADR。
|
||||||
|
|
||||||
|
### UR-016 Workflow Run Provenance
|
||||||
|
|
||||||
|
每次生产 Run 必须具有一个可验证、可撤销、可过期的 Workflow Provenance,用于绑定 Run、Tenant、Task、Agent 与 Principal,但父 Workflow 本身不得授予操作权限。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Workflow Provenance 使用 Ed25519 签名。
|
||||||
|
- `allowed_paths` 与 `allowed_git_operations` 为空。
|
||||||
|
- 上下文、签名、TTL、Nonce、Key ID 不合法时默认拒绝。
|
||||||
|
- 撤销父 Workflow 后全部子 Grant 失效。
|
||||||
|
|
||||||
|
### UR-017 最小权限子 Grant
|
||||||
|
|
||||||
|
所有生产副作用必须通过与单一 ExecutionPath 对应的子 Grant 授权。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Runtime、Evidence、Git、Deployment、Rollback、Audit 和 Code Change 使用独立 Grant。
|
||||||
|
- 一个 Grant 只能包含一个路径。
|
||||||
|
- Git Grant 必须限定具体 GitOperation。
|
||||||
|
- 子 Grant 具有独立 TTL、撤销、状态和重放防护。
|
||||||
|
|
||||||
|
### UR-018 Runtime Result 信任绑定
|
||||||
|
|
||||||
|
OpenClaw/Hermes 结果只有在与持久 Dispatch 的身份和执行上下文完全匹配时,才能更新权威 Task。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 至少校验 tenant、task、agent、principal、trace、runtime、runtime execution、provenance 和 run。
|
||||||
|
- 不同终态结果摘要不得覆盖已有终态。
|
||||||
|
- Hermes 终态结果必须来自持有 Job Lease 的 Worker。
|
||||||
|
- Runtime callback 审计不得绕过 Orchestrator Reconcile。
|
||||||
|
|
||||||
|
### UR-019 Dispatch 持久化与恢复
|
||||||
|
|
||||||
|
Runtime 副作用之前必须持久化唯一 Dispatch,重启后能够恢复并继续严格 Reconcile。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 同租户 Task 和 Runtime Execution 各自唯一绑定一个 Dispatch。
|
||||||
|
- Dispatch 支持按 ID、Task 与 Runtime Execution 查询。
|
||||||
|
- 终态 Dispatch 不得迁移到不同终态。
|
||||||
|
- 有 SQLite Repository 重启恢复测试。
|
||||||
|
|
||||||
|
### UR-020 OpenClaw Ed25519 命令合同
|
||||||
|
|
||||||
|
OpenClaw 生产命令必须使用版本化 Ed25519 合同,不得继续使用 HMAC 作为新命令信任根。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 合同版本为 `aios.openclaw.command.v2`。
|
||||||
|
- 签名覆盖 Key ID、算法、公钥 Fingerprint、Tenant、Task、Trace、Payload、Idempotency 与时间字段。
|
||||||
|
- Key ID/Fingerprint/签名/过期或时钟偏差不合法时拒绝。
|
||||||
|
- Fingerprint 使用恒定时间比较。
|
||||||
|
|
||||||
|
### UR-021 服务级权限隔离
|
||||||
|
|
||||||
|
每个生产服务或 Worker 只能加载其职责所需的 Grant,不得加载全部 Grant 备用。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 默认 AIOS Application 仅加载 Runtime、Evidence 与 Audit Grant。
|
||||||
|
- Git、Deployment、Rollback 与 Code Change 由独立 Scope 加载。
|
||||||
|
- 空 Scope、多余 Grant 映射和未声明路径调用全部拒绝。
|
||||||
|
- Authorization Receipt 记录 Grant ID、Writer ID 和 Scope。
|
||||||
|
|
||||||
|
### UR-022 CLI 单 Grant 授权
|
||||||
|
|
||||||
|
运维 CLI 的一次授权操作只能加载一个与请求路径一致的 Grant。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- `authorize` 使用一个 `--path` 和一个 `--grant-id`。
|
||||||
|
- Grant 类型与 Path 不一致时拒绝。
|
||||||
|
- CLI 不提供一次加载七个生产 Grant 的授权入口。
|
||||||
|
- 旧全权限 `issue` 仅允许候选兼容,不得进入生产流程。
|
||||||
|
|
||||||
|
## 4. 非功能需求
|
||||||
|
|
||||||
|
### NFR-001 安全
|
||||||
|
|
||||||
|
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计、可撤销 Provenance 和 Runtime 结果绑定。
|
||||||
|
|
||||||
|
### NFR-002 可靠性
|
||||||
|
|
||||||
|
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、Dispatch 恢复、健康检查和回滚。
|
||||||
|
|
||||||
|
### NFR-003 可维护性
|
||||||
|
|
||||||
|
- 模块边界稳定,Control Plane 与 Runtime 解耦;代码、文档、测试和部署可追踪。
|
||||||
|
|
||||||
|
### NFR-004 可扩展性
|
||||||
|
|
||||||
|
- Store、Transport、Policy、EventPublisher、Runtime Adapter、Provenance Verifier 与 Writer 均通过接口扩展。
|
||||||
|
|
||||||
|
### NFR-005 可运维性
|
||||||
|
|
||||||
|
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告、Grant 状态和 Receipt。
|
||||||
|
|
||||||
|
### NFR-006 性能候选指标
|
||||||
|
|
||||||
|
候选版上线前至少验证:
|
||||||
|
|
||||||
|
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间、Grant 授权延迟和 Dispatch 恢复时间。
|
||||||
|
- 具体 SLO 在预发布基准测试后固化,禁止无证据承诺。
|
||||||
|
|
||||||
|
## 5. 本候选版本范围
|
||||||
|
|
||||||
|
### 已纳入代码
|
||||||
|
|
||||||
|
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、Run Provenance、Least-Privilege Grant、Scoped Writer、Dispatch Repository、部署和回滚资产。
|
||||||
|
|
||||||
|
### 已纳入测试代码
|
||||||
|
|
||||||
|
- Control Plane/Runtime 候选流程。
|
||||||
|
- OpenClaw Ed25519 签名与验证。
|
||||||
|
- Hermes Worker Lease 与严格 Reconcile。
|
||||||
|
- Dispatch SQLite 重启恢复。
|
||||||
|
- Workflow/Grant 撤销、过期、重放和服务 Scope 隔离。
|
||||||
|
|
||||||
|
### 尚需环境验证
|
||||||
|
|
||||||
|
- 完整 Acceptance 实际执行证据。
|
||||||
|
- OpenClaw 真实 Runtime 联调。
|
||||||
|
- 生产数据库/队列替换或容量验证。
|
||||||
|
- 预发布性能、安全、重启和回滚演练。
|
||||||
|
- Owner 对 merge、Tag 和生产部署的批准。
|
||||||
|
|
||||||
|
## 6. 用户验收场景
|
||||||
|
|
||||||
|
1. 平台管理员创建租户并注册 OpenClaw/Hermes Agent。
|
||||||
|
2. 操作员创建任务,审批人批准。
|
||||||
|
3. Owner 或受控签发端签发 Workflow Provenance 与所需最小权限 Grant。
|
||||||
|
4. AIOS Orchestrator 持久化 Dispatch,并创建 Ed25519 命令或租户分区 Job。
|
||||||
|
5. Runtime 执行并返回与 Dispatch 完全匹配的结果。
|
||||||
|
6. AIOS 更新 Task、Audit、Authorization Receipt 和 Execution Evidence。
|
||||||
|
7. 租户用户在 Web Console 查看完整链路。
|
||||||
|
8. 跨租户、跨 Principal、跨 Grant Path 和重放请求被拒绝。
|
||||||
|
9. 服务重启后 Control Plane、Dispatch 和 Provenance 状态恢复。
|
||||||
|
10. 候选部署健康检查、Acceptance 和回滚演练实际执行并保存证据。
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
# AIOS V2 目标架构
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 架构状态:目标架构已实现为候选代码,待预发布验证
|
||||||
|
- 适用范围:Control Plane、Agent Router、OpenClaw、Hermes、Web Console、GitOps、审计、部署与运维
|
||||||
|
|
||||||
|
## 1. 架构目标
|
||||||
|
|
||||||
|
AIOS V2 的核心目标是把分散在 OpenClaw、Hermes、Agent Router、局部 WebUI 和 GitOps 中的控制能力收敛为一个权威控制面,同时保留现有执行能力,避免推倒重写。
|
||||||
|
|
||||||
|
目标状态:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户 / Web Console / Public API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Control Plane
|
||||||
|
Tenant | Identity | Agent | Task | Policy | Approval | Audit
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
OpenClaw Adapter Hermes Boundary
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
OpenClaw Runtime Hermes Workers
|
||||||
|
\ /
|
||||||
|
v v
|
||||||
|
Result Reconciliation + Execution Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
设计结论:
|
||||||
|
|
||||||
|
- AIOS 是唯一控制面。
|
||||||
|
- Agent Router 是调度平面,不是第二控制面。
|
||||||
|
- OpenClaw 是执行适配与 Runtime。
|
||||||
|
- Hermes 是队列、Worker、Profile 与执行 Runtime。
|
||||||
|
- Web Console 只调用 AIOS API。
|
||||||
|
- GitOps 是代码、配置、文档和发布变更的唯一入口。
|
||||||
|
|
||||||
|
## 2. 从现有架构到目标架构
|
||||||
|
|
||||||
|
### 2.1 现有能力
|
||||||
|
|
||||||
|
现有系统已经具备:
|
||||||
|
|
||||||
|
- OpenClaw 执行与 Fleet 能力。
|
||||||
|
- Hermes Worker、Profile、队列和工作区能力。
|
||||||
|
- Agent Router 路由经验和部分调度接口。
|
||||||
|
- GitOps 分支、提交、审批、回滚和审计能力。
|
||||||
|
- Tenant Context、Tenant Router、Memory Scope、SecretRef、Message Ledger 和审计候选实现。
|
||||||
|
- 局部 WebUI、Gateway 和自动任务领取能力。
|
||||||
|
|
||||||
|
### 2.2 现有问题
|
||||||
|
|
||||||
|
- 控制逻辑分散,可能存在多个任务事实源。
|
||||||
|
- OpenClaw/Hermes 内部仍可能保存租户、策略或任务状态。
|
||||||
|
- 候选能力分散在多个分支,主线缺少权威实现。
|
||||||
|
- WebUI 与 API 入口不统一。
|
||||||
|
- 分支名称、代码、测试和部署证据之间缺少映射。
|
||||||
|
- 多租户隔离多为逻辑约束,尚缺全链路生产证据。
|
||||||
|
|
||||||
|
### 2.3 演进原则
|
||||||
|
|
||||||
|
- 先加边界和适配器,再迁移控制权。
|
||||||
|
- 先复用接口,再替换内部实现。
|
||||||
|
- 先在候选分支验证,再受控提升。
|
||||||
|
- 不允许同时存在两套权威任务生命周期。
|
||||||
|
- 不以“大分支存在”代替验收证据。
|
||||||
|
|
||||||
|
## 3. 逻辑分层
|
||||||
|
|
||||||
|
### 3.1 Web 与 API 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 提供 Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence 和 GitOps 页面/API。
|
||||||
|
- 依赖可信身份代理注入 `X-AIOS-*` 身份头。
|
||||||
|
- 不允许浏览器直接访问 OpenClaw 或 Hermes。
|
||||||
|
- 不允许客户端自行选择或覆盖 `tenant_id`。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/app.py`
|
||||||
|
- `active-runtime/app/aios_v2/gateway.py`
|
||||||
|
- `active-runtime/app/aios_v2/console.py`
|
||||||
|
|
||||||
|
### 3.2 AIOS Control Plane
|
||||||
|
|
||||||
|
权威职责:
|
||||||
|
|
||||||
|
- Tenant Registry
|
||||||
|
- Agent Registry
|
||||||
|
- Task Lifecycle
|
||||||
|
- Policy Decision
|
||||||
|
- Approval
|
||||||
|
- Audit Event
|
||||||
|
- Runtime Inventory
|
||||||
|
- Execution Evidence
|
||||||
|
- Deployment/GitOps Status
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- OpenClaw 内部执行细节。
|
||||||
|
- Hermes Worker 内部执行细节。
|
||||||
|
- 直接持有 Runtime 凭据明文。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
- `active-runtime/app/aios_v2/persistence.py`
|
||||||
|
- `active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
- `active-runtime/app/aios_v2/gitops_status.py`
|
||||||
|
|
||||||
|
### 3.3 Execution Orchestrator / Agent Router
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只接受 `APPROVED` 任务。
|
||||||
|
- 根据 Agent Registry 和 Policy Result 选择 Runtime。
|
||||||
|
- 生成 OpenClawCommand 或 HermesJob。
|
||||||
|
- 传播 tenant、task、trace、idempotency 和 policy version。
|
||||||
|
- 将 Runtime Result 回写 AIOS Task。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- 创建租户。
|
||||||
|
- 修改策略。
|
||||||
|
- 绕过审批。
|
||||||
|
- 保存第二套任务状态机。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/orchestrator.py`
|
||||||
|
|
||||||
|
### 3.4 OpenClaw Adapter
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 验证签名命令。
|
||||||
|
- 执行、查询、取消、健康检查和回调。
|
||||||
|
- 支持幂等、超时和租户隔离。
|
||||||
|
- 把 Runtime 生命周期转换为 AIOS 审计事件。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- Tenant、Policy、Approval、Task Lifecycle。
|
||||||
|
- 向浏览器暴露公网控制接口。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_openclaw/contract.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/adapter.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/transport.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/health.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/callback.py`
|
||||||
|
|
||||||
|
### 3.5 Hermes Runtime Boundary
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- Tenant/Cell/Profile 分区队列。
|
||||||
|
- Worker claim、start、heartbeat、complete、fail、cancel。
|
||||||
|
- Lease 超时和自动回队列。
|
||||||
|
- Result 回传和 Runtime Health。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- Policy、Approval、Tenant Registry、权威 Task Lifecycle。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_hermes/contract.py`
|
||||||
|
- `active-runtime/app/aios_hermes/adapter.py`
|
||||||
|
- `active-runtime/app/aios_hermes/worker.py`
|
||||||
|
|
||||||
|
### 3.6 Persistence
|
||||||
|
|
||||||
|
候选实现使用 SQLite WAL 原子快照,持久化:
|
||||||
|
|
||||||
|
- Tenant
|
||||||
|
- Agent
|
||||||
|
- Task
|
||||||
|
- Audit
|
||||||
|
- Idempotency
|
||||||
|
- Execution Evidence 文件
|
||||||
|
|
||||||
|
生产演进:
|
||||||
|
|
||||||
|
- Store 接口保持稳定。
|
||||||
|
- 可替换为 PostgreSQL、对象存储或分布式事件存储。
|
||||||
|
- 生产替换必须提供迁移、备份、恢复、HA、容量和租户隔离证据。
|
||||||
|
|
||||||
|
## 4. 多租户架构
|
||||||
|
|
||||||
|
### 4.1 权威租户上下文
|
||||||
|
|
||||||
|
每次请求和执行至少传播:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
session_id
|
||||||
|
trace_id
|
||||||
|
task_id
|
||||||
|
policy_version
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime 还传播:
|
||||||
|
|
||||||
|
```text
|
||||||
|
command_id / job_id
|
||||||
|
cell_id
|
||||||
|
profile_id
|
||||||
|
idempotency_key
|
||||||
|
worker_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 隔离层级
|
||||||
|
|
||||||
|
| 层级 | 隔离要求 |
|
||||||
|
|---|---|
|
||||||
|
| HTTP/API | 可信身份头、租户不可由浏览器覆盖 |
|
||||||
|
| Control Plane | 所有资源带 `tenant_id`,跨租户默认拒绝 |
|
||||||
|
| OpenClaw | 签名命令绑定 tenant/task/trace |
|
||||||
|
| Hermes | tenant/cell/profile 三层分区 |
|
||||||
|
| Storage | 候选为显式租户字段;生产需 RLS 或物理隔离策略 |
|
||||||
|
| Evidence/Audit | 仅按当前租户查询 |
|
||||||
|
| Cache/Memory | key 必须包含 tenant namespace |
|
||||||
|
| Secret | 只保存引用,不保存明文 |
|
||||||
|
| Logs/Metrics | 必须包含 tenant/task/trace 标签,并限制查询范围 |
|
||||||
|
|
||||||
|
### 4.3 失败行为
|
||||||
|
|
||||||
|
- 跨租户资源访问返回拒绝或“未找到”。
|
||||||
|
- 不泄露其他租户资源存在性。
|
||||||
|
- 缺少 tenant context 时 fail closed。
|
||||||
|
- Runtime 返回的 tenant/task/trace 不匹配时拒绝回写。
|
||||||
|
|
||||||
|
## 5. 任务与执行数据流
|
||||||
|
|
||||||
|
### 5.1 任务创建与审批
|
||||||
|
|
||||||
|
```text
|
||||||
|
User/API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Trusted Identity
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Gateway
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Policy Gate
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task CREATED
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Approval
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 OpenClaw 执行
|
||||||
|
|
||||||
|
```text
|
||||||
|
APPROVED Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Signed OpenClawCommand
|
||||||
|
|
|
||||||
|
v
|
||||||
|
OpenClaw Adapter
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Runtime Result
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task Reconciliation + Audit + Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Hermes 执行
|
||||||
|
|
||||||
|
```text
|
||||||
|
APPROVED Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Tenant/Cell/Profile HermesJob
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Worker Claim + Lease + Heartbeat
|
||||||
|
|
|
||||||
|
v
|
||||||
|
HermesResult
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task Reconciliation + Audit + Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 安全架构
|
||||||
|
|
||||||
|
### 6.1 身份边界
|
||||||
|
|
||||||
|
- Public API 位于可信反向代理之后。
|
||||||
|
- 反向代理完成登录、会话、MFA 和身份头注入。
|
||||||
|
- 候选应用拒绝未认证身份断言。
|
||||||
|
- 生产不得允许客户端直接伪造 `X-AIOS-*`。
|
||||||
|
|
||||||
|
### 6.2 Runtime 命令完整性
|
||||||
|
|
||||||
|
OpenClawCommand 使用规范化序列化和 HMAC-SHA256:
|
||||||
|
|
||||||
|
- 签名覆盖 tenant、task、trace、action、payload、idempotency 和时间。
|
||||||
|
- 支持 key ID、到期时间和时钟偏差。
|
||||||
|
- Runtime 密钥通过受保护文件或外部 Secret Provider 解析。
|
||||||
|
|
||||||
|
### 6.3 Secret 边界
|
||||||
|
|
||||||
|
Git 允许:
|
||||||
|
|
||||||
|
```text
|
||||||
|
secret_ref
|
||||||
|
credential_ref
|
||||||
|
key_id
|
||||||
|
受保护文件路径
|
||||||
|
```
|
||||||
|
|
||||||
|
Git 禁止:
|
||||||
|
|
||||||
|
```text
|
||||||
|
API Key
|
||||||
|
密码
|
||||||
|
Token
|
||||||
|
私钥
|
||||||
|
签名材料
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Web 安全
|
||||||
|
|
||||||
|
候选应用设置:
|
||||||
|
|
||||||
|
- no-store
|
||||||
|
- nosniff
|
||||||
|
- DENY frame
|
||||||
|
- no-referrer
|
||||||
|
- CSP
|
||||||
|
- 请求体大小限制
|
||||||
|
|
||||||
|
生产还需验证:
|
||||||
|
|
||||||
|
- CSRF/CORS
|
||||||
|
- Rate Limit
|
||||||
|
- Session Timeout
|
||||||
|
- CSP Nonce/Hash
|
||||||
|
- WAF 和反向代理访问策略
|
||||||
|
|
||||||
|
## 7. 可靠性架构
|
||||||
|
|
||||||
|
### 7.1 幂等
|
||||||
|
|
||||||
|
- Control Plane:`tenant_id + idempotency_key + request_digest`。
|
||||||
|
- OpenClaw:`tenant_id + idempotency_key + signature`。
|
||||||
|
- Hermes:`tenant_id + idempotency_key + job_digest`。
|
||||||
|
|
||||||
|
### 7.2 并发
|
||||||
|
|
||||||
|
- Task 使用 revision。
|
||||||
|
- Hermes 使用 revision + worker lease。
|
||||||
|
- GitOps 使用 detached controller + branch lease。
|
||||||
|
- 持久化使用 SQLite `BEGIN IMMEDIATE` 和原子快照。
|
||||||
|
|
||||||
|
### 7.3 恢复
|
||||||
|
|
||||||
|
- Controller restart:detached guard。
|
||||||
|
- AIOS restart:SQLite restore。
|
||||||
|
- Worker crash:lease 到期后 Job 回到 QUEUED。
|
||||||
|
- Release failure:健康检查失败自动恢复 previous release。
|
||||||
|
|
||||||
|
## 8. 可观测架构
|
||||||
|
|
||||||
|
统一关联键:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
trace_id
|
||||||
|
task_id
|
||||||
|
agent_id
|
||||||
|
command_id / job_id
|
||||||
|
worker_id
|
||||||
|
commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Web Console 展示:
|
||||||
|
|
||||||
|
- Tasks
|
||||||
|
- Agents
|
||||||
|
- Runtime Health
|
||||||
|
- Audit
|
||||||
|
- Execution Evidence
|
||||||
|
- GitOps Status
|
||||||
|
|
||||||
|
生产还需接入:
|
||||||
|
|
||||||
|
- 结构化日志
|
||||||
|
- Metrics
|
||||||
|
- Distributed Tracing
|
||||||
|
- Alerting
|
||||||
|
- SLO Dashboard
|
||||||
|
|
||||||
|
## 9. 部署架构
|
||||||
|
|
||||||
|
支持两种候选部署:
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
- 非 root `aios` 用户。
|
||||||
|
- `ProtectSystem=strict`、`NoNewPrivileges` 等安全选项。
|
||||||
|
- 状态目录 `/var/lib/aios-v2`。
|
||||||
|
|
||||||
|
### Docker/Compose
|
||||||
|
|
||||||
|
- 非 root UID/GID。
|
||||||
|
- Read-only root filesystem。
|
||||||
|
- Drop all capabilities。
|
||||||
|
- State volume。
|
||||||
|
- HTTP healthcheck。
|
||||||
|
|
||||||
|
## 10. 架构约束
|
||||||
|
|
||||||
|
以下行为视为架构违规:
|
||||||
|
|
||||||
|
1. OpenClaw/Hermes 直接创建或批准 AIOS Task。
|
||||||
|
2. Web Console 直接调用 Runtime。
|
||||||
|
3. 新模块不携带 `tenant_id`。
|
||||||
|
4. 新增第二套任务状态机或租户事实源。
|
||||||
|
5. 在 Git 中提交明文凭据。
|
||||||
|
6. 绕过 GitOps 修改生产代码/配置。
|
||||||
|
7. 架构或功能变化未同步更新 `docs/aios-v2/`。
|
||||||
|
8. 未通过测试和预发布门禁直接提升候选版本。
|
||||||
|
|
||||||
|
## 11. 目标架构完成判定
|
||||||
|
|
||||||
|
目标架构只有在以下证据齐全时才视为完成:
|
||||||
|
|
||||||
|
- 文档与代码映射完整。
|
||||||
|
- Candidate tests 对精确 commit 执行通过。
|
||||||
|
- OpenClaw/Hermes 实际 Runtime 联调通过。
|
||||||
|
- 多租户负向测试通过。
|
||||||
|
- 重启恢复和回滚演练通过。
|
||||||
|
- GitOps 本地/Gitea OID 一致。
|
||||||
|
- Owner 批准 merge、Tag 和生产部署。
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# AIOS V2 迁移与交付策略
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 交付原则:增量迁移、能力复用、控制权收敛、GitOps 唯一变更入口
|
||||||
|
|
||||||
|
## 1. 迁移目标
|
||||||
|
|
||||||
|
AIOS V2 不通过一次性重写替换现有系统,而是在保留 OpenClaw、Hermes、Agent Router、GitOps 和既有运行能力的前提下,将租户、策略、任务、审批、审计和发布治理收敛到 AIOS Control Plane。
|
||||||
|
|
||||||
|
迁移完成的标志不是“新代码存在”,而是:
|
||||||
|
|
||||||
|
- AIOS 成为唯一权威控制面;
|
||||||
|
- OpenClaw/Hermes 不再保存第二套控制状态;
|
||||||
|
- 所有控制功能 Web/API 化;
|
||||||
|
- 所有模块通过租户隔离、测试和预发布门禁;
|
||||||
|
- 本地 Git、Gitea、测试、部署和回滚证据可追踪。
|
||||||
|
|
||||||
|
## 2. 为什么采用增量迁移
|
||||||
|
|
||||||
|
### 2.1 避免能力回退
|
||||||
|
|
||||||
|
OpenClaw、Hermes 和 Agent Router 已经包含大量运行经验、边界处理和执行能力。整体重写会扩大风险面,并导致业务能力、操作流程和历史数据回退。
|
||||||
|
|
||||||
|
### 2.2 保持可回滚
|
||||||
|
|
||||||
|
每个迁移步骤必须:
|
||||||
|
|
||||||
|
- 独立分支;
|
||||||
|
- 小范围提交;
|
||||||
|
- 独立测试;
|
||||||
|
- 可单独回滚;
|
||||||
|
- 不要求立即关闭旧执行路径。
|
||||||
|
|
||||||
|
### 2.3 先收敛控制权,再优化内部实现
|
||||||
|
|
||||||
|
迁移优先级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
唯一入口
|
||||||
|
-> 权威租户/任务/策略
|
||||||
|
-> Runtime Adapter
|
||||||
|
-> 数据层生产化
|
||||||
|
-> Web 与可观测
|
||||||
|
-> 性能与 HA
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 分阶段迁移路线
|
||||||
|
|
||||||
|
### Phase 0:GitOps 与架构事实源
|
||||||
|
|
||||||
|
目标:建立可靠变更链和权威设计基线。
|
||||||
|
|
||||||
|
已完成候选能力:
|
||||||
|
|
||||||
|
- Controller detached guard;
|
||||||
|
- 避免 fetch 到已检出本地分支;
|
||||||
|
- Gitea SSH 镜像;
|
||||||
|
- `docs/aios-v2/` 架构事实源;
|
||||||
|
- 候选分支、部署和回滚资产。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- Controller detached 且 clean;
|
||||||
|
- 本地/Gitea OID 一致;
|
||||||
|
- 架构与需求文档纳入版本。
|
||||||
|
|
||||||
|
### Phase 1:Control Plane 权威化
|
||||||
|
|
||||||
|
目标:将 Tenant、Agent、Task、Policy、Approval 和 Audit 收敛到 AIOS。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `aios_v2/control_plane.py`
|
||||||
|
- `aios_v2/persistence.py`
|
||||||
|
- `aios_v2/gateway.py`
|
||||||
|
- `aios_v2/app.py`
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 任务状态机统一;
|
||||||
|
- 幂等、revision、审计和跨租户拒绝通过测试;
|
||||||
|
- 服务重启后权威状态恢复。
|
||||||
|
|
||||||
|
### Phase 2:OpenClaw 解耦
|
||||||
|
|
||||||
|
目标:OpenClaw 从控制逻辑转为执行适配器。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
|
||||||
|
1. 定义签名命令合同;
|
||||||
|
2. 引入 Adapter;
|
||||||
|
3. 将直接调用迁移到 AIOS Orchestrator;
|
||||||
|
4. Runtime 只返回执行结果和健康状态;
|
||||||
|
5. 关闭或网络隔离旧公共控制入口。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 未批准任务无法生成有效命令;
|
||||||
|
- 命令绑定 tenant/task/trace/idempotency;
|
||||||
|
- 旧控制接口冻结并记录替代路径。
|
||||||
|
|
||||||
|
### Phase 3:Hermes 解耦
|
||||||
|
|
||||||
|
目标:Hermes 只承担租户分区队列和 Worker Runtime。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
|
||||||
|
1. 定义 HermesJob/HermesResult;
|
||||||
|
2. 强制 tenant/cell/profile 分区;
|
||||||
|
3. 引入 claim/lease/heartbeat;
|
||||||
|
4. 将结果统一回写 AIOS;
|
||||||
|
5. 移除 Hermes 内部策略和任务事实源。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- Worker 无权创建 AIOS Task;
|
||||||
|
- 租约过期后 Job 可恢复;
|
||||||
|
- 跨租户 claim/read/result 被拒绝。
|
||||||
|
|
||||||
|
### Phase 4:Web 与运维统一
|
||||||
|
|
||||||
|
目标:所有用户与运维功能通过 AIOS Web/API。
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
- Tenant、Agent、Task、Runtime、Audit、Execution Evidence、GitOps;
|
||||||
|
- systemd/Docker/Compose;
|
||||||
|
- healthcheck、rollback、release report。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 浏览器不直接调用 Runtime;
|
||||||
|
- 生产身份代理和权限模型通过验证;
|
||||||
|
- 部署、重启和回滚演练通过。
|
||||||
|
|
||||||
|
### Phase 5:生产数据层与 HA
|
||||||
|
|
||||||
|
目标:将候选 SQLite/内存运行状态替换或强化为生产级后端。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 数据迁移;
|
||||||
|
- RLS 或物理隔离;
|
||||||
|
- 备份恢复;
|
||||||
|
- HA 与容量;
|
||||||
|
- 多实例并发;
|
||||||
|
- 灾难恢复目标。
|
||||||
|
|
||||||
|
该阶段不得改变上层合同,避免 Runtime 和 Web 再次耦合存储实现。
|
||||||
|
|
||||||
|
## 4. 分支与提交策略
|
||||||
|
|
||||||
|
### 4.1 分支类型
|
||||||
|
|
||||||
|
| 类型 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `task/*` | 可执行任务合同与主体实现 |
|
||||||
|
| `patch/*` | 独立修复或窄范围增强 |
|
||||||
|
| `integration/*` | 多模块候选收敛 |
|
||||||
|
| `result/*` | 自动执行结果与证据 |
|
||||||
|
| `release/*` | 受控发布候选 |
|
||||||
|
|
||||||
|
### 4.2 规则
|
||||||
|
|
||||||
|
- 禁止直接修改 `main`;
|
||||||
|
- 一个提交应可独立审阅和回滚;
|
||||||
|
- 大文件和大范围变更应拆分;
|
||||||
|
- 所有行为变化必须同步文档和测试;
|
||||||
|
- 生产 Tag、部署和回滚需要 Owner 批准。
|
||||||
|
|
||||||
|
## 5. GitOps 交付流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
需求/架构更新
|
||||||
|
|
|
||||||
|
v
|
||||||
|
任务分支 + 文档 + 测试
|
||||||
|
|
|
||||||
|
v
|
||||||
|
代码提交 + Secret Gate
|
||||||
|
|
|
||||||
|
v
|
||||||
|
本地 bare repo 同步
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Gitea 镜像与 OID 校验
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Candidate 集成
|
||||||
|
|
|
||||||
|
v
|
||||||
|
自动测试与预发布
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Owner 审批
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Merge / Tag / Production Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 同步状态
|
||||||
|
|
||||||
|
GitOps 返回必须区分:
|
||||||
|
|
||||||
|
- local sync;
|
||||||
|
- Gitea sync;
|
||||||
|
- branch OID;
|
||||||
|
- workspace mode;
|
||||||
|
- dirty state。
|
||||||
|
|
||||||
|
仅返回 `common_git_dir synced` 不足以证明 Web 远端可见。
|
||||||
|
|
||||||
|
## 6. 发布门禁
|
||||||
|
|
||||||
|
### Gate A:设计完整性
|
||||||
|
|
||||||
|
- 用户需求、目标架构、模块契约、ADR 和追踪矩阵同步;
|
||||||
|
- 无冲突标记、断链或过期路径。
|
||||||
|
|
||||||
|
### Gate B:代码质量
|
||||||
|
|
||||||
|
- 包可导入;
|
||||||
|
- 静态编译通过;
|
||||||
|
- Secret Scanner 通过;
|
||||||
|
- 无已知越权旁路。
|
||||||
|
|
||||||
|
### Gate C:测试
|
||||||
|
|
||||||
|
- 单元、集成、多租户负向、重启恢复、Runtime 边界测试通过;
|
||||||
|
- 记录 commit、命令、exit code、pass/fail 和 artifact digest。
|
||||||
|
|
||||||
|
### Gate D:预发布
|
||||||
|
|
||||||
|
- systemd 或 Compose 启动成功;
|
||||||
|
- 健康检查通过;
|
||||||
|
- OpenClaw/Hermes 真实联调通过;
|
||||||
|
- 备份、重启和回滚演练通过;
|
||||||
|
- 日志中无敏感信息。
|
||||||
|
|
||||||
|
### Gate E:生产批准
|
||||||
|
|
||||||
|
- Owner 批准 merge、Tag 和生产部署;
|
||||||
|
- 发布窗口、回滚负责人和观察指标明确;
|
||||||
|
- 生产变更通过 GitOps 记录。
|
||||||
|
|
||||||
|
## 7. 回滚策略
|
||||||
|
|
||||||
|
### 7.1 代码回滚
|
||||||
|
|
||||||
|
- 使用 `/opt/aios/current` 原子 symlink 切换;
|
||||||
|
- 回滚前备份状态;
|
||||||
|
- 健康检查失败自动恢复 previous release。
|
||||||
|
|
||||||
|
### 7.2 状态回滚
|
||||||
|
|
||||||
|
默认不自动回滚状态,避免覆盖候选运行期间新增的 Task、Audit 和 Evidence。
|
||||||
|
|
||||||
|
状态恢复只有在:
|
||||||
|
|
||||||
|
- 明确确认 schema 不兼容;
|
||||||
|
- 数据备份可验证;
|
||||||
|
- Owner 显式授权;
|
||||||
|
|
||||||
|
时启用。
|
||||||
|
|
||||||
|
### 7.3 Runtime 回滚
|
||||||
|
|
||||||
|
OpenClaw/Hermes Adapter 采用稳定合同。若候选执行层失败,可恢复旧 Runtime 版本,但不得恢复旧控制权旁路。
|
||||||
|
|
||||||
|
## 8. Bug 修复流程
|
||||||
|
|
||||||
|
生产或预发布 Bug:
|
||||||
|
|
||||||
|
1. 创建 `patch/bugfix/*`;
|
||||||
|
2. 添加可复现测试;
|
||||||
|
3. 修复代码;
|
||||||
|
4. 更新相关需求/架构/契约/ADR;
|
||||||
|
5. 运行候选验收;
|
||||||
|
6. 进入新的 release candidate;
|
||||||
|
7. Owner 批准部署。
|
||||||
|
|
||||||
|
禁止在生产目录直接编辑文件后不回写 Git。
|
||||||
|
|
||||||
|
## 9. 文档同步规则
|
||||||
|
|
||||||
|
以下变化必须更新文档:
|
||||||
|
|
||||||
|
- 用户行为或页面;
|
||||||
|
- API 路径、Header、请求/响应;
|
||||||
|
- 数据模型、状态机、策略;
|
||||||
|
- OpenClaw/Hermes/Router 边界;
|
||||||
|
- 多租户与安全模型;
|
||||||
|
- 部署、健康检查、回滚;
|
||||||
|
- 测试或发布门禁。
|
||||||
|
|
||||||
|
对应关系:
|
||||||
|
|
||||||
|
- 需求变化:更新 `01-USER-REQUIREMENTS.md`;
|
||||||
|
- 架构变化:更新 `02-TARGET-ARCHITECTURE.md` 和 ADR;
|
||||||
|
- 迁移/发布变化:更新本文件;
|
||||||
|
- 合同变化:更新 `04-MODULE-CONTRACTS.md`;
|
||||||
|
- 代码/测试路径变化:更新 `05-TRACEABILITY-MATRIX.md`。
|
||||||
|
|
||||||
|
## 10. 当前候选后续步骤
|
||||||
|
|
||||||
|
1. 完成文档同步门禁和 Acceptance Runner;
|
||||||
|
2. 在精确 Candidate commit 执行测试;
|
||||||
|
3. 推送 Gitea 并校验 OID;
|
||||||
|
4. 部署隔离预发布环境;
|
||||||
|
5. 联调真实 OpenClaw/Hermes;
|
||||||
|
6. 执行重启、性能、安全和回滚演练;
|
||||||
|
7. 生成最终 evidence;
|
||||||
|
8. 请求 Owner 的 merge/Tag/production approval。
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
# AIOS V2 模块契约
|
||||||
|
|
||||||
|
- 文档版本:`V2R1-hardening-candidate`
|
||||||
|
- 状态:加固候选契约基线
|
||||||
|
- 原则:Control Plane 权威、Runtime 仅执行、默认拒绝、服务级最小权限、全链路租户隔离、所有权威状态可审计和恢复
|
||||||
|
|
||||||
|
## 1. 通用可信上下文契约
|
||||||
|
|
||||||
|
所有控制与执行请求必须携带以下上下文:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `tenant_id` | 是 | 权威租户标识 |
|
||||||
|
| `principal_id` | 控制和 Reconcile 是 | 已认证主体 |
|
||||||
|
| `trace_id` | 是 | 全链路追踪 |
|
||||||
|
| `task_id` | 执行阶段是 | AIOS 权威任务 |
|
||||||
|
| `agent_id` | 执行和 Provenance 是 | 任务绑定 Agent |
|
||||||
|
| `provenance_id` | 生产副作用操作是 | 父 Workflow Provenance |
|
||||||
|
| `run_id` | 生产副作用操作是 | 本次执行 Run |
|
||||||
|
| `dispatch_id` | Runtime Result 是 | 持久化 Dispatch 记录 |
|
||||||
|
| `policy_version` | 控制请求是 | 策略版本 |
|
||||||
|
| `idempotency_key` | 创建/执行是 | 幂等键 |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 缺少必填字段时 fail closed。
|
||||||
|
- 客户端不得覆盖可信代理或 AIOS 生成的租户、身份和追踪字段。
|
||||||
|
- Runtime 返回的 tenant/task/trace/runtime execution 必须与持久 Dispatch 完全匹配。
|
||||||
|
- Runtime Result 还必须匹配 Principal、Parent Provenance 和 Run。
|
||||||
|
- 所有持久化键、缓存键、队列分区、Receipt 和 Evidence 路径必须包含租户命名空间。
|
||||||
|
|
||||||
|
## 2. Tenant Contract
|
||||||
|
|
||||||
|
### 2.1 TenantRecord
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
display_name
|
||||||
|
status
|
||||||
|
metadata
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `tenant_id` 不可为空且不可变。
|
||||||
|
- 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` 仅保存非敏感引用,不得保存明文凭据。
|
||||||
|
|
||||||
|
## 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 | Control Plane Approval/Cancel |
|
||||||
|
| APPROVED | RUNNING, CANCELLED | Orchestrator Dispatch / Control Plane Cancel |
|
||||||
|
| RUNNING | COMPLETED, FAILED, CANCELLED | Orchestrator Reconcile / Control Plane Cancel |
|
||||||
|
| COMPLETED | 无 | 终态 |
|
||||||
|
| FAILED | 无 | 终态 |
|
||||||
|
| CANCELLED | 无 | 终态 |
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 每次转换必须校验 `revision`。
|
||||||
|
- COMPLETED 必须带 `result`;FAILED 必须带 `error`。
|
||||||
|
- 每次转换生成 AuditEvent。
|
||||||
|
- Runtime 不得直接写 Task Store。
|
||||||
|
- 公共 API 不得直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`。
|
||||||
|
|
||||||
|
## 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` 不得包含明文凭据、私钥或完整敏感 Payload。
|
||||||
|
- Runtime callback 可以生成非权威审计事件,但不得单独触发 Task 状态变化。
|
||||||
|
- Task 状态变化必须经过 Orchestrator 严格 Reconcile。
|
||||||
|
- 生产版需增加不可变外部固化和完整性验证。
|
||||||
|
|
||||||
|
## 7. Workflow Provenance Contract
|
||||||
|
|
||||||
|
### 7.1 RunProvenance
|
||||||
|
|
||||||
|
父 Workflow Provenance 只绑定身份与 Run,不授予操作权限:
|
||||||
|
|
||||||
|
```text
|
||||||
|
provenance_id
|
||||||
|
run_id
|
||||||
|
issuer
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
agent_id
|
||||||
|
principal_id
|
||||||
|
allowed_paths = []
|
||||||
|
allowed_git_operations = []
|
||||||
|
issued_at
|
||||||
|
expires_at
|
||||||
|
policy_version
|
||||||
|
key_id
|
||||||
|
nonce
|
||||||
|
signature
|
||||||
|
schema_version
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 使用 Ed25519 签名。
|
||||||
|
- `allowed_paths` 和 `allowed_git_operations` 必须为空。
|
||||||
|
- 撤销或过期父 Workflow 后,全部子 Grant 自动失效。
|
||||||
|
- 私钥只存在签发端,生产应用只读取公钥。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_v2/provenance.py`
|
||||||
|
|
||||||
|
## 8. Provenance Grant Contract
|
||||||
|
|
||||||
|
### 8.1 ProvenanceGrant
|
||||||
|
|
||||||
|
```text
|
||||||
|
grant_id
|
||||||
|
parent_provenance_id
|
||||||
|
grant_type
|
||||||
|
execution_path
|
||||||
|
run_id
|
||||||
|
issuer
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
agent_id
|
||||||
|
principal_id
|
||||||
|
allowed_git_operations
|
||||||
|
issued_at
|
||||||
|
expires_at
|
||||||
|
policy_version
|
||||||
|
key_id
|
||||||
|
nonce
|
||||||
|
signature
|
||||||
|
schema_version
|
||||||
|
```
|
||||||
|
|
||||||
|
Grant 类型与唯一允许路径:
|
||||||
|
|
||||||
|
| GrantType | ExecutionPath |
|
||||||
|
|---|---|
|
||||||
|
| `code_change` | `code_change` |
|
||||||
|
| `git` | `git_operation` |
|
||||||
|
| `deployment` | `deployment` |
|
||||||
|
| `runtime` | `runtime_execute` |
|
||||||
|
| `evidence` | `evidence_write` |
|
||||||
|
| `rollback` | `rollback` |
|
||||||
|
| `audit` | `audit` |
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 一个 Grant 只允许一个路径。
|
||||||
|
- Git Grant 必须显式声明具体 GitOperation;非 Git Grant 不得包含 Git 操作。
|
||||||
|
- 子 Grant 身份字段必须与父 Workflow 完全一致。
|
||||||
|
- 子 Grant TTL 不得超过父 Workflow。
|
||||||
|
- `(grant_id, path, git_operation, request_digest)` 重复使用时拒绝。
|
||||||
|
- Grant 支持独立 `issued/verified/used/expired/revoked` 生命周期。
|
||||||
|
|
||||||
|
实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/provenance_grants.py`
|
||||||
|
- `active-runtime/app/aios_v2/provenance_scoped.py`
|
||||||
|
|
||||||
|
## 9. Service Scoped Writer Contract
|
||||||
|
|
||||||
|
生产进程只加载职责所需 Grant:
|
||||||
|
|
||||||
|
- AIOS Application:`runtime_execute`、`evidence_write`、`audit`。
|
||||||
|
- Git Worker:`git_operation`,代码提交场景另需独立 `code_change` Grant。
|
||||||
|
- Deployment Worker:`deployment`。
|
||||||
|
- Rollback Worker:`rollback`。
|
||||||
|
- CLI 单次授权:一个 `--path` + 一个 `--grant-id`。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 空 Scope 拒绝。
|
||||||
|
- 多余 Grant 映射拒绝。
|
||||||
|
- 未配置路径的方法调用拒绝。
|
||||||
|
- Receipt 必须记录 `grant_id`、`writer_id` 和服务 Scope。
|
||||||
|
|
||||||
|
## 10. Dispatch Contract
|
||||||
|
|
||||||
|
### 10.1 DispatchRecord
|
||||||
|
|
||||||
|
```text
|
||||||
|
dispatch_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
agent_id
|
||||||
|
principal_id
|
||||||
|
runtime
|
||||||
|
runtime_execution_id
|
||||||
|
trace_id
|
||||||
|
provenance_id
|
||||||
|
run_id
|
||||||
|
authorization_id
|
||||||
|
state
|
||||||
|
expected_worker_id
|
||||||
|
result_digest
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
completed_at
|
||||||
|
```
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PREPARED
|
||||||
|
RUNNING
|
||||||
|
QUEUED
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
CANCELLED
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Dispatch 必须在 Runtime 副作用之前持久化。
|
||||||
|
- 同一租户的 Task 和 Runtime Execution 各只能绑定一个 Dispatch。
|
||||||
|
- 终态 Dispatch 不得跳转到不同终态。
|
||||||
|
- 相同终态结果允许幂等读取,不同结果摘要拒绝。
|
||||||
|
- SQLite Repository 必须支持按 Task 和 Runtime Execution 恢复。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_v2/dispatch_store.py`
|
||||||
|
|
||||||
|
## 11. OpenClaw Contract
|
||||||
|
|
||||||
|
### 11.1 OpenClawCommand v2
|
||||||
|
|
||||||
|
```text
|
||||||
|
contract_version = aios.openclaw.command.v2
|
||||||
|
signature_algorithm = Ed25519
|
||||||
|
command_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
action
|
||||||
|
payload
|
||||||
|
idempotency_key
|
||||||
|
created_at
|
||||||
|
expires_at
|
||||||
|
key_id
|
||||||
|
public_key_fingerprint
|
||||||
|
signature
|
||||||
|
```
|
||||||
|
|
||||||
|
签名覆盖全部未签名字段,包括算法、Key ID 和公钥 Fingerprint。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 使用规范化 JSON 与 Ed25519。
|
||||||
|
- HMAC-SHA256 不再是生产命令合同。
|
||||||
|
- 只接受由已批准任务和已授权 Dispatch 生成的命令。
|
||||||
|
- 过期、未来时间超差、签名错误、Key ID 或 Fingerprint 不匹配时拒绝。
|
||||||
|
- Fingerprint 使用恒定时间比较。
|
||||||
|
- `key_id` 只引用密钥,不包含密钥材料。
|
||||||
|
|
||||||
|
### 11.2 OpenClawResult
|
||||||
|
|
||||||
|
```text
|
||||||
|
command_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
state
|
||||||
|
output
|
||||||
|
error
|
||||||
|
started_at
|
||||||
|
finished_at
|
||||||
|
runtime_id
|
||||||
|
attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
终态:`COMPLETED`、`FAILED`、`CANCELLED`、`TIMED_OUT`、`REJECTED`。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Result 必须匹配持久 Dispatch 的 command、tenant、task、trace、principal、provenance 和 run。
|
||||||
|
- 只有 Orchestrator 可以更新 AIOS Task。
|
||||||
|
- callback 审计不是权威 Reconcile。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_openclaw/`
|
||||||
|
|
||||||
|
## 12. Hermes Contract
|
||||||
|
|
||||||
|
### 12.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
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `tenant_id + cell_id + profile_id` 形成执行分区。
|
||||||
|
- claim 只能领取同分区 Job。
|
||||||
|
- Lease 到期 Job 回到 QUEUED。
|
||||||
|
- revision 冲突不得覆盖。
|
||||||
|
|
||||||
|
### 12.2 HermesResult
|
||||||
|
|
||||||
|
```text
|
||||||
|
job_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
state
|
||||||
|
worker_id
|
||||||
|
output
|
||||||
|
error
|
||||||
|
started_at
|
||||||
|
finished_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 终态 Result 必须携带 Worker ID。
|
||||||
|
- Worker 必须持有 Job Lease。
|
||||||
|
- Result 必须匹配 Dispatch、Principal、Parent Provenance 和 Run。
|
||||||
|
- 只允许内部入口 `POST /api/v2/internal/runtime/hermes/reconcile` 处理。
|
||||||
|
- 旧入口 `POST /api/v2/hermes/results/reconcile` 返回 `410 Gone`。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_hermes/`
|
||||||
|
|
||||||
|
## 13. Execution Evidence Contract
|
||||||
|
|
||||||
|
### 13.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
|
||||||
|
provenance_id
|
||||||
|
run_id
|
||||||
|
last_authorization_id
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 按租户存储和查询。
|
||||||
|
- 生产 `claim/heartbeat/complete` 落盘前必须通过 Evidence Grant。
|
||||||
|
- Evidence ID 不得被不同 Task、Agent 或 Provenance Context 复用。
|
||||||
|
- 不得包含 Secret 或完整敏感 Payload。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
|
||||||
|
## 14. Git Writer Contract
|
||||||
|
|
||||||
|
允许操作:`READ_REF`、`FETCH`、`CREATE_BRANCH`、`COMMIT`、`MERGE`、`PUSH`、`UPDATE_EVIDENCE`,具体能力由 Git Grant 限定。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 禁止直接操作 `main` 或 `master`。
|
||||||
|
- 所有命令使用参数数组,禁止 Shell 字符串拼接。
|
||||||
|
- Commit 和 Evidence Commit 必须先计算文件内容摘要并完成 Grant 授权,再执行 `git add`。
|
||||||
|
- `UPDATE_EVIDENCE` 只允许 `.agent/` 路径。
|
||||||
|
- 输出必须脱敏并截断。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_v2/provenance_git.py`
|
||||||
|
|
||||||
|
## 15. Web/API Contract
|
||||||
|
|
||||||
|
### 15.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
|
||||||
|
X-AIOS-Provenance-ID
|
||||||
|
X-AIOS-Run-ID
|
||||||
|
```
|
||||||
|
|
||||||
|
生产约束:
|
||||||
|
|
||||||
|
- 可信代理必须删除客户端同名头后重新注入。
|
||||||
|
- 应用不得直接暴露公网端口。
|
||||||
|
- Header 与 Body 中重复的 Provenance/Run 值必须一致。
|
||||||
|
|
||||||
|
### 15.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}/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/internal/runtime/hermes/reconcile
|
||||||
|
```
|
||||||
|
|
||||||
|
公共生命周期旁路:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v2/tasks/{id}/start -> 403
|
||||||
|
POST /api/v2/tasks/{id}/complete -> 403
|
||||||
|
POST /api/v2/tasks/{id}/fail -> 403
|
||||||
|
```
|
||||||
|
|
||||||
|
## 16. Persistence Contract
|
||||||
|
|
||||||
|
候选版:
|
||||||
|
|
||||||
|
- Control Plane:SQLite Snapshot。
|
||||||
|
- Dispatch:独立 SQLite Repository。
|
||||||
|
- Provenance/Grant/Usage/Event:SQLite WAL。
|
||||||
|
- Evidence/Receipt:租户隔离原子文件写入。
|
||||||
|
- 写事务使用原子替换或 `BEGIN IMMEDIATE`。
|
||||||
|
|
||||||
|
生产替换要求:
|
||||||
|
|
||||||
|
- 不改变 Domain/API Contract。
|
||||||
|
- 提供 migration、backup、restore、HA、隔离和容量测试。
|
||||||
|
- 恢复时保留已使用 Grant 与终态 Dispatch,禁止旧授权重放。
|
||||||
|
|
||||||
|
## 17. Deployment Contract
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
- 非 root `aios` 用户。
|
||||||
|
- 状态目录 `/var/lib/aios-v2`。
|
||||||
|
- 私钥通过受保护 credential 注入,不写入 Git 或普通环境文件。
|
||||||
|
- 私钥文件必须是 regular file、禁止 symlink、权限 `0600` 或更严格。
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
- 非 root UID/GID。
|
||||||
|
- read-only root filesystem。
|
||||||
|
- drop all capabilities。
|
||||||
|
- 独立状态 volume 和 Secret mount。
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
- 回滚前备份状态。
|
||||||
|
- 默认只回滚代码。
|
||||||
|
- 状态恢复需独立 Rollback Grant 和显式批准。
|
||||||
|
- 回滚后必须执行 Healthcheck 与恢复验证。
|
||||||
|
|
||||||
|
## 18. 契约变更规则
|
||||||
|
|
||||||
|
以下变化必须在同一分支更新本文档、追踪矩阵和相关 ADR:
|
||||||
|
|
||||||
|
- 字段、状态、Header、API、错误语义;
|
||||||
|
- 租户、安全和 Provenance 约束;
|
||||||
|
- OpenClaw/Hermes 边界;
|
||||||
|
- Dispatch、Persistence、Deployment、Rollback Contract;
|
||||||
|
- Evidence、Audit、Receipt 格式;
|
||||||
|
- 服务 Scope 或 Grant 类型。
|
||||||
|
|
||||||
|
不兼容变化必须新增 ADR 和版本号,不得静默修改。正式 Gate A 结论必须基于实际执行 Acceptance 和保存的证据,不得仅凭测试文件存在宣布通过。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# AIOS V2 需求—架构—代码—测试—部署追踪矩阵
|
||||||
|
|
||||||
|
- 文档版本:`V2R1-hardening-candidate`
|
||||||
|
- 状态:实现与测试代码已提交;测试执行和 Acceptance 证据待完成
|
||||||
|
- 基线需求:`UR-001`~`UR-022`
|
||||||
|
- 判定规则:测试文件存在不等于测试通过;只有实际执行并保存证据后,状态才能改为“已验证”
|
||||||
|
|
||||||
|
## 1. 状态定义
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 已实现 | 代码、配置或文档已经提交到当前加固分支 |
|
||||||
|
| 测试已提交 | 自动测试代码已经提交,但本报告没有实际执行结果 |
|
||||||
|
| 待验收 | 需要运行 `deploy/aios-v2/acceptance/run-all.sh` 或对应预发布检查并保存证据 |
|
||||||
|
| 未批准 | 尚未获得 Owner 的 Merge、Tag 或生产部署批准 |
|
||||||
|
|
||||||
|
## 2. 用户需求追踪
|
||||||
|
|
||||||
|
| 需求 | 架构/决策 | 主要实现 | 测试代码 | 部署/验收入口 | 当前状态 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| UR-001 唯一控制面 | ADR-001;模块契约第 2~6 节 | `active-runtime/app/aios_v2/control_plane.py`、`gateway.py`、`orchestrator.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/acceptance/run-all.sh`;公共生命周期旁路负向检查 | 已实现;测试已提交;待验收 |
|
||||||
|
| UR-002 复用现有能力 | ADR-002 | `active-runtime/app/aios_openclaw/`、`active-runtime/app/aios_hermes/`、`active-runtime/app/aios_v2/orchestrator.py` | `test_candidate_flow.py` | OpenClaw/Hermes 联调 | 已实现;真实 Runtime 联调待验收 |
|
||||||
|
| UR-003 OpenClaw/Hermes 解耦 | ADR-002;模块契约第 11、12 节 | OpenClaw Adapter、Hermes Adapter/Worker、内部 Reconcile | `test_candidate_flow.py` | `/api/v2/internal/runtime/hermes/reconcile`;Runtime callback 审计检查 | 已实现;测试已提交;待验收 |
|
||||||
|
| UR-004 全面 Web 化 | 目标架构;模块契约第 15 节 | `active-runtime/app/aios_v2/app.py`、`console.py`、`gateway.py` | `test_candidate_flow.py` | Web/API 冒烟、Healthcheck | 已实现;UI/浏览器验收待执行 |
|
||||||
|
| UR-005 多租户隔离 | ADR-004 | `control_plane.py`、`persistence.py`、`execution_tracking.py`、`dispatch_store.py` | `test_candidate_flow.py::test_cross_tenant_task_read_is_denied` | 跨租户负向 Acceptance | 已实现;测试已提交;待验收 |
|
||||||
|
| UR-006 权威任务生命周期 | ADR-001;模块契约第 4 节 | `control_plane.py`、`gateway.py`、`orchestrator.py` | `test_candidate_flow.py` | `/start`、`/complete`、`/fail` 应返回拒绝;Runtime 严格 Reconcile | 已实现;测试/接口验收待执行 |
|
||||||
|
| UR-007 策略与审批 | ADR-001 | `control_plane.py`、`gateway.py` | `test_candidate_flow.py` | Approval、deny audit、policy version 检查 | 已实现;策略场景验收待执行 |
|
||||||
|
| UR-008 幂等与并发安全 | 模块契约第 4、10、12 节 | Task idempotency/revision、Hermes lease/revision、Dispatch 唯一约束 | `test_candidate_flow.py` | 幂等冲突、Lease、终态冲突 Acceptance | 已实现;测试代码覆盖部分场景;待验收 |
|
||||||
|
| UR-009 可观测与执行证据 | 模块契约第 6、13 节 | `execution_tracking.py`、`gitops_status.py`、`console.py` | `test_candidate_flow.py::test_execution_tracking_persists_claim_heartbeat_and_result` | `/api/v2/executions`、Evidence Receipt 检查 | 已实现;测试已提交;待验收 |
|
||||||
|
| UR-010 GitOps 唯一变更流程 | ADR-003 | `active-runtime/app/aios_v2/provenance_git.py`、GitOps 工具与任务资产 | `test_provenance.py`、Grant 隔离测试 | Branch/Commit/Push 门禁、Controller clean、OID 同步 | 已实现候选控制;环境 GitOps 验收待执行 |
|
||||||
|
| UR-011 持久化与恢复 | ADR-006;模块契约第 10、16 节 | `persistence.py`、`dispatch_store.py`、`provenance.py`、`provenance_grants.py` | `test_candidate_flow.py` 中 Control Plane 与 Dispatch recovery | 重启、备份、恢复、锁等待、容量测试 | 候选实现完成;恢复测试已提交;生产 HA/容量待验收 |
|
||||||
|
| UR-012 审计与合规 | ADR-001、ADR-004、ADR-009 | `control_plane.py` AuditEvent、`app.py` callback audit、Provenance/Grant Event/Receipt | `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` | `/api/v2/audit`、Receipt/Event 完整性检查 | 已实现候选审计;不可变外部固化待预发布 |
|
||||||
|
| UR-013 密钥安全 | ADR-009;模块契约第 6、11、17 节 | Ed25519 Signer/Verifier、protected file reader、SecretRef 配置 | `test_provenance.py`、`test_candidate_flow.py` | Secret scanner、权限检查、容器/systemd credential 检查 | 已实现;环境密钥注入验收待执行 |
|
||||||
|
| UR-014 部署与回滚 | 迁移与交付文档;模块契约第 17 节 | `deploy/aios-v2/systemd/`、`docker/`、`healthcheck/`、`rollback/` | Acceptance 脚本与候选测试 | systemd/Docker 安装、Healthcheck、备份与回滚演练 | 资产已提交;实际部署/回滚未执行 |
|
||||||
|
| UR-015 文档与代码同步 | ADR-005 | `tools/aios_v2_docs_code_check.py`、`docs/aios-v2/` | 文档门禁脚本 | Acceptance Runner 调用文档门禁 | 已实现;门禁尚未实际执行 |
|
||||||
|
| UR-016 Workflow Run Provenance | ADR-009;模块契约第 7 节 | `active-runtime/app/aios_v2/provenance.py`、`provenance_grants.py::WorkflowProvenanceIssuer` | `test_provenance.py`、`test_provenance_grant_isolation.py::test_parent_workflow_has_no_operational_rights` | CLI `issue-workflow`、状态/撤销检查 | 已实现;测试已提交;待验收 |
|
||||||
|
| UR-017 最小权限子 Grant | ADR-009;模块契约第 8 节 | `provenance_grants.py`、`provenance_scoped.py` | `test_provenance_grant_isolation.py` | `issue-grant`、Grant status/revoke、Receipt 检查 | 已实现;专项测试已提交;待验收 |
|
||||||
|
| UR-018 Runtime Result 信任绑定 | ADR-009;模块契约第 10~12 节 | `orchestrator.py`、`dispatch_store.py`、内部 Hermes Reconcile | `test_candidate_flow.py` | OpenClaw/Hermes 伪造 tenant/task/trace/principal/provenance/run 负向验收 | 已实现;恢复正向测试已提交;完整负向验收待执行 |
|
||||||
|
| UR-019 Dispatch 持久化与恢复 | ADR-009;模块契约第 10 节 | `dispatch_store.py`、`orchestrator.py`、`app.py` | `test_candidate_flow.py::test_dispatch_and_control_plane_recover_before_hermes_reconcile` | 进程重启后读取 Dispatch 并完成 Reconcile | 已实现;测试已提交;待执行 |
|
||||||
|
| UR-020 OpenClaw Ed25519 命令合同 | ADR-009;模块契约第 11 节 | `active-runtime/app/aios_openclaw/contract.py`、`adapter.py`、`app.py` | `test_candidate_flow.py`、`test_provenance.py` | 真实 OpenClaw v2 合同联调、Key rotation、clock skew/expiry 负向验收 | 已实现;测试代码已升级;真实联调待执行 |
|
||||||
|
| UR-021 服务级权限隔离 | ADR-009;模块契约第 9 节 | `provenance_scoped.py`、`provenance_operations.py`、`app.py` | `test_provenance_grant_isolation.py` | Application 仅加载 Runtime/Evidence/Audit;Git/Deploy/Rollback 单独 Scope | 已实现;专项测试已提交;配置验收待执行 |
|
||||||
|
| UR-022 CLI 单 Grant 授权 | ADR-009;模块契约第 9 节 | `provenance_cli.py` | `test_provenance_grant_isolation.py` 提供 Writer/Grant 基础验证;CLI 端到端待补执行证据 | `authorize --path ... --grant-id ...`;旧批量 Grant 参数应不存在 | 已实现;CLI 端到端验收待执行 |
|
||||||
|
|
||||||
|
## 3. P0 加固项追踪
|
||||||
|
|
||||||
|
| P0 | 风险 | 实现 | 测试/证据 | 状态 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P0-1 公共生命周期旁路 | 客户端直接推进 RUNNING/COMPLETED/FAILED | `gateway.py` 公共入口拒绝;`orchestrator.py` 权威推进 | API 负向 Acceptance | 代码完成;待执行 |
|
||||||
|
| P0-2 Dispatch 持久化 | 重启丢失 Runtime 绑定 | `dispatch_store.py` SQLite Repository;`app.py` 接线 | Dispatch restart recovery 测试 | 代码/测试已提交;待执行 |
|
||||||
|
| P0-3 Result 严格绑定 | 伪造或错配 Runtime Result 更新 Task | `orchestrator.py` 校验 Runtime Execution、Tenant、Task、Trace、Principal、Provenance、Run | Runtime 正向恢复测试;负向 Acceptance | 代码完成;完整验收待执行 |
|
||||||
|
| P0-4 OpenClaw Ed25519 | HMAC 共享密钥和合同不一致 | `aios_openclaw/contract.py` v2 Ed25519;安全文件读取 | 候选 Flow 测试;真实 Runtime 联调 | 代码/测试已提交;联调待执行 |
|
||||||
|
| P0-5 最小权限 Grant | 单凭证拥有全部生产路径 | Workflow + Child Grant + Scoped Writer + 单 Grant CLI | Grant isolation 专项测试 | 代码/测试已提交;待执行 |
|
||||||
|
|
||||||
|
## 4. Acceptance 证据要求
|
||||||
|
|
||||||
|
完整 Acceptance 至少应保存:
|
||||||
|
|
||||||
|
1. 测试命令、开始/结束时间、退出码和完整日志。
|
||||||
|
2. `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` 的实际结果。
|
||||||
|
3. 文档—代码同步门禁实际结果。
|
||||||
|
4. Secret scanner、依赖和容器/systemd 安全检查结果。
|
||||||
|
5. OpenClaw Ed25519 v2 真实联调结果。
|
||||||
|
6. Hermes 内部 Reconcile 正向与伪造上下文负向结果。
|
||||||
|
7. Control Plane、Dispatch、Provenance/Grant 的重启恢复结果。
|
||||||
|
8. 部署、Healthcheck、备份和回滚演练结果。
|
||||||
|
9. 分支、Commit、Controller clean、远端 OID 同步证据。
|
||||||
|
10. Owner 对 Merge、Tag 和生产部署的独立批准。
|
||||||
|
|
||||||
|
## 5. 当前发布门禁
|
||||||
|
|
||||||
|
| Gate | 状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 实现 | 完成 | 五项 P0 代码与配置已提交当前加固分支 |
|
||||||
|
| 测试代码 | 完成 | 回归、Dispatch recovery、Grant isolation 测试已提交 |
|
||||||
|
| 测试执行 | 未完成 | 当前没有可验证的实际运行日志和退出码 |
|
||||||
|
| 文档同步 | 已提交 | 需求、契约、ADR、追踪矩阵已更新;门禁尚未执行 |
|
||||||
|
| Acceptance | 未完成 | `run-all.sh` 尚无本轮实际执行证据 |
|
||||||
|
| Gate A | 未批准 | 只能在 Acceptance 通过并完成独立审查后批准 |
|
||||||
|
| Merge/Tag/生产部署 | 未批准 | 需要 AIOS Owner 明确批准 |
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# AIOS V2 文档治理规范
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 适用范围:`docs/aios-v2/`、架构决策记录、生产候选报告、在线任务合同、部署与验收说明
|
||||||
|
- 状态:已采纳
|
||||||
|
|
||||||
|
## 1. 目的
|
||||||
|
|
||||||
|
本规范用于保证 AIOS V2 的需求、架构、代码、测试、部署和运行事实始终保持一致,避免出现以下问题:
|
||||||
|
|
||||||
|
- 代码已经变化,但架构文档仍描述旧边界;
|
||||||
|
- API、字段或状态机变化后,测试与追踪矩阵未同步;
|
||||||
|
- OpenClaw、Hermes 或 Agent Router 的控制职责发生漂移;
|
||||||
|
- 候选分支存在实现,但版本无法解释为什么这样设计;
|
||||||
|
- 部署、回滚或安全策略发生变化,却没有形成可审计文档;
|
||||||
|
- 归档包只包含摘要或占位符,无法作为正式交付材料。
|
||||||
|
|
||||||
|
## 2. 文档语言要求
|
||||||
|
|
||||||
|
所有面向用户、产品、架构、运维、审计和发布的正式文档统一使用中文。
|
||||||
|
|
||||||
|
以下内容保留原始英文技术标识:
|
||||||
|
|
||||||
|
- 类名、函数名、字段名、环境变量;
|
||||||
|
- API 路径、HTTP 方法和 Header;
|
||||||
|
- Git 分支、Commit、Tag;
|
||||||
|
- OpenClaw、Hermes、Agent Router、GitOps 等专有名称;
|
||||||
|
- systemd、Docker、SQLite、HMAC、HTTP、JSON 等标准术语。
|
||||||
|
|
||||||
|
禁止在同一文档中无必要地切换中英文叙述。
|
||||||
|
|
||||||
|
## 3. 文档完整性要求
|
||||||
|
|
||||||
|
每份正式文档至少包含:
|
||||||
|
|
||||||
|
1. 文档目的;
|
||||||
|
2. 适用范围;
|
||||||
|
3. 当前版本和状态;
|
||||||
|
4. 完整正文;
|
||||||
|
5. 与代码、测试或部署的映射;
|
||||||
|
6. 验收规则;
|
||||||
|
7. 已知限制;
|
||||||
|
8. 后续演进方向;
|
||||||
|
9. 更新触发条件。
|
||||||
|
|
||||||
|
正式归档包禁止使用以下内容代替正文:
|
||||||
|
|
||||||
|
- “后续补充”;
|
||||||
|
- “待完善”;
|
||||||
|
- 只有标题没有说明;
|
||||||
|
- 只包含目录摘要;
|
||||||
|
- 与候选分支代码不一致的旧文档。
|
||||||
|
|
||||||
|
确实尚未验证的项目,应明确标记为“已实现但尚未验证”,并说明验证命令、责任方和进入下一状态的条件。
|
||||||
|
|
||||||
|
## 4. 文档分类与维护责任
|
||||||
|
|
||||||
|
| 文档 | 主要内容 | 触发更新的变化 |
|
||||||
|
|---|---|---|
|
||||||
|
| `README.md` | 文档索引、版本状态、实现地图 | 新增模块、目录、版本或交付物 |
|
||||||
|
| `01-USER-REQUIREMENTS.md` | 用户目标、功能需求、非功能需求、验收场景 | 用户行为、产品范围、验收标准变化 |
|
||||||
|
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户、部署架构 | 控制权、Runtime 边界、数据流或安全模型变化 |
|
||||||
|
| `03-MIGRATION-AND-DELIVERY.md` | 迁移阶段、GitOps、发布门禁、回滚、Bug 修复 | 迁移路线、发布流程、门禁或回滚方式变化 |
|
||||||
|
| `04-MODULE-CONTRACTS.md` | 数据模型、API、状态机、错误语义、Runtime Contract | 字段、接口、状态机、Header、错误行为变化 |
|
||||||
|
| `05-TRACEABILITY-MATRIX.md` | 需求到代码、测试、部署、验收的映射 | 代码、测试、部署路径或状态变化 |
|
||||||
|
| `adr/*` | 跨模块、长期或不可逆架构决策 | 关键决策新增、替换或废弃 |
|
||||||
|
| `AIOS-V2R1-PRODUCTION-CANDIDATE.md` | 候选版本总览、测试、部署、风险和发布结论 | Candidate SHA、测试证据、部署或风险变化 |
|
||||||
|
| `tasks/*` | OpenClaw、Codex 等在线执行任务 | 职责、边界、阻塞、验收或执行流程变化 |
|
||||||
|
|
||||||
|
## 5. 代码变化与文档同步规则
|
||||||
|
|
||||||
|
以下代码或配置路径发生变化时,必须同步更新 `docs/aios-v2/`:
|
||||||
|
|
||||||
|
### 5.1 Control Plane
|
||||||
|
|
||||||
|
触发路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_v2/control_plane.py
|
||||||
|
active-runtime/app/aios_v2/persistence.py
|
||||||
|
active-runtime/app/aios_v2/gateway.py
|
||||||
|
active-runtime/app/aios_v2/app.py
|
||||||
|
active-runtime/app/aios_v2/orchestrator.py
|
||||||
|
active-runtime/app/aios_v2/execution_tracking.py
|
||||||
|
active-runtime/app/aios_v2/gitops_status.py
|
||||||
|
```
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- 用户需求;
|
||||||
|
- 目标架构;
|
||||||
|
- 模块契约;
|
||||||
|
- 追踪矩阵;
|
||||||
|
- 必要时新增或更新 ADR。
|
||||||
|
|
||||||
|
### 5.2 OpenClaw
|
||||||
|
|
||||||
|
触发路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_openclaw/
|
||||||
|
```
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- OpenClawCommand、OpenClawResult、RuntimeHealth;
|
||||||
|
- 签名、幂等、取消、超时和审计边界;
|
||||||
|
- ADR-002;
|
||||||
|
- 真实 Runtime 联调要求。
|
||||||
|
|
||||||
|
### 5.3 Hermes
|
||||||
|
|
||||||
|
触发路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_hermes/
|
||||||
|
```
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- HermesJob、HermesResult、HermesHealth;
|
||||||
|
- tenant/cell/profile 分区;
|
||||||
|
- Worker Lease、Heartbeat、Recovery;
|
||||||
|
- ADR-002、ADR-004。
|
||||||
|
|
||||||
|
### 5.4 部署与运维
|
||||||
|
|
||||||
|
触发路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
deploy/aios-v2/
|
||||||
|
```
|
||||||
|
|
||||||
|
至少检查:
|
||||||
|
|
||||||
|
- 迁移与交付策略;
|
||||||
|
- 部署契约;
|
||||||
|
- 健康检查;
|
||||||
|
- 回滚;
|
||||||
|
- 候选报告。
|
||||||
|
|
||||||
|
### 5.5 API、Web 与用户行为
|
||||||
|
|
||||||
|
任何 API 路径、Header、请求/响应、页面或用户操作变化,必须同步:
|
||||||
|
|
||||||
|
- `01-USER-REQUIREMENTS.md`;
|
||||||
|
- `02-TARGET-ARCHITECTURE.md`;
|
||||||
|
- `04-MODULE-CONTRACTS.md`;
|
||||||
|
- `05-TRACEABILITY-MATRIX.md`。
|
||||||
|
|
||||||
|
## 6. ADR 治理
|
||||||
|
|
||||||
|
必须新增或更新 ADR 的场景:
|
||||||
|
|
||||||
|
- AIOS、OpenClaw、Hermes、Agent Router 的控制边界变化;
|
||||||
|
- 多租户隔离模型变化;
|
||||||
|
- 持久化、消息、审计、密钥或部署出现跨模块变化;
|
||||||
|
- 不兼容 API、字段或状态机变化;
|
||||||
|
- GitOps、文档门禁或发布策略变化;
|
||||||
|
- 替换核心基础设施或引入新的权威事实源。
|
||||||
|
|
||||||
|
ADR 状态:
|
||||||
|
|
||||||
|
- `已提出`:尚未正式采纳;
|
||||||
|
- `已采纳`:当前有效;
|
||||||
|
- `已替代`:被新 ADR 替代;
|
||||||
|
- `已废弃`:保留历史但禁止新实现采用。
|
||||||
|
|
||||||
|
任何 ADR 替代必须说明:
|
||||||
|
|
||||||
|
- 被替代的 ADR;
|
||||||
|
- 迁移影响;
|
||||||
|
- 兼容性和回滚方案;
|
||||||
|
- 受影响代码、测试和文档。
|
||||||
|
|
||||||
|
## 7. 文档—代码匹配门禁
|
||||||
|
|
||||||
|
验收必须执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 tools/aios_v2_docs_code_check.py
|
||||||
|
```
|
||||||
|
|
||||||
|
门禁至少检查:
|
||||||
|
|
||||||
|
1. 必需文档存在且非空;
|
||||||
|
2. 文档语言以中文为主;
|
||||||
|
3. 文档引用的关键代码、测试和部署路径存在;
|
||||||
|
4. 文档列出的核心 API 能在代码中找到;
|
||||||
|
5. 文档列出的核心模型和字段能在代码中找到;
|
||||||
|
6. Traceability Matrix 覆盖所有 `UR-*` 需求;
|
||||||
|
7. ADR 索引引用的文件存在;
|
||||||
|
8. 架构或功能代码变化时,当前交付范围包含 `docs/aios-v2/` 变化;
|
||||||
|
9. 文档中不存在未解决 Git 冲突标记;
|
||||||
|
10. 正式文档中不存在摘要占位符或空章节。
|
||||||
|
|
||||||
|
门禁失败时:
|
||||||
|
|
||||||
|
- Candidate 不得标记为 `Test-verified`;
|
||||||
|
- OpenClaw 不得继续生产部署;
|
||||||
|
- Codex 必须登记 blocker 并给出修复建议;
|
||||||
|
- 修复必须进入 Git 分支并统一审视。
|
||||||
|
|
||||||
|
## 8. 在线任务文档要求
|
||||||
|
|
||||||
|
OpenClaw 和 Codex 任务必须使用中文说明,并保留可执行字段名。
|
||||||
|
|
||||||
|
每个任务至少包含:
|
||||||
|
|
||||||
|
- 任务目标;
|
||||||
|
- 责任范围;
|
||||||
|
- 禁止事项;
|
||||||
|
- 输入和输出;
|
||||||
|
- 分支;
|
||||||
|
- 执行状态;
|
||||||
|
- 验收命令;
|
||||||
|
- claim、heartbeat、progress、blocker、commit、test_status、result 证据要求;
|
||||||
|
- 阻塞升级路径。
|
||||||
|
|
||||||
|
## 9. 归档包要求
|
||||||
|
|
||||||
|
正式归档包必须包含完整正文:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/aios-v2/
|
||||||
|
tasks/openclaw-production-deploy-task.yaml
|
||||||
|
tasks/codex-production-supervisor-task.yaml
|
||||||
|
deploy/aios-v2/
|
||||||
|
AIOS-V2R1-PRODUCTION-CANDIDATE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
归档包必须同时生成清单:
|
||||||
|
|
||||||
|
- 文件路径;
|
||||||
|
- 文件大小;
|
||||||
|
- SHA-256;
|
||||||
|
- Candidate branch;
|
||||||
|
- Candidate commit;
|
||||||
|
- 生成时间。
|
||||||
|
|
||||||
|
禁止只打包摘要副本。
|
||||||
|
|
||||||
|
## 10. 审查职责
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
|
||||||
|
- 部署和运行修复时同步相关部署、运维和已知问题文档;
|
||||||
|
- 不得修改架构边界;
|
||||||
|
- 发现架构影响时升级给 Codex。
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
- 监督需求、架构、契约、追踪和 ADR 一致性;
|
||||||
|
- 解决阻塞;
|
||||||
|
- 拒绝缺少文档或证据的补丁;
|
||||||
|
- 输出统一发布建议。
|
||||||
|
|
||||||
|
### AIOS Owner
|
||||||
|
|
||||||
|
- 审批合并、Tag 和生产部署;
|
||||||
|
- 对重大架构决策和风险接受做最终确认。
|
||||||
|
|
||||||
|
## 11. 验收状态
|
||||||
|
|
||||||
|
文档完成状态只有在以下条件全部满足时才标记为“完整”:
|
||||||
|
|
||||||
|
- 所有必需文档和 ADR 已提交;
|
||||||
|
- 文档—代码匹配检查通过;
|
||||||
|
- 归档包包含仓库完整正文;
|
||||||
|
- 任务合同为中文完整版本;
|
||||||
|
- Candidate commit、清单和 SHA-256 已记录;
|
||||||
|
- Codex 监督结论不存在未解决阻塞。
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- 严重级别:高
|
||||||
|
- 状态:开放
|
||||||
|
- 现象:OpenClaw 写工具被 `run_provenance_missing` 策略拒绝。
|
||||||
|
- 风险:无法证明写操作来自已领取、已授权、可审计的执行会话。
|
||||||
|
- 外部受信输入:允许七个 `.agent` JSON 写入的 OpenClaw Provenance 授权。
|
||||||
|
- 解除条件:
|
||||||
|
1. 创建带任务 ID、执行器实例、Claim OID、Candidate OID 和授权主体的 Run Provenance;
|
||||||
|
2. Provenance 与七个 `.agent` JSON 写入范围绑定;
|
||||||
|
3. 权限仅覆盖当前任务分支和 Evidence 路径;
|
||||||
|
4. 由 Owner 或批准策略签发;
|
||||||
|
5. 写入、Commit、Push 和审计链验证通过。
|
||||||
|
|
||||||
|
### 4.5 BLK-CONTROLLER-LEGACY-GITEA-CHECK
|
||||||
|
|
||||||
|
- 严重级别:高
|
||||||
|
- 状态:开放
|
||||||
|
- 现象:Controller 健康检查仍硬编码旧 `aios-v23`,并返回 `gitea=false`、`branch_sync=false`。
|
||||||
|
- 风险:健康状态与当前 AIOS V2R1 仓库不一致,可能错误阻止或错误放行发布。
|
||||||
|
- 解除条件:
|
||||||
|
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:文档与设计 | 未运行 | Candidate 未合并到任务,阻塞未解除 |
|
||||||
|
| Gate B:代码与测试 | 未运行 | 不能基于不完整任务分支测试 |
|
||||||
|
| Gate C:预发布 | 禁止 | Secret、签名、Provenance、Controller Health 未通过 |
|
||||||
|
| 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. 外部受信输入
|
||||||
|
|
||||||
|
目前仅有两项确实需要外部受信输入:
|
||||||
|
|
||||||
|
1. **批准的签名材料**
|
||||||
|
- 不得通过聊天、Git 或普通日志传递;
|
||||||
|
- 必须通过批准的 Secret/Key 交付链安装。
|
||||||
|
|
||||||
|
2. **OpenClaw Run Provenance 授权**
|
||||||
|
- 仅允许当前任务所需的七个 `.agent` JSON 写入;
|
||||||
|
- 必须绑定任务、执行器实例、Claim、Candidate 和分支;
|
||||||
|
- 不得授予通用生产写权限。
|
||||||
|
|
||||||
|
其余阻塞均可由 OpenClaw、Codex、Windows ChatGPT Owner 和 GitOps 运维流程处理。
|
||||||
|
|
||||||
|
## 9. 监督结论
|
||||||
|
|
||||||
|
当前状态保持:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BLOCKED
|
||||||
|
STOP_PREPRODUCTION
|
||||||
|
```
|
||||||
|
|
||||||
|
虽然三分支已经同步 Gitea、凭据已经最小权限轮换、执行服务已经恢复,但当前尚无足够证据允许执行 Gate A–D 或进入预发布。
|
||||||
|
|
||||||
|
任何后续状态更新必须同步:
|
||||||
|
|
||||||
|
- OpenClaw `.agent/status.json`、`blockers.json`、`progress.json`、`result.json`;
|
||||||
|
- Codex 对应监督 Evidence;
|
||||||
|
- 本文档;
|
||||||
|
- `05-TRACEABILITY-MATRIX.md`;
|
||||||
|
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md`;
|
||||||
|
- 如涉及架构或发布语义,更新 ADR。
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# AIOS V2R1 加固与实际验收刷新报告
|
||||||
|
|
||||||
|
- 报告版本:`V2R1-execution-refresh-20260721.1`
|
||||||
|
- 日期:`2026-07-21`
|
||||||
|
- 分支:`patch/chatgpt/AIOS-V2R1-HARDENING-20260721`
|
||||||
|
- 总体状态:`PARTIAL / BLOCKED_EVIDENCE_REQUIRED`
|
||||||
|
- 发布决策:`STOP_PREPRODUCTION`
|
||||||
|
- Gate A:`PASS`(执行方报告 `0 errors / 0 warnings`)
|
||||||
|
- Gate B:`PASS`(执行方报告 `10/10`)
|
||||||
|
- 离线 Acceptance:`PASS_WITH_EXPECTED_WARNING`(`8 passed / 0 failed / 1 expected warning`)
|
||||||
|
- 生产发布:`NOT_APPROVED`
|
||||||
|
|
||||||
|
> 本报告刷新并取代此前“Run Provenance 尚未实现、Gate A 不允许执行”的旧快照。当前五项原始高风险 Blocker 均已解除。唯一剩余发布阻塞是 Live AIOS/Hermes 缺少可调用的完整在线 Worker 生命周期端点与对应证据。
|
||||||
|
|
||||||
|
## 1. 证据来源与可信度说明
|
||||||
|
|
||||||
|
本报告依据执行方提供的实际执行结论、OID、同步状态、验收统计和日志路径刷新。当前 GitOps 工具可以提交和读取仓库内容,但不能直接读取所列主机绝对路径下的日志,因此:
|
||||||
|
|
||||||
|
- 本报告将这些结果标记为“执行方已实际验证并提供证据路径”。
|
||||||
|
- 本会话未独立重放或读取日志内容。
|
||||||
|
- Merge、Tag、生产部署和风险接受仍需 AIOS Owner 独立批准。
|
||||||
|
- 任何与原始日志不一致的情况,应以不可变日志、退出码和签名 Evidence 为准。
|
||||||
|
|
||||||
|
主要证据路径:
|
||||||
|
|
||||||
|
- `/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/online-runtime-openclaw-final4.log`
|
||||||
|
- `/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/restart-recovery.log`
|
||||||
|
- `/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/rollback-drill-final3.log`
|
||||||
|
|
||||||
|
## 2. 权威版本与仓库状态
|
||||||
|
|
||||||
|
| 项目 | 刷新值 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| 权威 Bare Repo | `/data/aios-gitops/repo.git` | 已报告可用 |
|
||||||
|
| Candidate OID | `ba7669c3b49f98cf3500af6ea966ba0db472ff3b` | 已同步 |
|
||||||
|
| OpenClaw Task/Gitea OID | `0be3ba09e42b4f6db56461e3ca3a3fcc0a487f31` | `local_oid == gitea_oid` |
|
||||||
|
| Codex Task/Gitea OID | `6b0db21a852928106600914075ca50114cb07def` | `local_oid == gitea_oid` |
|
||||||
|
| 活动隔离 Release OID | `b158041b8633e658c34ce182f3c8aa97b4bf5f9a` | 当前验收 Release |
|
||||||
|
| Controller 修复 OID | `6188da26453505dacde9d6d298191437164c8d27` | detached、clean、fully synced |
|
||||||
|
| OpenClaw Runtime 插件 | `2.1.0-rc.3` | 已用于在线验收 |
|
||||||
|
| OpenClaw 包含最新 Candidate | 是 | 通过 |
|
||||||
|
| OpenClaw/Codex Worktree | clean | 通过 |
|
||||||
|
| Worktree 锁 | `0` | 通过 |
|
||||||
|
| Prunable Worktree | `0` | 通过 |
|
||||||
|
| `main` 修改 | 无 | 通过 |
|
||||||
|
| Tag 创建 | 无 | 通过 |
|
||||||
|
| Force Push | 无 | 通过 |
|
||||||
|
| 生产流量切换 | 无 | 通过 |
|
||||||
|
|
||||||
|
## 3. 原始五项高风险 Blocker
|
||||||
|
|
||||||
|
以下五项均已解除,当前未发现回退:
|
||||||
|
|
||||||
|
| Blocker | 刷新状态 | 证据摘要 |
|
||||||
|
|---|---|---|
|
||||||
|
| `BLK-OPENCLAW-PLAINTEXT-SECRETS` | `RESOLVED` | `plaintext/unresolved/shadowed/legacy = 0`;Gateway Token 已轮换;未记录 Secret 值 |
|
||||||
|
| `BLK-AIOS-V2-SIGNING-MATERIAL-MISSING` | `RESOLVED` | Ed25519 Key ID `openclaw-v2r1-candidate-ed25519`;`aios:aios 0600`;sign/verify、篡改拒绝通过 |
|
||||||
|
| `BLK-CANDIDATE-NOT-MERGED` | `RESOLVED` | OpenClaw 包含最新 Candidate;三分支本地与 Gitea OID 一致 |
|
||||||
|
| `BLK-OPENCLAW-RUN-PROVENANCE-MISSING` | `RESOLVED` | 已实现 Workflow Provenance、持久 Store、最小权限 Child Grant、Scoped Writer、Dispatch/Result 严格绑定及相关测试 |
|
||||||
|
| `BLK-CONTROLLER-LEGACY-GITEA-CHECK` | `RESOLVED` | 活跃代码清除旧 `aios-v23` 硬编码;Controller 12/12;Token 降权为 `read:repository` 并通过 systemd `LoadCredential` 注入 |
|
||||||
|
|
||||||
|
### 3.1 Run Provenance 刷新说明
|
||||||
|
|
||||||
|
旧状态认为插件只有进程内 `runId/sender` Map,缺少 Owner-signed schema、持久 Store 和专用 Writer。当前加固实现已经提供:
|
||||||
|
|
||||||
|
- 无操作权限的父 Workflow Provenance;
|
||||||
|
- Ed25519 签名与公钥验证;
|
||||||
|
- SQLite WAL Provenance/Grant/Usage/Event Store;
|
||||||
|
- Runtime、Evidence、Git、Deployment、Rollback、Audit、Code Change 独立 Child Grant;
|
||||||
|
- GitOperation 级别限制;
|
||||||
|
- 服务级 Scoped Writer 与 CLI 单 Grant 授权;
|
||||||
|
- 撤销、过期、重放和上下文绑定;
|
||||||
|
- 持久 Dispatch 与 Runtime Result 严格 Reconcile。
|
||||||
|
|
||||||
|
因此,`BLK-OPENCLAW-RUN-PROVENANCE-MISSING` 不再成立。
|
||||||
|
|
||||||
|
## 4. 安全验证结果
|
||||||
|
|
||||||
|
### 4.1 Secret 与凭据
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 原明文 Secret 发现 | `3` |
|
||||||
|
| 当前明文 Secret | `0` |
|
||||||
|
| unresolved | `0` |
|
||||||
|
| shadowed | `0` |
|
||||||
|
| legacy | `0` |
|
||||||
|
| Gateway Token 轮换 | 通过 |
|
||||||
|
| OneAPI 实际 Agent 调用 | 通过 |
|
||||||
|
| Controller Token 权限 | `read:repository` |
|
||||||
|
| Controller Token 注入 | systemd `LoadCredential` |
|
||||||
|
| Secret 值写入记录 | 否 |
|
||||||
|
|
||||||
|
### 4.2 Signing Material
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 文件存在 | 是 |
|
||||||
|
| Key ID | `openclaw-v2r1-candidate-ed25519` |
|
||||||
|
| 所有者/权限 | `aios:aios 0600` |
|
||||||
|
| 合同 | 真实 Ed25519,`aios.openclaw.command.v2` |
|
||||||
|
| sign/verify | 通过 |
|
||||||
|
| 篡改拒绝 | 通过 |
|
||||||
|
| 过期拒绝 | 通过 |
|
||||||
|
| 跨租户拒绝 | 通过 |
|
||||||
|
|
||||||
|
## 5. Gate 与 Acceptance 刷新
|
||||||
|
|
||||||
|
| Gate/验收项 | 实际结果 | 刷新状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| Gate A | `0 errors / 0 warnings` | `PASS` |
|
||||||
|
| Gate B | `10/10` | `PASS` |
|
||||||
|
| 离线 Acceptance | `8 passed / 0 failed / 1 expected warning` | `PASS_WITH_EXPECTED_WARNING` |
|
||||||
|
| OpenClaw 在线 Health | 通过 | `PASS` |
|
||||||
|
| OpenClaw Execute | 通过 | `PASS` |
|
||||||
|
| OpenClaw Status | 通过 | `PASS` |
|
||||||
|
| OpenClaw Cancel | 通过 | `PASS` |
|
||||||
|
| OpenClaw Timeout | 通过 | `PASS` |
|
||||||
|
| OpenClaw 幂等 | 通过 | `PASS` |
|
||||||
|
| OpenClaw 篡改拒绝 | 通过 | `PASS` |
|
||||||
|
| OpenClaw 过期拒绝 | 通过 | `PASS` |
|
||||||
|
| OpenClaw 跨租户拒绝 | 通过 | `PASS` |
|
||||||
|
| AIOS → OpenClaw 真实 HTTP | `COMPLETED` | `PASS` |
|
||||||
|
| AIOS/Hermes 基础 enqueue/reconcile | 通过 | `PASS` |
|
||||||
|
| OpenClaw 跨重启幂等 | 通过 | `PASS` |
|
||||||
|
| AIOS Tenant/Agent/Task/Audit/Idempotency/Evidence 恢复 | 通过 | `PASS` |
|
||||||
|
| SQLite `PRAGMA quick_check` | `ok` | `PASS` |
|
||||||
|
| 受控双向回滚 | Previous `2a2b702…` ↔ Current `b158041…` | `PASS` |
|
||||||
|
| 回滚业务状态保护 | 未覆盖业务状态 | `PASS` |
|
||||||
|
| Live Hermes 完整生命周期 | 在线端点与证据不完整 | `BLOCKED_EVIDENCE_REQUIRED` |
|
||||||
|
|
||||||
|
## 6. Controller Health
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 活跃修复 OID | `6188da26453505dacde9d6d298191437164c8d27` |
|
||||||
|
| 旧 `aios-v23` 硬编码 | 活跃代码中已清除 |
|
||||||
|
| Controller 测试 | `12/12` |
|
||||||
|
| 部署 Worktree | detached、clean |
|
||||||
|
| Gitea | `true` |
|
||||||
|
| local_sync | `synced` |
|
||||||
|
| gitea_sync | `synced` |
|
||||||
|
| oid_match | `true` |
|
||||||
|
| branch_sync | `true` |
|
||||||
|
| sync_status | `fully_synced` |
|
||||||
|
| 首次部署失败回滚 | 已实际触发并验证 |
|
||||||
|
|
||||||
|
## 7. 当前唯一发布阻塞
|
||||||
|
|
||||||
|
### `BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING`
|
||||||
|
|
||||||
|
Live AIOS 当前只暴露 Hermes `enqueue/reconcile`,没有可调用或可观测的完整在线 Worker 生命周期端点,因此以下场景尚无完整在线证据:
|
||||||
|
|
||||||
|
- `claim`
|
||||||
|
- `start`
|
||||||
|
- `heartbeat`
|
||||||
|
- `fail`
|
||||||
|
- `cancel`
|
||||||
|
- `lease expiry`
|
||||||
|
- `worker crash recovery`
|
||||||
|
- `Cell/Profile rejection`
|
||||||
|
- `invalid-result rejection`
|
||||||
|
|
||||||
|
这不是 Run Provenance 缺失,也不是基础 Hermes Adapter 失效;它是在线验收可观测面和完整生命周期证据不足。
|
||||||
|
|
||||||
|
### 7.1 解除条件
|
||||||
|
|
||||||
|
必须在隔离预发布环境中实际完成并保存:
|
||||||
|
|
||||||
|
1. Worker claim/start/heartbeat 正向链路;
|
||||||
|
2. Worker fail/cancel 终态链路;
|
||||||
|
3. Lease 到期重新排队或重新领取;
|
||||||
|
4. Worker 进程崩溃后的恢复;
|
||||||
|
5. 错误 Cell/Profile 的拒绝;
|
||||||
|
6. 非 Lease Owner 或上下文不匹配 Result 的拒绝;
|
||||||
|
7. Dispatch、Task、Audit、Evidence、Grant Receipt 的一致性;
|
||||||
|
8. 服务重启后继续 Reconcile;
|
||||||
|
9. 完整命令、时间、退出码、日志路径和 Artifact Hash;
|
||||||
|
10. Owner/Reviewer 对证据的独立复核。
|
||||||
|
|
||||||
|
### 7.2 不接受的替代证据
|
||||||
|
|
||||||
|
以下不能解除本 Blocker:
|
||||||
|
|
||||||
|
- 仅有单元测试文件;
|
||||||
|
- 仅有离线 Hermes enqueue/reconcile;
|
||||||
|
- 仅证明 OpenClaw 在线链路;
|
||||||
|
- 仅修改状态文档;
|
||||||
|
- 仅提供无退出码或无日志路径的口头结论;
|
||||||
|
- 通过手工修改 Task 状态伪造 Worker 生命周期。
|
||||||
|
|
||||||
|
## 8. 发布决策
|
||||||
|
|
||||||
|
### 8.1 当前决策
|
||||||
|
|
||||||
|
```text
|
||||||
|
Overall: PARTIAL / BLOCKED_EVIDENCE_REQUIRED
|
||||||
|
Gate A: PASS
|
||||||
|
Gate B: PASS
|
||||||
|
Offline: PASS_WITH_EXPECTED_WARNING
|
||||||
|
OpenClaw Live: PASS
|
||||||
|
Hermes Basic: PASS
|
||||||
|
Hermes Full: BLOCKED
|
||||||
|
Preproduction: STOP
|
||||||
|
Production: NOT APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 允许事项
|
||||||
|
|
||||||
|
- 继续在当前非 `main` 加固分支修复与补证据;
|
||||||
|
- 在隔离 Release/预发布环境增加 Hermes 生命周期端点或受控验收驱动;
|
||||||
|
- 运行在线负向测试、重启恢复和回滚演练;
|
||||||
|
- 更新 `.agent` Evidence、Acceptance 日志与本报告;
|
||||||
|
- 提交 Reviewer 可复核的 Evidence Manifest。
|
||||||
|
|
||||||
|
### 8.3 禁止事项
|
||||||
|
|
||||||
|
- 不得修改 `main`;
|
||||||
|
- 不得创建生产 Tag;
|
||||||
|
- 不得 Force Push;
|
||||||
|
- 不得切换生产流量;
|
||||||
|
- 不得因 Gate A/B 和 OpenClaw 已通过而忽略 Hermes 完整在线证据;
|
||||||
|
- 不得把当前状态描述为“全部完成”或“可放行预生产”。
|
||||||
|
|
||||||
|
## 9. 后续最小执行计划
|
||||||
|
|
||||||
|
1. 为 Live AIOS/Hermes 提供受保护的内部 Worker 生命周期验收面,或使用等价受信测试驱动直接调用真实在线 Queue/Lease/Worker 实现。
|
||||||
|
2. 执行完整 Hermes 正向、负向、Lease、Crash、Restart 场景。
|
||||||
|
3. 保存日志、退出码、时间戳、OID、配置摘要和 Artifact Hash。
|
||||||
|
4. 将 Evidence 写入租户隔离 `.agent` 路径并提交非 `main` 分支。
|
||||||
|
5. 由独立 Reviewer 复核 Result/Dispatch/Task/Audit/Grant Receipt 一致性。
|
||||||
|
6. 仅在 Blocker 解除后将总体状态刷新为 `READY_FOR_PREPRODUCTION_REVIEW`;最终放行仍需 Owner 批准。
|
||||||
|
|
||||||
|
## 10. 最终结论
|
||||||
|
|
||||||
|
五项原始高风险 Blocker 已全部解除,Gate A、Gate B、离线 Acceptance、OpenClaw 在线链路、基础 Hermes、重启恢复、SQLite 完整性和双向回滚均已有执行方报告的实际通过结果。
|
||||||
|
|
||||||
|
但 Live Hermes 完整 Worker 生命周期仍缺少可调用端点和完整在线证据。因此本次刷新结论为:
|
||||||
|
|
||||||
|
> **AIOS V2R1 已从“核心安全 Blocker 未解除”提升为“核心加固与主要验收通过,但 Hermes 在线生命周期证据未闭环”。继续停止预生产放行,直到 `BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING` 被实际证据解除。**
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# AIOS V2 架构与交付文档索引
|
||||||
|
|
||||||
|
- 版本线:`V2R1C0-candidate`
|
||||||
|
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||||
|
- 架构负责人:AIOS V2 CTO
|
||||||
|
- 当前状态:候选代码闭环已完成,运行时、预发布和生产验证尚待执行
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本目录是 AIOS V2 用户需求、目标架构、模块边界、迁移策略、发布门禁和架构决策的唯一事实源。
|
||||||
|
|
||||||
|
所有后续架构、功能、接口、数据模型、安全策略、部署方式和验收流程变化,都必须在同一交付流程中同步更新本目录。
|
||||||
|
|
||||||
|
AIOS V2 遵循以下不可协商原则:
|
||||||
|
|
||||||
|
1. 不推倒重写现有平台。
|
||||||
|
2. 优先复用 OpenClaw、Hermes 和 Agent Router 能力。
|
||||||
|
3. OpenClaw 与 Hermes 从控制面职责中逐步解耦。
|
||||||
|
4. AIOS 是唯一控制面。
|
||||||
|
5. 所有面向用户和运维的能力必须通过 Web 或 API 访问。
|
||||||
|
6. 所有模块必须强制多租户隔离。
|
||||||
|
7. 所有修改必须通过 Git 分支、提交、测试、审查和受控提升完成。
|
||||||
|
8. 代码变化必须同步更新需求、架构、契约、追踪矩阵或 ADR。
|
||||||
|
|
||||||
|
## 2. 文档地图
|
||||||
|
|
||||||
|
| 文档 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| [01-USER-REQUIREMENTS.md](01-USER-REQUIREMENTS.md) | 产品目标、用户角色、功能需求、非功能需求和验收标准 |
|
||||||
|
| [02-TARGET-ARCHITECTURE.md](02-TARGET-ARCHITECTURE.md) | 目标逻辑架构、控制边界、数据流、安全模型和多租户模型 |
|
||||||
|
| [03-MIGRATION-AND-DELIVERY.md](03-MIGRATION-AND-DELIVERY.md) | 增量迁移、能力复用、GitOps 流程、发布门禁和回滚策略 |
|
||||||
|
| [04-MODULE-CONTRACTS.md](04-MODULE-CONTRACTS.md) | 租户上下文、任务生命周期、OpenClaw、Hermes、审计和执行证据契约 |
|
||||||
|
| [05-TRACEABILITY-MATRIX.md](05-TRACEABILITY-MATRIX.md) | 需求、设计、代码、测试、部署和验收之间的追踪关系 |
|
||||||
|
| [06-DOCUMENT-GOVERNANCE.md](06-DOCUMENT-GOVERNANCE.md) | 文档语言、完整性、同步门禁、版本与审查要求 |
|
||||||
|
| [adr/README.md](adr/README.md) | 架构决策记录索引与治理规则 |
|
||||||
|
|
||||||
|
## 3. 当前实现映射
|
||||||
|
|
||||||
|
| 架构能力 | 候选实现 |
|
||||||
|
|---|---|
|
||||||
|
| Tenant、Agent、Task、Policy 和 Audit 控制面 | `active-runtime/app/aios_v2/control_plane.py` |
|
||||||
|
| 控制面持久化 | `active-runtime/app/aios_v2/persistence.py` |
|
||||||
|
| Web/API Gateway | `active-runtime/app/aios_v2/gateway.py` |
|
||||||
|
| 统一应用入口 | `active-runtime/app/aios_v2/app.py` |
|
||||||
|
| Runtime 调度与结果回写 | `active-runtime/app/aios_v2/orchestrator.py` |
|
||||||
|
| Agent 执行证据 | `active-runtime/app/aios_v2/execution_tracking.py` |
|
||||||
|
| GitOps 状态可视化 | `active-runtime/app/aios_v2/gitops_status.py` |
|
||||||
|
| Web Console | `active-runtime/app/aios_v2/console.py` |
|
||||||
|
| OpenClaw 执行边界 | `active-runtime/app/aios_openclaw/` |
|
||||||
|
| Hermes Runtime 边界 | `active-runtime/app/aios_hermes/` |
|
||||||
|
| 候选测试 | `tests/aios_v2_candidate/` |
|
||||||
|
| 部署、健康检查与回滚 | `deploy/aios-v2/` |
|
||||||
|
| OpenClaw 生产部署任务 | `tasks/openclaw-production-deploy-task.yaml` |
|
||||||
|
| Codex 生产监督任务 | `tasks/codex-production-supervisor-task.yaml` |
|
||||||
|
|
||||||
|
## 4. 状态术语
|
||||||
|
|
||||||
|
项目必须严格区分“代码存在”和“可生产发布”:
|
||||||
|
|
||||||
|
- **已设计**:文档中存在明确边界和验收标准。
|
||||||
|
- **已实现**:代码已经提交到候选分支。
|
||||||
|
- **已定义测试**:Git 中存在自动化测试代码。
|
||||||
|
- **已验证测试**:已对精确 commit 执行测试并保存证据。
|
||||||
|
- **已验证预发布**:已在隔离环境完成部署、健康检查、重启和回滚演练。
|
||||||
|
- **已生产发布**:已取得 Owner 批准并完成合并、Tag 和生产部署。
|
||||||
|
|
||||||
|
分支、文件、测试脚本或部署脚本存在,并不自动代表能力已通过生产验收。
|
||||||
|
|
||||||
|
## 5. 设计摘要
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器 / Web Console / Public API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS 唯一控制面
|
||||||
|
Tenant | Agent | Task | Policy | Audit
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
OpenClaw Adapter Hermes Boundary
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
OpenClaw Runtime Hermes Workers
|
||||||
|
\
|
||||||
|
v
|
||||||
|
结果回写 + 审计 + 执行证据
|
||||||
|
```
|
||||||
|
|
||||||
|
控制面拥有权威身份、策略、审批、任务生命周期和审计状态。OpenClaw 与 Hermes 只接收带租户上下文、已经批准的执行工作,并返回运行结果。
|
||||||
|
|
||||||
|
## 6. 发布规则
|
||||||
|
|
||||||
|
候选版本在满足以下条件前不得提升:
|
||||||
|
|
||||||
|
- 需求到代码的追踪关系完整;
|
||||||
|
- 文档—代码匹配检查通过;
|
||||||
|
- 多租户负向测试通过;
|
||||||
|
- GitOps Controller worktree 保持 detached 且 clean;
|
||||||
|
- 本地 Git 与 Gitea 目标分支 OID 一致;
|
||||||
|
- 持久化重启恢复测试通过;
|
||||||
|
- 预发布健康检查和回滚演练通过;
|
||||||
|
- Git 中不存在明文密钥;
|
||||||
|
- OpenClaw 与 Hermes 真实 Runtime 联调通过;
|
||||||
|
- Owner 对合并、Tag 和生产部署做出明确批准。
|
||||||
|
|
||||||
|
## 7. 文档完整性要求
|
||||||
|
|
||||||
|
每份正式文档必须包含:
|
||||||
|
|
||||||
|
- 文档目的与适用范围;
|
||||||
|
- 当前版本和状态;
|
||||||
|
- 关键设计或需求正文;
|
||||||
|
- 代码、测试或部署映射;
|
||||||
|
- 验收与更新规则;
|
||||||
|
- 已知限制和后续演进方向。
|
||||||
|
|
||||||
|
禁止在正式归档包中以摘要、占位符或“后续补充”代替正文。
|
||||||
|
|
||||||
|
## 8. 中文输出要求
|
||||||
|
|
||||||
|
所有面向用户、产品、架构、运维和审计的正式文档统一使用中文。以下技术标识保留原始英文,避免与代码不一致:
|
||||||
|
|
||||||
|
- 类名、函数名、字段名;
|
||||||
|
- API 路径和 HTTP Header;
|
||||||
|
- Git 分支、Commit、Tag;
|
||||||
|
- OpenClaw、Hermes、Agent Router、GitOps 等专有名称;
|
||||||
|
- systemd、Docker、SQLite、HMAC、HTTP 等标准技术术语。
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# ADR-001:AIOS 作为唯一控制面
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:Control Plane、Agent Router、OpenClaw、Hermes、Web Console、GitOps、审计
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
现有系统已经包含多个执行、调度和治理能力。如果 OpenClaw、Hermes、Agent Router 或 Web 层分别保存自己的租户、策略、审批和任务状态,就会形成多个权威事实源,带来以下问题:
|
||||||
|
|
||||||
|
- 同一任务在不同模块中状态不一致;
|
||||||
|
- 审计链不完整;
|
||||||
|
- 多租户隔离难以统一验证;
|
||||||
|
- Web、Runtime 和运维入口可能绕过策略;
|
||||||
|
- Bug 修复和发布无法判断应该修改哪个事实源。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
AIOS Control Plane 是唯一权威控制面,负责:
|
||||||
|
|
||||||
|
- Tenant Registry;
|
||||||
|
- Identity 与角色上下文;
|
||||||
|
- Agent Registry;
|
||||||
|
- Task Lifecycle;
|
||||||
|
- Policy Decision;
|
||||||
|
- Approval;
|
||||||
|
- Audit Event;
|
||||||
|
- Runtime Inventory;
|
||||||
|
- Execution Evidence;
|
||||||
|
- GitOps 与发布状态可视化。
|
||||||
|
|
||||||
|
OpenClaw、Hermes 和 Agent Router 只能执行、路由或调度已经由 AIOS 批准的工作,不得拥有第二套租户、策略、审批或任务生命周期。
|
||||||
|
|
||||||
|
## 3. 目标边界
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web / API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Control Plane
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
|
|
||||||
|
+--> OpenClaw Adapter --> OpenClaw Runtime
|
||||||
|
|
|
||||||
|
+--> Hermes Boundary --> Hermes Workers
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:保持多个控制面
|
||||||
|
|
||||||
|
拒绝原因:状态会持续分裂,无法形成统一审计和租户隔离。
|
||||||
|
|
||||||
|
### 方案 B:由 OpenClaw 或 Hermes 成为主控制面
|
||||||
|
|
||||||
|
拒绝原因:二者主要价值是执行与 Runtime,不适合作为统一产品、租户、审批和发布控制面。
|
||||||
|
|
||||||
|
### 方案 C:推倒重写全部能力
|
||||||
|
|
||||||
|
拒绝原因:会丢失现有执行能力、运行经验和迁移可回滚性。
|
||||||
|
|
||||||
|
## 5. 正面影响
|
||||||
|
|
||||||
|
- 任务、策略和租户状态唯一;
|
||||||
|
- Web/API、OpenClaw、Hermes 和 GitOps 使用同一权威模型;
|
||||||
|
- 审计、执行证据和发布判断可以统一;
|
||||||
|
- 新 Runtime 可以通过稳定 Adapter 接入;
|
||||||
|
- 多租户和权限模型可集中验证。
|
||||||
|
|
||||||
|
## 6. 负面影响与风险
|
||||||
|
|
||||||
|
- 所有旧执行入口需要迁移到 Orchestrator;
|
||||||
|
- Runtime 需要稳定 Contract;
|
||||||
|
- Control Plane 可靠性要求提高;
|
||||||
|
- 在迁移完成前可能短期存在旧旁路,需要明确冻结和监控。
|
||||||
|
|
||||||
|
## 7. 迁移与回滚
|
||||||
|
|
||||||
|
迁移顺序:
|
||||||
|
|
||||||
|
1. 建立 AIOS Control Plane 权威模型;
|
||||||
|
2. 引入 Orchestrator;
|
||||||
|
3. OpenClaw/Hermes 通过 Adapter 接入;
|
||||||
|
4. 将 Web 和自动任务切换到 AIOS API;
|
||||||
|
5. 关闭旧控制旁路。
|
||||||
|
|
||||||
|
回滚只允许回滚 Runtime 或应用版本,不允许恢复第二套权威控制状态。
|
||||||
|
|
||||||
|
## 8. 合规检查
|
||||||
|
|
||||||
|
任何新功能必须回答:
|
||||||
|
|
||||||
|
- 权威状态是否存放在 AIOS?
|
||||||
|
- Runtime 是否只接收已批准工作?
|
||||||
|
- Web 是否只调用 AIOS API?
|
||||||
|
- 是否新增了第二套状态机或租户事实源?
|
||||||
|
|
||||||
|
若最后一项为“是”,变更必须拒绝或新增替代 ADR。
|
||||||
|
|
||||||
|
## 9. 实现与测试映射
|
||||||
|
|
||||||
|
- 实现:`active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
- 编排:`active-runtime/app/aios_v2/orchestrator.py`
|
||||||
|
- 应用入口:`active-runtime/app/aios_v2/app.py`
|
||||||
|
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
|
- 追踪:`docs/aios-v2/05-TRACEABILITY-MATRIX.md`
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# ADR-002:OpenClaw 与 Hermes 从控制面职责中解耦
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:OpenClaw、Hermes、Execution Orchestrator、Agent Router、Audit、Web Console
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
OpenClaw 和 Hermes 已经具备成熟的执行能力,但历史实现可能同时承担部分租户、策略、任务状态或运维控制职责。如果继续扩展这种模式,AIOS 将无法成为唯一控制面,也无法统一 Web、审计和多租户隔离。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
OpenClaw 与 Hermes 通过稳定的 Adapter/Boundary 接入 AIOS:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AIOS Control Plane
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator
|
||||||
|
|
|
||||||
|
+--> OpenClaw Adapter --> OpenClaw Runtime
|
||||||
|
|
|
||||||
|
+--> Hermes Boundary --> Hermes Workers
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenClaw 只负责:
|
||||||
|
|
||||||
|
- 验证签名命令;
|
||||||
|
- 执行;
|
||||||
|
- 查询状态;
|
||||||
|
- 取消;
|
||||||
|
- 健康检查;
|
||||||
|
- 返回结果和生命周期事件。
|
||||||
|
|
||||||
|
Hermes 只负责:
|
||||||
|
|
||||||
|
- tenant/cell/profile 分区队列;
|
||||||
|
- Worker claim;
|
||||||
|
- Lease 与 Heartbeat;
|
||||||
|
- 执行;
|
||||||
|
- 完成、失败、取消和结果回传。
|
||||||
|
|
||||||
|
二者均不得:
|
||||||
|
|
||||||
|
- 创建或批准 AIOS Task;
|
||||||
|
- 修改租户或策略;
|
||||||
|
- 维护第二套任务生命周期;
|
||||||
|
- 绕过 AIOS Audit;
|
||||||
|
- 向浏览器提供直接控制入口。
|
||||||
|
|
||||||
|
## 3. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:让 OpenClaw 继续拥有控制职责
|
||||||
|
|
||||||
|
拒绝原因:会产生第二控制面,并使 Web、审批和审计持续分裂。
|
||||||
|
|
||||||
|
### 方案 B:让 Hermes 统一控制和执行
|
||||||
|
|
||||||
|
拒绝原因:Hermes 的核心价值是 Worker Runtime 与队列,不应承担产品、租户和策略治理。
|
||||||
|
|
||||||
|
### 方案 C:直接替换 OpenClaw/Hermes
|
||||||
|
|
||||||
|
拒绝原因:不符合能力复用原则,会扩大迁移风险和开发量。
|
||||||
|
|
||||||
|
## 4. 正面影响
|
||||||
|
|
||||||
|
- AIOS 保持唯一权威状态;
|
||||||
|
- OpenClaw/Hermes 可以独立升级;
|
||||||
|
- 新 Runtime 可使用相同接入模式;
|
||||||
|
- Web 和审计不再依赖 Runtime 内部实现;
|
||||||
|
- 可逐步关闭旧旁路,而不需要一次性重写执行层。
|
||||||
|
|
||||||
|
## 5. 负面影响与风险
|
||||||
|
|
||||||
|
- 需要维护稳定的命令、结果和健康 Contract;
|
||||||
|
- 旧调用路径可能在迁移期并存;
|
||||||
|
- Runtime 联调和错误语义需要统一;
|
||||||
|
- Adapter 本身成为重要可靠性边界。
|
||||||
|
|
||||||
|
## 6. 迁移步骤
|
||||||
|
|
||||||
|
1. 定义 OpenClawCommand/OpenClawResult/RuntimeHealth;
|
||||||
|
2. 定义 HermesJob/HermesResult/HermesHealth;
|
||||||
|
3. 引入 AIOS Orchestrator;
|
||||||
|
4. 将新任务先通过 Adapter 执行;
|
||||||
|
5. 迁移 Web、Codex 和自动任务入口;
|
||||||
|
6. 冻结旧 Runtime 控制接口;
|
||||||
|
7. 通过网络策略或代码门禁关闭旁路。
|
||||||
|
|
||||||
|
## 7. 回滚原则
|
||||||
|
|
||||||
|
允许回滚 Runtime 或 Adapter 版本,但不允许回滚到 Runtime 重新拥有 Tenant、Policy、Approval 或 Task Lifecycle 的模式。
|
||||||
|
|
||||||
|
## 8. 合规检查
|
||||||
|
|
||||||
|
- OpenClaw 输入是否为 AIOS 生成的签名命令?
|
||||||
|
- Hermes Job 是否包含 tenant/cell/profile?
|
||||||
|
- Runtime 是否只返回结果,不直接写 AIOS Store?
|
||||||
|
- Web 是否只调用 AIOS API?
|
||||||
|
- Runtime Bug 修复是否同步更新 Contract、测试和文档?
|
||||||
|
|
||||||
|
## 9. 实现与测试映射
|
||||||
|
|
||||||
|
- OpenClaw:`active-runtime/app/aios_openclaw/`
|
||||||
|
- Hermes:`active-runtime/app/aios_hermes/`
|
||||||
|
- Orchestrator:`active-runtime/app/aios_v2/orchestrator.py`
|
||||||
|
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
|
- 模块契约:`docs/aios-v2/04-MODULE-CONTRACTS.md`
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# ADR-003:所有变更必须通过 GitOps
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:代码、配置、文档、测试、部署、回滚、生产修复
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
直接在生产目录、长期 worktree、主分支或运行容器中修改代码,会造成以下问题:
|
||||||
|
|
||||||
|
- 变更不可追踪;
|
||||||
|
- 无法稳定回滚;
|
||||||
|
- 本地仓库与 Gitea 不一致;
|
||||||
|
- Worktree 和 Branch Ref 冲突;
|
||||||
|
- 紧急修复无法统一审视;
|
||||||
|
- 文档、测试和部署资产无法同步。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
所有代码、配置、文档、测试、部署和回滚资产必须经过以下流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
非 main 分支
|
||||||
|
-> Commit
|
||||||
|
-> 本地仓库同步
|
||||||
|
-> Gitea 镜像与 OID 校验
|
||||||
|
-> 自动测试
|
||||||
|
-> Codex 监督审查
|
||||||
|
-> 候选集成
|
||||||
|
-> Owner 批准
|
||||||
|
-> Merge / Tag / Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
GitOps Controller worktree 必须长期保持 detached 且 clean。相同分支写入必须串行,任务工作区必须可回收。
|
||||||
|
|
||||||
|
## 3. 必需证据
|
||||||
|
|
||||||
|
每个任务必须记录:
|
||||||
|
|
||||||
|
- 任务 ID;
|
||||||
|
- 执行器;
|
||||||
|
- 分支;
|
||||||
|
- Commit;
|
||||||
|
- 修改文件;
|
||||||
|
- claim;
|
||||||
|
- heartbeat;
|
||||||
|
- progress;
|
||||||
|
- blocker;
|
||||||
|
- 测试命令、exit code 和结果;
|
||||||
|
- 本地/Gitea 同步状态;
|
||||||
|
- 回滚说明;
|
||||||
|
- 最终结果和监督结论。
|
||||||
|
|
||||||
|
## 4. 禁止事项
|
||||||
|
|
||||||
|
- 直接 push `main`;
|
||||||
|
- 未审批创建生产 Tag;
|
||||||
|
- 未记录任务直接修改生产;
|
||||||
|
- 在生产目录手工修复后不回写 Git;
|
||||||
|
- fetch 到已被 worktree 检出的本地 branch ref;
|
||||||
|
- 以新分支名反复规避 Worktree 生命周期缺陷;
|
||||||
|
- 在 Git 中提交明文凭据;
|
||||||
|
- 缺少文档和测试时标记为完成。
|
||||||
|
|
||||||
|
## 5. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:允许紧急生产手工修改
|
||||||
|
|
||||||
|
拒绝原因:即使紧急修复也必须形成 Patch、Commit 和证据,否则无法统一审视和回滚。
|
||||||
|
|
||||||
|
### 方案 B:只同步本地 Bare Repo
|
||||||
|
|
||||||
|
拒绝原因:Web Gitea 无法看到真实任务进度,团队无法审查。
|
||||||
|
|
||||||
|
### 方案 C:Controller 长期检出任务分支
|
||||||
|
|
||||||
|
拒绝原因:会触发 `refusing to fetch into branch checked out at worktree`,并破坏并发任务。
|
||||||
|
|
||||||
|
## 6. 正面影响
|
||||||
|
|
||||||
|
- 所有变更可审计;
|
||||||
|
- 本地和 Web 远端一致;
|
||||||
|
- 紧急修复也可回滚;
|
||||||
|
- OpenClaw/Codex 进度可统一查询;
|
||||||
|
- 文档与代码可通过门禁同步。
|
||||||
|
|
||||||
|
## 7. 负面影响与风险
|
||||||
|
|
||||||
|
- 生产修复增加提交与证据步骤;
|
||||||
|
- GitOps Runtime 成为关键基础设施;
|
||||||
|
- 需要处理分支锁、任务恢复和镜像失败;
|
||||||
|
- 自动执行器必须遵循统一任务格式。
|
||||||
|
|
||||||
|
## 8. 运行规则
|
||||||
|
|
||||||
|
- Controller:detached + clean;
|
||||||
|
- Fetch:更新 remote-tracking ref,不写已检出本地 branch;
|
||||||
|
- Write:独立任务工作区或受控写入;
|
||||||
|
- Lock:同一分支串行;
|
||||||
|
- Mirror:本地成功和 Gitea 成功分别记录;
|
||||||
|
- Failure:状态标记为 degraded/blocked,不得伪报 synced。
|
||||||
|
|
||||||
|
## 9. 紧急 Bug 修复流程
|
||||||
|
|
||||||
|
1. 创建 `patch/bugfix/*`;
|
||||||
|
2. 登记 blocker 和影响;
|
||||||
|
3. 添加复现测试;
|
||||||
|
4. 修复代码;
|
||||||
|
5. 同步文档;
|
||||||
|
6. 执行验收;
|
||||||
|
7. Codex 审核;
|
||||||
|
8. Owner 批准发布;
|
||||||
|
9. 生产观察并记录结果。
|
||||||
|
|
||||||
|
## 10. 实现与验收映射
|
||||||
|
|
||||||
|
- GitOps 状态:`active-runtime/app/aios_v2/gitops_status.py`
|
||||||
|
- 健康检查:`deploy/aios-v2/healthcheck/check.sh`
|
||||||
|
- 回滚:`deploy/aios-v2/rollback/rollback.sh`
|
||||||
|
- 任务:`tasks/openclaw-production-deploy-task.yaml`、`tasks/codex-production-supervisor-task.yaml`
|
||||||
|
- 文档门禁:`tools/aios_v2_docs_code_check.py`
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# ADR-004:多租户隔离采用默认拒绝模型
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:Web/API、Control Plane、OpenClaw、Hermes、Persistence、Audit、Execution Evidence、Logs、Metrics、Secret
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
AIOS V2 同时承载多个租户的用户请求、控制面状态、OpenClaw 命令、Hermes 队列、执行证据、审计和 GitOps 状态。任何一层缺失租户边界,都可能造成数据泄露、跨租户任务执行或错误回写。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
多租户隔离采用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
显式租户上下文
|
||||||
|
+
|
||||||
|
默认拒绝
|
||||||
|
+
|
||||||
|
全链路传播
|
||||||
|
+
|
||||||
|
负向测试
|
||||||
|
```
|
||||||
|
|
||||||
|
所有资源、命令、Job、证据和审计记录都必须显式绑定 `tenant_id`。缺少上下文、上下文不匹配或无法确认所有权时,系统必须 fail closed。
|
||||||
|
|
||||||
|
## 3. 权威上下文
|
||||||
|
|
||||||
|
控制请求至少包含:
|
||||||
|
|
||||||
|
- `tenant_id`;
|
||||||
|
- `principal_id`;
|
||||||
|
- `trace_id`;
|
||||||
|
- `policy_version`;
|
||||||
|
- 可选 `session_id`。
|
||||||
|
|
||||||
|
执行请求增加:
|
||||||
|
|
||||||
|
- `task_id`;
|
||||||
|
- `command_id` 或 `job_id`;
|
||||||
|
- `idempotency_key`。
|
||||||
|
|
||||||
|
Hermes 额外包含:
|
||||||
|
|
||||||
|
- `cell_id`;
|
||||||
|
- `profile_id`;
|
||||||
|
- `worker_id`。
|
||||||
|
|
||||||
|
## 4. 各层执行规则
|
||||||
|
|
||||||
|
### Web/API
|
||||||
|
|
||||||
|
- 可信代理必须删除客户端同名 `X-AIOS-*` Header 后重新注入;
|
||||||
|
- 浏览器不得自行选择或覆盖租户;
|
||||||
|
- 未认证身份断言必须拒绝。
|
||||||
|
|
||||||
|
### Control Plane
|
||||||
|
|
||||||
|
- Tenant、Agent、Task、Audit 全部带不可为空的 `tenant_id`;
|
||||||
|
- 跨租户读取返回拒绝或“未找到”;
|
||||||
|
- 不泄露其他租户资源存在性。
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
|
||||||
|
- 签名覆盖 tenant/task/trace/action/payload/idempotency/time;
|
||||||
|
- Result 身份字段必须与命令完全一致;
|
||||||
|
- 跨租户 status/cancel 返回“未找到”。
|
||||||
|
|
||||||
|
### Hermes
|
||||||
|
|
||||||
|
- Job 按 tenant/cell/profile 分区;
|
||||||
|
- Worker 只能 claim 自己的分区;
|
||||||
|
- Worker ID 不得在未授权分区之间迁移;
|
||||||
|
- Result 必须匹配 tenant/task/trace。
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
- 候选版使用显式租户字段;
|
||||||
|
- 生产数据库必须提供 RLS 或物理隔离策略;
|
||||||
|
- 备份、恢复和迁移必须保持租户边界。
|
||||||
|
|
||||||
|
### Audit 与 Evidence
|
||||||
|
|
||||||
|
- 仅按当前租户查询;
|
||||||
|
- 不记录明文 Secret;
|
||||||
|
- 统一包含 tenant/task/trace。
|
||||||
|
|
||||||
|
### Cache、Memory、Artifact、Logs、Metrics
|
||||||
|
|
||||||
|
- Key 或标签必须包含租户命名空间;
|
||||||
|
- 查询和导出必须受租户权限限制;
|
||||||
|
- 不允许使用无租户前缀的全局缓存保存业务数据。
|
||||||
|
|
||||||
|
## 5. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:仅在 API 层检查租户
|
||||||
|
|
||||||
|
拒绝原因:后台任务、Runtime、缓存和结果回写仍可能跨租户。
|
||||||
|
|
||||||
|
### 方案 B:仅依赖数据库隔离
|
||||||
|
|
||||||
|
拒绝原因:命令、队列、审计和文件系统仍需要显式边界。
|
||||||
|
|
||||||
|
### 方案 C:发生冲突时自动推断租户
|
||||||
|
|
||||||
|
拒绝原因:推断可能产生跨租户副作用,必须默认拒绝。
|
||||||
|
|
||||||
|
## 6. 正面影响
|
||||||
|
|
||||||
|
- 安全模型一致;
|
||||||
|
- Runtime 无法绕过租户边界;
|
||||||
|
- 负向测试可自动化;
|
||||||
|
- 审计和问题定位更明确。
|
||||||
|
|
||||||
|
## 7. 负面影响与风险
|
||||||
|
|
||||||
|
- 所有接口和模型都需要传播租户上下文;
|
||||||
|
- 日志、指标和缓存设计更复杂;
|
||||||
|
- 候选 SQLite 只有逻辑隔离,生产仍需更强存储证据;
|
||||||
|
- 身份代理配置错误可能破坏入口隔离。
|
||||||
|
|
||||||
|
## 8. 测试要求
|
||||||
|
|
||||||
|
至少覆盖:
|
||||||
|
|
||||||
|
- Tenant B 读取 Tenant A Task;
|
||||||
|
- Tenant B 查询或取消 Tenant A OpenClawCommand;
|
||||||
|
- Worker 跨 tenant/cell/profile claim;
|
||||||
|
- Runtime 返回错误 tenant/task/trace;
|
||||||
|
- Evidence 和 Audit 跨租户查询;
|
||||||
|
- 缺少 Tenant Context 的请求;
|
||||||
|
- 客户端伪造身份 Header。
|
||||||
|
|
||||||
|
## 9. 合规检查
|
||||||
|
|
||||||
|
任何新模块没有以下内容时不得进入 Candidate:
|
||||||
|
|
||||||
|
- 显式 `tenant_id`;
|
||||||
|
- 所有权检查;
|
||||||
|
- 跨租户负向测试;
|
||||||
|
- 审计映射;
|
||||||
|
- 文档与追踪矩阵更新。
|
||||||
|
|
||||||
|
## 10. 实现与测试映射
|
||||||
|
|
||||||
|
- Control Plane:`active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
- OpenClaw:`active-runtime/app/aios_openclaw/`
|
||||||
|
- Hermes:`active-runtime/app/aios_hermes/`
|
||||||
|
- Evidence:`active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# ADR-005:文档与代码同步门禁
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:需求、架构、代码、测试、部署、在线任务、发布和归档
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
架构、功能和部署变化如果不同步文档,会导致版本无法审计,后续 Agent 无法判断真实设计,发布人员也无法确认代码是否符合用户需求。历史上还出现过文档目录不存在、文档含冲突标记、分支名与代码内容不一致等问题。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
所有影响以下内容的变更必须在同一交付流程中同步 `docs/aios-v2/`:
|
||||||
|
|
||||||
|
- 用户行为和功能范围;
|
||||||
|
- API、Header、请求和响应;
|
||||||
|
- 数据模型、字段和状态机;
|
||||||
|
- Control Plane、Agent Router、OpenClaw、Hermes 的边界;
|
||||||
|
- 多租户和安全策略;
|
||||||
|
- 持久化、审计和执行证据;
|
||||||
|
- 部署、健康检查和回滚;
|
||||||
|
- 测试和发布门禁。
|
||||||
|
|
||||||
|
正式文档统一使用中文,技术标识保留代码中的原始英文名称。
|
||||||
|
|
||||||
|
## 3. 门禁要求
|
||||||
|
|
||||||
|
验收必须执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 tools/aios_v2_docs_code_check.py
|
||||||
|
```
|
||||||
|
|
||||||
|
检查至少包括:
|
||||||
|
|
||||||
|
1. 必需文档和 ADR 存在且非空;
|
||||||
|
2. 文档以中文叙述为主;
|
||||||
|
3. 文档引用的代码、测试和部署路径存在;
|
||||||
|
4. 核心 API 能在代码中找到;
|
||||||
|
5. 核心模型和字段能在代码中找到;
|
||||||
|
6. Traceability Matrix 覆盖所有 `UR-*`;
|
||||||
|
7. ADR 索引引用的文件存在;
|
||||||
|
8. 文档中不存在 Git 冲突标记;
|
||||||
|
9. 不存在摘要占位符或空章节;
|
||||||
|
10. 架构/功能代码变化时,同一交付范围包含文档变化。
|
||||||
|
|
||||||
|
## 4. 状态影响
|
||||||
|
|
||||||
|
门禁失败时:
|
||||||
|
|
||||||
|
- Candidate 不得标记为 `Test-verified`;
|
||||||
|
- OpenClaw 不得继续生产部署;
|
||||||
|
- Codex 必须登记 blocker;
|
||||||
|
- 修复必须进入 Git 分支;
|
||||||
|
- Owner 不得批准 Tag 或生产部署。
|
||||||
|
|
||||||
|
## 5. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:依赖人工记忆更新文档
|
||||||
|
|
||||||
|
拒绝原因:长期必然产生文档漂移。
|
||||||
|
|
||||||
|
### 方案 B:发布前集中补文档
|
||||||
|
|
||||||
|
拒绝原因:无法确定每次代码变化对应的真实设计,也会增加错误归因。
|
||||||
|
|
||||||
|
### 方案 C:只检查文件存在
|
||||||
|
|
||||||
|
拒绝原因:文件存在不代表内容完整、路径有效或与代码一致。
|
||||||
|
|
||||||
|
## 6. 正面影响
|
||||||
|
|
||||||
|
- 架构和代码长期一致;
|
||||||
|
- Agent 有明确执行上下文;
|
||||||
|
- Bug 修复可以追踪需求和决策;
|
||||||
|
- 归档包可以作为正式交付材料;
|
||||||
|
- 发布风险和未验证项更透明。
|
||||||
|
|
||||||
|
## 7. 负面影响与风险
|
||||||
|
|
||||||
|
- 小功能也需要更新对应文档;
|
||||||
|
- 检查器需要随代码结构维护;
|
||||||
|
- 自动检查不能替代架构审查;
|
||||||
|
- 文档完整性会增加提交内容,但这是可审计交付的必要成本。
|
||||||
|
|
||||||
|
## 8. 文档更新映射
|
||||||
|
|
||||||
|
- 用户需求变化:`01-USER-REQUIREMENTS.md`;
|
||||||
|
- 架构边界变化:`02-TARGET-ARCHITECTURE.md` 和 ADR;
|
||||||
|
- 迁移/发布变化:`03-MIGRATION-AND-DELIVERY.md`;
|
||||||
|
- Contract 变化:`04-MODULE-CONTRACTS.md`;
|
||||||
|
- 路径和状态变化:`05-TRACEABILITY-MATRIX.md`;
|
||||||
|
- 文档规则变化:`06-DOCUMENT-GOVERNANCE.md`。
|
||||||
|
|
||||||
|
## 9. 在线任务要求
|
||||||
|
|
||||||
|
OpenClaw 和 Codex 的任何修改或阻塞必须回写:
|
||||||
|
|
||||||
|
- claim;
|
||||||
|
- heartbeat;
|
||||||
|
- progress;
|
||||||
|
- blocker;
|
||||||
|
- branch;
|
||||||
|
- commit;
|
||||||
|
- test_status;
|
||||||
|
- result;
|
||||||
|
- 文档同步状态。
|
||||||
|
|
||||||
|
OpenClaw 发现架构影响时必须停止扩大修复范围,并升级给 Codex。
|
||||||
|
|
||||||
|
## 10. 实现与验收映射
|
||||||
|
|
||||||
|
- 检查器:`tools/aios_v2_docs_code_check.py`
|
||||||
|
- 验收入口:`deploy/aios-v2/acceptance/run-all.sh`
|
||||||
|
- 治理规范:`docs/aios-v2/06-DOCUMENT-GOVERNANCE.md`
|
||||||
|
- 追踪矩阵:`docs/aios-v2/05-TRACEABILITY-MATRIX.md`
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# ADR-006:候选持久化采用可替换 SQLite Snapshot
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:Control Plane、Persistence、Deployment、Backup、Recovery、HA 演进
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
AIOS V2 候选版本需要在服务重启后恢复 Tenant、Agent、Task、Audit 和 Idempotency 状态,同时又必须尽快进入预发布部署与 Bug 修复阶段。
|
||||||
|
|
||||||
|
候选阶段如果立即引入完整分布式数据库、消息系统和 HA,会显著扩大开发范围,推迟控制面、Runtime Boundary 和多租户模型的验证。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
候选版本使用可替换的 SQLite WAL 原子 Snapshot:
|
||||||
|
|
||||||
|
- `SQLiteSnapshotRepository` 保存版本化 Control Plane Snapshot;
|
||||||
|
- `DurableControlPlaneService` 在成功变更后持久化;
|
||||||
|
- 使用 WAL、`synchronous=FULL` 和 `BEGIN IMMEDIATE`;
|
||||||
|
- 状态目录独立于代码版本;
|
||||||
|
- 上层 Domain、API、Orchestrator 和 Runtime Contract 不依赖 SQLite 实现。
|
||||||
|
|
||||||
|
执行证据使用租户分区 JSON 文件,由 `ExecutionTracker` 原子写入。
|
||||||
|
|
||||||
|
## 3. 持久化范围
|
||||||
|
|
||||||
|
候选 Snapshot 包含:
|
||||||
|
|
||||||
|
- Tenant;
|
||||||
|
- Agent;
|
||||||
|
- Task;
|
||||||
|
- Audit;
|
||||||
|
- Idempotency。
|
||||||
|
|
||||||
|
独立 Evidence 目录包含:
|
||||||
|
|
||||||
|
- claim;
|
||||||
|
- heartbeat;
|
||||||
|
- progress;
|
||||||
|
- blocker;
|
||||||
|
- commit;
|
||||||
|
- test_status;
|
||||||
|
- result。
|
||||||
|
|
||||||
|
## 4. 选择理由
|
||||||
|
|
||||||
|
- 无额外外部依赖;
|
||||||
|
- 易于快速部署和回滚;
|
||||||
|
- 支持事务、WAL 和完整性检查;
|
||||||
|
- 可以验证上层控制面和 Runtime Contract;
|
||||||
|
- 便于在预发布阶段复现和修复 Bug;
|
||||||
|
- 可通过 Repository 边界替换为生产数据库。
|
||||||
|
|
||||||
|
## 5. 备选方案
|
||||||
|
|
||||||
|
### 方案 A:候选阶段直接使用 PostgreSQL 集群
|
||||||
|
|
||||||
|
暂不采用原因:会增加部署、迁移、HA、Secret、网络和容量范围,延迟核心架构验证。
|
||||||
|
|
||||||
|
### 方案 B:继续使用纯内存状态
|
||||||
|
|
||||||
|
拒绝原因:服务重启会丢失权威状态,无法进入预发布。
|
||||||
|
|
||||||
|
### 方案 C:每个模块各自使用 SQLite
|
||||||
|
|
||||||
|
拒绝原因:会形成多个事实源和跨模块事务问题。
|
||||||
|
|
||||||
|
### 方案 D:将完整状态写入 Git
|
||||||
|
|
||||||
|
拒绝原因:运行状态不应成为 Git 内容,可能暴露敏感业务数据并造成提交风暴。
|
||||||
|
|
||||||
|
## 6. 正面影响
|
||||||
|
|
||||||
|
- 候选版具备重启恢复能力;
|
||||||
|
- 状态与代码版本解耦;
|
||||||
|
- 回滚默认只切换代码,不覆盖新状态;
|
||||||
|
- 可快速进入预发布测试;
|
||||||
|
- 生产后端替换不改变上层 Contract。
|
||||||
|
|
||||||
|
## 7. 负面影响与风险
|
||||||
|
|
||||||
|
- SQLite 主要适用于单进程候选部署;
|
||||||
|
- 尚未提供多实例写入和分布式锁;
|
||||||
|
- 租户隔离是逻辑隔离,不是 RLS 或物理隔离;
|
||||||
|
- 大规模任务、审计和并发容量尚未验证;
|
||||||
|
- Evidence 文件数量增长需要归档策略;
|
||||||
|
- Snapshot 粒度可能导致状态增长后的写放大。
|
||||||
|
|
||||||
|
## 8. 生产迁移要求
|
||||||
|
|
||||||
|
生产数据层替换必须满足:
|
||||||
|
|
||||||
|
1. 保持 Control Plane Domain/API Contract 不变;
|
||||||
|
2. 提供 schema migration;
|
||||||
|
3. 提供租户 RLS 或物理隔离;
|
||||||
|
4. 提供备份、恢复、PITR 和灾难恢复;
|
||||||
|
5. 提供 HA、多实例并发和故障转移;
|
||||||
|
6. 提供容量、延迟和锁等待测试;
|
||||||
|
7. 保持 Audit 与 Evidence 可追踪;
|
||||||
|
8. 提供从 SQLite Candidate 到生产后端的数据迁移工具;
|
||||||
|
9. 通过新的 ADR 说明生产持久化决策。
|
||||||
|
|
||||||
|
## 9. 回滚策略
|
||||||
|
|
||||||
|
- 回滚前备份 `/var/lib/aios-v2`;
|
||||||
|
- 默认只回滚代码;
|
||||||
|
- 状态恢复必须由 Owner 显式授权;
|
||||||
|
- 恢复前必须验证 Snapshot schema 兼容性;
|
||||||
|
- 回滚后执行 SQLite `PRAGMA quick_check` 和 AIOS Healthcheck。
|
||||||
|
|
||||||
|
## 10. 验收要求
|
||||||
|
|
||||||
|
候选版至少通过:
|
||||||
|
|
||||||
|
- 创建 Tenant、Agent、Task;
|
||||||
|
- 服务重启;
|
||||||
|
- 读取恢复后的 Task;
|
||||||
|
- Idempotency 仍有效;
|
||||||
|
- Audit 仍存在;
|
||||||
|
- SQLite `quick_check=ok`;
|
||||||
|
- 状态目录权限正确;
|
||||||
|
- 回滚演练不丢失新增状态。
|
||||||
|
|
||||||
|
## 11. 实现与测试映射
|
||||||
|
|
||||||
|
- Repository:`active-runtime/app/aios_v2/persistence.py`
|
||||||
|
- 应用入口:`active-runtime/app/aios_v2/app.py`
|
||||||
|
- Evidence:`active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
- 测试:`tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
|
- 健康检查:`deploy/aios-v2/healthcheck/check.sh`
|
||||||
|
- 回滚:`deploy/aios-v2/rollback/rollback.sh`
|
||||||
|
|
||||||
|
## 12. 更新触发条件
|
||||||
|
|
||||||
|
以下变化必须更新或替代本 ADR:
|
||||||
|
|
||||||
|
- 引入生产数据库;
|
||||||
|
- 支持多实例写入;
|
||||||
|
- 修改 Snapshot 格式;
|
||||||
|
- 改变 Evidence 存储;
|
||||||
|
- 增加 RLS、HA、分片或事件溯源;
|
||||||
|
- 修改状态回滚策略。
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# ADR-007:AIOS V2 Run Provenance 可信执行来源控制
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-20
|
||||||
|
- 影响范围:Runtime 执行、Evidence、GitOps、部署、回滚、审计
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
AIOS V2 在进入预生产阶段前,需要证明每一次高权限动作来源可信。仅依靠进程内 runId、sender 或任务字段无法证明:
|
||||||
|
|
||||||
|
- 谁授权了执行;
|
||||||
|
- 执行是否属于当前任务;
|
||||||
|
- Git、部署、回滚和 Evidence 写入是否经过批准;
|
||||||
|
- 是否存在重放或伪造运行记录。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
引入 Run Provenance:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Owner / Control Plane
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Ed25519 Signed Provenance
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Persistent Provenance Store
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Controlled Writer
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Runtime / GitOps / Deploy / Evidence / Rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 七类受保护路径
|
||||||
|
|
||||||
|
生产 Provenance 必须覆盖:
|
||||||
|
|
||||||
|
1. code_change
|
||||||
|
2. git_operation
|
||||||
|
3. deployment
|
||||||
|
4. runtime_execute
|
||||||
|
5. evidence_write
|
||||||
|
6. rollback
|
||||||
|
7. audit
|
||||||
|
|
||||||
|
任何未授权路径默认拒绝。
|
||||||
|
|
||||||
|
## 4. 安全规则
|
||||||
|
|
||||||
|
- 私钥只用于签发端。
|
||||||
|
- Runtime 只持有公钥验证能力。
|
||||||
|
- Provenance 使用 Ed25519 签名。
|
||||||
|
- 状态持久保存 issued、verified、used、expired、revoked。
|
||||||
|
- 相同 provenance/path/request_digest 不允许重复授权。
|
||||||
|
- Secret 不进入 Git、日志和 Evidence。
|
||||||
|
|
||||||
|
## 5. 实现映射
|
||||||
|
|
||||||
|
代码:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_v2/provenance.py
|
||||||
|
active-runtime/app/aios_v2/provenance_git.py
|
||||||
|
active-runtime/app/aios_v2/provenance_operations.py
|
||||||
|
active-runtime/app/aios_v2/provenance_cli.py
|
||||||
|
```
|
||||||
|
|
||||||
|
接入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
orchestrator.py
|
||||||
|
execution_tracking.py
|
||||||
|
app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 验收要求
|
||||||
|
|
||||||
|
必须验证:
|
||||||
|
|
||||||
|
- 签名正确执行;
|
||||||
|
- 篡改拒绝;
|
||||||
|
- 过期拒绝;
|
||||||
|
- revoke 拒绝;
|
||||||
|
- replay 拒绝;
|
||||||
|
- 无 Provenance Runtime Dispatch 拒绝;
|
||||||
|
- 无 Evidence 授权写入拒绝;
|
||||||
|
- 无 Git 授权操作拒绝;
|
||||||
|
- 无 Deployment/Rollback 授权拒绝。
|
||||||
|
|
||||||
|
## 7. 回滚
|
||||||
|
|
||||||
|
若 Provenance 服务异常:
|
||||||
|
|
||||||
|
- 不允许降级绕过安全检查进入生产;
|
||||||
|
- 候选环境可以在明确测试配置中关闭强制模式;
|
||||||
|
- 生产模式必须保持 AIOS_REQUIRE_RUN_PROVENANCE=true。
|
||||||
|
|
||||||
|
## 8. 后续演进
|
||||||
|
|
||||||
|
生产阶段可进一步接入:
|
||||||
|
|
||||||
|
- HSM/KMS;
|
||||||
|
- 外部不可变审计;
|
||||||
|
- 多签审批;
|
||||||
|
- 硬件密钥认证。
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# ADR-009:Workflow Provenance 与最小权限子 Grant
|
||||||
|
|
||||||
|
- 状态:已采纳
|
||||||
|
- 日期:2026-07-21
|
||||||
|
- 决策范围:AIOS V2R1 生产授权、Runtime 调度、Evidence、Git、部署、回滚与审计
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
早期候选实现使用一个 Run Provenance 同时承载身份绑定和七类生产操作权限。该模型可以证明一次执行来自哪个租户、任务、Agent 与 Principal,但同一凭证能够授权 Runtime、Evidence、Git、部署、回滚、审计和代码变更,违反最小权限原则。任何持有该凭证的进程都可能获得超出职责的能力。
|
||||||
|
|
||||||
|
同时,Runtime Result 回写、Git 写入和部署包装器需要可撤销、可过期、可重放防护且可持久恢复的独立授权。仅依赖进程角色或环境变量不足以形成可审计的生产信任链。
|
||||||
|
|
||||||
|
## 2. 决策
|
||||||
|
|
||||||
|
采用两层 Provenance 模型:
|
||||||
|
|
||||||
|
1. **Workflow Provenance** 只绑定 `run_id`、`tenant_id`、`task_id`、`agent_id`、`principal_id`、签发者、TTL、Key ID、Nonce 和签名,不授予任何操作路径。
|
||||||
|
2. **Provenance Grant** 由 Workflow Provenance 派生,每个 Grant 只绑定一个 `ExecutionPath`,拥有独立的 TTL、Nonce、状态、撤销和重放记录。
|
||||||
|
3. Git Grant 还必须显式限定允许的 `GitOperation`,例如只允许 `READ_REF` 而不允许 `PUSH`。
|
||||||
|
4. 子 Grant 的身份字段必须与父 Workflow 完全一致,且子 Grant 的有效期不得超过父 Workflow。
|
||||||
|
5. 父 Workflow 被撤销或过期后,其全部子 Grant 自动失效。
|
||||||
|
6. 授权使用 `(grant_id, execution_path, git_operation, request_digest)` 建立唯一使用记录,重复请求默认拒绝。
|
||||||
|
|
||||||
|
## 3. Grant 类型
|
||||||
|
|
||||||
|
| GrantType | 唯一允许路径 |
|
||||||
|
|---|---|
|
||||||
|
| `code_change` | `code_change` |
|
||||||
|
| `git` | `git_operation` |
|
||||||
|
| `deployment` | `deployment` |
|
||||||
|
| `runtime` | `runtime_execute` |
|
||||||
|
| `evidence` | `evidence_write` |
|
||||||
|
| `rollback` | `rollback` |
|
||||||
|
| `audit` | `audit` |
|
||||||
|
|
||||||
|
Grant 类型与路径不一致时,构造阶段即拒绝。非 Git Grant 不得包含 Git 操作;Git Grant 未声明具体操作时不得签发。
|
||||||
|
|
||||||
|
## 4. 服务级 Scope
|
||||||
|
|
||||||
|
生产进程不得加载全部 Grant。`ScopedLeastPrivilegeProvenanceWriter` 只接受当前服务声明的路径和对应 Grant ID,多余 Grant 映射会导致启动失败。
|
||||||
|
|
||||||
|
默认 AIOS Application Scope 仅包含:
|
||||||
|
|
||||||
|
- `runtime_execute`
|
||||||
|
- `evidence_write`
|
||||||
|
- `audit`
|
||||||
|
|
||||||
|
Git、部署、回滚和代码变更必须由独立进程或包装器显式声明自己的单一路径 Scope。运维 CLI 的一次授权命令只接受一个 `--grant-id`,并要求它与 `--path` 一致。
|
||||||
|
|
||||||
|
## 5. Runtime 与 Dispatch 绑定
|
||||||
|
|
||||||
|
Runtime 执行还必须经过持久化 `DispatchRecord`。记录至少绑定:
|
||||||
|
|
||||||
|
- `dispatch_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `task_id`
|
||||||
|
- `agent_id`
|
||||||
|
- `principal_id`
|
||||||
|
- `runtime`
|
||||||
|
- `runtime_execution_id`
|
||||||
|
- `trace_id`
|
||||||
|
- `provenance_id`
|
||||||
|
- `run_id`
|
||||||
|
- `authorization_id`
|
||||||
|
|
||||||
|
OpenClaw/Hermes 结果只有在上述上下文完全匹配时才能由 Orchestrator 更新 Task。公开 Task API 不允许直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`;Hermes 结果只通过内部 Runtime Reconcile 入口处理。
|
||||||
|
|
||||||
|
## 6. 密钥与签名
|
||||||
|
|
||||||
|
Workflow Provenance、子 Grant 和 OpenClaw Command 均采用 Ed25519。生产应用只读取公钥;私钥仅存在于受保护的签发端或 systemd credential。Git、日志、环境模板和审计中不得出现私钥内容。
|
||||||
|
|
||||||
|
Key ID 用于选择受信任密钥,不包含密钥材料。公钥 Fingerprint 使用恒定时间比较。OpenClaw Command 合同版本为 `aios.openclaw.command.v2`。
|
||||||
|
|
||||||
|
## 7. 备选方案
|
||||||
|
|
||||||
|
### 7.1 单一全权限 Provenance
|
||||||
|
|
||||||
|
拒绝。实现简单,但任何服务泄漏都可能获得全部生产能力,无法独立撤销 Runtime、Git 或部署权限。
|
||||||
|
|
||||||
|
### 7.2 仅依赖 RBAC
|
||||||
|
|
||||||
|
拒绝。RBAC 可以限制 API 调用者,但不能把具体 Run、Task、Agent、请求摘要和 Runtime Result 绑定到一次可验证授权。
|
||||||
|
|
||||||
|
### 7.3 每个操作重新签发完整 Workflow
|
||||||
|
|
||||||
|
拒绝。会复制身份数据和生命周期状态,难以统一撤销整次工作流,也增加签发和审计复杂度。
|
||||||
|
|
||||||
|
## 8. 正面影响
|
||||||
|
|
||||||
|
- 服务与操作权限按职责隔离。
|
||||||
|
- Runtime、Evidence、Git、部署、回滚和审计可以独立撤销。
|
||||||
|
- 父 Workflow 可作为整次 Run 的紧急停止开关。
|
||||||
|
- SQLite 持久化支持重启后的授权与 Dispatch 恢复。
|
||||||
|
- Receipt 明确记录 Grant、Scope、请求摘要和授权时间。
|
||||||
|
- Git Index 只有在 Grant 授权完成后才允许修改。
|
||||||
|
|
||||||
|
## 9. 负面影响与风险
|
||||||
|
|
||||||
|
- 签发和部署配置比单凭证模式复杂。
|
||||||
|
- 每个生产服务必须配置正确的 Grant ID。
|
||||||
|
- Grant 状态与 Dispatch 状态需要备份、迁移和容量治理。
|
||||||
|
- 当前候选实现使用 SQLite;高可用生产部署仍需外部持久化和并发容量证据。
|
||||||
|
- OpenClaw callback 目前只作为非权威审计事件;状态变化仍必须经过 Orchestrator 严格 Reconcile。
|
||||||
|
|
||||||
|
## 10. 迁移与回滚
|
||||||
|
|
||||||
|
迁移步骤:
|
||||||
|
|
||||||
|
1. 签发无操作权限的 Workflow Provenance。
|
||||||
|
2. 按所需路径签发独立子 Grant。
|
||||||
|
3. 为每个服务配置其最小 Grant ID。
|
||||||
|
4. 启用 `AIOS_REQUIRE_RUN_PROVENANCE=true`。
|
||||||
|
5. 验证 Scope 拒绝、撤销、过期、重放和重启恢复测试。
|
||||||
|
6. 完成预发布 Acceptance 后,禁止旧全权限 `issue` 命令进入生产流程。
|
||||||
|
|
||||||
|
回滚仅允许回到前一个已验证候选版本,不得回滚为生产全权限 Provenance。状态恢复必须保留父 Workflow、Grant 使用记录、Dispatch 和 Receipt,防止旧授权被再次使用。
|
||||||
|
|
||||||
|
## 11. 合规检查
|
||||||
|
|
||||||
|
必须满足:
|
||||||
|
|
||||||
|
- 父 Workflow 无操作路径。
|
||||||
|
- 子 Grant 只包含一个路径。
|
||||||
|
- 生产服务只加载职责所需 Grant。
|
||||||
|
- 上下文不匹配、签名错误、过期、撤销和重放全部 fail closed。
|
||||||
|
- Runtime Result 必须匹配持久 Dispatch。
|
||||||
|
- 测试与文档在同一分支提交。
|
||||||
|
- 正式 Gate A 结论必须基于实际执行 Acceptance,不得仅凭测试文件存在声明通过。
|
||||||
|
|
||||||
|
## 12. 受影响资产
|
||||||
|
|
||||||
|
代码:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/provenance.py`
|
||||||
|
- `active-runtime/app/aios_v2/provenance_grants.py`
|
||||||
|
- `active-runtime/app/aios_v2/provenance_scoped.py`
|
||||||
|
- `active-runtime/app/aios_v2/provenance_operations.py`
|
||||||
|
- `active-runtime/app/aios_v2/provenance_cli.py`
|
||||||
|
- `active-runtime/app/aios_v2/dispatch_store.py`
|
||||||
|
- `active-runtime/app/aios_v2/orchestrator.py`
|
||||||
|
- `active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
- `active-runtime/app/aios_v2/provenance_git.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/contract.py`
|
||||||
|
|
||||||
|
测试:
|
||||||
|
|
||||||
|
- `tests/aios_v2_candidate/test_provenance.py`
|
||||||
|
- `tests/aios_v2_candidate/test_provenance_grant_isolation.py`
|
||||||
|
- `tests/aios_v2_candidate/test_candidate_flow.py`
|
||||||
|
|
||||||
|
部署与文档:
|
||||||
|
|
||||||
|
- `deploy/aios-v2/env/aios-v2.env.example`
|
||||||
|
- `docs/aios-v2/04-MODULE-CONTRACTS.md`
|
||||||
|
- `docs/aios-v2/05-TRACEABILITY-MATRIX.md`
|
||||||
|
- `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# AIOS V2 架构决策记录索引
|
||||||
|
|
||||||
|
- 文档版本:`V2R1-hardening-candidate`
|
||||||
|
- 适用范围:影响多个模块、长期演进、兼容性、安全、数据、部署或交付流程的关键架构决策
|
||||||
|
|
||||||
|
## 1. 架构决策记录列表
|
||||||
|
|
||||||
|
| 编号 | 决策 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| [ADR-001](ADR-001-single-control-plane.md) | AIOS 作为唯一控制面 | 已采纳 |
|
||||||
|
| [ADR-002](ADR-002-openclaw-hermes-decoupling.md) | OpenClaw 与 Hermes 从控制面职责中解耦 | 已采纳 |
|
||||||
|
| [ADR-003](ADR-003-gitops-only-change-flow.md) | 所有变更必须通过 GitOps | 已采纳 |
|
||||||
|
| [ADR-004](ADR-004-multi-tenant-isolation.md) | 多租户隔离采用默认拒绝模型 | 已采纳 |
|
||||||
|
| [ADR-005](ADR-005-document-code-sync-gate.md) | 文档与代码同步门禁 | 已采纳 |
|
||||||
|
| [ADR-006](ADR-006-candidate-persistence-strategy.md) | 候选持久化采用可替换 SQLite Snapshot | 已采纳 |
|
||||||
|
| ADR-007 | 当前仓库未分配;禁止补写不存在的历史决策 | 未分配 |
|
||||||
|
| ADR-008 | 当前仓库未分配;禁止补写不存在的历史决策 | 未分配 |
|
||||||
|
| [ADR-009](ADR-009-provenance-least-privilege.md) | Workflow Provenance、最小权限子 Grant 与服务级 Scope | 已采纳 |
|
||||||
|
|
||||||
|
## 2. 编号规则
|
||||||
|
|
||||||
|
- ADR 编号一经发布不得重用。
|
||||||
|
- 编号缺口可以保留,不能通过伪造历史文件填补。
|
||||||
|
- 新决策使用下一个未占用编号,并在同一分支更新本索引、模块契约和追踪矩阵。
|
||||||
|
- ADR-007 与 ADR-008 在当前仓库没有可验证历史文件,因此明确标记为未分配。
|
||||||
|
|
||||||
|
## 3. 状态定义
|
||||||
|
|
||||||
|
- **已提出**:决策已提出,但尚未被正式采纳。
|
||||||
|
- **已采纳**:当前有效,所有新实现必须遵守。
|
||||||
|
- **已替代**:已被新的 ADR 替代,保留用于历史追踪。
|
||||||
|
- **已废弃**:保留历史,但禁止新实现继续采用。
|
||||||
|
- **未分配**:该编号当前没有 ADR 文件,不代表存在隐含历史决策。
|
||||||
|
|
||||||
|
## 4. 必须新增或更新 ADR 的场景
|
||||||
|
|
||||||
|
以下变化必须新增或更新 ADR:
|
||||||
|
|
||||||
|
1. AIOS、OpenClaw、Hermes、Agent Router 的控制边界变化;
|
||||||
|
2. 多租户隔离模型变化;
|
||||||
|
3. API、数据模型或状态机出现不兼容变化;
|
||||||
|
4. 持久化、消息、密钥、审计或部署方案发生跨模块变化;
|
||||||
|
5. GitOps、发布、回滚或文档门禁变化;
|
||||||
|
6. 替换 OpenClaw、Hermes、Agent Router 或核心基础设施;
|
||||||
|
7. 引入新的权威事实源、任务生命周期或租户状态;
|
||||||
|
8. 生产安全模型、身份代理、Run Provenance、Grant 或 Secret 管理方式变化;
|
||||||
|
9. Runtime Result、Dispatch 或服务 Scope 信任边界变化。
|
||||||
|
|
||||||
|
## 5. ADR 内容要求
|
||||||
|
|
||||||
|
每份 ADR 必须包含:
|
||||||
|
|
||||||
|
- 背景;
|
||||||
|
- 决策;
|
||||||
|
- 备选方案;
|
||||||
|
- 采用理由;
|
||||||
|
- 正面影响;
|
||||||
|
- 负面影响和风险;
|
||||||
|
- 迁移与回滚;
|
||||||
|
- 合规检查;
|
||||||
|
- 受影响的代码、测试和文档。
|
||||||
|
|
||||||
|
## 6. ADR 变更联动
|
||||||
|
|
||||||
|
ADR 新增、替代或废弃时,必须同步检查并更新:
|
||||||
|
|
||||||
|
- `01-USER-REQUIREMENTS.md`;
|
||||||
|
- `02-TARGET-ARCHITECTURE.md`;
|
||||||
|
- `03-MIGRATION-AND-DELIVERY.md`;
|
||||||
|
- `04-MODULE-CONTRACTS.md`;
|
||||||
|
- `05-TRACEABILITY-MATRIX.md`;
|
||||||
|
- 相关代码、测试和部署资产;
|
||||||
|
- 候选或 Gate 报告中的风险与发布结论。
|
||||||
|
|
||||||
|
## 7. 审查规则
|
||||||
|
|
||||||
|
- OpenClaw 不得自行采纳改变控制面边界的 ADR;发现需求后必须升级给 Codex。
|
||||||
|
- Codex 负责审查 ADR 与代码、测试和文档是否一致。
|
||||||
|
- 涉及合并、Tag、生产部署或重大风险接受的 ADR,必须由 AIOS Owner 最终批准。
|
||||||
|
- ADR 未完成审查前,相关变更不得标记为 `Test-verified` 或 `Production released`。
|
||||||
|
- ADR、测试文件或 Acceptance 脚本存在,不等于 Gate 已通过;必须有实际执行日志、退出码和证据。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user