Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be3ba09e4 | ||
|
|
b158041b86 | ||
|
|
2a2b702eb8 | ||
|
|
341556c780 | ||
|
|
bd45c0ad77 | ||
|
|
c81d4f40f3 | ||
|
|
ea2a466d96 | ||
|
|
63b31165b7 | ||
|
|
07b5c2c9c2 | ||
|
|
d71b7903df | ||
|
|
79fd83cd61 | ||
|
|
fedb6b08fb | ||
|
|
83f8f878c7 | ||
|
|
1352a664ab | ||
|
|
57209760cd | ||
|
|
850bb669b7 | ||
|
|
9c20bf1362 | ||
|
|
09fa4f9d73 | ||
|
|
816be7bec5 | ||
|
|
25c25abd3e | ||
|
|
8edb96a0fa | ||
|
|
695a846645 | ||
|
|
d8f01f745d | ||
|
|
c75fa77898 | ||
|
|
9206b883ab | ||
|
|
b199935db8 | ||
|
|
f7307c82b2 | ||
|
|
bf01a13f3a | ||
|
|
8a36f7832b | ||
|
|
f1aeefff20 | ||
|
|
f55dfd975e | ||
|
|
ba7669c3b4 | ||
|
|
bd2784015d | ||
|
|
1aa71f233d | ||
|
|
558dc932fa | ||
|
|
5fd5ae0a33 | ||
|
|
81572b8231 | ||
|
|
8c3d80fe53 | ||
|
|
99bcc03b40 | ||
|
|
c6e7edcf54 | ||
|
|
634f6b164f | ||
|
|
c9ce12d102 | ||
|
|
db50d2fa5a | ||
|
|
63fd23a464 | ||
|
|
2e03ed74f6 | ||
|
|
b86f51c9ab | ||
|
|
09448a5d3d | ||
|
|
18c719b2c6 | ||
|
|
b0a78fa9a8 | ||
|
|
0aca7dbcfc | ||
|
|
3f84d8991e | ||
|
|
5ac8f3ea6f | ||
|
|
bfb1c2fd52 | ||
|
|
cbfe1211c1 | ||
|
|
efd99e620c | ||
|
|
c464560a0f | ||
|
|
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 |
@@ -0,0 +1,412 @@
|
||||
# OpenClaw 在线执行指令:启动执行器、配置 Secret 引用并确认 Gitea 同步
|
||||
|
||||
- 任务 ID:`AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
|
||||
- 执行器:OpenClaw
|
||||
- 任务分支:`task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720`
|
||||
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||
- 语言:中文
|
||||
- 执行模式:无人值守、证据驱动、默认拒绝
|
||||
|
||||
## 1. 任务目标
|
||||
|
||||
一次性完成以下工作:
|
||||
|
||||
1. 发现并启动环境中已注册的 OpenClaw 在线执行器;
|
||||
2. 领取本任务并持续写入 claim、heartbeat、progress、blocker 和 result;
|
||||
3. 将任务分支同步到最新 Candidate HEAD;
|
||||
4. 确认 Candidate、OpenClaw Task、Codex Task 三个分支已同步到 Gitea,且本地与远端 OID 完全一致;
|
||||
5. 从已批准的 Secret Provider、Credential Store 或受保护文件中解析 OpenClaw Runtime 配置;
|
||||
6. 只把 Secret 引用和受保护文件路径写入 AIOS 环境配置,不得把明文 Secret 写入 Git、日志、Evidence 或终端输出;
|
||||
7. 执行文档—代码检查、候选测试、全量验收和预发布健康检查;
|
||||
8. 对低风险部署或 Runtime 缺陷创建 Patch 并修复;
|
||||
9. 把所有修改和阻塞提交到系统,交由 Codex 统一审视。
|
||||
|
||||
## 2. 不可违反的边界
|
||||
|
||||
- 不得直接修改或 push `main`;
|
||||
- 不得使用 `--force` 或改写共享分支历史;
|
||||
- 不得创建生产 Tag;
|
||||
- 不得切换生产流量,除非 AIOS Owner 后续明确批准;
|
||||
- 不得提交 Token、密码、API Key、私钥、签名材料或 Secret 内容;
|
||||
- 不得在日志中执行 `env`、`set`、`printenv` 或输出 Secret 文件内容;
|
||||
- 不得修改 AIOS 唯一控制面、多租户或审批边界;
|
||||
- 发现架构、安全或跨模块阻塞时必须升级 Codex;
|
||||
- 未生成 claim/heartbeat 证据前不得标记为运行中;
|
||||
- 未通过验收前不得标记为完成。
|
||||
|
||||
## 3. 第一阶段:执行器发现与领取
|
||||
|
||||
### 3.1 发现执行器入口
|
||||
|
||||
使用当前环境中已经注册和批准的 OpenClaw 执行入口。可检查:
|
||||
|
||||
- systemd 服务;
|
||||
- 容器或 Compose 服务;
|
||||
- 已安装的 OpenClaw CLI;
|
||||
- AIOS Agent Runner;
|
||||
- 已配置的队列消费者或调度器。
|
||||
|
||||
不得臆造不存在的命令、服务名或路径。
|
||||
|
||||
如果找不到可执行入口,立即创建 Blocker:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-EXECUTOR-ENTRYPOINT-MISSING",
|
||||
"severity": "high",
|
||||
"description": "环境中未发现已注册的 OpenClaw 执行器入口",
|
||||
"owner": "AIOS 运维负责人",
|
||||
"exit_condition": "提供或启用已批准的 OpenClaw systemd、容器、CLI 或 Agent Runner 入口"
|
||||
}
|
||||
```
|
||||
|
||||
状态更新为 `blocked`,不得伪报已经启动。
|
||||
|
||||
### 3.2 启动执行器
|
||||
|
||||
发现入口后:
|
||||
|
||||
1. 验证配置来源和运行用户;
|
||||
2. 启动或恢复执行器;
|
||||
3. 验证进程/容器处于运行状态;
|
||||
4. 验证执行器能够读取本任务分支;
|
||||
5. 立即写入 `.agent/claim.json`;
|
||||
6. 更新 `.agent/status.json`:`queued -> claimed -> running`;
|
||||
7. 写入首次 `.agent/heartbeat.json`;
|
||||
8. 将证据提交到任务分支并同步 Gitea。
|
||||
|
||||
`claim.json` 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.v2.agent-claim.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "<领取时实际 Candidate OID>",
|
||||
"claimed_at": "<UTC ISO-8601>",
|
||||
"executor_instance": "<非敏感实例标识>"
|
||||
}
|
||||
```
|
||||
|
||||
Heartbeat 间隔不得超过 300 秒。
|
||||
|
||||
## 4. 第二阶段:同步最新 Candidate
|
||||
|
||||
### 4.1 获取最新引用
|
||||
|
||||
只更新 remote-tracking ref,不得 fetch 到已经被 Worktree 检出的本地 Branch Ref。
|
||||
|
||||
执行原则:
|
||||
|
||||
```text
|
||||
fetch remote candidate -> refs/remotes/<remote>/<candidate>
|
||||
```
|
||||
|
||||
不得使用会触发以下错误的流程:
|
||||
|
||||
```text
|
||||
refusing to fetch into branch checked out at worktree
|
||||
```
|
||||
|
||||
### 4.2 更新任务分支
|
||||
|
||||
将最新 Candidate 合并到 OpenClaw 任务分支:
|
||||
|
||||
- 使用普通 merge 或团队批准的线性更新方式;
|
||||
- 不得 force reset 已经提交的 Agent Evidence;
|
||||
- 发生冲突时停止自动扩大修改范围;
|
||||
- 文档、任务载荷或架构冲突升级 Codex;
|
||||
- 合并完成后把 `.agent/task.json` 与 `.agent/status.json` 中的 `candidate_commit` 更新为实际 Candidate OID。
|
||||
|
||||
### 4.3 必须记录
|
||||
|
||||
在 `.agent/progress.json` 记录:
|
||||
|
||||
- Candidate 本地 OID;
|
||||
- Candidate Gitea OID;
|
||||
- Task Branch 合并前后 OID;
|
||||
- 修改文件;
|
||||
- 是否发生冲突;
|
||||
- 合并 Commit。
|
||||
|
||||
## 5. 第三阶段:确认 Gitea 同步
|
||||
|
||||
需要验证以下分支:
|
||||
|
||||
```text
|
||||
integration/aios-v2r1-cto-production-candidate-20260720
|
||||
task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720
|
||||
task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720
|
||||
```
|
||||
|
||||
目标仓库:
|
||||
|
||||
```text
|
||||
g.cnnetmkt.top/git/aios/aios
|
||||
```
|
||||
|
||||
### 5.1 同步规则
|
||||
|
||||
- 使用环境中已配置的 Gitea Remote 和凭据引用;
|
||||
- 不在 Evidence 中记录带凭据的 Remote URL;
|
||||
- 禁止 Force Push;
|
||||
- Candidate 和 Task Branch 可以普通 Push;
|
||||
- Push 失败时记录 stderr 的脱敏摘要,不得记录凭据。
|
||||
|
||||
### 5.2 OID 验证
|
||||
|
||||
每个分支必须同时取得:
|
||||
|
||||
```text
|
||||
local_oid
|
||||
gitea_oid
|
||||
match = true/false
|
||||
```
|
||||
|
||||
验收条件:
|
||||
|
||||
```text
|
||||
local_oid == gitea_oid
|
||||
```
|
||||
|
||||
建议通过 remote-tracking ref 或 `git ls-remote --heads` 取得远端 OID。输出中不得泄露认证信息。
|
||||
|
||||
如果 Push 或 OID 校验失败,创建 Blocker:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-GITEA-SYNC",
|
||||
"severity": "high",
|
||||
"description": "本地与 Gitea 分支 OID 不一致或无推送权限",
|
||||
"owner": "GitOps/Gitea 运维负责人",
|
||||
"exit_condition": "三个目标分支均完成普通推送且 local_oid 等于 gitea_oid"
|
||||
}
|
||||
```
|
||||
|
||||
在 Gitea OID 一致前,不得继续预发布部署。
|
||||
|
||||
## 6. 第四阶段:配置 Secret 引用
|
||||
|
||||
### 6.1 需要解析的配置
|
||||
|
||||
至少确认:
|
||||
|
||||
```text
|
||||
AIOS_OPENCLAW_URL
|
||||
AIOS_OPENCLAW_CREDENTIAL_REF
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE
|
||||
AIOS_OPENCLAW_SIGNING_KEY_ID
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `AIOS_OPENCLAW_URL` 是经过批准的 Runtime Endpoint;
|
||||
- `AIOS_OPENCLAW_CREDENTIAL_REF` 只保存 Secret Provider 引用;
|
||||
- `AIOS_OPENCLAW_SIGNING_MATERIAL_FILE` 指向仓库外的受保护文件;
|
||||
- `AIOS_OPENCLAW_SIGNING_KEY_ID` 是 Key 标识,不是 Key 内容。
|
||||
|
||||
### 6.2 Secret 来源优先级
|
||||
|
||||
按以下顺序查找:
|
||||
|
||||
1. 已批准的运行时 Secret Store;
|
||||
2. 已批准的 Credential Provider;
|
||||
3. systemd Credential;
|
||||
4. 只读 Secret Volume;
|
||||
5. 仓库外受保护文件。
|
||||
|
||||
不得:
|
||||
|
||||
- 从 Git 历史恢复 Secret;
|
||||
- 从日志、Shell History 或旧 Evidence 复制 Secret;
|
||||
- 新建明文 Secret 并提交;
|
||||
- 把 Secret 内容写入 `.env` 模板或任务 JSON。
|
||||
|
||||
### 6.3 配置文件与权限
|
||||
|
||||
生产候选环境文件:
|
||||
|
||||
```text
|
||||
/etc/aios-v2/aios-v2.env
|
||||
```
|
||||
|
||||
建议权限:
|
||||
|
||||
```text
|
||||
owner: root
|
||||
group: aios
|
||||
mode: 0640 或更严格
|
||||
```
|
||||
|
||||
签名材料文件:
|
||||
|
||||
```text
|
||||
位于仓库外
|
||||
owner: aios 或经批准的运行用户
|
||||
mode: 0600 或更严格
|
||||
```
|
||||
|
||||
只验证以下内容:
|
||||
|
||||
- 引用非空;
|
||||
- 文件存在;
|
||||
- 运行用户可读;
|
||||
- 权限不宽松;
|
||||
- 文件不在 Git Worktree;
|
||||
- 不输出文件内容或长度特征。
|
||||
|
||||
### 6.4 缺少 Secret 时的行为
|
||||
|
||||
若无法从已批准来源解析任一必需配置,创建:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocker_id": "BLK-SECRET-REF-MISSING",
|
||||
"severity": "high",
|
||||
"description": "缺少已批准的 OpenClaw Runtime Endpoint、Credential Ref、Signing File 或 Key ID",
|
||||
"owner": "AIOS Secret/运维负责人",
|
||||
"exit_condition": "四项配置均可从批准来源解析,且权限检查通过"
|
||||
}
|
||||
```
|
||||
|
||||
不得要求用户在聊天或 Git 中提供 Secret 明文。
|
||||
|
||||
## 7. 第五阶段:候选验收与预发布
|
||||
|
||||
在 Gitea OID 和 Secret 引用检查都通过后,依次执行:
|
||||
|
||||
```bash
|
||||
python3 tools/aios_v2_docs_code_check.py \
|
||||
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||
|
||||
python3 -m unittest discover \
|
||||
-s tests/aios_v2_candidate \
|
||||
-p 'test_*.py' \
|
||||
-v
|
||||
|
||||
bash deploy/aios-v2/acceptance/run-all.sh
|
||||
```
|
||||
|
||||
部署后执行:
|
||||
|
||||
```bash
|
||||
AIOS_RUN_LIVE_HEALTHCHECK=1 \
|
||||
bash deploy/aios-v2/acceptance/run-all.sh
|
||||
|
||||
bash deploy/aios-v2/healthcheck/check.sh
|
||||
```
|
||||
|
||||
必须验证:
|
||||
|
||||
- AIOS HTTP Health;
|
||||
- SQLite `quick_check=ok`;
|
||||
- GitOps Controller detached + clean;
|
||||
- OpenClaw execute/status/cancel/timeout/health;
|
||||
- Hermes claim/heartbeat/complete/fail/lease recovery;
|
||||
- 服务重启后 Tenant/Agent/Task/Audit/Idempotency 恢复;
|
||||
- 回滚后健康检查通过;
|
||||
- 日志与 Evidence 无 Secret。
|
||||
|
||||
## 8. Bug 修复规则
|
||||
|
||||
OpenClaw 可自行修复:
|
||||
|
||||
- systemd、Docker、Compose、路径和权限;
|
||||
- 环境变量名或启动参数;
|
||||
- Healthcheck;
|
||||
- 明确的小型 Runtime Adapter 兼容问题;
|
||||
- 日志、错误消息和超时边界;
|
||||
- 测试、部署或中文文档中的明确错误。
|
||||
|
||||
每个修复必须:
|
||||
|
||||
1. 创建 `patch/bugfix/*`;
|
||||
2. 记录复现步骤;
|
||||
3. 添加或更新测试;
|
||||
4. 修改代码;
|
||||
5. 同步 `docs/aios-v2/` 和追踪矩阵;
|
||||
6. 执行文档门禁和相关测试;
|
||||
7. 提交 Git;
|
||||
8. 更新 `.agent/blockers.json` 和 `.agent/progress.json`;
|
||||
9. 提交 Codex 审查。
|
||||
|
||||
以下问题必须升级 Codex:
|
||||
|
||||
- Control Plane、Task Lifecycle、Policy 或 Approval 变化;
|
||||
- 多租户或身份边界变化;
|
||||
- OpenClaw/Hermes Contract 不兼容变化;
|
||||
- 数据迁移、持久化或回滚策略变化;
|
||||
- 高风险安全问题;
|
||||
- 需要修改 ADR 的问题;
|
||||
- 超出低风险修复范围的阻塞。
|
||||
|
||||
## 9. 证据文件
|
||||
|
||||
任务分支必须持续维护:
|
||||
|
||||
```text
|
||||
.agent/claim.json
|
||||
.agent/heartbeat.json
|
||||
.agent/progress.json
|
||||
.agent/blockers.json
|
||||
.agent/result.json
|
||||
.agent/status.json
|
||||
```
|
||||
|
||||
`result.json` 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "aios.v2.agent-result.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"final_status": "complete|failed|blocked",
|
||||
"candidate_commit": "<OID>",
|
||||
"task_commit": "<OID>",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"secret_reference_status": "configured|blocked",
|
||||
"executor_status": "running|blocked",
|
||||
"test_status": "passed|failed|not_run",
|
||||
"deployment_status": "passed|failed|not_run",
|
||||
"rollback_status": "passed|failed|not_run",
|
||||
"documentation_sync_status": "passed|failed",
|
||||
"changed_files": [],
|
||||
"commits": [],
|
||||
"open_blockers": [],
|
||||
"remaining_risks": [],
|
||||
"completed_at": "<UTC ISO-8601>"
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 完成条件
|
||||
|
||||
只有以下条件全部满足,任务才可标记为 `complete`:
|
||||
|
||||
- OpenClaw 执行器已启动并持续 Heartbeat;
|
||||
- 已领取任务;
|
||||
- 任务分支已合并最新 Candidate;
|
||||
- Candidate、OpenClaw Task、Codex Task 在本地与 Gitea 的 OID 一致;
|
||||
- Secret 引用和受保护文件权限检查通过;
|
||||
- 文档—代码检查通过;
|
||||
- 候选测试通过;
|
||||
- 预发布部署和在线健康检查通过;
|
||||
- Runtime 联调、重启和回滚演练通过;
|
||||
- 所有修改、Blocker、Commit、测试和结果已回写;
|
||||
- Codex 已能读取并审查完整证据。
|
||||
|
||||
如果缺少外部权限、执行入口或已批准 Secret,任务必须标记为 `blocked`,并准确说明 Owner 与解除条件,不得伪报完成。
|
||||
|
||||
## 11. Owner Gate
|
||||
|
||||
完成本指令不等于自动批准生产发布。以下动作仍需 AIOS Owner 明确批准:
|
||||
|
||||
- 合并 `main`;
|
||||
- 创建生产 Tag;
|
||||
- 正式生产部署;
|
||||
- 生产流量切换;
|
||||
- 接受高风险剩余问题。
|
||||
-397
@@ -1,397 +0,0 @@
|
||||
# Personal-Weixin B3/B4 Owner Evidence Authorization
|
||||
|
||||
```yaml
|
||||
authorization_id: AUTH-CODEX-V2R1-PERSONAL-WEIXIN-B3-B4-EVIDENCE-20260716
|
||||
owner_approved: true
|
||||
executor: Codex isolated runner
|
||||
|
||||
foundation_implementation_commit: 2fcab6f5833a405a9a26683811600b6e5f1aa7b1
|
||||
foundation_result_commit: 64a71b6199a8e2bd609ba145349e67b874888505
|
||||
r10_lineage_commit: 74e603c0c943501110577dbe3c7f6a0b18b8ec7d
|
||||
evidence_gate_commit: 62a4067cf7a462ebe7cf29524238bc5d0a70b813
|
||||
upstream_audit_commit: b2f5f531019a70801444987dbb97dc1f69cf47de
|
||||
|
||||
production_baseline: V1R4C6
|
||||
production_changed: false
|
||||
release_status: paused
|
||||
force_push_authorized: false
|
||||
```
|
||||
|
||||
## 1. 授权结论
|
||||
|
||||
Owner 明确授权 Codex:
|
||||
|
||||
1. 完成 B3 owner evidence packet;
|
||||
2. 在 vendor `client_id → authoritative message_id` 证据缺失时,采用 B4 quote-authority-disabled amendment;
|
||||
3. B3、B4 均 PASS 后,按已有 exact allowlist 继续 B5–B7;
|
||||
4. B3–B7 全部 PASS 后才能开始 Release Preflight;
|
||||
5. Release Preflight PASS 后才允许 Canary;
|
||||
6. Codex 不得直接修改生产。
|
||||
|
||||
该授权不允许 Codex编造 raw-wire、credential、vendor 或 reviewer 证据。
|
||||
|
||||
## 2. B3 owner evidence 必须包含
|
||||
|
||||
B3 evidence manifest 必须引用上述五个权威提交,并包含以下五类证据。
|
||||
|
||||
### A. Raw-wire evidence
|
||||
|
||||
至少包含:
|
||||
|
||||
```yaml
|
||||
raw_wire:
|
||||
- direction: inbound
|
||||
- direction: outbound
|
||||
```
|
||||
|
||||
每项必须记录:
|
||||
|
||||
```yaml
|
||||
restricted_path:
|
||||
restricted_sha256:
|
||||
restricted_bytes:
|
||||
redacted_path:
|
||||
redacted_sha256:
|
||||
captured_at:
|
||||
credential_label: credential-a | credential-b
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
* unrestricted raw-wire 只能保存在 owner 批准的受限存储;
|
||||
* Git 中只能提交 SHA-256、大小、受限路径引用及 redacted view;
|
||||
* 不得提交 token、API key、OAuth code、sessionKey、cookie 或完整 credential;
|
||||
* inbound 与 outbound 必须各至少一份;
|
||||
-两份 credential 都必须实际覆盖。
|
||||
|
||||
### B. Authority map
|
||||
|
||||
必须提供 commit-safe authority map,明确:
|
||||
|
||||
```yaml
|
||||
vendor_message_id_field:
|
||||
client_id_field:
|
||||
client_id_authoritative: false
|
||||
mapping_verified: true
|
||||
```
|
||||
|
||||
Authority map 必须区分:
|
||||
|
||||
```yaml
|
||||
authoritative:
|
||||
- verified inbound vendor message_id
|
||||
|
||||
non_authoritative:
|
||||
- local client_id
|
||||
- outbound correlation ID
|
||||
- local sequence
|
||||
- timestamp
|
||||
- content hash
|
||||
- outbound receipt ID
|
||||
```
|
||||
|
||||
禁止把任一非权威值提升为 message authority。
|
||||
|
||||
### C. 双 credential 证据
|
||||
|
||||
必须有精确两项:
|
||||
|
||||
```yaml
|
||||
credentials:
|
||||
- label: credential-a
|
||||
fingerprint_sha256:
|
||||
secret_included: false
|
||||
scope:
|
||||
test_result: PASS
|
||||
|
||||
- label: credential-b
|
||||
fingerprint_sha256:
|
||||
secret_included: false
|
||||
scope:
|
||||
test_result: PASS
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
* 两个 fingerprint 必须不同;
|
||||
* 不得提交 secret 原文;
|
||||
* 两个 credential 必须各自通过实际测试;
|
||||
* credential scope 必须明确。
|
||||
|
||||
### D. Mode evidence
|
||||
|
||||
必须明确处于下列模式之一:
|
||||
|
||||
```yaml
|
||||
mode:
|
||||
name: sandbox | shadow | restricted-capture
|
||||
production_traffic: false
|
||||
writes_production: false
|
||||
capture_only: true
|
||||
```
|
||||
|
||||
不得使用生产流量或写生产数据来补证据。
|
||||
|
||||
### E. Reviewer evidence
|
||||
|
||||
必须由独立 reviewer 签署:
|
||||
|
||||
```yaml
|
||||
review:
|
||||
reviewer:
|
||||
independent_from_executor: true
|
||||
decision: PASS
|
||||
reviewed_manifest_sha256:
|
||||
reviewed_at:
|
||||
```
|
||||
|
||||
Reviewer 必须核对:
|
||||
|
||||
* raw-wire 哈希;
|
||||
* redacted view;
|
||||
* authority map;
|
||||
-两个 credential fingerprint;
|
||||
* mode;
|
||||
* manifest 完整 SHA-256。
|
||||
|
||||
## 3. B3 PASS 条件
|
||||
|
||||
只有以下全部成立才允许 B3 `status: PASS`:
|
||||
|
||||
```yaml
|
||||
raw_wire_inbound: PASS
|
||||
raw_wire_outbound: PASS
|
||||
dual_credential: PASS
|
||||
authority_map: PASS
|
||||
client_id_authoritative: false
|
||||
mode_non_production: PASS
|
||||
reviewer_independent: PASS
|
||||
reviewer_decision: PASS
|
||||
secrets_committed: false
|
||||
production_changed: false
|
||||
baseline_after: V1R4C6
|
||||
release_status: paused
|
||||
```
|
||||
|
||||
真实证据缺失时必须继续:
|
||||
|
||||
```yaml
|
||||
status: BLOCKED_EVIDENCE_REQUIRED
|
||||
```
|
||||
|
||||
不得生成模拟证据代替 owner/vendor 证据。
|
||||
|
||||
## 4. B4 Owner Amendment
|
||||
|
||||
Owner 明确批准以下 B4 amendment:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contract": "personal-weixin-b4-outbound-quote-authority-amendment",
|
||||
"ownerApproved": true,
|
||||
"lineage": {
|
||||
"foundationImplementationCommit": "2fcab6f5833a405a9a26683811600b6e5f1aa7b1",
|
||||
"foundationResultCommit": "64a71b6199a8e2bd609ba145349e67b874888505",
|
||||
"r10LineageCommit": "74e603c0c943501110577dbe3c7f6a0b18b8ec7d",
|
||||
"evidenceGateCommit": "62a4067cf7a462ebe7cf29524238bc5d0a70b813",
|
||||
"upstreamAuditCommit": "b2f5f531019a70801444987dbb97dc1f69cf47de"
|
||||
},
|
||||
"vendorClientIdMessageIdEvidence": {
|
||||
"status": "absent",
|
||||
"clientIdPromotable": false
|
||||
},
|
||||
"authorityPolicy": {
|
||||
"outboundQuoteAuthority": "disabled",
|
||||
"clientIdRole": "non-authoritative-correlation-only",
|
||||
"authoritativeMessageIdSources": [
|
||||
"validated-inbound-raw-wire.vendor_message_id"
|
||||
],
|
||||
"quotedSendWithoutAuthoritativeMessageId": "fail-closed",
|
||||
"plainOutboundWithoutQuoteAuthority": "allowed-only-by-existing-b4-allowlist",
|
||||
"syntheticMapping": false,
|
||||
"timestampFallback": false,
|
||||
"localSequenceFallback": false,
|
||||
"contentHashFallback": false,
|
||||
"outboundReceiptFallback": false
|
||||
},
|
||||
"implementationConstraints": {
|
||||
"scopeExpansion": false,
|
||||
"exactAllowlistRequired": true,
|
||||
"missingAllowlistStatus": "BLOCKED_SCOPE",
|
||||
"productionChangeAuthorized": false,
|
||||
"canaryAuthorized": false,
|
||||
"tagAuthorized": false,
|
||||
"forcePushAuthorized": false
|
||||
},
|
||||
"production": {
|
||||
"baseline": "V1R4C6",
|
||||
"changed": false,
|
||||
"releaseStatus": "paused"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
该 JSON 必须作为独立 control-plane amendment 提交。
|
||||
|
||||
## 5. B4 强制语义
|
||||
|
||||
B4 实现必须遵守:
|
||||
|
||||
```yaml
|
||||
client_id:
|
||||
role: correlation-only
|
||||
authoritative: false
|
||||
|
||||
vendor_message_id:
|
||||
authoritative_only_when:
|
||||
- observed in validated inbound raw-wire
|
||||
- present in committed authority map
|
||||
|
||||
quoted_send:
|
||||
without_authoritative_vendor_message_id: fail-closed
|
||||
|
||||
plain_outbound:
|
||||
allowed_only_when:
|
||||
- existing B4 exact allowlist permits it
|
||||
- no quote/reply authority field is emitted
|
||||
```
|
||||
|
||||
禁止:
|
||||
|
||||
* `client_id` → `message_id` alias;
|
||||
* client ID hash fallback;
|
||||
* timestamp fallback;
|
||||
* local sequence fallback;
|
||||
* content hash fallback;
|
||||
* outbound receipt fallback;
|
||||
* synthetic message ID;
|
||||
-“先发送后补 authority”。
|
||||
|
||||
## 6. B4 路径授权
|
||||
|
||||
该 amendment 不增加任何实现路径。
|
||||
|
||||
Codex必须先找到已有 B4 exact allowlist。
|
||||
|
||||
若不存在或不完整:
|
||||
|
||||
```yaml
|
||||
status: BLOCKED_SCOPE
|
||||
```
|
||||
|
||||
不得由 Codex自行发明路径。
|
||||
|
||||
## 7. B5–B7 条件
|
||||
|
||||
只有 B3 和 B4 均有 qualifying PASS RESULT 后,才可继续 B5–B7。
|
||||
|
||||
每个阶段必须:
|
||||
|
||||
* 独立 branch;
|
||||
* 独立 worktree;
|
||||
* 已提交 exact allowlist;
|
||||
* 一个非 merge implementation commit;
|
||||
* 独立 RESULT;
|
||||
* 依赖实际 F0 commit 与 immutable image digest;
|
||||
* 不修改生产;
|
||||
* 不 force push。
|
||||
|
||||
任一阶段缺少 exact allowlist:
|
||||
|
||||
```yaml
|
||||
status: BLOCKED_SCOPE
|
||||
```
|
||||
|
||||
## 8. Release Preflight
|
||||
|
||||
只有以下全部 PASS 后才能开始:
|
||||
|
||||
```yaml
|
||||
F0: PASS
|
||||
B3: PASS
|
||||
B4: PASS
|
||||
B5: PASS
|
||||
B6: PASS
|
||||
B7: PASS
|
||||
R10_lineage: PASS
|
||||
immutable_image_digest: present
|
||||
gitleaks: PASS
|
||||
rollback_contract: present
|
||||
```
|
||||
|
||||
Preflight 不得创建 tag 或部署生产。
|
||||
|
||||
## 9. Canary
|
||||
|
||||
仅在 Release Preflight PASS 后授权一次 Canary。
|
||||
|
||||
Canary 必须:
|
||||
|
||||
* 使用固定 immutable image digest;
|
||||
* 使用已验证 rollback target;
|
||||
* 不修改生产 schema;
|
||||
* 不暴露 secrets;
|
||||
* 有明确成功指标;
|
||||
* 有自动/立即 rollback 条件。
|
||||
|
||||
Canary 失败:
|
||||
|
||||
```yaml
|
||||
rollback: required
|
||||
release_status: paused
|
||||
production_baseline: V1R4C6
|
||||
```
|
||||
|
||||
不得继续 production promotion。
|
||||
|
||||
## 10. Production 边界
|
||||
|
||||
Codex不得直接执行生产部署。
|
||||
|
||||
只有以下全部提交并核验后,Codex才可创建 deployment request:
|
||||
|
||||
```yaml
|
||||
F0_and_B3_B7: PASS
|
||||
release_preflight: PASS
|
||||
canary: PASS
|
||||
immutable_release_digest: present
|
||||
rollback_target: verified
|
||||
rollback_procedure: verified
|
||||
go_live_evidence: committed
|
||||
```
|
||||
|
||||
实际 V2R1C0 部署只能由:
|
||||
|
||||
```text
|
||||
owner/admin GitOps release action
|
||||
```
|
||||
|
||||
执行。
|
||||
|
||||
在该动作前:
|
||||
|
||||
```yaml
|
||||
production: V1R4C6
|
||||
production_changed: false
|
||||
release: paused
|
||||
V2R1C0_tag: absent
|
||||
```
|
||||
|
||||
必须保持不变。
|
||||
|
||||
## 11. Codex 下一步
|
||||
|
||||
立即执行顺序:
|
||||
|
||||
1. 在现有 control branch 提交本授权文件;
|
||||
2. 提交 byte-exact B4 amendment;
|
||||
3. 检查 B3 packet 中是否已有真实 owner raw-wire、双 credential、authority map、mode 和 reviewer evidence;
|
||||
4. 有真实证据则完成 B3 manifest 与审核;
|
||||
5. 无真实证据则继续 `BLOCKED_EVIDENCE_REQUIRED`,明确缺失项;
|
||||
6. B3 PASS 后按已有 exact allowlist执行 B3;
|
||||
7. B4 按 quote-authority-disabled 模式执行;
|
||||
8. B3/B4 PASS 后再检查 B5–B7;
|
||||
9. 不得跳到 Release Preflight、Canary 或生产。
|
||||
|
||||
本授权已解除 B4 vendor mapping 阻塞,但没有伪造或豁免 B3 owner evidence。
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-blockers.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"updated_at": "2026-07-21T04:30:00Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"blockers": [
|
||||
{
|
||||
"blocker_id": "BLK-OPENCLAW-PLAINTEXT-SECRETS",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "OpenClaw managed config、auth profile store 与 models.json 存在三处明文凭据。",
|
||||
"fix": "使用 OpenClaw 原生 secrets apply 迁移为单一 file Secret Provider;Gateway Token 已轮换,OneAPI 使用单一 auth-profile SecretRef。",
|
||||
"verification": "audit plaintext/unresolved/shadowed/legacy 全部为 0,实际 OneAPI agent 调用通过。",
|
||||
"commit": "bf01a13f3aaf42a386232ed2f5409feb0f79fcf6",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-AIOS-V2-SIGNING-MATERIAL-MISSING",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "Key ID 标称 Ed25519,但代码实际使用 HMAC 且材料文件缺失。",
|
||||
"fix": "签名合同改为真实 Ed25519,并由 Owner 在受保护边界生成运行材料。",
|
||||
"verification": "aios:aios 0600;resolver sign/verify 与篡改拒绝通过。",
|
||||
"commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-CANDIDATE-NOT-MERGED",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "OpenClaw 任务未包含最新 Candidate。",
|
||||
"fix": "保护 Evidence 后以 --no-ff 合并 Candidate ba7669c。",
|
||||
"verification": "Candidate 为任务分支祖先,普通 Push 后 local_oid 等于 gitea_oid。",
|
||||
"commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-OPENCLAW-RUN-PROVENANCE-MISSING",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "旧插件只有进程内 runId/sender Map,缺少 Owner-signed Schema、持久 Store 和限定 Git Writer。",
|
||||
"fix": "部署 aios-tenant-boundary rc2,增加 Ed25519 issuer/store、Run/Sender/OID/期限绑定、一次性 permit 与七路径专用 Writer。",
|
||||
"verification": "Provenance openclaw-v2r1-20260721T031523Z 完成 heartbeat 写入、Commit、普通 Push 和审计;README.md 越权写入被拒绝;Node 测试 9/9。",
|
||||
"commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb",
|
||||
"resolved_at": "2026-07-21T03:23:27Z"
|
||||
},
|
||||
{
|
||||
"blocker_id": "BLK-CONTROLLER-LEGACY-GITEA-CHECK",
|
||||
"severity": "high",
|
||||
"status": "resolved",
|
||||
"root_cause": "Controller 硬编码旧 aios-v23 仓库与 API,无法区分本地和 Gitea OID。",
|
||||
"fix": "配置化 V2R1 仓库/分支,使用只读 systemd Credential,部署到 detached clean 工作树。",
|
||||
"verification": "12 项测试通过;gitea/branch_sync/oid_match 为 true,sync_status=fully_synced。",
|
||||
"commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||
"resolved_at": "2026-07-20T10:17:49Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-claim.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"task_branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"claimed_at": "2026-07-20T08:24:30Z",
|
||||
"executor_instance": "openclaw-main-cn-network-mkt",
|
||||
"owner_observation_at": "2026-07-21T04:30:00Z",
|
||||
"owner_observation": "五项高风险 Blocker、Gate A/B、离线验收、OpenClaw 在线合同、双服务重启恢复与受控回滚均已形成真实证据;Hermes 完整在线 Worker 生命周期仍未暴露为服务端点。",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
,"latest_task_commit_before_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"schema": "aios.v2.credential-status.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"credential_name": "openclaw-deploy-cli",
|
||||
"credential_type": "gitea-api-token-reference",
|
||||
"endpoints": [
|
||||
"http://127.0.0.1:3000",
|
||||
"https://g.cnnetmkt.top/git"
|
||||
],
|
||||
"reported_scope": "all",
|
||||
"reported_status": "working",
|
||||
"verification_source": "owner-reported",
|
||||
"security_status": "secretref_resolved",
|
||||
"reason": "令牌值曾在聊天中以明文出现,必须按已暴露凭据处理",
|
||||
"required_actions": [
|
||||
"已完成",
|
||||
"新令牌已保存到受保护文件",
|
||||
"令牌用于HTTPS Git认证"
|
||||
],
|
||||
"rotation_completed_at": "2026-07-20T08:48:00Z",
|
||||
"new_token_created": "openclaw-deploy-task-commit",
|
||||
"new_token_scope": "write:repository,read:repository",
|
||||
"token_file_path": "/etc/openclaw/secrets/openclaw-gitea-deploy-token",
|
||||
"token_file_owner": "root:root",
|
||||
"token_file_mode": "0600",
|
||||
"secret_provider_contract_path": "/etc/openclaw/SECRET_PROVIDER_CONTRACT.json",
|
||||
"last_verified": "2026-07-21T04:30:00Z",
|
||||
"verification_result": "HTTPS Git push successful with new token",
|
||||
"env_file_reference": "AIOS_OPENCLAW_CREDENTIAL_REF=file:///etc/openclaw/secrets/openclaw-gitea-deploy-token",
|
||||
"env_file_path": "/etc/aios-v2/aios-v2.env",
|
||||
"env_file_mode": "0640 root:gitops",
|
||||
"last_updated": "2026-07-21T04:30:00Z",
|
||||
"old_credential_revoked": true,
|
||||
"revoked": true,
|
||||
"new_credential_name": "openclaw-deploy-cli-v2-20260720",
|
||||
"rotated": true,
|
||||
"new_account": "gitops-bot",
|
||||
"scope": "write:repository",
|
||||
"credential_ref": "/data/aios-gitops/runtime/secrets/openclaw-deploy-cli-v2-20260720",
|
||||
"protected_file_owner": "root",
|
||||
"protected_file_group": "gitops",
|
||||
"protected_file_mode": "0640",
|
||||
"verified_at": "2026-07-20T09:02:18Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"plaintext_findings_before": 3,
|
||||
"plaintext_findings_after": 0,
|
||||
"credentials_rotated": true,
|
||||
"secret_values_recorded": false,
|
||||
"signing_material_configured": true,
|
||||
"signing_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||
"signing_permission_check": "passed",
|
||||
"runtime_sign_verify_test": "passed",
|
||||
"run_provenance_status": "resolved",
|
||||
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||
"provenance_public_key_configured": true,
|
||||
"provenance_secret_values_recorded": false,
|
||||
"openclaw_runtime_plugin_version": "2.1.0-rc.3",
|
||||
"runtime_public_key_only_hot_path": true,
|
||||
"runtime_http_contract_test": "passed",
|
||||
"secret_values_recorded_in_runtime_evidence": false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"current_step": "ONLINE_HERMES_WORKER_GAP",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"executor": "openclaw",
|
||||
"heartbeat_at": "2026-07-21T04:30:00Z",
|
||||
"heartbeat_source": "owner-live-acceptance",
|
||||
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||
"schema": "aios.v2.agent-heartbeat.v1",
|
||||
"state": "running",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"valid_for_running": true
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-progress.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"state": "running",
|
||||
"status": "PARTIAL",
|
||||
"updated_at": "2026-07-21T04:30:00Z",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"task_commit_before_owner_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match_before_owner_evidence": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"credential_rotation_status": "passed",
|
||||
"executor_model_path": "passed",
|
||||
"tests": "controller 12/12;provenance/Runtime Node 14/14;Candidate 10/10;Gate A 0错误0警告;Gate B 10/10;离线 8通过0失败1警告;OpenClaw 在线合同与 AIOS/Hermes 基础联调通过",
|
||||
"deployment": "aios-tenant-boundary rc3 与隔离 Release b158041 已部署;仅监听 127.0.0.1:18921,未切流量",
|
||||
"rollback": "Previous 2a2b702 与 Current b158041 双向切换通过;默认不恢复状态,业务状态、SQLite、Execution Evidence 均保留",
|
||||
"current_step": "ONLINE_HERMES_WORKER_GAP",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"aios_v2_env_status": {
|
||||
"exists": true,
|
||||
"owner": "root",
|
||||
"group": "gitops",
|
||||
"mode": "0640",
|
||||
"four_fields_nonempty": true,
|
||||
"credential_ref_target_exists": true,
|
||||
"signing_material_target_exists": true
|
||||
},
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z",
|
||||
"worktree_repair": {
|
||||
"status": "resolved",
|
||||
"branch_unique_checkout": true,
|
||||
"clean_before_merge": true,
|
||||
"stale_lock_count": 0,
|
||||
"prunable_count": 0,
|
||||
"evidence_backup": "protected external backup created before merge",
|
||||
"verified_at": "2026-07-20T09:39:16Z"
|
||||
},
|
||||
"release_recommendation": "STOP_PREPRODUCTION",
|
||||
"gate_a_allowed": true,
|
||||
"resolved_blockers": 5,
|
||||
"open_blockers": 0,
|
||||
"controller_patch_commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||
"signing_contract_commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||
"secret_audit": {
|
||||
"plaintext_before": 3,
|
||||
"plaintext_after": 0,
|
||||
"unresolved_after": 0,
|
||||
"shadowed_after": 0,
|
||||
"legacy_after": 0,
|
||||
"gateway_token_rotated": true,
|
||||
"secret_values_recorded": false
|
||||
},
|
||||
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb"
|
||||
,"gate_a": "passed"
|
||||
,"gate_b": "passed_10_of_10"
|
||||
,"offline_acceptance": "passed_8_failed_0_warning_1"
|
||||
,"online_openclaw_contract": "passed"
|
||||
,"aios_restart_recovery": "passed"
|
||||
,"openclaw_restart_idempotency": "passed"
|
||||
,"rollback_drill": "passed"
|
||||
,"online_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/online-runtime-openclaw-final4.log"
|
||||
,"restart_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/restart-recovery.log"
|
||||
,"rollback_evidence_log": "/var/lib/aios-gitops/acceptance/aios-v2r1-20260721T0325Z/rollback-drill-final3.log"
|
||||
,"remaining_acceptance_gap": "Hermes claim/start/heartbeat/fail/cancel/lease-expiry/worker-crash-recovery 与 Cell/Profile 错误结果拒绝未暴露为在线服务端点"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-result.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"final_status": "PARTIAL",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"task_commit_before_owner_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
|
||||
"gitea_sync": {
|
||||
"candidate_match": true,
|
||||
"openclaw_task_match_before_owner_evidence": true,
|
||||
"codex_task_match": true
|
||||
},
|
||||
"secret_reference_status": "resolved",
|
||||
"executor_status": "running",
|
||||
"test_status": "Gate A、Gate B 10/10、离线 8/0/1、OpenClaw 正式在线、重启恢复和回滚通过;Hermes 完整在线 Worker 生命周期未覆盖",
|
||||
"deployment_status": "OpenClaw Runtime rc3 与 AIOS 隔离 Release b158041 active;无生产流量切换",
|
||||
"rollback_status": "Previous 2a2b702 与 Current b158041 双向演练通过,业务状态保留",
|
||||
"documentation_sync_status": "updated and Gate A passed",
|
||||
"changed_files": [
|
||||
".agent/claim.json",
|
||||
".agent/heartbeat.json",
|
||||
".agent/progress.json",
|
||||
".agent/blockers.json",
|
||||
".agent/result.json",
|
||||
".agent/status.json",
|
||||
".agent/credential-status.json"
|
||||
],
|
||||
"open_blockers": [],
|
||||
"remaining_risks": [
|
||||
"Hermes claim/start/heartbeat/fail/cancel/lease expiry/worker crash recovery/Cell/Profile 错误结果拒绝尚无在线服务端点证据"
|
||||
],
|
||||
"completed_at": "2026-07-21T04:30:00Z",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z",
|
||||
"release_recommendation": "STOP_PREPRODUCTION",
|
||||
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||
"provenance_live_commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb",
|
||||
"online_openclaw_contract": "passed",
|
||||
"restart_recovery": "passed",
|
||||
"rollback_drill": "passed",
|
||||
"formal_online_acceptance": "partial"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-status.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"executor": "openclaw",
|
||||
"state": "running",
|
||||
"status": "running",
|
||||
"claimed_at": "2026-07-20T08:24:30Z",
|
||||
"updated_at": "2026-07-21T04:30:00Z",
|
||||
"last_heartbeat": "2026-07-21T04:30:00Z",
|
||||
"heartbeat_valid_for_running": true,
|
||||
"current_step": "ONLINE_HERMES_WORKER_GAP",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"release_recommendation": "STOP_PREPRODUCTION",
|
||||
"evidence_writer": "windows-chatgpt-owner",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
,"task_commit_before_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
|
||||
,"gate_a": "passed"
|
||||
,"gate_b": "passed_10_of_10"
|
||||
,"offline_acceptance": "passed_8_failed_0_warning_1"
|
||||
,"online_openclaw": "passed"
|
||||
,"restart_recovery": "passed"
|
||||
,"rollback_drill": "passed"
|
||||
,"hermes_full_online_worker_lifecycle": "not_exposed"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schema": "aios.v2.agent-task.v1",
|
||||
"task_id": "AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"display_name": "AIOS V2R1 OpenClaw 预发布部署与小型缺陷修复任务",
|
||||
"language": "zh-CN",
|
||||
"executor": "openclaw",
|
||||
"role": "deployment-operator",
|
||||
"state": "queued",
|
||||
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||
"canonical_task": "tasks/openclaw-production-deploy-task.yaml",
|
||||
"supervisor_task": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||
"claim_required": true,
|
||||
"heartbeat_interval_seconds": 300,
|
||||
"evidence_directory": ".agent/evidence",
|
||||
"instructions": [
|
||||
"领取前读取候选分支完整中文文档和生产候选报告",
|
||||
"领取后写入 .agent/claim.json 并把状态改为 claimed",
|
||||
"每五分钟或每个主要步骤写入 heartbeat 和 progress",
|
||||
"所有部署修改、小型缺陷、阻塞、测试和结果必须提交到 Git",
|
||||
"架构、控制面、多租户或安全问题必须升级 Codex",
|
||||
"禁止提交明文密钥、直接修改 main 或绕过 GitOps"
|
||||
],
|
||||
"acceptance_commands": [
|
||||
"python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"python3 -m unittest discover -s tests/aios_v2_candidate -p test_*.py -v",
|
||||
"bash deploy/aios-v2/acceptance/run-all.sh",
|
||||
"bash deploy/aios-v2/healthcheck/check.sh"
|
||||
],
|
||||
"created_at": "2026-07-20T00:00:00Z",
|
||||
"previous_candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||
"candidate_merge_commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||
"candidate_updated_at": "2026-07-20T09:39:16Z"
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
# TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8
|
||||
|
||||
## Authorization and boundary
|
||||
|
||||
Owner-led preparation task for Codex to implement an AIOS-side managed Spark isolated runner through GitOps. This task does not authorize direct host mutation, production deployment, main-branch push, release, tag creation, or bypass of owner/admin approval.
|
||||
|
||||
Current protected state:
|
||||
|
||||
```yaml
|
||||
baseline: V1R4C6
|
||||
release: paused
|
||||
production_release: NOT_STARTED
|
||||
repair3_base: 8d9c6da90fc8caa69b691d243c6fefb40b6b0575
|
||||
```
|
||||
|
||||
## Required provisioning deliverables
|
||||
|
||||
Codex must inspect repository conventions and prepare an append-only provisioning patch containing all of the following, using existing repository paths and patterns where available:
|
||||
|
||||
1. A fail-closed provisioning or installation script.
|
||||
2. A fixed runner artifact reference by immutable digest or versioned repository path.
|
||||
3. SHA-256 or signature verification before installation.
|
||||
4. A managed systemd unit or approved container definition.
|
||||
5. A dedicated least-privilege run user and group.
|
||||
6. A dedicated work directory outside controller and production worktrees.
|
||||
7. A managed heartbeat file or endpoint updated only by the runner.
|
||||
8. A runtime `secret_ref`; no plaintext secret may enter Git, logs, task files, environment dumps, or RESULT files.
|
||||
9. Sandboxing and resource limits appropriate to repository conventions.
|
||||
10. Cleanup for temporary branches and worktrees created by runner acceptance tests.
|
||||
|
||||
## Required configuration inputs
|
||||
|
||||
The implementation must fail closed until owner/admin supplies or approves:
|
||||
|
||||
```yaml
|
||||
runner_artifact_uri_or_repo_path: gitea://aios/aios.git#tools/aios_spark_runner
|
||||
runner_artifact_version_or_digest: generated_from_final_implementation_commit
|
||||
runner_artifact_sha256_or_signature: generated_sha256_of_deterministic_artifact
|
||||
runner_secret_ref: null
|
||||
runner_run_user: aios-spark-runner
|
||||
runner_run_group: aios-spark-runner
|
||||
runner_workdir: /var/lib/aios-spark-runner
|
||||
runner_heartbeat_path_or_endpoint: /run/aios-spark-runner/heartbeat.json
|
||||
service_name_or_container_name: aios-spark-runner.service
|
||||
```
|
||||
|
||||
Placeholders must never be interpreted as runnable defaults.
|
||||
|
||||
## Security requirements
|
||||
|
||||
- No download from an unapproved or mutable URL.
|
||||
- No `curl | sh`, shell/tmux/nohup daemon, or interactive session used as a service.
|
||||
- No controller worktree mutation.
|
||||
- No direct main push.
|
||||
- No force push, reset, amend, rebase, or Gitea database mutation.
|
||||
- No secret disclosure.
|
||||
- Service must use the dedicated account, explicit working directory, restrictive umask, bounded resources, restart policy, and managed logs.
|
||||
- Runner must create isolated temporary Git worktrees and must not reuse the controller or production worktree.
|
||||
- Repository writes remain subject to existing GitOps branch and review controls.
|
||||
|
||||
## Required validation
|
||||
|
||||
Before any Repair 3 execution, owner/admin must merge and apply the provisioning patch through the approved GitOps mechanism, then Codex must verify:
|
||||
|
||||
```yaml
|
||||
artifact_integrity: PASS
|
||||
managed_service_present: PASS
|
||||
managed_service_active: PASS
|
||||
runner_process_owned_by_expected_user: PASS
|
||||
heartbeat_age_under_5_minutes: PASS
|
||||
repo_read_8d9c6da: PASS
|
||||
temporary_worktree_create: PASS
|
||||
temporary_worktree_read_only_probe: PASS
|
||||
temporary_worktree_remove: PASS
|
||||
temporary_branch_remove: PASS
|
||||
controller_unchanged: PASS
|
||||
main_unchanged: PASS
|
||||
production_unchanged: PASS
|
||||
```
|
||||
|
||||
Success output:
|
||||
|
||||
```text
|
||||
RUNNER_RECOVERED=YES artifact=<immutable-ref> service=<name> user=<user> heartbeat=<timestamp> repo_read=PASS temp_worktree=PASS cleanup=PASS
|
||||
```
|
||||
|
||||
Failure output:
|
||||
|
||||
```text
|
||||
RUNNER_RECOVERED=NO status=BLOCKED_OWNER_RUNNER_PROVISIONING missing=<items> required_admin_action=<exact action>
|
||||
```
|
||||
|
||||
## Repair 3 continuation gate
|
||||
|
||||
Only after `RUNNER_RECOVERED=YES` may Codex create a new isolated Repair 3 worktree from `8d9c6da90fc8caa69b691d243c6fefb40b6b0575` and implement the separately approved four-path B3 security repair. All B3, B4, merge, tag, canary, and production gates remain independently mandatory.
|
||||
|
||||
## GitOps implementation status
|
||||
|
||||
```yaml
|
||||
runner_kind: AIOS isolated Python worker
|
||||
artifact_source: repository-owned deterministic build
|
||||
artifact_transport: local AIOS path only
|
||||
artifact_integrity: SHA-256 required before mutation
|
||||
managed_service: aios-spark-runner.service
|
||||
run_user: aios-spark-runner
|
||||
run_group: aios-spark-runner
|
||||
workdir: /var/lib/aios-spark-runner
|
||||
heartbeat: /run/aios-spark-runner/heartbeat.json
|
||||
runner_secret_ref: null
|
||||
runner_code: COMPLETE
|
||||
runner_install: NOT_STARTED
|
||||
runner_recovered: false
|
||||
blocker: BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE
|
||||
repair3_started: false
|
||||
```
|
||||
@@ -1,96 +0,0 @@
|
||||
# TASK-CODEX-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716
|
||||
|
||||
```yaml
|
||||
task_id: TASK-CODEX-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716
|
||||
executor: Codex isolated runner
|
||||
source_base: 53c67bd9780074bad9781fb97971463156bc6609
|
||||
authorization_commit: a1f933e500151dc8afc1b25b30448a31899a8f64 # gitleaks:allow
|
||||
b4_amendment_commit: 1d334b1ccaac54c3c3ec4dd84693ab68fc67cef3
|
||||
production: V1R4C6
|
||||
release: paused
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
**B3 status**: `BLOCKED_EVIDENCE_REQUIRED` (evidence collection not started, owner must prepare facts and produce restricted raw data)
|
||||
**B2 status**: `NOT_APPLICABLE` (reserved, not yet reached)
|
||||
**Production baseline**: V1R4C6 (the most recent approved production release)
|
||||
|
||||
## Scope
|
||||
|
||||
本任务仅使用 Git 中的以下工具与模板,采集并验证真实 owner evidence:
|
||||
|
||||
```text
|
||||
tools/personal_weixin/pwx_b3_evidence_tool.py
|
||||
tools/personal_weixin/pwx_b3_evidence_facts.template.json
|
||||
tools/personal_weixin/README-B3-EVIDENCE.md
|
||||
```
|
||||
|
||||
工具是 verifier/generator,不是网络 capture client。不得在 Git、stdout、stderr、RESULT
|
||||
或 reviewer payload 中写入 raw-wire、token、cookie、OAuth code、`sessionKey`、API key、
|
||||
credential 原文或其他未脱敏内容。
|
||||
|
||||
## Two-stage evidence flow
|
||||
|
||||
1. Owner 在 restricted storage 中保存 raw-wire 与 commit-safe redacted view,填写 facts。
|
||||
2. `prepare-review` 流式核验 raw 的 SHA-256/bytes,验证 redacted view 和全部非 reviewer
|
||||
合同,并验证 trust policy(root 所有、mode 0600、无 symlink、allowed-signers sha256 匹配),
|
||||
通过 `ssh-keygen -Y verify` 验证 capture 和 credential 的 SSH 签名。
|
||||
生成 byte-exact canonical reviewer payload 与 SHA-256。该阶段始终返回
|
||||
`BLOCKED_EVIDENCE_REQUIRED`,不能作为 B3 PASS。
|
||||
3. 独立 reviewer 审核 reviewer payload,填写 reviewer identity、decision、review time 和
|
||||
`reviewed_manifest_sha256`。
|
||||
4. `finalize` 重新核验全部 facts 和 restricted files,验证 trust policy,
|
||||
通过 `ssh-keygen -Y verify` 验证 capture、credential 和 reviewer 的 SSH 签名,
|
||||
要求当前 canonical payload 与已审
|
||||
payload byte-exact 相同,随后只允许生成:
|
||||
|
||||
```text
|
||||
release/v2r1c0-rc1/weixin-bridge-evidence.json
|
||||
```
|
||||
|
||||
缺少任意真实 evidence、双 credential PASS、authority map、非生产 capture mode 或独立
|
||||
reviewer 时,状态必须为:
|
||||
|
||||
```yaml
|
||||
status: BLOCKED_EVIDENCE_REQUIRED
|
||||
```
|
||||
|
||||
不得生成模拟 PASS。
|
||||
|
||||
## Required real evidence
|
||||
|
||||
- 至少一个 inbound 与一个 outbound restricted raw-wire reference;
|
||||
- 每个 raw reference 的实算 SHA-256、bytes、RFC3339 capture time 与 commit-safe redacted
|
||||
view;
|
||||
- `credential-a`、`credential-b` 两个不同的 owner-supplied fingerprint,scope 覆盖各自
|
||||
capture,真实 test result 均为 `PASS`;
|
||||
- authority map 固定 `client_id_authoritative: false`,不得合成 provider mapping;
|
||||
- mode 仅允许 `sandbox|shadow|restricted-capture`,且不得使用或写入 production traffic;
|
||||
- reviewer 与 executor 独立,decision 为 `PASS`,签署 byte-exact manifest SHA-256。
|
||||
|
||||
## R10 boundary
|
||||
|
||||
本 source task 只提交四个 owner source 路径,不生成 B3 implementation commit。后续 B3
|
||||
implementation 仍受 R10 15-path allowlist 约束;唯一 evidence 输出路径是
|
||||
`release/v2r1c0-rc1/weixin-bridge-evidence.json`。只有 qualifying B3 PASS RESULT 后才允许
|
||||
启动 B4 implementation。
|
||||
|
||||
## Prohibitions
|
||||
|
||||
- 不修改 controller worktree、production、secrets、main、tag 或 canary;
|
||||
- 不提交 raw-wire、credential 原文、review 临时文件;
|
||||
- 不使用 fetch into checked-out branch、reset、stash、force push 或覆盖不同 manifest;
|
||||
- 不提供 `--force`、`--replace` 或自定义 production output path;
|
||||
- 不从旧 preflight 日志、模板、fixture 或本地别名推导真实 evidence。
|
||||
|
||||
## Result
|
||||
|
||||
执行后生成:
|
||||
|
||||
```text
|
||||
.agent/results/codex/RESULT-CODEX-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-<timestamp>.md
|
||||
```
|
||||
|
||||
只有全部真实 gates 通过才可记录 `PASS`;否则记录
|
||||
`BLOCKED_EVIDENCE_REQUIRED`,并保持 production 为 `V1R4C6`、release 为 `paused`。
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": "aios.v2.acceptance.v1",
|
||||
"generated_at": "2026-07-21T03:26:12.120179Z",
|
||||
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"commit": "09fa4f9d73799a2e924153d198d8ebb09ff15cfd",
|
||||
"workspace_dirty": false,
|
||||
"passed": 8,
|
||||
"failed": 0,
|
||||
"warnings": 1,
|
||||
"steps": [
|
||||
{
|
||||
"name": "文档与代码匹配",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/文档与代码匹配.log"
|
||||
},
|
||||
{
|
||||
"name": "Python 语法编译",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Python_语法编译.log"
|
||||
},
|
||||
{
|
||||
"name": "候选单元与集成测试",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/候选单元与集成测试.log"
|
||||
},
|
||||
{
|
||||
"name": "Shell 脚本语法",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Shell_脚本语法.log"
|
||||
},
|
||||
{
|
||||
"name": "文档 Git 冲突标记",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/文档_Git_冲突标记.log"
|
||||
},
|
||||
{
|
||||
"name": "明文密钥基础扫描",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/明文密钥基础扫描.log"
|
||||
},
|
||||
{
|
||||
"name": "systemd 单元验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/systemd_单元验证.log"
|
||||
},
|
||||
{
|
||||
"name": "Docker Compose 配置验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T032611Z/Docker_Compose_配置验证.log"
|
||||
},
|
||||
{
|
||||
"name": "在线健康检查",
|
||||
"status": "警告",
|
||||
"exit_code": 0,
|
||||
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||
}
|
||||
],
|
||||
"final_status": "passed"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": "aios.v2.acceptance.v1",
|
||||
"generated_at": "2026-07-21T03:40:03.455790Z",
|
||||
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"commit": "57209760cd159e564d7de1160090917dbbc7ce0a",
|
||||
"workspace_dirty": false,
|
||||
"passed": 8,
|
||||
"failed": 0,
|
||||
"warnings": 1,
|
||||
"steps": [
|
||||
{
|
||||
"name": "文档与代码匹配",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/文档与代码匹配.log"
|
||||
},
|
||||
{
|
||||
"name": "Python 语法编译",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Python_语法编译.log"
|
||||
},
|
||||
{
|
||||
"name": "候选单元与集成测试",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/候选单元与集成测试.log"
|
||||
},
|
||||
{
|
||||
"name": "Shell 脚本语法",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Shell_脚本语法.log"
|
||||
},
|
||||
{
|
||||
"name": "文档 Git 冲突标记",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/文档_Git_冲突标记.log"
|
||||
},
|
||||
{
|
||||
"name": "明文密钥基础扫描",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/明文密钥基础扫描.log"
|
||||
},
|
||||
{
|
||||
"name": "systemd 单元验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/systemd_单元验证.log"
|
||||
},
|
||||
{
|
||||
"name": "Docker Compose 配置验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T034002Z/Docker_Compose_配置验证.log"
|
||||
},
|
||||
{
|
||||
"name": "在线健康检查",
|
||||
"status": "警告",
|
||||
"exit_code": 0,
|
||||
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||
}
|
||||
],
|
||||
"final_status": "passed"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": "aios.v2.acceptance.v1",
|
||||
"generated_at": "2026-07-21T04:11:34.291792Z",
|
||||
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"commit": "fedb6b08fbb8c825972f700318a81e902e89cda5",
|
||||
"workspace_dirty": false,
|
||||
"passed": 8,
|
||||
"failed": 0,
|
||||
"warnings": 1,
|
||||
"steps": [
|
||||
{
|
||||
"name": "文档与代码匹配",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/文档与代码匹配.log"
|
||||
},
|
||||
{
|
||||
"name": "Python 语法编译",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Python_语法编译.log"
|
||||
},
|
||||
{
|
||||
"name": "候选单元与集成测试",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/候选单元与集成测试.log"
|
||||
},
|
||||
{
|
||||
"name": "Shell 脚本语法",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Shell_脚本语法.log"
|
||||
},
|
||||
{
|
||||
"name": "文档 Git 冲突标记",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/文档_Git_冲突标记.log"
|
||||
},
|
||||
{
|
||||
"name": "明文密钥基础扫描",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/明文密钥基础扫描.log"
|
||||
},
|
||||
{
|
||||
"name": "systemd 单元验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/systemd_单元验证.log"
|
||||
},
|
||||
{
|
||||
"name": "Docker Compose 配置验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041133Z/Docker_Compose_配置验证.log"
|
||||
},
|
||||
{
|
||||
"name": "在线健康检查",
|
||||
"status": "警告",
|
||||
"exit_code": 0,
|
||||
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||
}
|
||||
],
|
||||
"final_status": "passed"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": "aios.v2.acceptance.v1",
|
||||
"generated_at": "2026-07-21T04:16:01.542048Z",
|
||||
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"commit": "07b5c2c9c28e7840b6261172a7d34aa7de7c48c9",
|
||||
"workspace_dirty": false,
|
||||
"passed": 8,
|
||||
"failed": 0,
|
||||
"warnings": 1,
|
||||
"steps": [
|
||||
{
|
||||
"name": "文档与代码匹配",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/文档与代码匹配.log"
|
||||
},
|
||||
{
|
||||
"name": "Python 语法编译",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Python_语法编译.log"
|
||||
},
|
||||
{
|
||||
"name": "候选单元与集成测试",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/候选单元与集成测试.log"
|
||||
},
|
||||
{
|
||||
"name": "Shell 脚本语法",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Shell_脚本语法.log"
|
||||
},
|
||||
{
|
||||
"name": "文档 Git 冲突标记",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/文档_Git_冲突标记.log"
|
||||
},
|
||||
{
|
||||
"name": "明文密钥基础扫描",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/明文密钥基础扫描.log"
|
||||
},
|
||||
{
|
||||
"name": "systemd 单元验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/systemd_单元验证.log"
|
||||
},
|
||||
{
|
||||
"name": "Docker Compose 配置验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T041601Z/Docker_Compose_配置验证.log"
|
||||
},
|
||||
{
|
||||
"name": "在线健康检查",
|
||||
"status": "警告",
|
||||
"exit_code": 0,
|
||||
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||
}
|
||||
],
|
||||
"final_status": "passed"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": "aios.v2.acceptance.v1",
|
||||
"generated_at": "2026-07-21T04:21:06.025742Z",
|
||||
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"commit": "c81d4f40f3191037cb1a34f169e0a9ed7825bdbf",
|
||||
"workspace_dirty": false,
|
||||
"passed": 8,
|
||||
"failed": 0,
|
||||
"warnings": 1,
|
||||
"steps": [
|
||||
{
|
||||
"name": "文档与代码匹配",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/文档与代码匹配.log"
|
||||
},
|
||||
{
|
||||
"name": "Python 语法编译",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Python_语法编译.log"
|
||||
},
|
||||
{
|
||||
"name": "候选单元与集成测试",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/候选单元与集成测试.log"
|
||||
},
|
||||
{
|
||||
"name": "Shell 脚本语法",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Shell_脚本语法.log"
|
||||
},
|
||||
{
|
||||
"name": "文档 Git 冲突标记",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/文档_Git_冲突标记.log"
|
||||
},
|
||||
{
|
||||
"name": "明文密钥基础扫描",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/明文密钥基础扫描.log"
|
||||
},
|
||||
{
|
||||
"name": "systemd 单元验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/systemd_单元验证.log"
|
||||
},
|
||||
{
|
||||
"name": "Docker Compose 配置验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042105Z/Docker_Compose_配置验证.log"
|
||||
},
|
||||
{
|
||||
"name": "在线健康检查",
|
||||
"status": "警告",
|
||||
"exit_code": 0,
|
||||
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||
}
|
||||
],
|
||||
"final_status": "passed"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": "aios.v2.acceptance.v1",
|
||||
"generated_at": "2026-07-21T04:24:51.586289Z",
|
||||
"branch": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||
"commit": "2a2b702eb8f9501a637b9f9b5f033482eec7cc75",
|
||||
"workspace_dirty": false,
|
||||
"passed": 8,
|
||||
"failed": 0,
|
||||
"warnings": 1,
|
||||
"steps": [
|
||||
{
|
||||
"name": "文档与代码匹配",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/文档与代码匹配.log"
|
||||
},
|
||||
{
|
||||
"name": "Python 语法编译",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Python_语法编译.log"
|
||||
},
|
||||
{
|
||||
"name": "候选单元与集成测试",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/候选单元与集成测试.log"
|
||||
},
|
||||
{
|
||||
"name": "Shell 脚本语法",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Shell_脚本语法.log"
|
||||
},
|
||||
{
|
||||
"name": "文档 Git 冲突标记",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/文档_Git_冲突标记.log"
|
||||
},
|
||||
{
|
||||
"name": "明文密钥基础扫描",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/明文密钥基础扫描.log"
|
||||
},
|
||||
{
|
||||
"name": "systemd 单元验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/systemd_单元验证.log"
|
||||
},
|
||||
{
|
||||
"name": "Docker Compose 配置验证",
|
||||
"status": "通过",
|
||||
"exit_code": 0,
|
||||
"log": "/data/aios-gitops/worktrees/aios-v2r1-deploy-20260720/.aios-evidence/acceptance/20260721T042451Z/Docker_Compose_配置验证.log"
|
||||
},
|
||||
{
|
||||
"name": "在线健康检查",
|
||||
"status": "警告",
|
||||
"exit_code": 0,
|
||||
"log": "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
|
||||
}
|
||||
],
|
||||
"final_status": "passed"
|
||||
}
|
||||
@@ -11,7 +11,3 @@ runtime/tmp/
|
||||
runtime/cache/
|
||||
logs/
|
||||
config/test_write_perms
|
||||
/data/webops.sqlite3
|
||||
/data/webops.sqlite3-wal
|
||||
/data/webops.sqlite3-shm
|
||||
/data/webops.sqlite3-journal
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
# AIOS V2R1 生产候选报告
|
||||
|
||||
- 候选版本:`V2R1C0-candidate`
|
||||
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||
- 报告语言:中文
|
||||
- 架构负责人:AIOS V2 CTO
|
||||
- 当前状态:代码与文档闭环已完成;精确 Commit 测试、真实 Runtime 联调和预发布验证待执行
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
AIOS V2R1 Candidate 已完成以下核心建设:
|
||||
|
||||
- AIOS 唯一控制面;
|
||||
- Tenant、Agent、Task、Policy、Approval 和 Audit 权威模型;
|
||||
- SQLite WAL 候选持久化与重启恢复;
|
||||
- Execution Orchestrator;
|
||||
- OpenClaw 签名命令、Adapter、Transport、Health 和 Audit Callback;
|
||||
- Hermes tenant/cell/profile 分区、Queue、Worker Lease、Heartbeat 和 Result;
|
||||
- Agent Execution Evidence;
|
||||
- Web Console;
|
||||
- GitOps Status;
|
||||
- systemd、Docker、Compose、Healthcheck 和 Rollback;
|
||||
- 用户需求、目标架构、迁移策略、模块契约、追踪矩阵、文档治理和 ADR;
|
||||
- 文档—代码匹配检查器;
|
||||
- 全量验收入口;
|
||||
- OpenClaw 预发布部署任务;
|
||||
- Codex 架构监督和阻塞处理任务。
|
||||
|
||||
本报告不把“代码已提交”误报为“生产已验证”。以下状态仍需由无人值守任务执行并形成证据:
|
||||
|
||||
- 精确 Candidate Commit 的自动测试;
|
||||
- 真实 OpenClaw Runtime 联调;
|
||||
- Hermes 长任务、Worker 崩溃和 Lease Recovery 演练;
|
||||
- systemd/Docker 预发布部署;
|
||||
- 重启恢复与回滚演练;
|
||||
- 本地仓库与 Gitea Candidate OID 一致验证;
|
||||
- 身份代理、安全、性能和容量验证;
|
||||
- Owner 对合并、Tag 和生产部署的批准。
|
||||
|
||||
## 2. 用户需求完成情况
|
||||
|
||||
| 需求 | 代码状态 | 测试状态 | 预发布状态 |
|
||||
|---|---|---|---|
|
||||
| AIOS 唯一控制面 | 已实现 | 已定义 | 待验证 |
|
||||
| 复用 OpenClaw/Hermes/Agent Router | 已实现 | 已定义 | 待真实联调 |
|
||||
| OpenClaw/Hermes 解耦 | 已实现 | 已定义 | 待旁路冻结验证 |
|
||||
| 全面 Web 化 | 候选实现完成 | 冒烟测试待执行 | 待反向代理接入 |
|
||||
| 多租户隔离 | 已实现 | Domain 负向测试已定义 | 待全链路安全验证 |
|
||||
| 统一任务生命周期 | 已实现 | 已定义 | 待精确 Commit 执行 |
|
||||
| 策略与审批 | 已实现 | 部分测试待补强 | 待身份代理验证 |
|
||||
| 幂等与并发安全 | 已实现 | 基础测试已定义 | 待压力和并发验证 |
|
||||
| 执行证据 | 已实现 | 已定义 | 待外部执行器持续回写 |
|
||||
| GitOps 唯一流程 | 已实现并修复 Controller 冲突 | Runtime 部分验证 | 待双目标持续镜像证据 |
|
||||
| 持久化与恢复 | 已实现 | 重启恢复测试已定义 | 待预发布演练 |
|
||||
| 审计与合规 | 已实现候选 | 部分断言待补强 | 待不可变外部固化 |
|
||||
| 密钥安全 | 引用模式已实现 | 基础扫描纳入验收 | 待 Secret Provider 联调 |
|
||||
| 部署与回滚 | 资产完成 | 验收脚本已定义 | 待实际演练 |
|
||||
| 文档与代码同步 | 已实现门禁 | 待精确 Commit 执行 | 待接入持续验收 |
|
||||
|
||||
## 3. 目标架构
|
||||
|
||||
```text
|
||||
浏览器 / Web Console / Public API
|
||||
|
|
||||
v
|
||||
AIOS 唯一控制面
|
||||
Tenant | Agent | Task | Policy | Approval | Audit
|
||||
|
|
||||
v
|
||||
Execution Orchestrator / Agent Router
|
||||
/ \
|
||||
v v
|
||||
OpenClaw Adapter Hermes Boundary
|
||||
| |
|
||||
v v
|
||||
OpenClaw Runtime Hermes Workers
|
||||
\ /
|
||||
v v
|
||||
结果回写 + 审计 + 执行证据
|
||||
```
|
||||
|
||||
权威边界:
|
||||
|
||||
- AIOS:租户、身份、Agent、任务、策略、审批、审计和执行证据;
|
||||
- Agent Router:调度与路由;
|
||||
- OpenClaw:签名命令执行、取消、状态、健康和回调;
|
||||
- Hermes:队列、Worker、Lease、Heartbeat 和结果;
|
||||
- Web:只调用 AIOS API;
|
||||
- GitOps:所有代码、文档、部署和修复变更入口。
|
||||
|
||||
## 4. 实现清单
|
||||
|
||||
### 4.1 Control Plane
|
||||
|
||||
```text
|
||||
active-runtime/app/aios_v2/control_plane.py
|
||||
active-runtime/app/aios_v2/persistence.py
|
||||
active-runtime/app/aios_v2/gateway.py
|
||||
active-runtime/app/aios_v2/app.py
|
||||
active-runtime/app/aios_v2/orchestrator.py
|
||||
active-runtime/app/aios_v2/execution_tracking.py
|
||||
active-runtime/app/aios_v2/gitops_status.py
|
||||
active-runtime/app/aios_v2/console.py
|
||||
```
|
||||
|
||||
### 4.2 OpenClaw
|
||||
|
||||
```text
|
||||
active-runtime/app/aios_openclaw/contract.py
|
||||
active-runtime/app/aios_openclaw/adapter.py
|
||||
active-runtime/app/aios_openclaw/transport.py
|
||||
active-runtime/app/aios_openclaw/health.py
|
||||
active-runtime/app/aios_openclaw/callback.py
|
||||
```
|
||||
|
||||
### 4.3 Hermes
|
||||
|
||||
```text
|
||||
active-runtime/app/aios_hermes/contract.py
|
||||
active-runtime/app/aios_hermes/adapter.py
|
||||
active-runtime/app/aios_hermes/worker.py
|
||||
```
|
||||
|
||||
### 4.4 测试与治理
|
||||
|
||||
```text
|
||||
tests/aios_v2_candidate/test_candidate_flow.py
|
||||
tools/aios_v2_docs_code_check.py
|
||||
deploy/aios-v2/acceptance/run-all.sh
|
||||
```
|
||||
|
||||
### 4.5 部署
|
||||
|
||||
```text
|
||||
deploy/aios-v2/systemd/aios-v2.service
|
||||
deploy/aios-v2/env/aios-v2.env.example
|
||||
deploy/aios-v2/docker/Dockerfile
|
||||
deploy/aios-v2/docker/compose.yaml
|
||||
deploy/aios-v2/healthcheck/check.sh
|
||||
deploy/aios-v2/rollback/rollback.sh
|
||||
```
|
||||
|
||||
### 4.6 文档与任务
|
||||
|
||||
```text
|
||||
docs/aios-v2/
|
||||
tasks/openclaw-production-deploy-task.yaml
|
||||
tasks/codex-production-supervisor-task.yaml
|
||||
```
|
||||
|
||||
## 5. 多租户与安全设计
|
||||
|
||||
### 5.1 权威上下文
|
||||
|
||||
```text
|
||||
tenant_id
|
||||
principal_id
|
||||
session_id
|
||||
trace_id
|
||||
task_id
|
||||
policy_version
|
||||
idempotency_key
|
||||
```
|
||||
|
||||
### 5.2 OpenClaw
|
||||
|
||||
- HMAC-SHA256 规范化签名;
|
||||
- 签名绑定 tenant/task/trace/action/payload/idempotency/time;
|
||||
- Runtime Result 身份字段必须匹配;
|
||||
- Secret 只通过受保护文件或引用解析。
|
||||
|
||||
### 5.3 Hermes
|
||||
|
||||
- tenant/cell/profile 分区;
|
||||
- Worker Lease 与 Heartbeat;
|
||||
- revision 乐观并发;
|
||||
- Lease 过期自动回到 QUEUED;
|
||||
- Worker 完成前停止 Heartbeat,避免 revision 竞态。
|
||||
|
||||
### 5.4 Web/API
|
||||
|
||||
- 可信代理注入 `X-AIOS-*`;
|
||||
- 浏览器不选择租户;
|
||||
- CSP、no-store、nosniff、DENY frame、no-referrer;
|
||||
- 应用端口不得直接暴露公网。
|
||||
|
||||
### 5.5 GitOps
|
||||
|
||||
- Controller detached + clean;
|
||||
- 禁止 fetch 到已检出本地分支;
|
||||
- 所有修改进入分支、Commit、测试和统一审查;
|
||||
- 明文 Secret 门禁;
|
||||
- 文档变化与代码变化同步门禁。
|
||||
|
||||
## 6. 持久化策略
|
||||
|
||||
候选版:
|
||||
|
||||
- SQLite WAL;
|
||||
- `synchronous=FULL`;
|
||||
- `BEGIN IMMEDIATE`;
|
||||
- 原子 Snapshot;
|
||||
- schema version;
|
||||
- 状态目录与代码版本分离;
|
||||
- Execution Evidence 租户分区原子 JSON。
|
||||
|
||||
已知限制:
|
||||
|
||||
- 单进程候选优先;
|
||||
- 尚无生产 RLS、多实例 HA、PITR 和容量证据;
|
||||
- 后续必须通过新 ADR 迁移到生产数据层。
|
||||
|
||||
## 7. 测试与验收
|
||||
|
||||
### 7.1 文档—代码匹配
|
||||
|
||||
```bash
|
||||
python3 tools/aios_v2_docs_code_check.py \
|
||||
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||
```
|
||||
|
||||
### 7.2 候选测试
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover \
|
||||
-s tests/aios_v2_candidate \
|
||||
-p 'test_*.py' \
|
||||
-v
|
||||
```
|
||||
|
||||
覆盖:
|
||||
|
||||
- OpenClaw 完整执行;
|
||||
- Hermes 完整执行;
|
||||
- 跨租户拒绝;
|
||||
- SQLite 重启恢复;
|
||||
- Execution Evidence。
|
||||
|
||||
### 7.3 全量验收
|
||||
|
||||
```bash
|
||||
bash deploy/aios-v2/acceptance/run-all.sh
|
||||
```
|
||||
|
||||
可选在线健康检查:
|
||||
|
||||
```bash
|
||||
AIOS_RUN_LIVE_HEALTHCHECK=1 \
|
||||
bash deploy/aios-v2/acceptance/run-all.sh
|
||||
```
|
||||
|
||||
### 7.4 健康检查
|
||||
|
||||
```bash
|
||||
bash deploy/aios-v2/healthcheck/check.sh
|
||||
```
|
||||
|
||||
检查:
|
||||
|
||||
- systemd;
|
||||
- HTTP Health;
|
||||
- 状态目录;
|
||||
- SQLite quick_check;
|
||||
- GitOps detached/clean;
|
||||
- Evidence 文件。
|
||||
|
||||
## 8. 部署方式
|
||||
|
||||
### 8.1 systemd
|
||||
|
||||
```text
|
||||
/etc/aios-v2/aios-v2.env
|
||||
/opt/aios/current
|
||||
/var/lib/aios-v2
|
||||
```
|
||||
|
||||
运行用户:`aios`。
|
||||
|
||||
安全选项:NoNewPrivileges、ProtectSystem、ProtectHome、PrivateTmp、Capability 限制。
|
||||
|
||||
### 8.2 Docker/Compose
|
||||
|
||||
- 非 root UID/GID;
|
||||
- Read-only root filesystem;
|
||||
- Drop all capabilities;
|
||||
- 独立 State Volume;
|
||||
- HTTP Healthcheck;
|
||||
- Secret 文件只读挂载。
|
||||
|
||||
## 9. 回滚
|
||||
|
||||
```bash
|
||||
bash deploy/aios-v2/rollback/rollback.sh <previous-release>
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- 回滚前自动备份状态;
|
||||
- 默认只回滚代码;
|
||||
- 状态恢复需显式 `AIOS_V2_RESTORE_STATE=1`;
|
||||
- 回滚后必须执行健康检查;
|
||||
- 目标版本失败时自动恢复 previous release。
|
||||
|
||||
## 10. 无人值守任务
|
||||
|
||||
### OpenClaw
|
||||
|
||||
任务:`tasks/openclaw-production-deploy-task.yaml`
|
||||
|
||||
职责:
|
||||
|
||||
- 预发布部署;
|
||||
- 健康检查;
|
||||
- Runtime 联调;
|
||||
- 小型部署与 Runtime Bug 修复;
|
||||
- 证据回写。
|
||||
|
||||
### Codex
|
||||
|
||||
任务:`tasks/codex-production-supervisor-task.yaml`
|
||||
|
||||
职责:
|
||||
|
||||
- 架构监督;
|
||||
- 阻塞分析和跨模块修复;
|
||||
- 文档、测试和安全审查;
|
||||
- 发布建议。
|
||||
|
||||
所有修改和阻塞必须进入 Git 和 Execution Evidence,最终统一审视。
|
||||
|
||||
## 11. 当前已知风险
|
||||
|
||||
| 风险 | 级别 | 当前处理 |
|
||||
|---|---|---|
|
||||
| 精确 Candidate Commit 尚未执行全部测试 | 高 | OpenClaw 执行,Codex 监督 |
|
||||
| 真实 OpenClaw Runtime 尚未联调 | 高 | 预发布任务必须完成 |
|
||||
| 候选 SQLite 不支持生产多实例 HA | 高 | Candidate 单实例;后续 ADR 迁移 |
|
||||
| 身份代理 Header 防伪尚未环境验证 | 高 | 预发布安全检查 |
|
||||
| Web Console 为候选只读 MVP | 中 | 生产前补充 E2E 与安全验证 |
|
||||
| 部分策略负向、并发和审计测试待补强 | 中 | Codex 登记 blocker 并补充 |
|
||||
| 本地/Gitea 自动双写状态仍需持续证明 | 中 | 验收记录 OID |
|
||||
| 性能、容量和 SLO 尚无实测 | 中 | 预发布基准测试 |
|
||||
| 外部不可变审计固化尚未实现 | 中 | 生产数据层阶段完成 |
|
||||
|
||||
## 12. 发布 Gate
|
||||
|
||||
### 允许进入预发布
|
||||
|
||||
条件:
|
||||
|
||||
- Candidate 分支可见;
|
||||
- 文档完整;
|
||||
- 任务合同已生成;
|
||||
- 部署资产存在;
|
||||
- 无明文 Secret。
|
||||
|
||||
### 允许进入合并审查
|
||||
|
||||
条件:
|
||||
|
||||
- 全量验收通过;
|
||||
- OpenClaw/Hermes 联调通过;
|
||||
- 重启和回滚通过;
|
||||
- 严重/高风险 blocker 已解决;
|
||||
- Codex 输出正向监督结论;
|
||||
- 本地/Gitea OID 一致。
|
||||
|
||||
### 允许生产发布
|
||||
|
||||
条件:
|
||||
|
||||
- Owner 明确批准;
|
||||
- Merge 与 Tag 通过受控流程;
|
||||
- 生产配置、Secret 和反向代理准备完成;
|
||||
- 发布窗口、观察指标、回滚负责人明确。
|
||||
|
||||
## 13. 当前结论
|
||||
|
||||
**代码和正式中文文档已经达到候选闭环状态,但尚不能宣称生产已验证。**
|
||||
|
||||
下一阶段由 OpenClaw 执行预发布部署和低风险 Bug 修复,由 Codex 监督架构、文档、安全、测试和阻塞。所有过程证据统一进入系统,满足 Gate 后再请求 Owner 批准。
|
||||
|
||||
## 14. 更新规则
|
||||
|
||||
以下变化必须更新本报告:
|
||||
|
||||
- Candidate Commit;
|
||||
- 测试结果;
|
||||
- 部署和回滚结果;
|
||||
- Blocker 和风险;
|
||||
- OpenClaw/Codex 任务状态;
|
||||
- 文档—代码匹配状态;
|
||||
- Gitea OID;
|
||||
- 发布建议和 Owner 决策。
|
||||
|
||||
## 15. Candidate 命令签名算法
|
||||
|
||||
Key ID `openclaw-v2r1-candidate-ed25519` 对应真实 Ed25519 私钥,不得再把同名材料作为 HMAC key 使用。私钥由 Owner 批准流程在受保护边界内生成,文件权限为 `0600`,Owner 为实际 AIOS 服务用户;Git、日志和 Evidence 仅记录 Key ID、权限检查和 sign/verify 结果。
|
||||
|
||||
## 16. 2026-07-20 阻塞清除快照
|
||||
|
||||
五项高风险 Blocker 均已形成运行证据。`BLK-OPENCLAW-RUN-PROVENANCE-MISSING` 通过 Git 中的 `aios-tenant-boundary` rc2 插件解除:Ed25519 Owner 签发记录持久保存,授权绑定 Run、Sender、Task、Claim OID、Candidate OID 与任务分支;专用 `aios_evidence_write` 只允许七个 `.agent` JSON 和固定 Git 操作,通用 Shell/写工具保持拒绝。
|
||||
|
||||
Provenance `openclaw-v2r1-20260721T031523Z` 已完成 `heartbeat.json` 更新、Commit、当前任务分支普通 Push 和追加式审计;`README.md` 越权写入被拒绝。由此允许启动 Gate A、Gate B 和离线验收,但在三项验收及后续在线联调完成前仍保持 `STOP_PREPRODUCTION`。
|
||||
@@ -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,37 @@
|
||||
"""AIOS-managed OpenClaw execution adapter."""
|
||||
|
||||
from .adapter import (
|
||||
AdapterError,
|
||||
CommandConflict,
|
||||
CommandNotFound,
|
||||
CommandRejected,
|
||||
InMemoryCallbackSink,
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from .contract import (
|
||||
CommandState,
|
||||
OpenClawCommand,
|
||||
OpenClawResult,
|
||||
RuntimeHealth,
|
||||
sign_command,
|
||||
verify_command_signature,
|
||||
)
|
||||
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
|
||||
|
||||
__all__ = [
|
||||
"AdapterError",
|
||||
"CommandConflict",
|
||||
"CommandNotFound",
|
||||
"CommandRejected",
|
||||
"CommandState",
|
||||
"HTTPRuntimeTransport",
|
||||
"InMemoryCallbackSink",
|
||||
"InProcessRuntimeTransport",
|
||||
"OpenClawAdapter",
|
||||
"OpenClawCommand",
|
||||
"OpenClawResult",
|
||||
"RuntimeHealth",
|
||||
"RuntimeTransport",
|
||||
"sign_command",
|
||||
"verify_command_signature",
|
||||
]
|
||||
@@ -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, *, tenant_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, tenant_id=tenant_id):
|
||||
raise AdapterError("runtime rejected cancellation")
|
||||
cancelled = replace(
|
||||
current,
|
||||
state=CommandState.CANCELLED,
|
||||
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
with self._lock:
|
||||
self._results[command_id] = cancelled
|
||||
self.callback_sink.on_failed(cancelled)
|
||||
return cancelled
|
||||
|
||||
def health(self) -> RuntimeHealth:
|
||||
return self.transport.health()
|
||||
|
||||
def snapshot(self, *, tenant_id: str) -> Mapping[str, OpenClawResult]:
|
||||
with self._lock:
|
||||
return {
|
||||
command_id: result
|
||||
for command_id, result in self._results.items()
|
||||
if self._commands[command_id].tenant_id == tenant_id
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Audit-oriented lifecycle callbacks for OpenClaw execution."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from .contract import OpenClawResult, utc_now
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
def publish(self, event: Mapping[str, Any]) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionEvent:
|
||||
event_type: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
trace_id: str
|
||||
command_id: str
|
||||
state: str
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"event_type": self.event_type,
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": self.task_id,
|
||||
"trace_id": self.trace_id,
|
||||
"command_id": self.command_id,
|
||||
"state": self.state,
|
||||
"created_at": self.created_at,
|
||||
"details": json.loads(
|
||||
json.dumps(dict(self.details), ensure_ascii=False, default=str)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class InMemoryEventPublisher:
|
||||
"""Thread-safe publisher used by tests and first integration slice."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def publish(self, event: Mapping[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self.events.append(
|
||||
json.loads(json.dumps(dict(event), ensure_ascii=False, default=str))
|
||||
)
|
||||
|
||||
|
||||
class AuditCallbackSink:
|
||||
"""Convert runtime callbacks into tenant-scoped audit events."""
|
||||
|
||||
def __init__(self, publisher: EventPublisher) -> None:
|
||||
self.publisher = publisher
|
||||
|
||||
@staticmethod
|
||||
def _event(
|
||||
event_type: str,
|
||||
result: OpenClawResult,
|
||||
) -> ExecutionEvent:
|
||||
details: dict[str, Any] = {
|
||||
"runtime_id": result.runtime_id,
|
||||
"attempts": result.attempts,
|
||||
}
|
||||
if result.error:
|
||||
details["error"] = dict(result.error)
|
||||
if result.output:
|
||||
details["output_present"] = True
|
||||
return ExecutionEvent(
|
||||
event_type=event_type,
|
||||
tenant_id=result.tenant_id,
|
||||
task_id=result.task_id,
|
||||
trace_id=result.trace_id,
|
||||
command_id=result.command_id,
|
||||
state=result.state.value,
|
||||
details=details,
|
||||
)
|
||||
|
||||
def on_started(self, result: OpenClawResult) -> None:
|
||||
self.publisher.publish(self._event("OPENCLAW_STARTED", result).as_dict())
|
||||
|
||||
def on_completed(self, result: OpenClawResult) -> None:
|
||||
self.publisher.publish(self._event("OPENCLAW_COMPLETED", result).as_dict())
|
||||
|
||||
def on_failed(self, result: OpenClawResult) -> None:
|
||||
event_type = {
|
||||
"CANCELLED": "OPENCLAW_CANCELLED",
|
||||
"TIMED_OUT": "OPENCLAW_TIMED_OUT",
|
||||
"REJECTED": "OPENCLAW_REJECTED",
|
||||
}.get(result.state.value, "OPENCLAW_FAILED")
|
||||
self.publisher.publish(self._event(event_type, result).as_dict())
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Versioned AIOS to OpenClaw execution contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
CONTRACT_VERSION = "aios.openclaw.command.v1"
|
||||
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("timestamp is required")
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp must include a timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||
|
||||
|
||||
class CommandState(str, Enum):
|
||||
ACCEPTED = "ACCEPTED"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
TIMED_OUT = "TIMED_OUT"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenClawCommand:
|
||||
command_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
trace_id: str
|
||||
action: str
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
idempotency_key: str = ""
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
expires_at: str | None = None
|
||||
contract_version: str = CONTRACT_VERSION
|
||||
key_id: str = "default"
|
||||
signature: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in (
|
||||
"command_id", "tenant_id", "task_id", "trace_id", "action",
|
||||
"idempotency_key", "created_at", "contract_version", "key_id",
|
||||
):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
if self.contract_version != CONTRACT_VERSION:
|
||||
raise ValueError(f"unsupported contract_version: {self.contract_version}")
|
||||
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||
_parse_timestamp(self.created_at)
|
||||
if self.expires_at is not None:
|
||||
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
||||
_parse_timestamp(self.expires_at)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
trace_id: str,
|
||||
action: str,
|
||||
payload: Mapping[str, Any] | None,
|
||||
idempotency_key: str,
|
||||
expires_at: str | None = None,
|
||||
key_id: str = "default",
|
||||
command_id: str | None = None,
|
||||
) -> "OpenClawCommand":
|
||||
return cls(
|
||||
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
trace_id=trace_id,
|
||||
action=action,
|
||||
payload=_safe_mapping(payload),
|
||||
idempotency_key=idempotency_key,
|
||||
expires_at=expires_at,
|
||||
key_id=key_id,
|
||||
)
|
||||
|
||||
def unsigned_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"command_id": self.command_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": self.task_id,
|
||||
"trace_id": self.trace_id,
|
||||
"action": self.action,
|
||||
"payload": _safe_mapping(self.payload),
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
"contract_version": self.contract_version,
|
||||
"key_id": self.key_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenClawResult:
|
||||
command_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
trace_id: str
|
||||
state: CommandState
|
||||
output: Mapping[str, Any] | None = None
|
||||
error: Mapping[str, Any] | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
runtime_id: str | None = None
|
||||
attempts: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
object.__setattr__(self, "state", CommandState(self.state))
|
||||
if self.output is not None:
|
||||
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||
if self.error is not None:
|
||||
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||
if self.started_at is not None:
|
||||
_parse_timestamp(self.started_at)
|
||||
if self.finished_at is not None:
|
||||
_parse_timestamp(self.finished_at)
|
||||
if self.attempts < 1:
|
||||
raise ValueError("attempts must be >= 1")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeHealth:
|
||||
runtime: str
|
||||
runtime_id: str
|
||||
status: str
|
||||
version: str
|
||||
heartbeat_at: str
|
||||
active_tasks: int = 0
|
||||
workspace_isolated: bool = False
|
||||
credential_ref_configured: bool = False
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("runtime", "runtime_id", "status", "version", "heartbeat_at"):
|
||||
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||
_parse_timestamp(self.heartbeat_at)
|
||||
if self.active_tasks < 0:
|
||||
raise ValueError("active_tasks must be >= 0")
|
||||
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||
|
||||
|
||||
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||
return json.dumps(
|
||||
command.unsigned_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _ed25519_private_key(key_material: bytes | str) -> Ed25519PrivateKey:
|
||||
material = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if material.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_private_key(material, password=None)
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise ValueError("signing material is not an Ed25519 private key")
|
||||
return key
|
||||
if len(material) != 32:
|
||||
raise ValueError("Ed25519 raw private signing material must be exactly 32 bytes")
|
||||
return Ed25519PrivateKey.from_private_bytes(material)
|
||||
|
||||
|
||||
def _decode_signature(value: str) -> bytes:
|
||||
encoded = value.encode("ascii")
|
||||
return base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
|
||||
|
||||
|
||||
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
|
||||
key = _ed25519_private_key(key_material)
|
||||
signature = base64.urlsafe_b64encode(key.sign(canonical_command_bytes(command))).decode("ascii").rstrip("=")
|
||||
return replace(command, signature=signature)
|
||||
|
||||
|
||||
def verify_command_signature(
|
||||
command: OpenClawCommand,
|
||||
key_material: bytes | str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||
) -> bool:
|
||||
if not command.signature:
|
||||
return False
|
||||
try:
|
||||
key = _ed25519_private_key(key_material)
|
||||
key.public_key().verify(_decode_signature(command.signature), canonical_command_bytes(command))
|
||||
except (InvalidSignature, ValueError, TypeError, UnicodeError):
|
||||
return False
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
created = _parse_timestamp(command.created_at)
|
||||
if created.timestamp() - current.timestamp() > clock_skew_seconds:
|
||||
return False
|
||||
if command.expires_at is not None:
|
||||
expires = _parse_timestamp(command.expires_at)
|
||||
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
|
||||
return False
|
||||
return True
|
||||
@@ -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,222 @@
|
||||
"""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, *, tenant_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()
|
||||
self._command_tenants: dict[str, str] = {}
|
||||
|
||||
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
with self._lock:
|
||||
known_tenant = self._command_tenants.get(command.command_id)
|
||||
if known_tenant is not None and known_tenant != command.tenant_id:
|
||||
raise TransportError("command id belongs to another tenant")
|
||||
self._command_tenants[command.command_id] = command.tenant_id
|
||||
if command.command_id in self._cancelled:
|
||||
return OpenClawResult(
|
||||
command_id=command.command_id,
|
||||
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, *, tenant_id: str) -> bool:
|
||||
with self._lock:
|
||||
if self._command_tenants.get(command_id) != tenant_id:
|
||||
return False
|
||||
self._cancelled.add(command_id)
|
||||
return command_id in self._running
|
||||
|
||||
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, *, tenant_id: str) -> bool:
|
||||
value = self._request(
|
||||
"POST",
|
||||
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel"
|
||||
f"?tenant_id={urllib.parse.quote(tenant_id, safe='')}",
|
||||
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,46 @@
|
||||
"""AIOS V2 control plane and execution orchestration."""
|
||||
|
||||
from .control_plane import (
|
||||
AgentRecord,
|
||||
AuditEvent,
|
||||
ControlPlaneError,
|
||||
ControlPlaneService,
|
||||
IdempotencyConflict,
|
||||
InMemoryControlPlaneStore,
|
||||
InvalidTransition,
|
||||
NotFound,
|
||||
PolicyDenied,
|
||||
RequestContext,
|
||||
RevisionConflict,
|
||||
TaskRecord,
|
||||
TaskState,
|
||||
TenantIsolationError,
|
||||
TenantRecord,
|
||||
Unauthorized,
|
||||
)
|
||||
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
|
||||
|
||||
__all__ = [
|
||||
"AgentRecord",
|
||||
"AuditEvent",
|
||||
"ControlPlaneError",
|
||||
"ControlPlaneService",
|
||||
"DispatchRecord",
|
||||
"ExecutionEvidence",
|
||||
"ExecutionOrchestrator",
|
||||
"ExecutionTracker",
|
||||
"IdempotencyConflict",
|
||||
"InMemoryControlPlaneStore",
|
||||
"InvalidTransition",
|
||||
"NotFound",
|
||||
"OrchestrationError",
|
||||
"PolicyDenied",
|
||||
"RequestContext",
|
||||
"RevisionConflict",
|
||||
"TaskRecord",
|
||||
"TaskState",
|
||||
"TenantIsolationError",
|
||||
"TenantRecord",
|
||||
"Unauthorized",
|
||||
]
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Production-candidate AIOS V2 application entrypoint.
|
||||
|
||||
This process owns the public control-plane API and read-only web console. It
|
||||
loads durable control-plane state, dispatches approved work through runtime
|
||||
adapters, exposes tenant-scoped execution evidence and sanitized GitOps status,
|
||||
and never exposes runtime credentials to the browser.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import urllib.parse
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesResult
|
||||
from aios_openclaw import HTTPRuntimeTransport, OpenClawAdapter
|
||||
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
||||
|
||||
from .console import CONSOLE_HTML
|
||||
from .control_plane import AuditEvent, RequestContext
|
||||
from .execution_tracking import ExecutionTracker
|
||||
from .gateway import GatewayApplication, error_response
|
||||
from .gitops_status import collect_gitops_status
|
||||
from .orchestrator import ExecutionOrchestrator
|
||||
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||
|
||||
|
||||
def _json_value(value: Any) -> Any:
|
||||
if dataclasses.is_dataclass(value):
|
||||
return {
|
||||
item.name: _json_value(getattr(value, item.name))
|
||||
for item in dataclasses.fields(value)
|
||||
}
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class FileSigningMaterialResolver:
|
||||
"""Resolve runtime signing material from a root-owned file reference."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
|
||||
def __call__(self, tenant_id: str, key_id: str) -> bytes:
|
||||
del tenant_id, key_id
|
||||
metadata = self.path.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError("signing material reference is not a regular file")
|
||||
if metadata.st_mode & 0o077:
|
||||
raise RuntimeError("signing material file permissions must be 0600 or stricter")
|
||||
value = self.path.read_bytes()
|
||||
if len(value) < 32:
|
||||
raise RuntimeError("signing material is too short")
|
||||
return value
|
||||
|
||||
|
||||
class ControlPlaneEventPublisher(EventPublisher):
|
||||
"""Append OpenClaw runtime events to the tenant audit stream."""
|
||||
|
||||
def __init__(self, service: DurableControlPlaneService) -> None:
|
||||
self.service = service
|
||||
|
||||
def publish(self, event: Mapping[str, Any]) -> None:
|
||||
tenant_id = str(event.get("tenant_id") or "").strip()
|
||||
trace_id = str(event.get("trace_id") or "").strip()
|
||||
command_id = str(event.get("command_id") or "").strip()
|
||||
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
|
||||
if not tenant_id or not trace_id or not command_id:
|
||||
raise ValueError("runtime audit event is missing tenant identity")
|
||||
state_value = str(event.get("state") or "unknown").lower()
|
||||
outcome = "allowed" if state_value == "completed" else state_value
|
||||
audit = AuditEvent(
|
||||
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
|
||||
tenant_id=tenant_id,
|
||||
principal_id="openclaw-runtime",
|
||||
trace_id=trace_id,
|
||||
action=event_type,
|
||||
resource_type="openclaw_command",
|
||||
resource_id=command_id,
|
||||
outcome=outcome,
|
||||
policy_version="runtime-v1",
|
||||
details=dict(event.get("details") or {}),
|
||||
)
|
||||
with self.service.store.lock:
|
||||
self.service.store.audit_events.append(audit)
|
||||
self.service.repository.save(self.service._snapshot())
|
||||
|
||||
|
||||
class CandidateApplication:
|
||||
"""Compose durable control plane, runtimes, evidence and console routes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gateway: GatewayApplication,
|
||||
orchestrator: ExecutionOrchestrator,
|
||||
tracker: ExecutionTracker,
|
||||
hermes: HermesAdapter,
|
||||
openclaw: OpenClawAdapter | None,
|
||||
) -> None:
|
||||
self.gateway = gateway
|
||||
self.orchestrator = orchestrator
|
||||
self.tracker = tracker
|
||||
self.hermes = hermes
|
||||
self.openclaw = openclaw
|
||||
|
||||
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||
return self.gateway.context_from_headers(headers)
|
||||
|
||||
def handle_json(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
|
||||
if method == "GET" and path == "/api/v2/gitops":
|
||||
context = self.context(headers)
|
||||
return HTTPStatus.OK, {
|
||||
"tenant_id": context.tenant_id,
|
||||
"gitops": collect_gitops_status().as_dict(),
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/runtime":
|
||||
context = self.context(headers)
|
||||
runtimes: list[dict[str, Any]] = []
|
||||
if self.openclaw is None:
|
||||
runtimes.append(
|
||||
{
|
||||
"name": "openclaw",
|
||||
"status": "disabled",
|
||||
"details": {"reason": "runtime_not_configured"},
|
||||
}
|
||||
)
|
||||
else:
|
||||
try:
|
||||
health = self.openclaw.health()
|
||||
runtimes.append(
|
||||
{
|
||||
"name": "openclaw",
|
||||
"status": health.status,
|
||||
"details": _json_value(health),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
runtimes.append(
|
||||
{
|
||||
"name": "openclaw",
|
||||
"status": "unavailable",
|
||||
"details": {"message": str(exc)},
|
||||
}
|
||||
)
|
||||
hermes_health = self.hermes.health()
|
||||
runtimes.append(
|
||||
{
|
||||
"name": "hermes",
|
||||
"status": hermes_health.status,
|
||||
"details": _json_value(hermes_health),
|
||||
}
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"tenant_id": context.tenant_id,
|
||||
"runtimes": runtimes,
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/executions":
|
||||
context = self.context(headers)
|
||||
return HTTPStatus.OK, {
|
||||
"executions": _json_value(
|
||||
self.tracker.list(tenant_id=context.tenant_id)
|
||||
)
|
||||
}
|
||||
|
||||
dispatch_match = __import__("re").fullmatch(
|
||||
r"/api/v2/tasks/([^/]+)/dispatch",
|
||||
path,
|
||||
)
|
||||
if method == "POST" and dispatch_match:
|
||||
context = self.context(headers)
|
||||
task_id = urllib.parse.unquote(dispatch_match.group(1))
|
||||
payload = body if isinstance(body, Mapping) else {}
|
||||
timeout = payload.get("timeout_seconds")
|
||||
record = self.orchestrator.dispatch(
|
||||
context,
|
||||
task_id=task_id,
|
||||
timeout_seconds=float(timeout) if timeout is not None else None,
|
||||
)
|
||||
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
||||
|
||||
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||
context = self.context(headers)
|
||||
if not isinstance(body, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
result = HermesResult(
|
||||
job_id=str(body["job_id"]),
|
||||
tenant_id=str(body["tenant_id"]),
|
||||
task_id=str(body["task_id"]),
|
||||
trace_id=str(body["trace_id"]),
|
||||
state=HermesJobState(str(body["state"])),
|
||||
worker_id=(
|
||||
str(body["worker_id"])
|
||||
if body.get("worker_id") is not None
|
||||
else None
|
||||
),
|
||||
output=body.get("output"),
|
||||
error=body.get("error"),
|
||||
started_at=body.get("started_at"),
|
||||
finished_at=body.get("finished_at"),
|
||||
)
|
||||
task = self.orchestrator.reconcile_hermes(context, result)
|
||||
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||
|
||||
return self.gateway.handle(method, raw_path, headers, body)
|
||||
|
||||
|
||||
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "AIOSV2Candidate/1.0"
|
||||
max_body_bytes = 1_048_576
|
||||
|
||||
@property
|
||||
def application(self) -> CandidateApplication:
|
||||
return self.server.application # type: ignore[attr-defined]
|
||||
|
||||
def _read_body(self) -> Any:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length < 0 or length > self.max_body_bytes:
|
||||
raise ValueError("request body is too large")
|
||||
if length == 0:
|
||||
return {}
|
||||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||
if content_type != "application/json":
|
||||
raise ValueError("Content-Type must be application/json")
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
|
||||
def _security_headers(self) -> None:
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
|
||||
)
|
||||
|
||||
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(
|
||||
_json_value(payload),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
self.send_response(int(status_code))
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self._security_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def _send_console(self) -> None:
|
||||
encoded = CONSOLE_HTML.encode("utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self._security_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def _dispatch(self, method: str) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if method == "GET" and path in {"/", "/console"}:
|
||||
self._send_console()
|
||||
return
|
||||
try:
|
||||
body = self._read_body() if method == "POST" else {}
|
||||
status_code, payload = self.application.handle_json(
|
||||
method,
|
||||
self.path,
|
||||
{key: value for key, value in self.headers.items()},
|
||||
body,
|
||||
)
|
||||
except BaseException as exc:
|
||||
status_code, payload = error_response(exc)
|
||||
self._send_json(status_code, payload)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
self._dispatch("GET")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
self._dispatch("POST")
|
||||
|
||||
|
||||
class CandidateHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
|
||||
self.application = application
|
||||
super().__init__(address, CandidateRequestHandler)
|
||||
|
||||
|
||||
def build_application() -> CandidateApplication:
|
||||
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
|
||||
service = DurableControlPlaneService(repository)
|
||||
tracker = ExecutionTracker(state_dir / "executions")
|
||||
hermes = HermesAdapter()
|
||||
|
||||
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
||||
material_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||
openclaw_key_id = os.getenv("AIOS_OPENCLAW_SIGNING_KEY_ID", "default").strip() or "default"
|
||||
openclaw = None
|
||||
resolver = None
|
||||
if openclaw_url and material_file:
|
||||
resolver = FileSigningMaterialResolver(material_file)
|
||||
transport = HTTPRuntimeTransport(
|
||||
openclaw_url,
|
||||
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
||||
)
|
||||
openclaw = OpenClawAdapter(
|
||||
transport,
|
||||
resolver,
|
||||
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
|
||||
)
|
||||
|
||||
gateway = GatewayApplication(service)
|
||||
orchestrator = ExecutionOrchestrator(
|
||||
service,
|
||||
openclaw=openclaw,
|
||||
hermes=hermes,
|
||||
openclaw_signing_material_resolver=resolver,
|
||||
openclaw_key_id=openclaw_key_id,
|
||||
)
|
||||
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,195 @@
|
||||
"""Git-backed agent execution evidence for AIOS V2.
|
||||
|
||||
Evidence is tenant-scoped, append-friendly, and contains no runtime credentials.
|
||||
It provides a deterministic source for claim, heartbeat, progress, result and
|
||||
verification state across Codex, OpenClaw and Hermes executors.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionEvidence:
|
||||
execution_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
executor: str
|
||||
branch: str
|
||||
status: str = "claimed"
|
||||
progress_completed: int = 0
|
||||
progress_total: int = 1
|
||||
current_step: str = "claim"
|
||||
last_commit: str | None = None
|
||||
test_status: str = "not_run"
|
||||
test_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||
result_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||
claimed_at: str = field(default_factory=utc_now)
|
||||
heartbeat_at: str = field(default_factory=utc_now)
|
||||
completed_at: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"execution_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"agent_id",
|
||||
"executor",
|
||||
"branch",
|
||||
"status",
|
||||
"current_step",
|
||||
"claimed_at",
|
||||
"heartbeat_at",
|
||||
):
|
||||
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||
if self.progress_completed < 0 or self.progress_total < 1:
|
||||
raise ValueError("invalid progress values")
|
||||
if self.progress_completed > self.progress_total:
|
||||
raise ValueError("progress_completed cannot exceed progress_total")
|
||||
object.__setattr__(self, "test_summary", _safe(self.test_summary))
|
||||
object.__setattr__(self, "result_summary", _safe(self.result_summary))
|
||||
|
||||
|
||||
class ExecutionTracker:
|
||||
"""Persist one JSON evidence record per tenant and execution."""
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
self.root = Path(root).resolve()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _component(value: str) -> str:
|
||||
normalized = _required(value, "path component")
|
||||
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||
raise ValueError("invalid evidence path component")
|
||||
return normalized
|
||||
|
||||
def _path(self, tenant_id: str, execution_id: str) -> Path:
|
||||
tenant = self._component(tenant_id)
|
||||
execution = self._component(execution_id)
|
||||
return self.root / tenant / f"{execution}.json"
|
||||
|
||||
def claim(self, evidence: ExecutionEvidence) -> ExecutionEvidence:
|
||||
path = self._path(evidence.tenant_id, evidence.execution_id)
|
||||
with self._lock:
|
||||
if path.exists():
|
||||
current = self._read(path)
|
||||
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
|
||||
raise RuntimeError("execution_id already belongs to another task")
|
||||
return current
|
||||
self._write(path, evidence)
|
||||
return evidence
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
execution_id: str,
|
||||
status: str | None = None,
|
||||
current_step: str | None = None,
|
||||
progress_completed: int | None = None,
|
||||
progress_total: int | None = None,
|
||||
last_commit: str | None = None,
|
||||
) -> ExecutionEvidence:
|
||||
path = self._path(tenant_id, execution_id)
|
||||
with self._lock:
|
||||
current = self._read(path)
|
||||
updated = replace(
|
||||
current,
|
||||
status=status or current.status,
|
||||
current_step=current_step or current.current_step,
|
||||
progress_completed=(
|
||||
progress_completed
|
||||
if progress_completed is not None
|
||||
else current.progress_completed
|
||||
),
|
||||
progress_total=(
|
||||
progress_total if progress_total is not None else current.progress_total
|
||||
),
|
||||
last_commit=last_commit or current.last_commit,
|
||||
heartbeat_at=utc_now(),
|
||||
)
|
||||
self._write(path, updated)
|
||||
return updated
|
||||
|
||||
def complete(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
execution_id: str,
|
||||
status: str,
|
||||
test_status: str,
|
||||
test_summary: Mapping[str, Any] | None = None,
|
||||
result_summary: Mapping[str, Any] | None = None,
|
||||
last_commit: str | None = None,
|
||||
) -> ExecutionEvidence:
|
||||
path = self._path(tenant_id, execution_id)
|
||||
with self._lock:
|
||||
current = self._read(path)
|
||||
updated = replace(
|
||||
current,
|
||||
status=_required(status, "status"),
|
||||
current_step="complete",
|
||||
progress_completed=current.progress_total,
|
||||
last_commit=last_commit or current.last_commit,
|
||||
test_status=_required(test_status, "test_status"),
|
||||
test_summary=_safe(test_summary),
|
||||
result_summary=_safe(result_summary),
|
||||
heartbeat_at=utc_now(),
|
||||
completed_at=utc_now(),
|
||||
)
|
||||
self._write(path, updated)
|
||||
return updated
|
||||
|
||||
def get(self, *, tenant_id: str, execution_id: str) -> ExecutionEvidence:
|
||||
return self._read(self._path(tenant_id, execution_id))
|
||||
|
||||
def list(self, *, tenant_id: str) -> list[ExecutionEvidence]:
|
||||
tenant_dir = self.root / self._component(tenant_id)
|
||||
if not tenant_dir.exists():
|
||||
return []
|
||||
records = [self._read(path) for path in tenant_dir.glob("*.json")]
|
||||
return sorted(records, key=lambda item: (item.heartbeat_at, item.execution_id), reverse=True)
|
||||
|
||||
@staticmethod
|
||||
def _read(path: Path) -> ExecutionEvidence:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise KeyError("execution evidence not found") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeError("invalid execution evidence file")
|
||||
return ExecutionEvidence(**dict(value))
|
||||
|
||||
@staticmethod
|
||||
def _write(path: Path, evidence: ExecutionEvidence) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(f".tmp-{os.getpid()}")
|
||||
temporary.write_text(
|
||||
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
@@ -0,0 +1,357 @@
|
||||
"""HTTP/JSON gateway for the AIOS V2 control plane.
|
||||
|
||||
The gateway is intentionally runtime-neutral: browsers and external clients
|
||||
call this API, while OpenClaw and Hermes remain behind authenticated adapters.
|
||||
Identity headers are accepted only from a trusted reverse proxy in v1; direct
|
||||
public exposure is prohibited until signed identity middleware is enabled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .control_plane import (
|
||||
ControlPlaneError,
|
||||
ControlPlaneService,
|
||||
IdempotencyConflict,
|
||||
InvalidTransition,
|
||||
NotFound,
|
||||
PolicyDenied,
|
||||
RequestContext,
|
||||
RevisionConflict,
|
||||
TaskState,
|
||||
TenantIsolationError,
|
||||
Unauthorized,
|
||||
)
|
||||
|
||||
|
||||
TENANT_HEADER = "X-AIOS-Tenant-ID"
|
||||
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
|
||||
ROLES_HEADER = "X-AIOS-Roles"
|
||||
TRACE_HEADER = "X-AIOS-Trace-ID"
|
||||
SESSION_HEADER = "X-AIOS-Session-ID"
|
||||
POLICY_HEADER = "X-AIOS-Policy-Version"
|
||||
AUTHENTICATED_HEADER = "X-AIOS-Authenticated"
|
||||
|
||||
_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
|
||||
|
||||
|
||||
def _json_value(value: Any) -> Any:
|
||||
if dataclasses.is_dataclass(value):
|
||||
return {
|
||||
field.name: _json_value(getattr(value, field.name))
|
||||
for field in dataclasses.fields(value)
|
||||
}
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _require_mapping(value: Any, *, label: str = "JSON body") -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _require_resource_id(value: Any, *, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not _RESOURCE_ID_RE.fullmatch(normalized):
|
||||
raise ValueError(f"invalid {label}")
|
||||
return normalized
|
||||
|
||||
|
||||
class GatewayApplication:
|
||||
"""Pure request router that can be tested without a listening socket."""
|
||||
|
||||
def __init__(self, service: ControlPlaneService | None = None) -> None:
|
||||
self.service = service or ControlPlaneService()
|
||||
|
||||
@staticmethod
|
||||
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
|
||||
# HTTP field names are case-insensitive. BaseHTTPRequestHandler commonly
|
||||
# presents them as title-cased (for example ``X-Aios-Authenticated``),
|
||||
# so copying the message into a plain dict must not make identity lookup
|
||||
# case-sensitive.
|
||||
normalized_headers = {
|
||||
str(key).strip().lower(): value for key, value in headers.items()
|
||||
}
|
||||
|
||||
def header(name: str, default: str = "") -> str:
|
||||
return str(normalized_headers.get(name.lower(), default))
|
||||
|
||||
authenticated = header(AUTHENTICATED_HEADER).strip().lower()
|
||||
if authenticated not in {"1", "true", "yes"}:
|
||||
raise Unauthorized("trusted authentication assertion is required")
|
||||
roles = [
|
||||
role.strip()
|
||||
for role in header(ROLES_HEADER).split(",")
|
||||
if role.strip()
|
||||
]
|
||||
return RequestContext.create(
|
||||
tenant_id=header(TENANT_HEADER),
|
||||
principal_id=header(PRINCIPAL_HEADER),
|
||||
trace_id=header(TRACE_HEADER).strip() or None,
|
||||
session_id=header(SESSION_HEADER).strip() or None,
|
||||
policy_version=header(POLICY_HEADER, "v1").strip() or "v1",
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
def handle(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: Any = None,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
|
||||
method = method.upper()
|
||||
|
||||
if method == "GET" and path in {"/healthz", "/api/v2/health"}:
|
||||
return HTTPStatus.OK, {
|
||||
"service": "aios-v2-control-plane",
|
||||
"status": "ok",
|
||||
"store": type(self.service.store).__name__,
|
||||
}
|
||||
|
||||
context = self.context_from_headers(headers)
|
||||
payload = _require_mapping(body or {})
|
||||
|
||||
if method == "POST" and path == "/api/v2/tenants":
|
||||
tenant = self.service.register_tenant(
|
||||
context,
|
||||
tenant_id=_require_resource_id(payload.get("tenant_id"), label="tenant_id"),
|
||||
display_name=str(payload.get("display_name", "")),
|
||||
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
||||
)
|
||||
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/tenants/current":
|
||||
tenant = self.service.get_tenant(context)
|
||||
return HTTPStatus.OK, {"tenant": _json_value(tenant)}
|
||||
|
||||
if method == "POST" and path == "/api/v2/agents":
|
||||
agent = self.service.register_agent(
|
||||
context,
|
||||
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
||||
display_name=str(payload.get("display_name", "")),
|
||||
runtime=str(payload.get("runtime", "")),
|
||||
capabilities=payload.get("capabilities", ()),
|
||||
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
||||
)
|
||||
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/agents":
|
||||
return HTTPStatus.OK, {
|
||||
"agents": _json_value(self.service.list_agents(context))
|
||||
}
|
||||
|
||||
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
|
||||
if method == "GET" and agent_match:
|
||||
agent_id = _require_resource_id(
|
||||
urllib.parse.unquote(agent_match.group(1)), label="agent_id"
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"agent": _json_value(self.service.get_agent(context, agent_id))
|
||||
}
|
||||
|
||||
if method == "POST" and path == "/api/v2/tasks":
|
||||
task = self.service.create_task(
|
||||
context,
|
||||
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
||||
action=str(payload.get("action", "")).strip(),
|
||||
payload=_require_mapping(payload.get("payload", {}), label="payload"),
|
||||
idempotency_key=(
|
||||
str(payload["idempotency_key"]).strip()
|
||||
if payload.get("idempotency_key") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return HTTPStatus.CREATED, {"task": _json_value(task)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/tasks":
|
||||
requested_states = query.get("state")
|
||||
tasks = self.service.list_tasks(context, states=requested_states)
|
||||
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
|
||||
|
||||
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
|
||||
if method == "GET" and task_match:
|
||||
task_id = _require_resource_id(
|
||||
urllib.parse.unquote(task_match.group(1)), label="task_id"
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"task": _json_value(self.service.get_task(context, task_id))
|
||||
}
|
||||
|
||||
transition_match = re.fullmatch(
|
||||
r"/api/v2/tasks/([^/]+)/(approve|start|complete|fail|cancel)", path
|
||||
)
|
||||
if method == "POST" and transition_match:
|
||||
task_id = _require_resource_id(
|
||||
urllib.parse.unquote(transition_match.group(1)), label="task_id"
|
||||
)
|
||||
transition = transition_match.group(2)
|
||||
target = {
|
||||
"approve": TaskState.APPROVED,
|
||||
"start": TaskState.RUNNING,
|
||||
"complete": TaskState.COMPLETED,
|
||||
"fail": TaskState.FAILED,
|
||||
"cancel": TaskState.CANCELLED,
|
||||
}[transition]
|
||||
expected_revision = payload.get("expected_revision")
|
||||
if expected_revision is not None:
|
||||
expected_revision = int(expected_revision)
|
||||
task = self.service.transition_task(
|
||||
context,
|
||||
task_id=task_id,
|
||||
target_state=target,
|
||||
expected_revision=expected_revision,
|
||||
result=(
|
||||
_require_mapping(payload.get("result"), label="result")
|
||||
if payload.get("result") is not None
|
||||
else None
|
||||
),
|
||||
error=(
|
||||
_require_mapping(payload.get("error"), label="error")
|
||||
if payload.get("error") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/audit":
|
||||
resource_id = query.get("resource_id", [None])[0]
|
||||
events = self.service.list_audit_events(
|
||||
context,
|
||||
resource_id=(
|
||||
_require_resource_id(resource_id, label="resource_id")
|
||||
if resource_id
|
||||
else None
|
||||
),
|
||||
)
|
||||
return HTTPStatus.OK, {"events": _json_value(events)}
|
||||
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "route not found",
|
||||
}
|
||||
|
||||
|
||||
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
|
||||
(Unauthorized, HTTPStatus.UNAUTHORIZED),
|
||||
(PolicyDenied, HTTPStatus.FORBIDDEN),
|
||||
(TenantIsolationError, HTTPStatus.NOT_FOUND),
|
||||
(NotFound, HTTPStatus.NOT_FOUND),
|
||||
(IdempotencyConflict, HTTPStatus.CONFLICT),
|
||||
(RevisionConflict, HTTPStatus.CONFLICT),
|
||||
(InvalidTransition, HTTPStatus.CONFLICT),
|
||||
(ValueError, HTTPStatus.BAD_REQUEST),
|
||||
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
|
||||
)
|
||||
|
||||
|
||||
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
|
||||
for error_type, status in _ERROR_STATUS:
|
||||
if isinstance(exc, error_type):
|
||||
return status, {
|
||||
"error": error_type.__name__,
|
||||
"message": str(exc),
|
||||
}
|
||||
return HTTPStatus.INTERNAL_SERVER_ERROR, {
|
||||
"error": "InternalServerError",
|
||||
"message": "internal server error",
|
||||
}
|
||||
|
||||
|
||||
class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "AIOSV2Gateway/1.0"
|
||||
max_body_bytes = 1_048_576
|
||||
|
||||
@property
|
||||
def application(self) -> GatewayApplication:
|
||||
return self.server.application # type: ignore[attr-defined]
|
||||
|
||||
def _read_body(self) -> Any:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length < 0 or length > self.max_body_bytes:
|
||||
raise ValueError("request body is too large")
|
||||
if length == 0:
|
||||
return {}
|
||||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||
if content_type != "application/json":
|
||||
raise ValueError("Content-Type must be application/json")
|
||||
raw = self.rfile.read(length)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(
|
||||
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
self.send_response(int(status))
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def _dispatch(self, method: str) -> None:
|
||||
try:
|
||||
body = self._read_body() if method in {"POST", "PUT", "PATCH"} else {}
|
||||
status, payload = self.application.handle(
|
||||
method,
|
||||
self.path,
|
||||
{key: value for key, value in self.headers.items()},
|
||||
body,
|
||||
)
|
||||
except BaseException as exc: # handler boundary; sanitized below
|
||||
status, payload = error_response(exc)
|
||||
self._send_json(status, payload)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
self._dispatch("GET")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
self._dispatch("POST")
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
# Production deployment routes structured access logs through the
|
||||
# reverse proxy; do not log identity headers or request bodies here.
|
||||
super().log_message(format, *args)
|
||||
|
||||
|
||||
class GatewayHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_address: tuple[str, int],
|
||||
application: GatewayApplication,
|
||||
) -> None:
|
||||
self.application = application
|
||||
super().__init__(server_address, GatewayRequestHandler)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
|
||||
server = GatewayHTTPServer((host, port), GatewayApplication())
|
||||
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,275 @@
|
||||
"""AIOS V2 execution orchestrator.
|
||||
|
||||
The orchestrator is the only component allowed to translate an approved AIOS
|
||||
task into an OpenClaw command or Hermes job. OpenClaw and Hermes remain pure
|
||||
execution runtimes and never own tenant policy or task lifecycle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_hermes import HermesAdapter, HermesJob, HermesResult
|
||||
from aios_openclaw import (
|
||||
CommandState,
|
||||
OpenClawAdapter,
|
||||
OpenClawCommand,
|
||||
OpenClawResult,
|
||||
sign_command,
|
||||
)
|
||||
|
||||
from .control_plane import (
|
||||
AgentRecord,
|
||||
ControlPlaneService,
|
||||
RequestContext,
|
||||
TaskRecord,
|
||||
TaskState,
|
||||
)
|
||||
|
||||
|
||||
class OrchestrationError(RuntimeError):
|
||||
"""AIOS failed to dispatch or reconcile a runtime execution."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispatchRecord:
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
runtime: str
|
||||
runtime_execution_id: str
|
||||
state: str
|
||||
trace_id: str
|
||||
|
||||
|
||||
class ExecutionOrchestrator:
|
||||
"""Dispatch approved AIOS tasks to runtime adapters and reconcile results."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
control_plane: ControlPlaneService,
|
||||
*,
|
||||
openclaw: OpenClawAdapter | None = None,
|
||||
hermes: HermesAdapter | None = None,
|
||||
openclaw_signing_material_resolver=None,
|
||||
openclaw_key_id: str = "default",
|
||||
default_cell_id: str = "default",
|
||||
default_profile_id: str = "default",
|
||||
) -> None:
|
||||
self.control_plane = control_plane
|
||||
self.openclaw = openclaw
|
||||
self.hermes = hermes
|
||||
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
|
||||
self.openclaw_key_id = openclaw_key_id
|
||||
self.default_cell_id = default_cell_id
|
||||
self.default_profile_id = default_profile_id
|
||||
self._dispatches: dict[tuple[str, str], DispatchRecord] = {}
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
task_id: str,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> DispatchRecord:
|
||||
task = self.control_plane.get_task(context, task_id)
|
||||
if task.state != TaskState.APPROVED:
|
||||
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||
agent = self.control_plane.get_agent(context, task.agent_id)
|
||||
existing = self._dispatches.get((task.tenant_id, task.task_id))
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
running = self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.RUNNING,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
|
||||
runtime = agent.runtime.strip().lower()
|
||||
try:
|
||||
if runtime == "openclaw":
|
||||
record = self._dispatch_openclaw(
|
||||
context,
|
||||
agent,
|
||||
running,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
elif runtime == "hermes":
|
||||
record = self._dispatch_hermes(context, agent, running)
|
||||
else:
|
||||
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
|
||||
except Exception as exc:
|
||||
current = self.control_plane.get_task(context, task.task_id)
|
||||
if current.state == TaskState.RUNNING:
|
||||
self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.FAILED,
|
||||
expected_revision=current.revision,
|
||||
error={"code": "dispatch_failed", "message": str(exc)},
|
||||
)
|
||||
raise
|
||||
|
||||
self._dispatches[(task.tenant_id, task.task_id)] = record
|
||||
return record
|
||||
|
||||
def _dispatch_openclaw(
|
||||
self,
|
||||
context: RequestContext,
|
||||
agent: AgentRecord,
|
||||
task: TaskRecord,
|
||||
*,
|
||||
timeout_seconds: float | None,
|
||||
) -> DispatchRecord:
|
||||
if self.openclaw is None or self.openclaw_signing_material_resolver is None:
|
||||
raise OrchestrationError("OpenClaw runtime is not configured")
|
||||
key_material = self.openclaw_signing_material_resolver(
|
||||
task.tenant_id,
|
||||
self.openclaw_key_id,
|
||||
)
|
||||
requested_timeout = (
|
||||
self.openclaw.default_timeout_seconds
|
||||
if timeout_seconds is None
|
||||
else float(timeout_seconds)
|
||||
)
|
||||
effective_timeout = min(requested_timeout, 300.0)
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=effective_timeout)
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
command = OpenClawCommand.create(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
trace_id=context.trace_id,
|
||||
action=task.action,
|
||||
payload=task.payload,
|
||||
idempotency_key=task.idempotency_key or task.task_id,
|
||||
expires_at=expires_at,
|
||||
key_id=self.openclaw_key_id,
|
||||
)
|
||||
command = sign_command(command, key_material)
|
||||
result = self.openclaw.execute(command, timeout_seconds=effective_timeout)
|
||||
self.reconcile_openclaw(context, result)
|
||||
return DispatchRecord(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
runtime="openclaw",
|
||||
runtime_execution_id=command.command_id,
|
||||
state=result.state.value,
|
||||
trace_id=context.trace_id,
|
||||
)
|
||||
|
||||
def _dispatch_hermes(
|
||||
self,
|
||||
context: RequestContext,
|
||||
agent: AgentRecord,
|
||||
task: TaskRecord,
|
||||
) -> DispatchRecord:
|
||||
if self.hermes is None:
|
||||
raise OrchestrationError("Hermes runtime is not configured")
|
||||
metadata = dict(agent.metadata)
|
||||
job = HermesJob.create(
|
||||
tenant_id=task.tenant_id,
|
||||
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
|
||||
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
|
||||
task_id=task.task_id,
|
||||
trace_id=context.trace_id,
|
||||
action=task.action,
|
||||
payload=task.payload,
|
||||
idempotency_key=task.idempotency_key or task.task_id,
|
||||
priority=int(metadata.get("priority", 100)),
|
||||
)
|
||||
queued = self.hermes.enqueue(job)
|
||||
return DispatchRecord(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
runtime="hermes",
|
||||
runtime_execution_id=queued.job_id,
|
||||
state=queued.state.value,
|
||||
trace_id=context.trace_id,
|
||||
)
|
||||
|
||||
def reconcile_openclaw(
|
||||
self,
|
||||
context: RequestContext,
|
||||
result: OpenClawResult,
|
||||
) -> TaskRecord:
|
||||
task = self.control_plane.get_task(context, result.task_id)
|
||||
if result.tenant_id != task.tenant_id:
|
||||
raise OrchestrationError("OpenClaw result tenant mismatch")
|
||||
if task.state != TaskState.RUNNING:
|
||||
return task
|
||||
if result.state == CommandState.COMPLETED:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.COMPLETED,
|
||||
expected_revision=task.revision,
|
||||
result=result.output or {},
|
||||
)
|
||||
if result.state in {
|
||||
CommandState.FAILED,
|
||||
CommandState.TIMED_OUT,
|
||||
CommandState.REJECTED,
|
||||
}:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.FAILED,
|
||||
expected_revision=task.revision,
|
||||
error=result.error or {"code": result.state.value.lower()},
|
||||
)
|
||||
if result.state == CommandState.CANCELLED:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.CANCELLED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
return task
|
||||
|
||||
def reconcile_hermes(
|
||||
self,
|
||||
context: RequestContext,
|
||||
result: HermesResult,
|
||||
) -> TaskRecord:
|
||||
task = self.control_plane.get_task(context, result.task_id)
|
||||
if result.tenant_id != task.tenant_id:
|
||||
raise OrchestrationError("Hermes result tenant mismatch")
|
||||
if task.state != TaskState.RUNNING:
|
||||
return task
|
||||
if result.state.value == "COMPLETED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.COMPLETED,
|
||||
expected_revision=task.revision,
|
||||
result=result.output or {},
|
||||
)
|
||||
if result.state.value == "FAILED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.FAILED,
|
||||
expected_revision=task.revision,
|
||||
error=result.error or {"code": "hermes_failed"},
|
||||
)
|
||||
if result.state.value == "CANCELLED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.CANCELLED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
return task
|
||||
|
||||
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
|
||||
return {
|
||||
task_id: record
|
||||
for (record_tenant, task_id), record in self._dispatches.items()
|
||||
if record_tenant == tenant_id
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
# Git is the single source of truth for all agent commands.
|
||||
version: 1.0.0
|
||||
created_at: "2026-07-08T15:20:00+08:00"
|
||||
created_at: 2026-07-08T15:20:00+08:00
|
||||
baseline: V1R3C1
|
||||
|
||||
commands:
|
||||
@@ -11,22 +12,6 @@ commands:
|
||||
completed_path: "agents/commands/completed/"
|
||||
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:
|
||||
path: "agents/policies/"
|
||||
source: "git"
|
||||
@@ -53,4 +38,30 @@ audit:
|
||||
agent_runs: "audit/agent-runs/"
|
||||
version_history: "audit/version-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,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/opt/aios/app \
|
||||
AIOS_V2_HOST=0.0.0.0 \
|
||||
AIOS_V2_PORT=18920 \
|
||||
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||
|
||||
RUN groupadd --system --gid 12001 aios \
|
||||
&& useradd --system --uid 12001 --gid aios --home-dir /nonexistent --shell /usr/sbin/nologin aios \
|
||||
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2
|
||||
|
||||
WORKDIR /opt/aios/app
|
||||
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
|
||||
|
||||
USER aios:aios
|
||||
EXPOSE 18920
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
|
||||
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
|
||||
|
||||
CMD ["python3", "-m", "aios_v2.app"]
|
||||
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
aios-v2:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: deploy/aios-v2/docker/Dockerfile
|
||||
image: aios-v2:V2R1C0-candidate
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${AIOS_V2_PORT:-18920}:18920"
|
||||
environment:
|
||||
AIOS_V2_HOST: 0.0.0.0
|
||||
AIOS_V2_PORT: 18920
|
||||
AIOS_V2_STATE_DIR: /var/lib/aios-v2
|
||||
AIOS_OPENCLAW_URL: ${AIOS_OPENCLAW_URL:-}
|
||||
AIOS_OPENCLAW_CREDENTIAL_REF: ${AIOS_OPENCLAW_CREDENTIAL_REF:-}
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE: ${AIOS_OPENCLAW_SIGNING_MATERIAL_FILE:-}
|
||||
AIOS_RELEASE_CHANNEL: candidate
|
||||
AIOS_RELEASE_VERSION: V2R1C0-candidate
|
||||
volumes:
|
||||
- aios-v2-state:/var/lib/aios-v2
|
||||
# Mount runtime signing material explicitly when OpenClaw is enabled.
|
||||
# The host file must be mode 0600; never store it in this repository.
|
||||
# - /etc/aios-v2/openclaw-signing-material:/run/secrets/openclaw-signing-material:ro
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python3
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 4
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
aios-v2-state:
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# AIOS V2 candidate runtime configuration.
|
||||
# Copy to /etc/aios-v2/aios-v2.env and keep ownership root:aios, mode 0640.
|
||||
|
||||
AIOS_V2_HOST=127.0.0.1
|
||||
AIOS_V2_PORT=18920
|
||||
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||
|
||||
# Optional OpenClaw runtime. Leave blank to run with OpenClaw disabled.
|
||||
AIOS_OPENCLAW_URL=
|
||||
AIOS_OPENCLAW_CREDENTIAL_REF=
|
||||
|
||||
# File path only. The referenced file must be mode 0600 or stricter and contain
|
||||
# at least 32 bytes. Never place signing material in this env file or Git.
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=
|
||||
|
||||
# Reverse proxy must authenticate users and inject X-AIOS-* identity headers.
|
||||
AIOS_TRUST_PROXY_HEADERS=true
|
||||
|
||||
# Candidate deployment metadata exposed through health tooling.
|
||||
AIOS_RELEASE_CHANNEL=candidate
|
||||
AIOS_RELEASE_VERSION=V2R1C0-candidate
|
||||
Executable
+86
@@ -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"
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/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}"
|
||||
HEALTH_RETRIES="${AIOS_V2_ROLLBACK_HEALTH_RETRIES:-30}"
|
||||
HEALTH_RETRY_DELAY="${AIOS_V2_ROLLBACK_HEALTH_RETRY_DELAY:-1}"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_healthcheck() {
|
||||
local release="$1"
|
||||
local attempt
|
||||
for ((attempt = 1; attempt <= HEALTH_RETRIES; attempt++)); do
|
||||
if AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$release/deploy/aios-v2/healthcheck/check.sh"; then
|
||||
return 0
|
||||
fi
|
||||
[[ "$attempt" -lt "$HEALTH_RETRIES" ]] && sleep "$HEALTH_RETRY_DELAY"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
|
||||
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
|
||||
|
||||
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 run_healthcheck "$TARGET_PATH"; 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
|
||||
run_healthcheck "$PREVIOUS_PATH" || 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,92 +0,0 @@
|
||||
# AIOS managed isolated Python runner
|
||||
|
||||
This package provisions the AIOS "Spark runner": a dedicated, systemd-managed
|
||||
Python worker for isolated Git worktree execution. It is not Apache Spark.
|
||||
|
||||
## Trust boundary
|
||||
|
||||
The repository fixes the source to
|
||||
`gitea://aios/aios.git#tools/aios_spark_runner`. The artifact builder accepts
|
||||
only a full lowercase commit OID that resolves to an actual Git commit and
|
||||
emits a deterministic tarball, an exact `sha256:<digest>` binding, a signed
|
||||
provenance manifest, and a non-secret configuration. The manifest contains
|
||||
`source_commit`, `artifact_digest`, `sha256`, `signature`, and `created_at`.
|
||||
The signature is Ed25519 and must verify against an independently provisioned,
|
||||
root-owned trusted public key; neither config nor manifest text is a trust root. The
|
||||
installer reads only local files, rejects mutable URLs, verifies the actual
|
||||
artifact SHA-256 before any install-root mutation, and rejects symlinked or
|
||||
group/world-writable inputs.
|
||||
|
||||
`runner_secret_ref` is a reference only. It must match
|
||||
`secret://runtime/aios-spark-runner/gateway/<version>` and be resolvable by
|
||||
the existing gateway secret broker. Production installation also requires an
|
||||
approved broker availability adapter; none is invented or shipped here.
|
||||
Secret values are never accepted in configuration, arguments, artifacts,
|
||||
logs, receipts, or Git. If the reference or broker adapter is absent or
|
||||
invalid, provisioning fails before installation with:
|
||||
|
||||
`BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE`
|
||||
|
||||
There is deliberately no runnable secret default, network-piped installer,
|
||||
interactive shell, terminal multiplexer, background shell, or unmanaged daemon fallback.
|
||||
|
||||
## Build
|
||||
|
||||
From a clean checkout of the approved implementation commit:
|
||||
|
||||
```bash
|
||||
deploy/spark-runner/build-artifact.sh \
|
||||
--commit <full-implementation-commit> \
|
||||
--signing-key /owner/offline/runner-artifact-ed25519.pem \
|
||||
--source-repo /approved/aios/worktree \
|
||||
--output /tmp/aios-spark-runner-dist
|
||||
```
|
||||
|
||||
Transfer is unnecessary when the output is built on the AIOS host. The
|
||||
provision command verifies the manifest binding and actual digest; mismatch is
|
||||
fatal.
|
||||
|
||||
## Provision and rollback
|
||||
|
||||
Owner/admin first fills only `runner_secret_ref` in the emitted configuration
|
||||
using an approved, scope-matching reference. Production provisioning requires
|
||||
UID 0 and root `/`; tests are restricted to explicit roots below `/tmp`.
|
||||
|
||||
```bash
|
||||
deploy/spark-runner/provision.sh \
|
||||
--artifact /approved/path/aios-spark-runner-<commit>.tar.gz \
|
||||
--manifest /approved/path/aios-spark-runner-<commit>.manifest.json \
|
||||
--trusted-public-key /etc/aios-spark-runner/trusted-artifact-key.pem \
|
||||
--source-repo /data/aios-gitops/repo.git \
|
||||
--config /approved/path/aios-spark-runner-<commit>.json
|
||||
```
|
||||
|
||||
The transaction installs a real directory at
|
||||
`/opt/aios-spark-runner/current`, creates the dedicated account with
|
||||
systemd-sysusers, creates private state/runtime directories with
|
||||
systemd-tmpfiles, writes a rollback receipt, and enables the sandboxed service.
|
||||
Rollback uses only that authoritative receipt. The receipt records
|
||||
`install_transaction_id`, `previous_artifact`, `rollback_target`, and
|
||||
`rollback_verified`; failed installs restore automatically and explicit rollback is
|
||||
idempotent:
|
||||
|
||||
```bash
|
||||
deploy/spark-runner/rollback.sh \
|
||||
--receipt /var/lib/aios-spark-runner/install-receipt.json
|
||||
```
|
||||
|
||||
## Acceptance
|
||||
|
||||
`acceptance.sh` verifies the heartbeat schema, source commit, artifact digest,
|
||||
live PID, and freshness, then creates a unique temporary branch/worktree from
|
||||
the requested immutable commit. It checks a clean exact OID and removes both
|
||||
worktree and branch without touching main or controller.
|
||||
|
||||
The heartbeat is atomically replaced at exactly
|
||||
`/run/aios-spark-runner/heartbeat.json`, mode 0600, with these fields only:
|
||||
`schema_version`, `service`, `pid`, `started_at`, `heartbeat_at`,
|
||||
`artifact_digest`, `source_commit`, and `status`.
|
||||
|
||||
Until an owner-approved, gateway-scope-matching `secret://` reference exists,
|
||||
the correct operational state remains
|
||||
`RUNNER_RECOVERED=NO` and `RUNNER_INSTALL=NOT_STARTED`.
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
exec python3 "$ROOT/tools/aios_spark_runner/runnerctl.py" acceptance "$@"
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://aios.internal/schemas/aios-spark-runner-artifact-v2.json",
|
||||
"title": "AIOS Spark runner verified artifact provenance",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"source_commit",
|
||||
"artifact_digest",
|
||||
"sha256",
|
||||
"signature",
|
||||
"created_at"
|
||||
],
|
||||
"properties": {
|
||||
"source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
|
||||
"artifact_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
|
||||
"sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
||||
"signature": {"type": "string", "pattern": "^ed25519:[A-Za-z0-9+/]+={0,2}$"},
|
||||
"created_at": {"type": "string", "format": "date-time"}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
exec python3 "$ROOT/tools/aios_spark_runner/runnerctl.py" build-artifact "$@"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
exec python3 "$ROOT/tools/aios_spark_runner/runnerctl.py" provision "$@"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
exec python3 "$ROOT/tools/aios_spark_runner/runnerctl.py" rollback "$@"
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://aios.internal/schemas/aios-spark-runner-v1.json",
|
||||
"title": "AIOS managed isolated Python runner configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"runner_artifact_uri_or_repo_path",
|
||||
"runner_artifact_version_or_digest",
|
||||
"runner_artifact_sha256_or_signature",
|
||||
"runner_secret_ref",
|
||||
"runner_run_user",
|
||||
"runner_run_group",
|
||||
"runner_workdir",
|
||||
"runner_heartbeat_path_or_endpoint",
|
||||
"service_name_or_container_name"
|
||||
],
|
||||
"properties": {
|
||||
"runner_artifact_uri_or_repo_path": {
|
||||
"const": "gitea://aios/aios.git#tools/aios_spark_runner"
|
||||
},
|
||||
"runner_artifact_version_or_digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{40}$"
|
||||
},
|
||||
"runner_artifact_sha256_or_signature": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"runner_secret_ref": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^secret://runtime/aios-spark-runner/gateway/[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"runner_run_user": {
|
||||
"const": "aios-spark-runner"
|
||||
},
|
||||
"runner_run_group": {
|
||||
"const": "aios-spark-runner"
|
||||
},
|
||||
"runner_workdir": {
|
||||
"const": "/var/lib/aios-spark-runner"
|
||||
},
|
||||
"runner_heartbeat_path_or_endpoint": {
|
||||
"const": "/run/aios-spark-runner/heartbeat.json"
|
||||
},
|
||||
"service_name_or_container_name": {
|
||||
"const": "aios-spark-runner.service"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"runner_artifact_uri_or_repo_path": "gitea://aios/aios.git#tools/aios_spark_runner",
|
||||
"runner_artifact_version_or_digest": null,
|
||||
"runner_artifact_sha256_or_signature": null,
|
||||
"runner_secret_ref": null,
|
||||
"runner_run_user": "aios-spark-runner",
|
||||
"runner_run_group": "aios-spark-runner",
|
||||
"runner_workdir": "/var/lib/aios-spark-runner",
|
||||
"runner_heartbeat_path_or_endpoint": "/run/aios-spark-runner/heartbeat.json",
|
||||
"service_name_or_container_name": "aios-spark-runner.service"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 0077
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
exec python3 "$ROOT/tools/aios_spark_runner/runnerctl.py" uninstall "$@"
|
||||
@@ -1,63 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS managed isolated Python runner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionFileNotEmpty=/etc/aios-spark-runner/runner.json
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-spark-runner
|
||||
Group=aios-spark-runner
|
||||
WorkingDirectory=/var/lib/aios-spark-runner
|
||||
UMask=0077
|
||||
ExecStart=/opt/aios-spark-runner/current/bin/aios-spark-runner run --config /etc/aios-spark-runner/runner.json
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=30s
|
||||
TimeoutStopSec=30s
|
||||
KillSignal=SIGTERM
|
||||
SyslogIdentifier=aios-spark-runner
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
NoNewPrivileges=true
|
||||
MemoryDenyWriteExecute=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
ProtectHostname=true
|
||||
ProtectProc=invisible
|
||||
ProcSubset=pid
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
RemoveIPC=true
|
||||
RuntimeDirectory=aios-spark-runner
|
||||
RuntimeDirectoryMode=0700
|
||||
StateDirectory=aios-spark-runner
|
||||
StateDirectoryMode=0700
|
||||
ReadWritePaths=/var/lib/aios-spark-runner /run/aios-spark-runner
|
||||
InaccessiblePaths=/data/aios-gitops/worktrees/controller
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
InaccessiblePaths=-/data/aios-gitops/worktrees/production
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
MemoryMax=512M
|
||||
TasksMax=128
|
||||
LimitNOFILE=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,2 +0,0 @@
|
||||
g aios-spark-runner -
|
||||
u aios-spark-runner - "AIOS isolated runner" /var/lib/aios-spark-runner /usr/sbin/nologin
|
||||
@@ -1,3 +0,0 @@
|
||||
d /var/lib/aios-spark-runner 0700 aios-spark-runner aios-spark-runner - -
|
||||
d /run/aios-spark-runner 0700 aios-spark-runner aios-spark-runner - -
|
||||
d /etc/aios-spark-runner 0750 root aios-spark-runner - -
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user