Compare commits

..
Author SHA1 Message Date
Codex 4e0e6dcb90 chore(release): close V2R1C1 companion validation metadata 2026-07-21 19:05:28 +08:00
Codex 0778a31ec0 chore(release): record OpenClaw v2 companion validation 2026-07-21 18:29:45 +08:00
Codex 80c4cbde03 release:prepare-V2R1C1-validation-metadata 2026-07-21 17:04:24 +08:00
Codex fb739f6e8f docs(hermes): preserve formal acceptance blocker 2026-07-21 13:59:01 +08:00
Codex e6da39a3cd docs(hermes): record final evidence and worktree blocker 2026-07-21 13:55:08 +08:00
Codex 95d2e52cb5 fix(hermes): reserve cancellation for control plane 2026-07-21 13:41:34 +08:00
Codex 283dec58ab fix(hermes): enforce minimum heartbeat lease duration 2026-07-21 13:21:09 +08:00
Codex 7de1fe80ac docs(hermes): retain key-id and rollback contracts 2026-07-21 13:19:21 +08:00
Codex 89a502b39b test(hermes): preserve validated ten-case release gate 2026-07-21 13:19:21 +08:00
Codex 61bec280e2 deploy(hermes): preserve bounded rollback readiness 2026-07-21 13:19:21 +08:00
Codex 1791c91c5b docs(hermes): define lifecycle contract and validation state 2026-07-21 13:13:15 +08:00
Codex e8c0f0250b test(hermes): cover live lifecycle and recovery matrix 2026-07-21 13:09:06 +08:00
Codex 94718308fc fix(hermes): normalize trusted runtime headers and conflicts 2026-07-21 13:09:06 +08:00
Codex a4cbed0f5f feat(hermes): persist and protect full runtime lifecycle 2026-07-21 13:09:06 +08:00
Codex 25c7740739 docs(hardening): refresh Gate A report with verified execution results 2026-07-21 12:39:53 +08:00
Codex 8bbb049c39 docs(hardening): index ADR-009 without inventing missing history 2026-07-21 12:36:22 +08:00
Codex 1707cc4953 docs(hardening): rebuild complete UR-001 to UR-022 traceability 2026-07-21 12:34:50 +08:00
Codex e397594200 docs(hardening): add Run Provenance and scoped Grant requirements 2026-07-21 12:31:58 +08:00
Codex 68b7f2c1d2 docs(hardening): align module contracts with hardened runtime and Grant model 2026-07-21 12:28:25 +08:00
Codex fda98e61f5 docs(hardening): record least-privilege Provenance decision 2026-07-21 12:16:22 +08:00
Codex ea9f599522 fix(hardening): scope CLI authorization to one Grant 2026-07-21 12:13:21 +08:00
Codex 1aabcd8b62 test(hardening): add least-privilege Grant isolation coverage 2026-07-21 12:04:02 +08:00
Codex 4d68af9524 test(hardening): upgrade runtime flows and add dispatch recovery coverage 2026-07-21 12:01:17 +08:00
Codex 467b127804 fix(hardening): give application a safe default Grant scope 2026-07-21 11:57:32 +08:00
Codex 50a73b211b fix(hardening): bind dispatch and runtime results to principal 2026-07-21 11:55:07 +08:00
Codex f14d4b6e14 feat(hardening): persist principal and runtime lookup in dispatch store 2026-07-21 11:51:01 +08:00
Codex fc8bd8c8d4 feat(hardening): build scoped Grant writers per service 2026-07-21 11:48:11 +08:00
Codex 382fe662a3 feat(hardening): add service-scoped least-privilege writer 2026-07-21 11:45:46 +08:00
Codex 4754e5762a fix(hardening): use constant-time fingerprint comparison 2026-07-21 11:42:08 +08:00
Codex ce7d35a811 config(hardening): add seven least-privilege Grant references 2026-07-21 11:37:58 +08:00
Codex 60ac7d9d0b refactor(hardening): make evidence tracking use least-privilege authorization interface 2026-07-21 11:34:43 +08:00
Codex 2a10d1cdd9 feat(hardening): add workflow and least-privilege Grant CLI 2026-07-21 11:31:51 +08:00
Codex 677ea738e7 fix(hardening): authorize Git content before modifying index 2026-07-21 11:27:28 +08:00
Codex 7154d62cca fix(hardening): export OpenClaw Ed25519 contract types 2026-07-21 11:24:35 +08:00
Codex 4e154c8baf feat(hardening): wire durable dispatch least-privilege grants and internal reconcile 2026-07-21 11:20:39 +08:00
Codex b848361079 feat(hardening): require least-privilege grants in production writer 2026-07-21 11:17:39 +08:00
Codex 74224cfbd5 feat(hardening): add least-privilege provenance child grants 2026-07-21 11:14:42 +08:00
Codex a7c09ba646 feat(hardening): upgrade OpenClaw command contract to Ed25519 v2 2026-07-21 11:10:21 +08:00
Codex f055cb74bb feat(hardening): persist dispatches and strictly bind runtime results 2026-07-21 11:04:59 +08:00
Codex 071a378de6 fix(hardening): close public runtime lifecycle bypass 2026-07-21 11:00:28 +08:00
Codex a492b15df6 feat(hardening): add durable dispatch repository 2026-07-21 10:53:32 +08:00
Codex a9f0a4359b patch(chatgpt): start full Gate A hardening 2026-07-21 10:46:59 +08:00
Codex 6a9fab894a docs(traceability): add run provenance implementation matrix 2026-07-21 10:30:54 +08:00
Codex e9111dea47 deploy(provenance): add mandatory operation authorization wrapper 2026-07-21 10:30:40 +08:00
Codex d25fbfa61e test(provenance): add signature lifecycle replay and seven-path gate coverage 2026-07-21 10:21:01 +08:00
Codex 6b03c47625 build(provenance): install Ed25519 runtime dependency 2026-07-21 10:16:28 +08:00
Codex 770fe1ef66 docs(aios-v2): add ADR-007 run provenance control 2026-07-21 10:12:03 +08:00
Codex 7a025430f9 config(provenance): add production environment references 2026-07-21 10:11:56 +08:00
Codex d079da4029 feat(provenance): export trusted provenance APIs 2026-07-21 10:11:49 +08:00
Codex b8f7dc4112 feat(provenance): wire production gate into API application 2026-07-21 10:07:19 +08:00
Codex 947cdaff42 feat(provenance): add issuer authorization revoke and status CLI 2026-07-21 10:00:02 +08:00
Codex 83b0c8bd74 feat(provenance): add deployment and rollback operation gate factory 2026-07-21 09:56:50 +08:00
Codex fbd880cf8c feat(provenance): add controlled Git writer with operation policy 2026-07-21 09:46:40 +08:00
Codex 819ac94c9f feat(provenance): enforce authorization before evidence writes 2026-07-21 09:42:25 +08:00
Codex b02401e60d feat(provenance): enforce signed provenance before runtime dispatch 2026-07-21 09:39:33 +08:00
Codex cbb2e232f5 feat(provenance): implement Ed25519 issuer persistent store and seven-path writer 2026-07-21 09:33:07 +08:00
Codex 9e46ac0504 patch(codex): start trusted run provenance issuer store and writer blocker resolution 2026-07-20 20:11:59 +08:00
Codex ba7669c3b4 docs(aios-v2): 更新监督状态与阻塞处置清单 2026-07-20 17:22:32 +08:00
Codex 634f6b164f docs(candidate): 新增完整中文生产候选报告 2026-07-20 12:06:55 +08:00
Codex c9ce12d102 deploy(candidate): 新增中文全量验收入口 2026-07-20 12:05:38 +08:00
Codex db50d2fa5a tasks(aios-v2): 完整中文化 Codex 生产监督任务 2026-07-20 12:04:43 +08:00
Codex 63fd23a464 tasks(aios-v2): 完整中文化 OpenClaw 生产部署任务 2026-07-20 12:04:11 +08:00
Codex 2e03ed74f6 feat(governance): 新增文档与代码匹配门禁 2026-07-20 12:03:36 +08:00
Codex b86f51c9ab docs(aios-v2): 新增 ADR-006 候选持久化策略 2026-07-20 12:01:49 +08:00
Codex 09448a5d3d docs(aios-v2): 完整中文化 ADR-005 2026-07-20 12:01:05 +08:00
Codex 18c719b2c6 docs(aios-v2): 完整中文化 ADR-004 2026-07-20 12:00:39 +08:00
Codex b0a78fa9a8 docs(aios-v2): 完整中文化 ADR-003 2026-07-20 12:00:07 +08:00
Codex 0aca7dbcfc docs(aios-v2): 完整中文化 ADR-002 2026-07-20 11:59:34 +08:00
Codex 3f84d8991e docs(aios-v2): 完整中文化 ADR-001 2026-07-20 11:59:10 +08:00
Codex 5ac8f3ea6f docs(aios-v2): 完整中文化 ADR 索引 2026-07-20 11:58:39 +08:00
Codex bfb1c2fd52 docs(aios-v2): 新增完整中文文档治理规范 2026-07-20 11:57:51 +08:00
Codex cbfe1211c1 docs(aios-v2): 将文档索引完整转换为中文 2026-07-20 11:56:30 +08:00
Codex efd99e620c docs(aios-v2): add ADR-004 tenant isolation decision 2026-07-20 11:53:04 +08:00
Codex f23b694ad3 tasks(aios-v2): add Codex production supervisor task 2026-07-20 11:46:43 +08:00
Codex f97dea55ac tasks(aios-v2): add OpenClaw production deployment task 2026-07-20 11:46:39 +08:00
Codex efd08d7771 docs(aios-v2): add ADR-003 GitOps-only flow 2026-07-20 11:46:31 +08:00
Codex ff6cb2e217 docs(aios-v2): add ADR-005 documentation sync gate 2026-07-20 11:45:16 +08:00
Codex d774620fea docs(aios-v2): add ADR-002 runtime decoupling decision 2026-07-20 11:45:12 +08:00
Codex b26ff010fc docs(aios-v2): add ADR-001 control plane decision 2026-07-20 11:45:09 +08:00
Codex 4b49f2ba4b docs(aios-v2): add ADR index 2026-07-20 11:45:04 +08:00
Codex b4b8fe8828 docs(aios-v2): add traceability matrix 2026-07-20 11:43:46 +08:00
Codex 4e8ab051a0 docs(aios-v2): add module contracts 2026-07-20 11:41:39 +08:00
Codex f61e35b218 docs(aios-v2): add migration and delivery strategy 2026-07-20 11:34:33 +08:00
Codex b2f90f24a3 docs(aios-v2): add target architecture 2026-07-20 11:32:16 +08:00
Codex 6721eb4752 docs(aios-v2): add user requirements baseline 2026-07-20 11:29:07 +08:00
Codex a9e4fd1e40 docs(aios-v2): establish architecture source of truth 2026-07-20 11:24:12 +08:00
Codex 621750976a deploy(candidate): add guarded release rollback 2026-07-20 11:22:04 +08:00
Codex 5359e88806 feat(candidate): expose sanitized GitOps status API 2026-07-20 11:19:37 +08:00
Codex 76636931ba feat(candidate): add sanitized GitOps status provider 2026-07-20 11:17:18 +08:00
Codex 180c1fc66e deploy(candidate): add end-to-end healthcheck 2026-07-20 11:14:53 +08:00
Codex c0ff052dab deploy(candidate): add Docker Compose runtime 2026-07-20 11:12:42 +08:00
Codex 94cffb6ba3 deploy(candidate): add hardened container image 2026-07-20 11:10:36 +08:00
Codex 9ad5628e69 deploy(candidate): add environment configuration template 2026-07-20 11:08:37 +08:00
Codex 6d397a7894 deploy(candidate): add hardened systemd service 2026-07-20 11:07:06 +08:00
Codex 165fea6ba9 fix(candidate): prevent Hermes completion heartbeat revision race 2026-07-20 11:05:45 +08:00
Codex d079e2087c test(candidate): add end-to-end runtime and persistence tests 2026-07-20 11:01:07 +08:00
Codex e3b36ebdcc feat(candidate): add production application entrypoint 2026-07-20 10:57:42 +08:00
Codex 5e791b11c0 feat(candidate): add tenant-safe web console 2026-07-20 10:54:43 +08:00
Codex 50be65b76e feat(candidate): add durable SQLite control-plane persistence 2026-07-20 10:50:31 +08:00
Codex 7bb86e30bf feat(candidate): export orchestration and execution tracking 2026-07-20 10:42:49 +08:00
Codex ea56b45b7e feat(candidate): add persistent agent execution tracking 2026-07-20 10:40:22 +08:00
Codex f53682da7a feat(candidate): add unified AIOS execution orchestrator 2026-07-20 10:38:06 +08:00
Codex 71884ac088 feat(candidate): add Hermes worker loop and lease heartbeat 2026-07-20 10:35:55 +08:00
Codex 969c278cb0 feat(candidate): implement Hermes queue and lease adapter 2026-07-20 10:34:25 +08:00
Codex 6fe2a1e036 feat(candidate): expose Hermes runtime package 2026-07-20 10:31:07 +08:00
Codex 9a60e0c68e feat(candidate): add Hermes runtime contracts 2026-07-20 10:27:47 +08:00
Codex 46f553ca11 feat(candidate): expose Hermes runtime boundary package 2026-07-20 10:17:02 +08:00
Codex 4f0a0c2184 feat(candidate): add OpenClaw audit callback sink 2026-07-20 10:07:54 +08:00
Codex c107b193d9 feat(candidate): add OpenClaw runtime health policy 2026-07-20 10:06:36 +08:00
Codex 2db7ced7a4 feat(candidate): add OpenClaw runtime transports 2026-07-20 10:01:59 +08:00
Codex 6361d9a334 feat(candidate): implement OpenClaw execution adapter 2026-07-20 09:59:53 +08:00
Codex 2cf54f04ef feat(candidate): expose OpenClaw adapter package 2026-07-20 09:56:06 +08:00
Codex f840ce5c94 feat(integration): add OpenClaw command contract 2026-07-20 09:49:15 +08:00
Codex 35e7b55490 integration(aios-v2): create CTO production candidate branch 2026-07-20 09:47:01 +08:00
Codex 1f8d1f23b3 feat(aios-v2): add tenant-aware HTTP control plane gateway 2026-07-20 02:24:19 +08:00
Codex e93011791e feat(aios-v2): add tenant-isolated control plane domain 2026-07-20 02:19:16 +08:00
Codex 4460564a48 feat(aios-v2): initialize control plane package 2026-07-20 01:11:11 +08:00
Codex 4d6658136e docs(task): add AIOS control plane gateway task contract 2026-07-20 01:06:11 +08:00
Codex 5767e665c2 task(codex): implement AIOS V2 control plane gateway v1 2026-07-20 01:06:04 +08:00
184 changed files with 15486 additions and 10000 deletions
@@ -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,3 @@
d23bb1ec24a7f49359314e04cd2c04c8f38876e45f25a701506499d1b34f270f MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json
5ce2894fdf8a22622954a1a10fb09fbab74af04f0260b249615c70236ff31731 RESULT-AIOS-V2R1-HERMES-LIFECYCLE-20260721.md
ca2ab3c22c2b2b33bbc03e59f1206b999ad1271de3e1938fd3a0b4234c613eef ../../../docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md
@@ -0,0 +1,44 @@
{
"schema_version": "aios.evidence.manifest.v1",
"generated_at": "2026-07-21T05:50:00Z",
"branch": "patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721",
"validated_oid": "95d2e52cb55c7b913e89bc6bed2bcdacf67512d4",
"evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z",
"evidence_hashes": {
"steps.tsv": "b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf",
"final-live-gate.log": "82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa",
"16R.log": "641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf",
"15.log": "b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e",
"13.log": "b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710",
"12R2.log": "0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67"
},
"validation": {
"candidate_tests": {"passed": 49, "failed": 0},
"hermes_lifecycle": {"passed": 17, "failed": 0},
"gate_b": {"passed": 10, "failed": 0},
"offline_acceptance": {"passed": 8, "failed": 0, "expected_warnings": 1},
"openclaw_real_http": "PASS",
"restart_recovery": "PASS",
"rollback_forward": "PASS",
"state_preservation": "PASS",
"final_health_corrected_16R": "PASS"
},
"git_gate": {
"oid_match": true,
"candidate_clean": true,
"active_controller_clean": true,
"locked": 0,
"prunable": 0,
"worktree_total": 88,
"worktree_dirty": 10,
"global_worktree_clean": false
},
"resolved_blockers": [],
"active_blockers": [
"BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING",
"BLK-GLOBAL-WORKTREE-CLEANLINESS"
],
"overall": "PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS",
"release_decision": "STOP_PREPRODUCTION",
"secrets_recorded": false
}
@@ -0,0 +1,16 @@
# AIOS V2R1 Hermes Lifecycle Result
- Branch: `patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
- Validated OID: `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
- Evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
- Hermes blocker: `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`
- Overall: `PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
- Release decision: `STOP_PREPRODUCTION`
Hermes lifecycle `17/17`, candidate `49/49`, Gate B `10/10`, offline acceptance `8/0/1`, OpenClaw real HTTP, restart recovery, rollback/forward, state preservation and corrected final health all passed at the validated OID. The Owner acceptance contract nevertheless forbids formally closing the Hermes blocker until every global gate also passes.
The candidate worktree and active Controller worktree are clean; local, worktree and Gitea OIDs match; locked/prunable are zero. The global authority currently has 10 dirty worktrees out of 88, so the Owner hard gate requiring every worktree to be clean fails.
No RC, Tag Approval Request, Tag, main mutation, force push, production traffic switch or secret disclosure was performed.
See `MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json` and `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`.
@@ -1,92 +0,0 @@
# RESULT-CODEX-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-GIT-WORKTREE-20260716T124948UTC8
```yaml
status: BLOCKED_MISSING_OWNER_SOURCE
recorded_at: 2026-07-16T12:49:48+08:00
source_task:
branch: task/codex/v2r1-personal-weixin-b3-evidence-git-20260716T123454UTC8
head: 53c67bd9780074bad9781fb97971463156bc6609
base: 1d334b1ccaac54c3c3ec4dd84693ab68fc67cef3
worktree: /data/aios-gitops/worktrees/codex/v2r1-personal-weixin-b3-evidence-git-20260716T123454UTC8
workspace_clean: true
bare_ref: present
gitea_ref: absent
commit_scope:
metadata_only: true
changed_paths:
- patches/task_codex_v2r1-personal-weixin-b3-evidence-git-20260716T123454UTC8.branch.json
controller:
path: /data/aios-gitops/worktrees/controller
branch: controller/runtime
head: 3cf9958cb94614449e093639cbf902216d73447f
workspace_clean: true
safely_restored: true
result_worktree:
path: /data/aios-gitops/worktrees/codex/v2r1-personal-weixin-b3-evidence-git-worktree-20260716T124948UTC8
branch: result/codex/v2r1-personal-weixin-b3-evidence-git-worktree-20260716T124948UTC8
base_head: 53c67bd9780074bad9781fb97971463156bc6609
isolated: true
required_git_sources:
all_present: false
paths:
- path: .agent/tasks/codex/TASK-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716.md
present_at_source_head: false
sha256: unavailable_missing_path
- path: tools/personal_weixin/pwx_b3_evidence_tool.py
present_at_source_head: false
sha256: unavailable_missing_path
- path: tools/personal_weixin/pwx_b3_evidence_facts.template.json
present_at_source_head: false
sha256: unavailable_missing_path
- path: tools/personal_weixin/README-B3-EVIDENCE.md
present_at_source_head: false
sha256: unavailable_missing_path
source_delivery:
git_hosted_tooling_commit: absent
gitea_target_ref: absent
owner_source_hashes: unavailable
tmp_download_used: false
execution:
owner_evidence_collected: false
b3_manifest_generated: false
independent_reviewer_signed: false
b3_implementation: NOT_STARTED
b4_amendment:
status: PASSED
commit: 1d334b1ccaac54c3c3ec4dd84693ab68fc67cef3
collision_metrics:
permission_denied_count: 0
checkout_collision_count: 0
git_lock_collision_count: 0
safety:
fetch_used: false
reset_used: false
stash_used: false
force_used: false
worktree_deleted: false
controller_modified: false
task_branch_modified: false
main_modified: false
production_modified: false
release:
production_changed: false
production_baseline: V1R4C6
release_status: paused
blocker:
reason: The four owner-authored Git source paths are absent from source HEAD 53c67bd9780074bad9781fb97971463156bc6609.
required_next_input: Commit the exact four owner-authored files to a new isolated task commit and publish its ref to Gitea.
```
## Decision
Execution stops fail-closed. No owner evidence, manifest, reviewer signature, B3 implementation, release, or production action is authorized from missing source material.
@@ -0,0 +1,12 @@
# V2R1C1 Cleanup Closure
- `CLEANUP_REVIEW=PASS`
- `ARCHIVE_VALIDATION=PASS`
- `GLOBAL_WORKTREE_CLEAN=true`
- `global_dirty_count=0`
- `locked=0`
- `prunable=0`
- Portal Archive OID: `99671cc14d2631f989e0763dc882e4001c50a1ea`
- Spark Archive OID: `d97cd3642528bd3373fe4cf5161a5d99c0b9285f`
Closure evidence: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-blocker-clear-20260721T075647Z`.
@@ -0,0 +1,57 @@
{
"schema_version": "aios.v2r1c1.deployment-manifest.v1",
"vrc": "V2R1C1",
"release_branch": "release/AIOS-V2R1C1-20260721",
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
"companion": {
"branch": "patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721",
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
"oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
"plugin_version": "2.1.0-rc.4",
"contract_version": "aios.openclaw.command.v2",
"signature_algorithm": "Ed25519",
"verification_key_id": "openclaw-v2r1-candidate-ed25519",
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671"
},
"isolated_validation": {
"service": "aios-v2r1c1-openclaw-companion.service",
"account": "aios-v2r1c1-openclaw",
"listen": "127.0.0.1:28789",
"accept_v2": true,
"accept_v1": false,
"public_key_only": true,
"production_traffic_switched": false,
"result": "PASS"
},
"previous_plugin": {
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
"plugin_version": "2.1.0-rc.3",
"artifact_sha256": "d0fd11be4aec03eee9ca24bf1585d9ef3597254ad4b1db0d0137eab4456b5737"
},
"rollback": {
"production_plugin_unchanged": true,
"failed_isolated_attempts_rolled_back": true,
"previous_plugin_evidence": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z/00-baseline",
"previous_oid": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
"isolated_rollback_forward": "PASS",
"production_symlink_modified": false
},
"release_validation": {
"gate_a": "PASS",
"gate_b": "PASS",
"openclaw_v2_live": "PASS",
"openclaw_negative_live": "PASS",
"hermes_live": "PASS",
"restart_recovery": "PASS",
"rollback_forward": "PASS",
"final_health": "PASS",
"secret_violations": 0,
"global_dirty_count": 0,
"sync_status": "fully_synced"
},
"tag_status": "ABSENT",
"preproduction_status": "NOT_STARTED",
"production_status": "NOT_STARTED"
}
@@ -0,0 +1,55 @@
{
"schema_version": "aios.v2r1c1.release-evidence.v2",
"vrc": "V2R1C1",
"semver": "2.1.0-rc.1",
"product_baseline_oid": "fb739f6e8ff9c031d319221ace68dce157694086",
"old_release_oid": "80c4cbde03a82c30648a09b817bc2f03d5402936",
"validated_release_parent_oid": "0778a31ec073e7c90003ef091f1d093c16bf22e5",
"release_branch": "release/AIOS-V2R1C1-20260721",
"release_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-release-20260721",
"companion_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z",
"openclaw_companion": {
"branch": "patch/openclaw/AIOS-V2R1C1-COMMAND-V2-COMPANION-20260721",
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
"companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
"plugin_version": "2.1.0-rc.4",
"contract_version": "aios.openclaw.command.v2",
"signature_algorithm": "Ed25519",
"verification_key_id": "openclaw-v2r1-candidate-ed25519",
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671"
},
"step13": {
"original_result": "FAIL_HTTP_401",
"original_diagnosis_manifest_sha256": "320017cbfc0f31392b4d0fb5996d0e32709982a967d102592ac45efdbdaad91f",
"remediated_result": "PASS",
"online_negative_evidence_sha256": "57ab8efd19d3bf659a65cbeda830785f6cea9172925a3cac9e027b9b842e644f",
"remediated_evidence_sha256": "5db59096c0586fcc0a51851d9a25d0a0b94f2bade45063fab16cc2a729f23130",
"task_state": "COMPLETED",
"dispatch_state": "COMPLETED",
"task_dispatch_audit_consistency": "PASS"
},
"release_validation": {
"static_revalidation": "PASS",
"static_evidence_sha256": "c33ffaf9a0f509d6e78bdfdc0848940c166b8a800940e259b7196adab04ec604",
"gate_a": "PASS",
"gate_b": "PASS",
"documentation_sync": "PASS",
"secret_scan_violations": 0,
"release_metadata": "PASS",
"hermes_live": "PASS",
"hermes_evidence_sha256": "e6553c99db55c3cdf9e16a4c1e6712064e00c41dc9965d0d179b6b1a2a1ad3ac",
"restart_recovery": "PASS",
"restart_evidence_sha256": "6031c92cd890dffd1de28ba75d81bed908e7da11a24aa129bcfde8efb23f0149",
"rollback_forward": "PASS",
"rollback_evidence_sha256": "f3d49c54e3f722facf68df2da3d82a9c82eb576f2c7c8dacb0f62c424d25737c",
"final_health": "PASS",
"final_health_evidence_sha256": "121cd52c0a4852b818e06be69211ae18b9fcccf976fa064e2a09c54eb8a01300",
"global_dirty_count": 0,
"sync_status": "fully_synced"
},
"validation_status": "PASS",
"tag_status": "ABSENT",
"preproduction_status": "NOT_STARTED",
"production_status": "NOT_STARTED",
"secrets_recorded": false
}
@@ -0,0 +1,52 @@
# V2R1C1 Validation Result
Status: `RELEASE_VALIDATION_PASS / FINAL_METADATA_COMMIT_REVALIDATION_REQUIRED`
Release decision: `STOP_PREPRODUCTION`
## OpenClaw Command v2 Companion
- Source OID: `83f8f878c7da5dd8263f2af2efa80750492e5cb3`
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`
- Plugin version: `2.1.0-rc.4`
- Contract: `aios.openclaw.command.v2`
- Signature: `Ed25519`
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`
- Companion unit/contract tests: `PASS`
- V1 fallback: `REJECTED`
- Isolated Gateway health: `PASS`
- Online positive/negative contract: `PASS`
- AIOS -> OpenClaw real HTTP: `PASS`
- Task state: `COMPLETED`
- Dispatch state: `COMPLETED`
- Task/Dispatch/Audit consistency: `PASS`
## Evidence linkage
- Original failed Release OID: `80c4cbde03a82c30648a09b817bc2f03d5402936`
- Original HTTP 401 diagnosis: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-release-20260721/pass1-80c4cbde/13-diagnosis`
- Original manifest SHA256: `320017cbfc0f31392b4d0fb5996d0e32709982a967d102592ac45efdbdaad91f`
- Companion evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`
- Validated Release parent OID: `0778a31ec073e7c90003ef091f1d093c16bf22e5`
- Online negative evidence SHA256: `57ab8efd19d3bf659a65cbeda830785f6cea9172925a3cac9e027b9b842e644f`
- Remediated Step 13 evidence SHA256: `5db59096c0586fcc0a51851d9a25d0a0b94f2bade45063fab16cc2a729f23130`
## Release validation closure
- Gate A: `PASS`
- Gate B: `PASS`
- Documentation sync: `PASS`
- Secret violations: `0`
- Release Metadata: `PASS`
- Hermes live: `PASS`
- Restart recovery: `PASS`
- Previous ↔ V2R1C1 isolated rollback/forward: `PASS`
- Final health: `PASS`
- Global dirty / locked / prunable: `0 / 0 / 0`
- OID sync: `fully_synced`
- Production traffic switched: `false`
- Tag: `ABSENT`
## Remaining mandatory validation
本次最终元数据闭环提交将形成新的 Release OID。必须针对该最终 OID 再次复验 Gate A、Gate B、文档同步、Secret Scan、Release Metadata 和 OpenClaw 第 13 步正向/负向验证。复验通过前不得创建 Tag 或部署预生产/生产。
-4
View File
@@ -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
+394
View File
@@ -0,0 +1,394 @@
# AIOS V2R1 生产候选报告
- 候选版本:`V2R1C0-candidate`
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
- 报告语言:中文
- 架构负责人:AIOS V2 CTO
- 当前状态:代码与文档闭环已完成;精确 Commit 测试、真实 Runtime 联调和预发布验证待执行
## 1. 执行摘要
AIOS V2R1 Candidate 已完成以下核心建设:
- AIOS 唯一控制面;
- Tenant、Agent、Task、Policy、Approval 和 Audit 权威模型;
- SQLite WAL 候选持久化与重启恢复;
- Execution Orchestrator;
- OpenClaw 签名命令、Adapter、Transport、Health 和 Audit Callback;
- Hermes tenant/cell/profile 分区、Queue、Worker Lease、Heartbeat 和 Result;
- Agent Execution Evidence;
- Web Console;
- GitOps Status;
- systemd、Docker、Compose、Healthcheck 和 Rollback;
- 用户需求、目标架构、迁移策略、模块契约、追踪矩阵、文档治理和 ADR;
- 文档—代码匹配检查器;
- 全量验收入口;
- OpenClaw 预发布部署任务;
- Codex 架构监督和阻塞处理任务。
本报告不把“代码已提交”误报为“生产已验证”。以下状态仍需由无人值守任务执行并形成证据:
- 精确 Candidate Commit 的自动测试;
- 真实 OpenClaw Runtime 联调;
- Hermes 长任务、Worker 崩溃和 Lease Recovery 演练;
- systemd/Docker 预发布部署;
- 重启恢复与回滚演练;
- 本地仓库与 Gitea Candidate OID 一致验证;
- 身份代理、安全、性能和容量验证;
- Owner 对合并、Tag 和生产部署的批准。
## 2. 用户需求完成情况
| 需求 | 代码状态 | 测试状态 | 预发布状态 |
|---|---|---|---|
| AIOS 唯一控制面 | 已实现 | 已定义 | 待验证 |
| 复用 OpenClaw/Hermes/Agent Router | 已实现 | 已定义 | 待真实联调 |
| OpenClaw/Hermes 解耦 | 已实现 | 已定义 | 待旁路冻结验证 |
| 全面 Web 化 | 候选实现完成 | 冒烟测试待执行 | 待反向代理接入 |
| 多租户隔离 | 已实现 | Domain 负向测试已定义 | 待全链路安全验证 |
| 统一任务生命周期 | 已实现 | 已定义 | 待精确 Commit 执行 |
| 策略与审批 | 已实现 | 部分测试待补强 | 待身份代理验证 |
| 幂等与并发安全 | 已实现 | 基础测试已定义 | 待压力和并发验证 |
| 执行证据 | 已实现 | 已定义 | 待外部执行器持续回写 |
| GitOps 唯一流程 | 已实现并修复 Controller 冲突 | Runtime 部分验证 | 待双目标持续镜像证据 |
| 持久化与恢复 | 已实现 | 重启恢复测试已定义 | 待预发布演练 |
| 审计与合规 | 已实现候选 | 部分断言待补强 | 待不可变外部固化 |
| 密钥安全 | 引用模式已实现 | 基础扫描纳入验收 | 待 Secret Provider 联调 |
| 部署与回滚 | 资产完成 | 验收脚本已定义 | 待实际演练 |
| 文档与代码同步 | 已实现门禁 | 待精确 Commit 执行 | 待接入持续验收 |
## 3. 目标架构
```text
浏览器 / Web Console / Public API
|
v
AIOS 唯一控制面
Tenant | Agent | Task | Policy | Approval | Audit
|
v
Execution Orchestrator / Agent Router
/ \
v v
OpenClaw Adapter Hermes Boundary
| |
v v
OpenClaw Runtime Hermes Workers
\ /
v v
结果回写 + 审计 + 执行证据
```
权威边界:
- AIOS:租户、身份、Agent、任务、策略、审批、审计和执行证据;
- Agent Router:调度与路由;
- OpenClaw:签名命令执行、取消、状态、健康和回调;
- Hermes:队列、Worker、Lease、Heartbeat 和结果;
- Web:只调用 AIOS API;
- GitOps:所有代码、文档、部署和修复变更入口。
## 4. 实现清单
### 4.1 Control Plane
```text
active-runtime/app/aios_v2/control_plane.py
active-runtime/app/aios_v2/persistence.py
active-runtime/app/aios_v2/gateway.py
active-runtime/app/aios_v2/app.py
active-runtime/app/aios_v2/orchestrator.py
active-runtime/app/aios_v2/execution_tracking.py
active-runtime/app/aios_v2/gitops_status.py
active-runtime/app/aios_v2/console.py
```
### 4.2 OpenClaw
```text
active-runtime/app/aios_openclaw/contract.py
active-runtime/app/aios_openclaw/adapter.py
active-runtime/app/aios_openclaw/transport.py
active-runtime/app/aios_openclaw/health.py
active-runtime/app/aios_openclaw/callback.py
```
### 4.3 Hermes
```text
active-runtime/app/aios_hermes/contract.py
active-runtime/app/aios_hermes/adapter.py
active-runtime/app/aios_hermes/worker.py
```
### 4.4 测试与治理
```text
tests/aios_v2_candidate/test_candidate_flow.py
tools/aios_v2_docs_code_check.py
deploy/aios-v2/acceptance/run-all.sh
```
### 4.5 部署
```text
deploy/aios-v2/systemd/aios-v2.service
deploy/aios-v2/env/aios-v2.env.example
deploy/aios-v2/docker/Dockerfile
deploy/aios-v2/docker/compose.yaml
deploy/aios-v2/healthcheck/check.sh
deploy/aios-v2/rollback/rollback.sh
```
### 4.6 文档与任务
```text
docs/aios-v2/
tasks/openclaw-production-deploy-task.yaml
tasks/codex-production-supervisor-task.yaml
```
## 5. 多租户与安全设计
### 5.1 权威上下文
```text
tenant_id
principal_id
session_id
trace_id
task_id
policy_version
idempotency_key
```
### 5.2 OpenClaw
- HMAC-SHA256 规范化签名;
- 签名绑定 tenant/task/trace/action/payload/idempotency/time;
- Runtime Result 身份字段必须匹配;
- Secret 只通过受保护文件或引用解析。
### 5.3 Hermes
- tenant/cell/profile 分区;
- Worker Lease 与 Heartbeat;
- revision 乐观并发;
- Lease 过期自动回到 QUEUED;
- Worker 完成前停止 Heartbeat,避免 revision 竞态。
### 5.4 Web/API
- 可信代理注入 `X-AIOS-*`;
- 浏览器不选择租户;
- CSP、no-store、nosniff、DENY frame、no-referrer;
- 应用端口不得直接暴露公网。
### 5.5 GitOps
- Controller detached + clean;
- 禁止 fetch 到已检出本地分支;
- 所有修改进入分支、Commit、测试和统一审查;
- 明文 Secret 门禁;
- 文档变化与代码变化同步门禁。
## 6. 持久化策略
候选版:
- SQLite WAL;
- `synchronous=FULL`;
- `BEGIN IMMEDIATE`;
- 原子 Snapshot;
- schema version;
- 状态目录与代码版本分离;
- Execution Evidence 租户分区原子 JSON。
已知限制:
- 单进程候选优先;
- 尚无生产 RLS、多实例 HA、PITR 和容量证据;
- 后续必须通过新 ADR 迁移到生产数据层。
## 7. 测试与验收
### 7.1 文档—代码匹配
```bash
python3 tools/aios_v2_docs_code_check.py \
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
```
### 7.2 候选测试
```bash
python3 -m unittest discover \
-s tests/aios_v2_candidate \
-p 'test_*.py' \
-v
```
覆盖:
- OpenClaw 完整执行;
- Hermes 完整执行;
- 跨租户拒绝;
- SQLite 重启恢复;
- Execution Evidence。
### 7.3 全量验收
```bash
bash deploy/aios-v2/acceptance/run-all.sh
```
可选在线健康检查:
```bash
AIOS_RUN_LIVE_HEALTHCHECK=1 \
bash deploy/aios-v2/acceptance/run-all.sh
```
### 7.4 健康检查
```bash
bash deploy/aios-v2/healthcheck/check.sh
```
检查:
- systemd;
- HTTP Health;
- 状态目录;
- SQLite quick_check;
- GitOps detached/clean;
- Evidence 文件。
## 8. 部署方式
### 8.1 systemd
```text
/etc/aios-v2/aios-v2.env
/opt/aios/current
/var/lib/aios-v2
```
运行用户:`aios`。
安全选项:NoNewPrivileges、ProtectSystem、ProtectHome、PrivateTmp、Capability 限制。
### 8.2 Docker/Compose
- 非 root UID/GID;
- Read-only root filesystem;
- Drop all capabilities;
- 独立 State Volume;
- HTTP Healthcheck;
- Secret 文件只读挂载。
## 9. 回滚
```bash
bash deploy/aios-v2/rollback/rollback.sh <previous-release>
```
原则:
- 回滚前自动备份状态;
- 默认只回滚代码;
- 状态恢复需显式 `AIOS_V2_RESTORE_STATE=1`;
- 回滚后必须执行健康检查;
- 目标版本失败时自动恢复 previous release。
## 10. 无人值守任务
### OpenClaw
任务:`tasks/openclaw-production-deploy-task.yaml`
职责:
- 预发布部署;
- 健康检查;
- Runtime 联调;
- 小型部署与 Runtime Bug 修复;
- 证据回写。
### Codex
任务:`tasks/codex-production-supervisor-task.yaml`
职责:
- 架构监督;
- 阻塞分析和跨模块修复;
- 文档、测试和安全审查;
- 发布建议。
所有修改和阻塞必须进入 Git 和 Execution Evidence,最终统一审视。
## 11. 当前已知风险
| 风险 | 级别 | 当前处理 |
|---|---|---|
| 精确 Candidate Commit 尚未执行全部测试 | 高 | OpenClaw 执行,Codex 监督 |
| 真实 OpenClaw Runtime 尚未联调 | 高 | 预发布任务必须完成 |
| 候选 SQLite 不支持生产多实例 HA | 高 | Candidate 单实例;后续 ADR 迁移 |
| 身份代理 Header 防伪尚未环境验证 | 高 | 预发布安全检查 |
| Web Console 为候选只读 MVP | 中 | 生产前补充 E2E 与安全验证 |
| 部分策略负向、并发和审计测试待补强 | 中 | Codex 登记 blocker 并补充 |
| 本地/Gitea 自动双写状态仍需持续证明 | 中 | 验收记录 OID |
| 性能、容量和 SLO 尚无实测 | 中 | 预发布基准测试 |
| 外部不可变审计固化尚未实现 | 中 | 生产数据层阶段完成 |
## 12. 发布 Gate
### 允许进入预发布
条件:
- Candidate 分支可见;
- 文档完整;
- 任务合同已生成;
- 部署资产存在;
- 无明文 Secret。
### 允许进入合并审查
条件:
- 全量验收通过;
- OpenClaw/Hermes 联调通过;
- 重启和回滚通过;
- 严重/高风险 blocker 已解决;
- Codex 输出正向监督结论;
- 本地/Gitea OID 一致。
### 允许生产发布
条件:
- Owner 明确批准;
- Merge 与 Tag 通过受控流程;
- 生产配置、Secret 和反向代理准备完成;
- 发布窗口、观察指标、回滚负责人明确。
## 13. 当前结论
**代码和正式中文文档已经达到候选闭环状态,但尚不能宣称生产已验证。**
下一阶段由 OpenClaw 执行预发布部署和低风险 Bug 修复,由 Codex 监督架构、文档、安全、测试和阻塞。所有过程证据统一进入系统,满足 Gate 后再请求 Owner 批准。
## 14. 更新规则
以下变化必须更新本报告:
- Candidate Commit;
- 测试结果;
- 部署和回滚结果;
- Blocker 和风险;
- OpenClaw/Codex 任务状态;
- 文档—代码匹配状态;
- Gitea OID;
- 发布建议和 Owner 决策。
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## V2R1C1 - 2.1.0-rc.1
- 固定发布基线为 `fb739f6e8ff9c031d319221ace68dce157694086`。
- 收口 Hermes 生命周期、Grant 隔离、Dispatch 恢复、运行证据和回滚/前滚验证。
- Portal Adapter 与 Spark Provisioning 仅保留在 Archive,不属于产品发布图。
- Future Incubator 保持隔离,不属于 V2R1C1 发布输入。
+1 -1
View File
@@ -1 +1 @@
V1R4C6
V2R1C1
@@ -0,0 +1,27 @@
"""AIOS-managed Hermes runtime boundary."""
from .adapter import (
HermesAdapter,
HermesConflict,
HermesNotFound,
HermesRuntimeError,
InMemoryHermesStore,
SQLiteHermesStore,
)
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
from .worker import HermesWorker, WorkerRunResult
__all__ = [
"HermesAdapter",
"HermesConflict",
"HermesHealth",
"HermesJob",
"HermesJobState",
"HermesNotFound",
"HermesResult",
"HermesRuntimeError",
"HermesWorker",
"InMemoryHermesStore",
"SQLiteHermesStore",
"WorkerRunResult",
]
+593
View File
@@ -0,0 +1,593 @@
"""Tenant-partitioned Hermes queue and worker lease boundary."""
from __future__ import annotations
import hashlib
import json
import sqlite3
import threading
from dataclasses import asdict, replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
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]] = {}
def persist_locked(self) -> None:
"""Persist a mutation while ``lock`` is held; memory stores are a no-op."""
class SQLiteHermesStore(InMemoryHermesStore):
"""Durable snapshot for the existing Hermes queue state machine.
The adapter remains the sole owner of transitions and lease rules. This
store only makes its jobs, results, idempotency keys and worker heartbeats
survive a process restart.
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
super().__init__()
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS hermes_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
payload TEXT NOT NULL
)
"""
)
self._load()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _record(value: HermesJob | HermesResult) -> dict[str, object]:
record = asdict(value)
record["state"] = value.state.value
return record
def _load(self) -> None:
with self.lock, self._connect() as connection:
row = connection.execute(
"SELECT payload FROM hermes_state WHERE singleton = 1"
).fetchone()
if row is None:
return
payload = json.loads(str(row[0]))
self.jobs = {
str(item["job_id"]): HermesJob(**item)
for item in payload.get("jobs", [])
}
self.results = {
str(item["job_id"]): HermesResult(**item)
for item in payload.get("results", [])
}
self.idempotency = {
(str(item["tenant_id"]), str(item["idempotency_key"])): (
str(item["job_id"]),
str(item["digest"]),
)
for item in payload.get("idempotency", [])
}
self.worker_heartbeats = {
str(item["worker_id"]): (
str(item["heartbeat_at"]),
str(item["job_id"]) if item.get("job_id") is not None else None,
)
for item in payload.get("worker_heartbeats", [])
}
def persist_locked(self) -> None:
payload = {
"version": 1,
"jobs": [self._record(item) for item in self.jobs.values()],
"results": [self._record(item) for item in self.results.values()],
"idempotency": [
{
"tenant_id": tenant_id,
"idempotency_key": idempotency_key,
"job_id": job_id,
"digest": digest,
}
for (tenant_id, idempotency_key), (job_id, digest)
in self.idempotency.items()
],
"worker_heartbeats": [
{
"worker_id": worker_id,
"heartbeat_at": heartbeat_at,
"job_id": job_id,
}
for worker_id, (heartbeat_at, job_id)
in self.worker_heartbeats.items()
],
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
INSERT INTO hermes_state(singleton, payload) VALUES(1, ?)
ON CONFLICT(singleton) DO UPDATE SET payload=excluded.payload
""",
(encoded,),
)
connection.commit()
class HermesAdapter:
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
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)
self.store.persist_locked()
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:
if self._requeue_expired_locked():
self.store.persist_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,
job_id: str | 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_id is None or job.job_id == job_id)
and job.state == HermesJobState.QUEUED
]
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
if not candidates:
self.store.persist_locked()
return None
current = sorted(
candidates,
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)
self.store.persist_locked()
return claimed
def start(
self,
job_id: str,
*,
tenant_id: str,
worker_id: str,
expected_revision: int | None = None,
) -> HermesJob:
with self.store.lock:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
self._assert_revision(current, expected_revision)
updated = self._transition_locked(current, HermesJobState.RUNNING)
self.store.persist_locked()
return updated
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)
if duration < 5:
raise ValueError("lease_seconds must be >= 5")
with self.store.lock:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
raise HermesConflict("job does not accept heartbeat")
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)
self.store.persist_locked()
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")
if result.state != HermesJobState.CANCELLED:
raise HermesConflict("job already has a different terminal result")
return result
updated = self._transition_locked(current, HermesJobState.CANCELLED)
result = HermesResult(
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
self.store.persist_locked()
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 requeue_expired(self) -> tuple[str, ...]:
"""Persistently release expired leases without changing policy state."""
with self.store.lock:
changed = self._requeue_expired_locked()
if changed:
self.store.persist_locked()
return tuple(sorted(changed))
def health(self) -> HermesHealth:
with self.store.lock:
if self._requeue_expired_locked():
self.store.persist_locked()
active_jobs = sum(
1 for job in self.store.jobs.values()
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
)
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:
self._requeue_expired_locked()
current = self._owned_job_locked(job_id, tenant_id, worker_id)
if current.state in TERMINAL_STATES:
existing = self.store.results.get(job_id)
if existing is None:
raise HermesConflict("terminal result missing")
if (
existing.state == state
and existing.worker_id == worker_id
and existing.output == output
and existing.error == error
):
return existing
raise HermesConflict("job already has a different terminal result")
self._assert_revision(current, expected_revision)
updated = self._transition_locked(current, state)
result = HermesResult(
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)
self.store.persist_locked()
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) -> set[str]:
now = datetime.now(timezone.utc)
changed: set[str] = set()
for current in list(self.store.jobs.values()):
expiry = self._parse(current.lease_expires_at)
if (
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(),
)
changed.add(current.job_id)
return changed
+153
View File
@@ -0,0 +1,153 @@
"""Tenant-scoped Hermes runtime contracts."""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Mapping
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
class HermesJobState(str, Enum):
QUEUED = "QUEUED"
CLAIMED = "CLAIMED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
HermesJobState.RUNNING: frozenset(
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
),
HermesJobState.COMPLETED: frozenset(),
HermesJobState.FAILED: frozenset(),
HermesJobState.CANCELLED: frozenset(),
}
TERMINAL_STATES = frozenset(
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
)
@dataclass(frozen=True)
class HermesJob:
job_id: str
tenant_id: str
cell_id: str
profile_id: str
task_id: str
trace_id: str
action: str
payload: Mapping[str, Any] = field(default_factory=dict)
idempotency_key: str = ""
priority: int = 100
state: HermesJobState = HermesJobState.QUEUED
revision: int = 1
assigned_worker_id: str | None = None
lease_expires_at: str | None = None
created_at: str = field(default_factory=utc_now)
updated_at: str = field(default_factory=utc_now)
def __post_init__(self) -> None:
for name in (
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
):
object.__setattr__(self, name, _required(getattr(self, name), name))
object.__setattr__(self, "payload", _safe_mapping(self.payload))
object.__setattr__(self, "state", HermesJobState(self.state))
if not 0 <= int(self.priority) <= 1000:
raise ValueError("priority must be between 0 and 1000")
if self.revision < 1:
raise ValueError("revision must be >= 1")
@classmethod
def create(
cls,
*,
tenant_id: str,
cell_id: str,
profile_id: str,
task_id: str,
trace_id: str,
action: str,
payload: Mapping[str, Any] | None,
idempotency_key: str,
priority: int = 100,
) -> "HermesJob":
return cls(
job_id=f"hermes_{uuid.uuid4().hex}",
tenant_id=tenant_id,
cell_id=cell_id,
profile_id=profile_id,
task_id=task_id,
trace_id=trace_id,
action=action,
payload=_safe_mapping(payload),
idempotency_key=idempotency_key,
priority=priority,
)
@dataclass(frozen=True)
class HermesResult:
job_id: str
tenant_id: str
task_id: str
trace_id: str
state: HermesJobState
worker_id: str | None = None
output: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
started_at: str | None = None
finished_at: str | None = None
def __post_init__(self) -> None:
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
object.__setattr__(self, name, _required(getattr(self, name), name))
object.__setattr__(self, "state", HermesJobState(self.state))
if self.output is not None:
object.__setattr__(self, "output", _safe_mapping(self.output))
if self.error is not None:
object.__setattr__(self, "error", _safe_mapping(self.error))
@dataclass(frozen=True)
class HermesHealth:
runtime_id: str
status: str
version: str
heartbeat_at: str
active_workers: int = 0
active_jobs: int = 0
queued_jobs: int = 0
tenant_partitions: int = 0
profile_isolation_enforced: bool = True
details: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("runtime_id", "status", "version", "heartbeat_at"):
object.__setattr__(self, name, _required(getattr(self, name), name))
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
if int(getattr(self, name)) < 0:
raise ValueError(f"{name} must be >= 0")
object.__setattr__(self, "details", _safe_mapping(self.details))
+155
View File
@@ -0,0 +1,155 @@
"""Hermes worker loop with lease renewal and bounded recovery."""
from __future__ import annotations
import threading
from dataclasses import dataclass
from typing import Any, Callable, Mapping
from .adapter import HermesAdapter, HermesConflict
from .contract import HermesJob, HermesJobState
JobHandler = Callable[[HermesJob], Mapping[str, Any]]
@dataclass(frozen=True)
class WorkerRunResult:
claimed: bool
job_id: str | None = None
state: str | None = None
error: str | None = None
class HermesWorker:
"""Poll one tenant/cell/profile partition and execute jobs safely."""
def __init__(
self,
adapter: HermesAdapter,
handler: JobHandler,
*,
worker_id: str,
tenant_id: str,
cell_id: str,
profile_id: str,
poll_seconds: float = 1.0,
heartbeat_seconds: float = 10.0,
) -> None:
if poll_seconds <= 0 or heartbeat_seconds <= 0:
raise ValueError("poll_seconds and heartbeat_seconds must be positive")
for name, value in {
"worker_id": worker_id,
"tenant_id": tenant_id,
"cell_id": cell_id,
"profile_id": profile_id,
}.items():
if not str(value or "").strip():
raise ValueError(f"{name} is required")
self.adapter = adapter
self.handler = handler
self.worker_id = worker_id
self.tenant_id = tenant_id
self.cell_id = cell_id
self.profile_id = profile_id
self.poll_seconds = float(poll_seconds)
self.heartbeat_seconds = float(heartbeat_seconds)
self._stop = threading.Event()
def stop(self) -> None:
self._stop.set()
def run_once(self) -> WorkerRunResult:
job = self.adapter.claim(
tenant_id=self.tenant_id,
cell_id=self.cell_id,
profile_id=self.profile_id,
worker_id=self.worker_id,
)
if job is None:
return WorkerRunResult(claimed=False)
running = self.adapter.start(
job.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
expected_revision=job.revision,
)
heartbeat_stop = threading.Event()
heartbeat_thread = threading.Thread(
target=self._heartbeat_loop,
args=(running.job_id, heartbeat_stop),
name=f"hermes-heartbeat-{self.worker_id}",
daemon=True,
)
heartbeat_thread.start()
output: Mapping[str, Any] | None = None
execution_error: Exception | None = None
try:
output = dict(self.handler(running))
except Exception as exc: # runtime boundary: normalize below
execution_error = exc
finally:
# Stop the lease heartbeat before reading the final revision. This
# prevents a revision increment racing with complete/fail.
heartbeat_stop.set()
heartbeat_thread.join(timeout=max(self.heartbeat_seconds * 2, 1.0))
try:
current = self.adapter.get(running.job_id, tenant_id=self.tenant_id)
if execution_error is None:
result = self.adapter.complete(
running.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
output=dict(output or {}),
expected_revision=current.revision,
)
return WorkerRunResult(
claimed=True,
job_id=result.job_id,
state=result.state.value,
)
result = self.adapter.fail(
running.job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
error={
"code": "worker_error",
"message": str(execution_error),
},
expected_revision=current.revision,
)
return WorkerRunResult(
claimed=True,
job_id=result.job_id,
state=result.state.value,
error=str(execution_error),
)
except HermesConflict as exc:
return WorkerRunResult(
claimed=True,
job_id=running.job_id,
state=HermesJobState.FAILED.value,
error=f"reconciliation_conflict: {exc}",
)
def run_forever(self) -> None:
while not self._stop.is_set():
result = self.run_once()
if not result.claimed:
self._stop.wait(self.poll_seconds)
def _heartbeat_loop(self, job_id: str, stop_event: threading.Event) -> None:
while not stop_event.wait(self.heartbeat_seconds):
try:
self.adapter.heartbeat(
job_id,
tenant_id=self.tenant_id,
worker_id=self.worker_id,
)
except Exception:
stop_event.set()
return
@@ -0,0 +1,45 @@
"""AIOS-managed OpenClaw Ed25519 execution adapter."""
from .adapter import (
AdapterError,
CommandConflict,
CommandNotFound,
CommandRejected,
InMemoryCallbackSink,
OpenClawAdapter,
)
from .contract import (
CONTRACT_VERSION,
SIGNATURE_ALGORITHM,
CommandState,
Ed25519CommandSigner,
Ed25519CommandVerifier,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
sign_command,
verify_command_signature,
)
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
__all__ = [
"AdapterError",
"CONTRACT_VERSION",
"CommandConflict",
"CommandNotFound",
"CommandRejected",
"CommandState",
"Ed25519CommandSigner",
"Ed25519CommandVerifier",
"HTTPRuntimeTransport",
"InMemoryCallbackSink",
"InProcessRuntimeTransport",
"OpenClawAdapter",
"OpenClawCommand",
"OpenClawResult",
"RuntimeHealth",
"RuntimeTransport",
"SIGNATURE_ALGORITHM",
"sign_command",
"verify_command_signature",
]
+228
View File
@@ -0,0 +1,228 @@
"""Signed, tenant-isolated execution adapter for OpenClaw runtimes."""
from __future__ import annotations
import threading
from dataclasses import replace
from typing import Callable, Mapping, Protocol
from .contract import (
CommandState,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
utc_now,
verify_command_signature,
)
class AdapterError(RuntimeError):
"""Base adapter failure."""
class CommandRejected(AdapterError):
"""A command failed signature or contract validation."""
class CommandNotFound(AdapterError):
"""A tenant-scoped command is not visible."""
class CommandConflict(AdapterError):
"""A command or idempotency key conflicts with an existing execution."""
class RuntimeTransport(Protocol):
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
def cancel(self, command_id: str) -> bool: ...
def health(self) -> RuntimeHealth: ...
class CallbackSink(Protocol):
def on_started(self, result: OpenClawResult) -> None: ...
def on_completed(self, result: OpenClawResult) -> None: ...
def on_failed(self, result: OpenClawResult) -> None: ...
class InMemoryCallbackSink:
def __init__(self) -> None:
self._lock = threading.RLock()
self.started: list[OpenClawResult] = []
self.completed: list[OpenClawResult] = []
self.failed: list[OpenClawResult] = []
def on_started(self, result: OpenClawResult) -> None:
with self._lock:
self.started.append(result)
def on_completed(self, result: OpenClawResult) -> None:
with self._lock:
self.completed.append(result)
def on_failed(self, result: OpenClawResult) -> None:
with self._lock:
self.failed.append(result)
SigningMaterialResolver = Callable[[str, str], bytes | str]
class OpenClawAdapter:
"""Verify, de-duplicate, execute, cancel and reconcile commands."""
def __init__(
self,
transport: RuntimeTransport,
signing_material_resolver: SigningMaterialResolver,
*,
callback_sink: CallbackSink | None = None,
default_timeout_seconds: float = 300.0,
) -> None:
if default_timeout_seconds <= 0:
raise ValueError("default_timeout_seconds must be positive")
self.transport = transport
self.signing_material_resolver = signing_material_resolver
self.callback_sink = callback_sink or InMemoryCallbackSink()
self.default_timeout_seconds = float(default_timeout_seconds)
self._lock = threading.RLock()
self._commands: dict[str, OpenClawCommand] = {}
self._results: dict[str, OpenClawResult] = {}
self._idempotency: dict[tuple[str, str], tuple[str, str]] = {}
def _verify(self, command: OpenClawCommand) -> None:
try:
key_material = self.signing_material_resolver(command.tenant_id, command.key_id)
except Exception as exc:
raise CommandRejected("signing material unavailable") from exc
if not verify_command_signature(command, key_material):
raise CommandRejected("invalid or expired command signature")
@staticmethod
def _validate_result(command: OpenClawCommand, result: OpenClawResult) -> None:
if (
result.command_id != command.command_id
or result.tenant_id != command.tenant_id
or result.task_id != command.task_id
or result.trace_id != command.trace_id
):
raise AdapterError("runtime returned mismatched command identity")
if result.state not in {
CommandState.COMPLETED,
CommandState.FAILED,
CommandState.CANCELLED,
CommandState.TIMED_OUT,
}:
raise AdapterError("runtime returned a non-terminal result")
def execute(
self,
command: OpenClawCommand,
*,
timeout_seconds: float | None = None,
) -> OpenClawResult:
timeout = self.default_timeout_seconds if timeout_seconds is None else float(timeout_seconds)
if timeout <= 0:
raise ValueError("timeout_seconds must be positive")
self._verify(command)
digest = command.signature
idem_ref = (command.tenant_id, command.idempotency_key)
with self._lock:
prior_command = self._commands.get(command.command_id)
if prior_command is not None:
if prior_command.signature != command.signature:
raise CommandConflict("command_id reused with different content")
return self._results[command.command_id]
prior_idem = self._idempotency.get(idem_ref)
if prior_idem is not None:
prior_id, prior_digest = prior_idem
if prior_digest != digest:
raise CommandConflict("idempotency key reused with different content")
return self._results[prior_id]
running = OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState.RUNNING,
started_at=utc_now(),
)
self._commands[command.command_id] = command
self._results[command.command_id] = running
self._idempotency[idem_ref] = (command.command_id, digest)
self.callback_sink.on_started(running)
try:
result = self.transport.execute(command, timeout)
self._validate_result(command, result)
except TimeoutError as exc:
result = replace(
running,
state=CommandState.TIMED_OUT,
error={"code": "runtime_timeout", "message": str(exc) or "runtime timeout"},
finished_at=utc_now(),
)
except Exception as exc:
result = replace(
running,
state=CommandState.FAILED,
error={"code": "runtime_error", "message": str(exc)},
finished_at=utc_now(),
)
if result.started_at is None:
result = replace(result, started_at=running.started_at)
if result.finished_at is None:
result = replace(result, finished_at=utc_now())
with self._lock:
self._results[command.command_id] = result
if result.state == CommandState.COMPLETED:
self.callback_sink.on_completed(result)
else:
self.callback_sink.on_failed(result)
return result
def status(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
with self._lock:
command = self._commands.get(command_id)
result = self._results.get(command_id)
if command is None or result is None or command.tenant_id != tenant_id:
raise CommandNotFound("command not found")
return result
def cancel(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
with self._lock:
command = self._commands.get(command_id)
current = self._results.get(command_id)
if command is None or current is None or command.tenant_id != tenant_id:
raise CommandNotFound("command not found")
if current.state in {
CommandState.COMPLETED,
CommandState.FAILED,
CommandState.CANCELLED,
CommandState.TIMED_OUT,
CommandState.REJECTED,
}:
return current
if not self.transport.cancel(command_id):
raise AdapterError("runtime rejected cancellation")
cancelled = replace(
current,
state=CommandState.CANCELLED,
error={"code": "cancelled", "message": "cancelled by AIOS"},
finished_at=utc_now(),
)
with self._lock:
self._results[command_id] = cancelled
self.callback_sink.on_failed(cancelled)
return cancelled
def health(self) -> RuntimeHealth:
return self.transport.health()
def snapshot(self, *, tenant_id: str) -> Mapping[str, OpenClawResult]:
with self._lock:
return {
command_id: result
for command_id, result in self._results.items()
if self._commands[command_id].tenant_id == tenant_id
}
@@ -0,0 +1,97 @@
"""Audit-oriented lifecycle callbacks for OpenClaw execution."""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol
from .contract import OpenClawResult, utc_now
class EventPublisher(Protocol):
def publish(self, event: Mapping[str, Any]) -> None: ...
@dataclass(frozen=True)
class ExecutionEvent:
event_type: str
tenant_id: str
task_id: str
trace_id: str
command_id: str
state: str
created_at: str = field(default_factory=utc_now)
details: Mapping[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"event_type": self.event_type,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"trace_id": self.trace_id,
"command_id": self.command_id,
"state": self.state,
"created_at": self.created_at,
"details": json.loads(
json.dumps(dict(self.details), ensure_ascii=False, default=str)
),
}
class InMemoryEventPublisher:
"""Thread-safe publisher used by tests and first integration slice."""
def __init__(self) -> None:
self._lock = threading.RLock()
self.events: list[dict[str, Any]] = []
def publish(self, event: Mapping[str, Any]) -> None:
with self._lock:
self.events.append(
json.loads(json.dumps(dict(event), ensure_ascii=False, default=str))
)
class AuditCallbackSink:
"""Convert runtime callbacks into tenant-scoped audit events."""
def __init__(self, publisher: EventPublisher) -> None:
self.publisher = publisher
@staticmethod
def _event(
event_type: str,
result: OpenClawResult,
) -> ExecutionEvent:
details: dict[str, Any] = {
"runtime_id": result.runtime_id,
"attempts": result.attempts,
}
if result.error:
details["error"] = dict(result.error)
if result.output:
details["output_present"] = True
return ExecutionEvent(
event_type=event_type,
tenant_id=result.tenant_id,
task_id=result.task_id,
trace_id=result.trace_id,
command_id=result.command_id,
state=result.state.value,
details=details,
)
def on_started(self, result: OpenClawResult) -> None:
self.publisher.publish(self._event("OPENCLAW_STARTED", result).as_dict())
def on_completed(self, result: OpenClawResult) -> None:
self.publisher.publish(self._event("OPENCLAW_COMPLETED", result).as_dict())
def on_failed(self, result: OpenClawResult) -> None:
event_type = {
"CANCELLED": "OPENCLAW_CANCELLED",
"TIMED_OUT": "OPENCLAW_TIMED_OUT",
"REJECTED": "OPENCLAW_REJECTED",
}.get(result.state.value, "OPENCLAW_FAILED")
self.publisher.publish(self._event(event_type, result).as_dict())
@@ -0,0 +1,383 @@
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import uuid
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Mapping, Protocol
CONTRACT_VERSION = "aios.openclaw.command.v2"
SIGNATURE_ALGORITHM = "Ed25519"
DEFAULT_CLOCK_SKEW_SECONDS = 300
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _parse_timestamp(value: str) -> datetime:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("timestamp is required")
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp must include a timezone")
return parsed.astimezone(timezone.utc)
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(
json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str)
)
def _load_crypto() -> tuple[Any, Any, Any]:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
except ImportError as exc: # pragma: no cover
raise RuntimeError("cryptography with Ed25519 support is required") from exc
return serialization, Ed25519PrivateKey, Ed25519PublicKey
class CommandSigner(Protocol):
key_id: str
fingerprint: str
def sign(self, payload: bytes) -> bytes: ...
class CommandVerifier(Protocol):
key_id: str
fingerprint: str
def verify(self, payload: bytes, signature: bytes) -> bool: ...
class Ed25519CommandSigner:
def __init__(self, private_key: Any, *, key_id: str) -> None:
serialization, private_cls, _ = _load_crypto()
if not isinstance(private_key, private_cls):
raise ValueError("private key is not Ed25519")
self.private_key = private_key
self.key_id = _required(key_id, "key_id")
raw = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
self.fingerprint = hashlib.sha256(raw).hexdigest()
@classmethod
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandSigner":
serialization, private_cls, _ = _load_crypto()
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
if raw.startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(raw, password=None)
elif len(raw) == 32:
key = private_cls.from_private_bytes(raw)
else:
raise ValueError("Ed25519 private key must be PEM or a raw 32-byte seed")
return cls(key, key_id=key_id)
def sign(self, payload: bytes) -> bytes:
return self.private_key.sign(bytes(payload))
def verifier(self) -> "Ed25519CommandVerifier":
return Ed25519CommandVerifier(self.private_key.public_key(), key_id=self.key_id)
class Ed25519CommandVerifier:
def __init__(self, public_key: Any, *, key_id: str) -> None:
serialization, _, public_cls = _load_crypto()
if not isinstance(public_key, public_cls):
raise ValueError("public key is not Ed25519")
self.public_key = public_key
self.key_id = _required(key_id, "key_id")
raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
self.fingerprint = hashlib.sha256(raw).hexdigest()
@classmethod
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandVerifier":
serialization, private_cls, public_cls = _load_crypto()
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
if raw.startswith(b"-----BEGIN"):
try:
key = serialization.load_pem_public_key(raw)
except ValueError:
private = serialization.load_pem_private_key(raw, password=None)
if not isinstance(private, private_cls):
raise ValueError("key is not Ed25519")
key = private.public_key()
elif len(raw) == 32:
key = public_cls.from_public_bytes(raw)
else:
raise ValueError("Ed25519 public key must be PEM or raw 32-byte key")
return cls(key, key_id=key_id)
def verify(self, payload: bytes, signature: bytes) -> bool:
try:
self.public_key.verify(bytes(signature), bytes(payload))
return True
except Exception:
return False
class CommandState(str, Enum):
ACCEPTED = "ACCEPTED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
TIMED_OUT = "TIMED_OUT"
REJECTED = "REJECTED"
@dataclass(frozen=True)
class OpenClawCommand:
command_id: str
tenant_id: str
task_id: str
trace_id: str
action: str
payload: Mapping[str, Any] = field(default_factory=dict)
idempotency_key: str = ""
created_at: str = field(default_factory=utc_now)
expires_at: str | None = None
contract_version: str = CONTRACT_VERSION
signature_algorithm: str = SIGNATURE_ALGORITHM
key_id: str = "default"
public_key_fingerprint: str = ""
signature: str = ""
def __post_init__(self) -> None:
for field_name in (
"command_id",
"tenant_id",
"task_id",
"trace_id",
"action",
"idempotency_key",
"created_at",
"contract_version",
"signature_algorithm",
"key_id",
):
object.__setattr__(
self, field_name, _required(getattr(self, field_name), field_name)
)
if self.contract_version != CONTRACT_VERSION:
raise ValueError(f"unsupported contract_version: {self.contract_version}")
if self.signature_algorithm != SIGNATURE_ALGORITHM:
raise ValueError(
f"unsupported signature_algorithm: {self.signature_algorithm}"
)
object.__setattr__(self, "payload", _safe_mapping(self.payload))
object.__setattr__(
self, "public_key_fingerprint", str(self.public_key_fingerprint or "").strip()
)
object.__setattr__(self, "signature", str(self.signature or "").strip())
_parse_timestamp(self.created_at)
if self.expires_at is not None:
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
if _parse_timestamp(self.expires_at) <= _parse_timestamp(self.created_at):
raise ValueError("expires_at must be later than created_at")
@classmethod
def create(
cls,
*,
tenant_id: str,
task_id: str,
trace_id: str,
action: str,
payload: Mapping[str, Any] | None,
idempotency_key: str,
expires_at: str | None = None,
key_id: str = "default",
command_id: str | None = None,
) -> "OpenClawCommand":
return cls(
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
tenant_id=tenant_id,
task_id=task_id,
trace_id=trace_id,
action=action,
payload=_safe_mapping(payload),
idempotency_key=idempotency_key,
expires_at=expires_at,
key_id=key_id,
)
def unsigned_dict(self) -> dict[str, Any]:
return {
"command_id": self.command_id,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"trace_id": self.trace_id,
"action": self.action,
"payload": _safe_mapping(self.payload),
"idempotency_key": self.idempotency_key,
"created_at": self.created_at,
"expires_at": self.expires_at,
"contract_version": self.contract_version,
"signature_algorithm": self.signature_algorithm,
"key_id": self.key_id,
"public_key_fingerprint": self.public_key_fingerprint,
}
@dataclass(frozen=True)
class OpenClawResult:
command_id: str
tenant_id: str
task_id: str
trace_id: str
state: CommandState
output: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
started_at: str | None = None
finished_at: str | None = None
runtime_id: str | None = None
attempts: int = 1
def __post_init__(self) -> None:
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
object.__setattr__(
self, field_name, _required(getattr(self, field_name), field_name)
)
object.__setattr__(self, "state", CommandState(self.state))
if self.output is not None:
object.__setattr__(self, "output", _safe_mapping(self.output))
if self.error is not None:
object.__setattr__(self, "error", _safe_mapping(self.error))
if self.started_at is not None:
_parse_timestamp(self.started_at)
if self.finished_at is not None:
_parse_timestamp(self.finished_at)
if self.attempts < 1:
raise ValueError("attempts must be >= 1")
@dataclass(frozen=True)
class RuntimeHealth:
runtime: str
runtime_id: str
status: str
version: str
heartbeat_at: str
active_tasks: int = 0
workspace_isolated: bool = False
credential_ref_configured: bool = False
details: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_name in (
"runtime",
"runtime_id",
"status",
"version",
"heartbeat_at",
):
object.__setattr__(
self, field_name, _required(getattr(self, field_name), field_name)
)
_parse_timestamp(self.heartbeat_at)
if self.active_tasks < 0:
raise ValueError("active_tasks must be >= 0")
object.__setattr__(self, "details", _safe_mapping(self.details))
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
return json.dumps(
command.unsigned_dict(),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def _as_signer(command: OpenClawCommand, value: Any) -> CommandSigner:
if hasattr(value, "sign") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
return value
return Ed25519CommandSigner.from_bytes(value, key_id=command.key_id)
def _as_verifier(command: OpenClawCommand, value: Any) -> CommandVerifier:
if isinstance(value, Ed25519CommandSigner):
return value.verifier()
if hasattr(value, "verify") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
return value
return Ed25519CommandVerifier.from_bytes(value, key_id=command.key_id)
def sign_command(command: OpenClawCommand, signing_key: Any) -> OpenClawCommand:
signer = _as_signer(command, signing_key)
if signer.key_id != command.key_id:
raise ValueError("command key_id does not match signer")
prepared = replace(
command,
signature_algorithm=SIGNATURE_ALGORITHM,
public_key_fingerprint=signer.fingerprint,
signature="",
)
signature = base64.b64encode(
signer.sign(canonical_command_bytes(prepared))
).decode("ascii")
return replace(prepared, signature=signature)
def verify_command_signature(
command: OpenClawCommand,
verification_key: Any,
*,
now: datetime | None = None,
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
) -> bool:
if (
not command.signature
or command.contract_version != CONTRACT_VERSION
or command.signature_algorithm != SIGNATURE_ALGORITHM
):
return False
try:
verifier = _as_verifier(command, verification_key)
signature = base64.b64decode(command.signature, validate=True)
except Exception:
return False
if verifier.key_id != command.key_id:
return False
if not command.public_key_fingerprint or not hmac.compare_digest(
command.public_key_fingerprint, verifier.fingerprint
):
return False
if not verifier.verify(canonical_command_bytes(command), signature):
return False
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
created = _parse_timestamp(command.created_at)
if created.timestamp() - current.timestamp() > clock_skew_seconds:
return False
if command.expires_at is not None:
expires = _parse_timestamp(command.expires_at)
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
return False
return True
+103
View File
@@ -0,0 +1,103 @@
"""Health policy and self-healing decisions for OpenClaw runtimes."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Mapping
from .contract import RuntimeHealth
def _parse(value: str) -> datetime:
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("heartbeat timestamp must include timezone")
return parsed.astimezone(timezone.utc)
@dataclass(frozen=True)
class HealthPolicy:
max_heartbeat_age_seconds: int = 90
require_workspace_isolation: bool = True
require_credential_ref: bool = True
accepted_statuses: frozenset[str] = field(
default_factory=lambda: frozenset({"healthy", "ready"})
)
def __post_init__(self) -> None:
if self.max_heartbeat_age_seconds < 5:
raise ValueError("max_heartbeat_age_seconds must be >= 5")
if not self.accepted_statuses:
raise ValueError("accepted_statuses must not be empty")
@dataclass(frozen=True)
class HealthAssessment:
healthy: bool
reasons: tuple[str, ...]
recoverable: bool
recommended_action: str
details: Mapping[str, object] = field(default_factory=dict)
class RuntimeHealthMonitor:
"""Evaluate runtime health and recommend bounded recovery actions."""
def __init__(self, policy: HealthPolicy | None = None) -> None:
self.policy = policy or HealthPolicy()
def assess(
self,
health: RuntimeHealth,
*,
now: datetime | None = None,
) -> HealthAssessment:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
heartbeat = _parse(health.heartbeat_at)
age = max(0.0, current.timestamp() - heartbeat.timestamp())
reasons: list[str] = []
if health.status.lower() not in self.policy.accepted_statuses:
reasons.append("runtime_status_unhealthy")
if age > self.policy.max_heartbeat_age_seconds:
reasons.append("heartbeat_stale")
if self.policy.require_workspace_isolation and not health.workspace_isolated:
reasons.append("workspace_not_isolated")
if self.policy.require_credential_ref and not health.credential_ref_configured:
reasons.append("credential_reference_missing")
if not reasons:
return HealthAssessment(
healthy=True,
reasons=(),
recoverable=True,
recommended_action="none",
details={"heartbeat_age_seconds": age},
)
non_recoverable = {
"workspace_not_isolated",
"credential_reference_missing",
}.intersection(reasons)
if non_recoverable:
action = "quarantine_runtime"
recoverable = False
elif "heartbeat_stale" in reasons:
action = "restart_runtime"
recoverable = True
else:
action = "drain_and_restart"
recoverable = True
return HealthAssessment(
healthy=False,
reasons=tuple(reasons),
recoverable=recoverable,
recommended_action=action,
details={
"heartbeat_age_seconds": age,
"runtime_id": health.runtime_id,
"active_tasks": health.active_tasks,
},
)
@@ -0,0 +1,214 @@
"""Runtime transport implementations for the OpenClaw adapter."""
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable, Mapping, Protocol
from .contract import (
CommandState,
OpenClawCommand,
OpenClawResult,
RuntimeHealth,
utc_now,
)
class RuntimeTransport(Protocol):
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
def cancel(self, command_id: str) -> bool: ...
def health(self) -> RuntimeHealth: ...
class TransportError(RuntimeError):
"""Runtime transport or protocol failure."""
RuntimeHandler = Callable[[OpenClawCommand], Mapping[str, Any] | OpenClawResult]
class InProcessRuntimeTransport:
"""Deterministic runtime used by tests and controlled local integrations."""
def __init__(
self,
handler: RuntimeHandler,
*,
runtime_id: str = "openclaw-in-process",
version: str = "v1",
workspace_isolated: bool = True,
credential_ref_configured: bool = True,
) -> None:
self.handler = handler
self.runtime_id = runtime_id
self.version = version
self.workspace_isolated = workspace_isolated
self.credential_ref_configured = credential_ref_configured
self._lock = threading.RLock()
self._running: set[str] = set()
self._cancelled: set[str] = set()
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
with self._lock:
if command.command_id in self._cancelled:
return OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState.CANCELLED,
error={"code": "cancelled", "message": "cancelled before execution"},
finished_at=utc_now(),
runtime_id=self.runtime_id,
)
self._running.add(command.command_id)
try:
raw = self.handler(command)
if isinstance(raw, OpenClawResult):
return raw
if not isinstance(raw, Mapping):
raise TransportError("runtime handler must return a mapping or OpenClawResult")
return OpenClawResult(
command_id=command.command_id,
tenant_id=command.tenant_id,
task_id=command.task_id,
trace_id=command.trace_id,
state=CommandState(str(raw.get("state", "COMPLETED"))),
output=raw.get("output"),
error=raw.get("error"),
started_at=str(raw.get("started_at") or utc_now()),
finished_at=str(raw.get("finished_at") or utc_now()),
runtime_id=str(raw.get("runtime_id") or self.runtime_id),
attempts=int(raw.get("attempts", 1)),
)
finally:
with self._lock:
self._running.discard(command.command_id)
def cancel(self, command_id: str) -> bool:
with self._lock:
self._cancelled.add(command_id)
return command_id in self._running
def health(self) -> RuntimeHealth:
with self._lock:
active = len(self._running)
return RuntimeHealth(
runtime="openclaw",
runtime_id=self.runtime_id,
status="healthy",
version=self.version,
heartbeat_at=utc_now(),
active_tasks=active,
workspace_isolated=self.workspace_isolated,
credential_ref_configured=self.credential_ref_configured,
details={"transport": "in_process"},
)
class HTTPRuntimeTransport:
"""Private HTTP transport for an OpenClaw runtime service."""
def __init__(
self,
base_url: str,
*,
runtime_id: str = "openclaw-http",
credential_ref: str | None = None,
max_response_bytes: int = 2_097_152,
) -> None:
normalized = str(base_url).rstrip("/")
if not normalized.startswith(("http://", "https://")):
raise ValueError("base_url must use http or https")
self.base_url = normalized
self.runtime_id = runtime_id
self.credential_ref = credential_ref
self.max_response_bytes = int(max_response_bytes)
def _request(
self,
method: str,
path: str,
*,
payload: Mapping[str, Any] | None,
timeout_seconds: float,
) -> Mapping[str, Any]:
body = None
headers = {"Accept": "application/json", "User-Agent": "AIOS-OpenClaw/1"}
if payload is not None:
body = json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")
headers["Content-Type"] = "application/json"
if self.credential_ref:
headers["X-AIOS-Credential-Ref"] = self.credential_ref
request = urllib.request.Request(
f"{self.base_url}{path}",
data=body,
method=method,
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
raw = response.read(self.max_response_bytes + 1)
except urllib.error.HTTPError as exc:
raise TransportError(f"runtime returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise TransportError(f"runtime unavailable: {exc.reason}") from exc
if len(raw) > self.max_response_bytes:
raise TransportError("runtime response exceeded size limit")
decoded = json.loads(raw.decode("utf-8"))
if not isinstance(decoded, Mapping):
raise TransportError("runtime response must be a JSON object")
return decoded
@staticmethod
def _command_payload(command: OpenClawCommand) -> dict[str, Any]:
return {**command.unsigned_dict(), "signature": command.signature}
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
value = self._request(
"POST",
"/v1/execute",
payload=self._command_payload(command),
timeout_seconds=timeout_seconds,
)
return OpenClawResult(
command_id=str(value["command_id"]),
tenant_id=str(value["tenant_id"]),
task_id=str(value["task_id"]),
trace_id=str(value["trace_id"]),
state=CommandState(str(value["state"])),
output=value.get("output"),
error=value.get("error"),
started_at=value.get("started_at"),
finished_at=value.get("finished_at"),
runtime_id=str(value.get("runtime_id") or self.runtime_id),
attempts=int(value.get("attempts", 1)),
)
def cancel(self, command_id: str) -> bool:
value = self._request(
"POST",
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel",
payload={},
timeout_seconds=10.0,
)
return bool(value.get("accepted"))
def health(self) -> RuntimeHealth:
value = self._request("GET", "/v1/health", payload=None, timeout_seconds=5.0)
return RuntimeHealth(
runtime=str(value.get("runtime") or "openclaw"),
runtime_id=str(value.get("runtime_id") or self.runtime_id),
status=str(value.get("status") or "unknown"),
version=str(value.get("version") or "unknown"),
heartbeat_at=str(value.get("heartbeat_at") or utc_now()),
active_tasks=int(value.get("active_tasks", 0)),
workspace_isolated=bool(value.get("workspace_isolated", False)),
credential_ref_configured=bool(value.get("credential_ref_configured", False)),
details=value.get("details") if isinstance(value.get("details"), Mapping) else {},
)
+110
View File
@@ -0,0 +1,110 @@
"""AIOS V2 控制面、执行编排与可信运行来源证明。"""
from .control_plane import (
AgentRecord,
AuditEvent,
ControlPlaneError,
ControlPlaneService,
IdempotencyConflict,
InMemoryControlPlaneStore,
InvalidTransition,
NotFound,
PolicyDenied,
RequestContext,
RevisionConflict,
TaskRecord,
TaskState,
TenantIsolationError,
TenantRecord,
Unauthorized,
)
from .execution_tracking import ExecutionEvidence, ExecutionTracker
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
from .provenance import (
Ed25519PrivateKeySigner,
Ed25519PublicKeyVerifier,
ExecutionPath,
GitOperation,
PRODUCTION_POLICY_VERSION,
ProvenanceAuthorization,
ProvenanceControlledWriter,
ProvenanceDenied,
ProvenanceError,
ProvenanceExpired,
ProvenanceIssuer,
ProvenanceNotFound,
ProvenanceReplay,
ProvenanceRevoked,
ProvenanceSignatureError,
ProvenanceStatus,
ProvenanceVerifier,
RunProvenance,
SQLiteProvenanceStore,
digest_payload,
)
from .provenance_git import (
GitCommandResult,
GitExecutionContext,
ProvenanceGitError,
ProvenanceGitWriter,
)
from .provenance_operations import (
OperationContext,
OperationReceipt,
ProvenanceOperationGate,
build_provenance_writer_from_env,
context_from_environment,
)
__all__ = [
"AgentRecord",
"AuditEvent",
"ControlPlaneError",
"ControlPlaneService",
"DispatchRecord",
"Ed25519PrivateKeySigner",
"Ed25519PublicKeyVerifier",
"ExecutionEvidence",
"ExecutionOrchestrator",
"ExecutionPath",
"ExecutionTracker",
"GitCommandResult",
"GitExecutionContext",
"GitOperation",
"IdempotencyConflict",
"InMemoryControlPlaneStore",
"InvalidTransition",
"NotFound",
"OperationContext",
"OperationReceipt",
"OrchestrationError",
"PRODUCTION_POLICY_VERSION",
"PolicyDenied",
"ProvenanceAuthorization",
"ProvenanceControlledWriter",
"ProvenanceDenied",
"ProvenanceError",
"ProvenanceExpired",
"ProvenanceGitError",
"ProvenanceGitWriter",
"ProvenanceIssuer",
"ProvenanceNotFound",
"ProvenanceOperationGate",
"ProvenanceReplay",
"ProvenanceRevoked",
"ProvenanceSignatureError",
"ProvenanceStatus",
"ProvenanceVerifier",
"RequestContext",
"RevisionConflict",
"RunProvenance",
"SQLiteProvenanceStore",
"TaskRecord",
"TaskState",
"TenantIsolationError",
"TenantRecord",
"Unauthorized",
"build_provenance_writer_from_env",
"context_from_environment",
"digest_payload",
]
+807
View File
@@ -0,0 +1,807 @@
"""AIOS V2 production-candidate application entrypoint."""
from __future__ import annotations
import dataclasses
import json
import os
import stat
import urllib.parse
from enum import Enum
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Mapping
from aios_hermes import HermesAdapter, HermesJobState, HermesResult, SQLiteHermesStore
from aios_openclaw import (
Ed25519CommandSigner,
HTTPRuntimeTransport,
OpenClawAdapter,
)
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
from .console import CONSOLE_HTML
from .control_plane import AuditEvent, RequestContext, Unauthorized
from .dispatch_store import SQLiteDispatchRepository
from .execution_tracking import ExecutionEvidence, ExecutionTracker
from .gateway import GatewayApplication, error_response
from .gitops_status import collect_gitops_status
from .orchestrator import ExecutionOrchestrator
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
from .provenance_operations import build_provenance_writer_from_env
def _json_value(value: Any) -> Any:
if dataclasses.is_dataclass(value):
return {
item.name: _json_value(getattr(value, item.name))
for item in dataclasses.fields(value)
}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_value(item) for item in value]
return value
def _enabled(value: str | None) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _header(headers: Mapping[str, str], name: str) -> str | None:
target = name.lower()
for key, value in headers.items():
if str(key).lower() == target:
normalized = str(value or "").strip()
return normalized or None
return None
def _provenance_context(
headers: Mapping[str, str],
payload: Mapping[str, Any],
) -> tuple[str | None, str | None]:
header_provenance = _header(headers, "X-AIOS-Provenance-ID")
header_run = _header(headers, "X-AIOS-Run-ID")
body_provenance = str(payload.get("provenance_id") or "").strip() or None
body_run = str(payload.get("run_id") or "").strip() or None
if header_provenance and body_provenance and header_provenance != body_provenance:
raise ValueError("provenance_id header/body mismatch")
if header_run and body_run and header_run != body_run:
raise ValueError("run_id header/body mismatch")
return header_provenance or body_provenance, header_run or body_run
def _read_protected_file(path: Path, *, private: bool) -> bytes:
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(str(path), flags)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("key reference is not a regular file")
denied = 0o077 if private else 0o022
if metadata.st_mode & denied:
raise RuntimeError(
"private key file permissions must be 0600 or stricter"
if private
else "public key file must not be group/world writable"
)
chunks: list[bytes] = []
while True:
chunk = os.read(descriptor, 65536)
if not chunk:
break
chunks.append(chunk)
value = b"".join(chunks)
if not value:
raise RuntimeError("key file is empty")
return value
finally:
os.close(descriptor)
class FileCommandSignerResolver:
"""Resolve tenant/key-id scoped OpenClaw Ed25519 signing material."""
def __init__(self, path: str | Path, *, configured_key_id: str) -> None:
self.path = Path(path).resolve()
self.configured_key_id = str(configured_key_id or "").strip()
if not self.configured_key_id:
raise ValueError("configured_key_id is required")
def __call__(self, tenant_id: str, key_id: str) -> Ed25519CommandSigner:
if not str(tenant_id or "").strip():
raise RuntimeError("tenant_id is required for command signing")
if key_id != self.configured_key_id:
raise RuntimeError("OpenClaw signing key_id mismatch")
return Ed25519CommandSigner.from_bytes(
_read_protected_file(self.path, private=True),
key_id=key_id,
)
class ControlPlaneEventPublisher(EventPublisher):
"""Append Runtime callback events to the tenant audit stream.
Callback audit remains informational. Authoritative task reconciliation is
performed only by the orchestrator after dispatch/result validation.
"""
def __init__(self, service: DurableControlPlaneService) -> None:
self.service = service
def publish(self, event: Mapping[str, Any]) -> None:
tenant_id = str(event.get("tenant_id") or "").strip()
trace_id = str(event.get("trace_id") or "").strip()
command_id = str(event.get("command_id") or "").strip()
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
if not tenant_id or not trace_id or not command_id:
raise ValueError("runtime audit event is missing tenant identity")
state_value = str(event.get("state") or "unknown").lower()
outcome = "allowed" if state_value == "completed" else state_value
runtime = str(event.get("runtime") or "openclaw").strip().lower()
principal_id = str(
event.get("principal_id") or f"{runtime}-runtime"
).strip()
audit = AuditEvent(
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
tenant_id=tenant_id,
principal_id=principal_id,
trace_id=trace_id,
action=event_type,
resource_type=f"{runtime}_execution",
resource_id=command_id,
outcome=outcome,
policy_version="runtime-v2",
details=dict(event.get("details") or {}),
)
with self.service.store.lock:
self.service.store.audit_events.append(audit)
self.service.repository.save(self.service._snapshot())
class CandidateApplication:
def __init__(
self,
gateway: GatewayApplication,
orchestrator: ExecutionOrchestrator,
tracker: ExecutionTracker,
hermes: HermesAdapter,
openclaw: OpenClawAdapter | None,
*,
hermes_lifecycle_api_enabled: bool = False,
) -> None:
self.gateway = gateway
self.orchestrator = orchestrator
self.tracker = tracker
self.hermes = hermes
self.openclaw = openclaw
self.hermes_lifecycle_api_enabled = bool(hermes_lifecycle_api_enabled)
self.runtime_events = ControlPlaneEventPublisher(orchestrator.control_plane)
def context(self, headers: Mapping[str, str]) -> RequestContext:
return self.gateway.context_from_headers(headers)
@staticmethod
def _require_runtime_role(context: RequestContext) -> None:
if not ({"runtime", "platform_admin"} & set(context.roles)):
raise Unauthorized("runtime reconciliation role is required")
@staticmethod
def _require_control_plane_cancel(context: RequestContext) -> None:
required = {"runtime", "platform_admin"}
if not required.issubset(set(context.roles)):
raise Unauthorized(
"Hermes cancellation requires trusted AIOS control-plane authority"
)
@staticmethod
def _required_identity(payload: Mapping[str, Any], name: str) -> str:
value = str(payload.get(name) or "").strip()
if not value:
raise ValueError(f"{name} is required")
return value
def _record_hermes_lifecycle(
self,
context: RequestContext,
dispatch: Any,
job: Any,
*,
action: str,
worker_id: str | None,
provenance_id: str,
run_id: str,
) -> None:
executor = worker_id or "aios-control-plane"
evidence = ExecutionEvidence(
execution_id=job.job_id,
tenant_id=job.tenant_id,
task_id=job.task_id,
agent_id=dispatch.agent_id,
executor=executor,
branch="runtime/hermes",
status=job.state.value.lower(),
progress_completed=0,
progress_total=3,
current_step=action,
provenance_id=provenance_id,
run_id=run_id,
)
current_evidence = self.tracker.claim(
evidence,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if job.state in {
HermesJobState.COMPLETED,
HermesJobState.FAILED,
HermesJobState.CANCELLED,
}:
if current_evidence.completed_at is None:
self.tracker.complete(
tenant_id=job.tenant_id,
execution_id=job.job_id,
status=job.state.value.lower(),
test_status="runtime_terminal",
test_summary={"lifecycle_action": action},
result_summary={
"state": job.state.value,
"worker_id": worker_id,
"dispatch_id": dispatch.dispatch_id,
},
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
else:
self.tracker.heartbeat(
tenant_id=job.tenant_id,
execution_id=job.job_id,
status=job.state.value.lower(),
current_step=action,
progress_completed=(1 if job.state == HermesJobState.RUNNING else 0),
progress_total=3,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
self.runtime_events.publish(
{
"runtime": "hermes",
"tenant_id": job.tenant_id,
"principal_id": context.principal_id,
"trace_id": job.trace_id,
"command_id": job.job_id,
"event_type": f"HERMES_{action.upper()}",
"state": job.state.value,
"details": {
"dispatch_id": dispatch.dispatch_id,
"task_id": job.task_id,
"worker_id": worker_id,
"cell_id": job.cell_id,
"profile_id": job.profile_id,
"revision": job.revision,
"provenance_id": provenance_id,
"run_id": run_id,
},
}
)
def handle_json(
self,
method: str,
raw_path: str,
headers: Mapping[str, str],
body: Any,
) -> tuple[int, Mapping[str, Any]]:
parsed = urllib.parse.urlparse(raw_path)
path = parsed.path.rstrip("/") or "/"
method = method.upper()
if method == "GET" and path == "/api/v2/gitops":
context = self.context(headers)
return HTTPStatus.OK, {
"tenant_id": context.tenant_id,
"gitops": collect_gitops_status().as_dict(),
}
if method == "GET" and path == "/api/v2/runtime":
context = self.context(headers)
runtimes: list[dict[str, Any]] = []
if self.openclaw is None:
runtimes.append(
{
"name": "openclaw",
"status": "disabled",
"details": {"reason": "runtime_not_configured"},
}
)
else:
try:
health = self.openclaw.health()
runtimes.append(
{
"name": "openclaw",
"status": health.status,
"details": _json_value(health),
}
)
except Exception as exc:
runtimes.append(
{
"name": "openclaw",
"status": "unavailable",
"details": {"message": str(exc)},
}
)
hermes_health = self.hermes.health()
runtimes.append(
{
"name": "hermes",
"status": hermes_health.status,
"details": _json_value(hermes_health),
}
)
return HTTPStatus.OK, {
"tenant_id": context.tenant_id,
"runtimes": runtimes,
}
if method == "GET" and path == "/api/v2/executions":
context = self.context(headers)
return HTTPStatus.OK, {
"executions": _json_value(
self.tracker.list(tenant_id=context.tenant_id)
)
}
dispatch_match = __import__("re").fullmatch(
r"/api/v2/tasks/([^/]+)/dispatch", path
)
if method == "POST" and dispatch_match:
context = self.context(headers)
task_id = urllib.parse.unquote(dispatch_match.group(1))
payload = body if isinstance(body, Mapping) else {}
timeout = payload.get("timeout_seconds")
provenance_id, run_id = _provenance_context(headers, payload)
record = self.orchestrator.dispatch(
context,
task_id=task_id,
timeout_seconds=float(timeout) if timeout is not None else None,
provenance_id=provenance_id,
run_id=run_id,
)
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
lifecycle_match = __import__("re").fullmatch(
r"/api/v2/internal/runtime/hermes/lifecycle/"
r"(claim|start|heartbeat|complete|fail|cancel|status)",
path,
)
if method == "POST" and lifecycle_match:
if not self.hermes_lifecycle_api_enabled:
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "route not found",
}
context = self.context(headers)
self._require_runtime_role(context)
if not isinstance(body, Mapping):
raise ValueError("JSON body must be an object")
payload = body
action = lifecycle_match.group(1)
identity = {
name: self._required_identity(payload, name)
for name in (
"dispatch_id",
"job_id",
"tenant_id",
"task_id",
"trace_id",
"principal_id",
"provenance_id",
"run_id",
"cell_id",
"profile_id",
)
}
header_provenance, header_run = _provenance_context(headers, payload)
if header_provenance != identity["provenance_id"]:
raise ValueError("provenance_id context mismatch")
if header_run != identity["run_id"]:
raise ValueError("run_id context mismatch")
requeued_job_ids = self.hermes.requeue_expired()
dispatch, job = self.orchestrator.hermes_lifecycle_context(
context,
dispatch_id=identity["dispatch_id"],
job_id=identity["job_id"],
tenant_id=identity["tenant_id"],
task_id=identity["task_id"],
trace_id=identity["trace_id"],
principal_id=identity["principal_id"],
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
if job.cell_id != identity["cell_id"]:
raise ValueError("cell_id mismatch")
if job.profile_id != identity["profile_id"]:
raise ValueError("profile_id mismatch")
if action == "status":
if job.job_id in requeued_job_ids:
self._record_hermes_lifecycle(
context,
dispatch,
job,
action="lease_expired_requeue",
worker_id=None,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
task = self.orchestrator.control_plane.get_task(
context, dispatch.task_id
)
response: dict[str, Any] = {
"job": _json_value(job),
"dispatch": _json_value(dispatch),
"task": _json_value(task),
}
if job.state in {
HermesJobState.COMPLETED,
HermesJobState.FAILED,
HermesJobState.CANCELLED,
}:
response["result"] = _json_value(
self.hermes.result(job.job_id, tenant_id=job.tenant_id)
)
return HTTPStatus.OK, response
expected_revision = payload.get("expected_revision")
expected = (
int(expected_revision) if expected_revision is not None else None
)
if action == "cancel":
self._require_control_plane_cancel(context)
if payload.get("worker_id") is not None:
raise Unauthorized("Hermes workers may observe but not initiate cancel")
if payload.get("cancellation_source") != "aios-control-plane":
raise ValueError(
"cancellation_source must be aios-control-plane"
)
result = self.hermes.cancel(
job.job_id,
tenant_id=job.tenant_id,
expected_revision=expected,
)
else:
worker_id = self._required_identity(payload, "worker_id")
if action == "claim":
claimed = self.hermes.claim(
tenant_id=job.tenant_id,
cell_id=job.cell_id,
profile_id=job.profile_id,
worker_id=worker_id,
lease_seconds=(
int(payload["lease_seconds"])
if payload.get("lease_seconds") is not None
else None
),
job_id=job.job_id,
)
if claimed is None:
raise ValueError("Hermes job is not claimable")
bound = self.orchestrator.bind_hermes_worker(
context,
claimed,
dispatch_id=dispatch.dispatch_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
worker_id=worker_id,
)
self._record_hermes_lifecycle(
context,
bound,
claimed,
action="claim",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {
"job": _json_value(claimed),
"dispatch": _json_value(bound),
}
if action == "start":
started = self.hermes.start(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
expected_revision=expected,
)
self._record_hermes_lifecycle(
context,
dispatch,
started,
action="start",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {"job": _json_value(started)}
if action == "heartbeat":
heartbeat = self.hermes.heartbeat(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
lease_seconds=(
int(payload["lease_seconds"])
if payload.get("lease_seconds") is not None
else None
),
)
self._record_hermes_lifecycle(
context,
dispatch,
heartbeat,
action="heartbeat",
worker_id=worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {"job": _json_value(heartbeat)}
if action == "complete":
output = payload.get("output")
if not isinstance(output, Mapping):
raise ValueError("output must be an object")
result = self.hermes.complete(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
output=output,
expected_revision=expected,
)
elif action == "fail":
error = payload.get("error")
if not isinstance(error, Mapping):
raise ValueError("error must be an object")
result = self.hermes.fail(
job.job_id,
tenant_id=job.tenant_id,
worker_id=worker_id,
error=error,
expected_revision=expected,
)
else:
raise ValueError("unsupported Hermes lifecycle action")
task = self.orchestrator.reconcile_hermes(
context,
result,
dispatch_id=dispatch.dispatch_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
updated_dispatch = self.orchestrator.dispatch_repository.get(
dispatch.dispatch_id, tenant_id=dispatch.tenant_id
)
terminal_job = self.hermes.get(job.job_id, tenant_id=job.tenant_id)
self._record_hermes_lifecycle(
context,
updated_dispatch,
terminal_job,
action=action,
worker_id=result.worker_id,
provenance_id=identity["provenance_id"],
run_id=identity["run_id"],
)
return HTTPStatus.OK, {
"result": _json_value(result),
"task": _json_value(task),
"dispatch": _json_value(updated_dispatch),
}
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
context = self.context(headers)
self._require_runtime_role(context)
if not isinstance(body, Mapping):
raise ValueError("JSON body must be an object")
provenance_id, run_id = _provenance_context(headers, body)
if not provenance_id or not run_id:
raise ValueError("provenance_id and run_id are required")
dispatch_id = str(body.get("dispatch_id") or "").strip()
if not dispatch_id:
raise ValueError("dispatch_id is required")
result = HermesResult(
job_id=str(body["job_id"]),
tenant_id=str(body["tenant_id"]),
task_id=str(body["task_id"]),
trace_id=str(body["trace_id"]),
state=HermesJobState(str(body["state"])),
worker_id=(
str(body["worker_id"])
if body.get("worker_id") is not None
else None
),
output=body.get("output"),
error=body.get("error"),
started_at=body.get("started_at"),
finished_at=body.get("finished_at"),
)
task = self.orchestrator.reconcile_hermes(
context,
result,
dispatch_id=dispatch_id,
provenance_id=provenance_id,
run_id=run_id,
)
return HTTPStatus.OK, {"task": _json_value(task)}
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
return HTTPStatus.GONE, {
"error": "RuntimeReconcileEndpointMoved",
"message": "use /api/v2/internal/runtime/hermes/reconcile",
}
return self.gateway.handle(method, raw_path, headers, body)
class CandidateRequestHandler(BaseHTTPRequestHandler):
server_version = "AIOSV2Candidate/2.0"
max_body_bytes = 1_048_576
@property
def application(self) -> CandidateApplication:
return self.server.application # type: ignore[attr-defined]
def _read_body(self) -> Any:
length = int(self.headers.get("Content-Length", "0") or "0")
if length < 0 or length > self.max_body_bytes:
raise ValueError("request body is too large")
if length == 0:
return {}
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
if content_type != "application/json":
raise ValueError("Content-Type must be application/json")
return json.loads(self.rfile.read(length).decode("utf-8"))
def _security_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
)
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(
_json_value(payload), ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
self.send_response(int(status_code))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self._security_headers()
self.end_headers()
self.wfile.write(encoded)
def _send_console(self) -> None:
encoded = CONSOLE_HTML.encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self._security_headers()
self.end_headers()
self.wfile.write(encoded)
def _dispatch(self, method: str) -> None:
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
if method == "GET" and path in {"/", "/console"}:
self._send_console()
return
try:
body = self._read_body() if method == "POST" else {}
status_code, payload = self.application.handle_json(
method,
self.path,
{key: value for key, value in self.headers.items()},
body,
)
except Exception as exc:
status_code, payload = error_response(exc)
self._send_json(status_code, payload)
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
class CandidateHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
self.application = application
super().__init__(address, CandidateRequestHandler)
def build_application() -> CandidateApplication:
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
state_dir.mkdir(parents=True, exist_ok=True)
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
dispatch_repository = SQLiteDispatchRepository(state_dir / "dispatches.sqlite3")
service = DurableControlPlaneService(repository)
require_provenance = _enabled(os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0"))
provenance_writer = build_provenance_writer_from_env(
state_dir=state_dir, required=require_provenance
)
tracker = ExecutionTracker(
state_dir / "executions",
provenance_writer=provenance_writer,
require_provenance=require_provenance,
)
hermes = HermesAdapter(store=SQLiteHermesStore(state_dir / "hermes.sqlite3"))
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
signing_key_id = os.getenv(
"AIOS_OPENCLAW_SIGNING_KEY_ID", "openclaw-v2r1-candidate-ed25519"
).strip()
openclaw = None
resolver = None
if openclaw_url and signing_file:
resolver = FileCommandSignerResolver(
signing_file,
configured_key_id=signing_key_id,
)
transport = HTTPRuntimeTransport(
openclaw_url,
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
)
openclaw = OpenClawAdapter(
transport,
resolver,
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
)
gateway = GatewayApplication(service)
orchestrator = ExecutionOrchestrator(
service,
openclaw=openclaw,
hermes=hermes,
openclaw_signing_material_resolver=resolver,
openclaw_key_id=signing_key_id,
provenance_writer=provenance_writer,
require_provenance=require_provenance,
dispatch_repository=dispatch_repository,
)
return CandidateApplication(
gateway,
orchestrator,
tracker,
hermes,
openclaw,
hermes_lifecycle_api_enabled=_enabled(
os.getenv("AIOS_HERMES_LIFECYCLE_API_ENABLED", "0")
),
)
def main() -> None:
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_PORT", "18920"))
server = CandidateHTTPServer((host, port), build_application())
print(f"AIOS V2 candidate listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
"""Dependency-free AIOS V2 web console assets.
Authentication and tenant identity are injected by the trusted reverse proxy.
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
"""
CONSOLE_HTML = r'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>AIOS V2 Console</title>
<style>
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
</style>
</head>
<body>
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
<main>
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
<section class="cards">
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
</section>
<section class="grid">
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
</section>
</main>
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
async function refresh(){
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
try{
const [agents,tasks,audit,runtime,executions]=await Promise.all([
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
]);
document.getElementById('agentCount').textContent=agents.agents.length;
document.getElementById('taskCount').textContent=tasks.tasks.length;
document.getElementById('auditCount').textContent=audit.events.length;
document.getElementById('executionCount').textContent=executions.executions.length;
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
}
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
</script>
</body>
</html>'''
+627
View File
@@ -0,0 +1,627 @@
"""Tenant-isolated AIOS V2 control-plane domain service.
This module deliberately contains no OpenClaw or Hermes implementation details.
It owns authoritative tenant, agent, task-lifecycle, idempotency and audit state.
Runtime adapters consume approved tasks through a separate boundary.
"""
from __future__ import annotations
import copy
import hashlib
import json
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Callable, Iterable, Mapping
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _new_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex}"
def _canonical_digest(value: Mapping[str, Any]) -> str:
encoded = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
class ControlPlaneError(RuntimeError):
"""Base error returned by the control-plane domain."""
class Unauthorized(ControlPlaneError):
"""The caller is not authenticated or lacks the required role."""
class TenantIsolationError(ControlPlaneError):
"""A request attempted to cross a tenant boundary."""
class NotFound(ControlPlaneError):
"""A tenant-scoped resource does not exist or is not visible."""
class InvalidTransition(ControlPlaneError):
"""A task state transition violates the lifecycle contract."""
class IdempotencyConflict(ControlPlaneError):
"""An idempotency key was reused for a different command."""
class RevisionConflict(ControlPlaneError):
"""Optimistic task revision does not match current state."""
class PolicyDenied(ControlPlaneError):
"""The policy gate denied a control-plane operation."""
@dataclass(frozen=True)
class RequestContext:
"""Authenticated request identity propagated across all AIOS layers."""
tenant_id: str
principal_id: str
trace_id: str
roles: frozenset[str] = field(default_factory=frozenset)
session_id: str | None = None
policy_version: str = "v1"
def __post_init__(self) -> None:
for name in ("tenant_id", "principal_id", "trace_id"):
if not str(getattr(self, name, "")).strip():
raise ValueError(f"{name} is required")
@classmethod
def create(
cls,
*,
tenant_id: str,
principal_id: str,
roles: Iterable[str] = (),
trace_id: str | None = None,
session_id: str | None = None,
policy_version: str = "v1",
) -> "RequestContext":
return cls(
tenant_id=tenant_id.strip(),
principal_id=principal_id.strip(),
trace_id=trace_id or _new_id("trace"),
roles=frozenset(str(role).strip() for role in roles if str(role).strip()),
session_id=session_id,
policy_version=policy_version,
)
@dataclass(frozen=True)
class TenantRecord:
tenant_id: str
display_name: str
status: str = "active"
metadata: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class AgentRecord:
agent_id: str
tenant_id: str
display_name: str
runtime: str
capabilities: tuple[str, ...] = field(default_factory=tuple)
enabled: bool = True
metadata: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
class TaskState(str, Enum):
CREATED = "CREATED"
APPROVED = "APPROVED"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
TERMINAL_TASK_STATES = frozenset(
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
)
ALLOWED_TASK_TRANSITIONS: Mapping[TaskState, frozenset[TaskState]] = {
TaskState.CREATED: frozenset({TaskState.APPROVED, TaskState.CANCELLED}),
TaskState.APPROVED: frozenset({TaskState.RUNNING, TaskState.CANCELLED}),
TaskState.RUNNING: frozenset(
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
),
TaskState.COMPLETED: frozenset(),
TaskState.FAILED: frozenset(),
TaskState.CANCELLED: frozenset(),
}
@dataclass(frozen=True)
class TaskRecord:
task_id: str
tenant_id: str
agent_id: str
action: str
payload: Mapping[str, Any]
state: TaskState = TaskState.CREATED
revision: int = 1
idempotency_key: str | None = None
request_digest: str = ""
result: Mapping[str, Any] | None = None
error: Mapping[str, Any] | None = None
created_by: str = ""
approved_by: str | None = None
created_at: str = field(default_factory=_utc_now)
updated_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class AuditEvent:
event_id: str
tenant_id: str
principal_id: str
trace_id: str
action: str
resource_type: str
resource_id: str
outcome: str
policy_version: str
details: Mapping[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=_utc_now)
@dataclass(frozen=True)
class PolicyDecision:
allowed: bool
reason: str
policy_version: str
PolicyEvaluator = Callable[[RequestContext, str, Mapping[str, Any]], PolicyDecision]
class InMemoryControlPlaneStore:
"""Thread-safe reference store used by tests and the first gateway slice.
Production backends must preserve the same tenant-scoped interface and add
database row-level security, migrations, backup and HA evidence.
"""
def __init__(self) -> None:
self.lock = threading.RLock()
self.tenants: dict[str, TenantRecord] = {}
self.agents: dict[str, AgentRecord] = {}
self.tasks: dict[str, TaskRecord] = {}
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
self.audit_events: list[AuditEvent] = []
class ControlPlaneService:
"""Authoritative tenant, agent and task lifecycle service."""
def __init__(
self,
store: InMemoryControlPlaneStore | None = None,
*,
policy_evaluator: PolicyEvaluator | None = None,
) -> None:
self.store = store or InMemoryControlPlaneStore()
self.policy_evaluator = policy_evaluator or self._default_policy
@staticmethod
def _default_policy(
context: RequestContext,
action: str,
resource: Mapping[str, Any],
) -> PolicyDecision:
del resource
role_requirements = {
"tenant.register": {"platform_admin"},
"tenant.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"agent.register": {"tenant_admin", "operator", "platform_admin"},
"agent.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"task.create": {"tenant_admin", "operator", "platform_admin"},
"task.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
"task.approve": {"tenant_admin", "approver", "platform_admin"},
"task.start": {"runtime", "operator", "platform_admin"},
"task.complete": {"runtime", "operator", "platform_admin"},
"task.fail": {"runtime", "operator", "platform_admin"},
"task.cancel": {"tenant_admin", "operator", "approver", "platform_admin"},
"audit.read": {"tenant_admin", "auditor", "platform_admin"},
}
required = role_requirements.get(action, {"platform_admin"})
allowed = bool(context.roles.intersection(required))
return PolicyDecision(
allowed=allowed,
reason="role_allowed" if allowed else "required_role_missing",
policy_version=context.policy_version,
)
def _require_context(self, context: RequestContext) -> None:
if not context.principal_id or not context.tenant_id or not context.trace_id:
raise Unauthorized("authenticated tenant context is required")
def _authorize(
self,
context: RequestContext,
action: str,
resource: Mapping[str, Any],
) -> PolicyDecision:
self._require_context(context)
decision = self.policy_evaluator(context, action, resource)
if not decision.allowed:
self._audit(
context,
action=action,
resource_type=str(resource.get("resource_type", "policy")),
resource_id=str(resource.get("resource_id", "unknown")),
outcome="denied",
details={"reason": decision.reason},
)
raise PolicyDenied(f"{action} denied: {decision.reason}")
return decision
@staticmethod
def _assert_same_tenant(context: RequestContext, tenant_id: str) -> None:
if context.tenant_id != tenant_id and "platform_admin" not in context.roles:
raise TenantIsolationError("cross-tenant access denied")
def _audit(
self,
context: RequestContext,
*,
action: str,
resource_type: str,
resource_id: str,
outcome: str,
details: Mapping[str, Any] | None = None,
) -> AuditEvent:
event = AuditEvent(
event_id=_new_id("audit"),
tenant_id=context.tenant_id,
principal_id=context.principal_id,
trace_id=context.trace_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
outcome=outcome,
policy_version=context.policy_version,
details=copy.deepcopy(dict(details or {})),
)
self.store.audit_events.append(event)
return event
def register_tenant(
self,
context: RequestContext,
*,
tenant_id: str,
display_name: str,
metadata: Mapping[str, Any] | None = None,
) -> TenantRecord:
tenant_id = tenant_id.strip()
if not tenant_id or not display_name.strip():
raise ValueError("tenant_id and display_name are required")
self._authorize(
context,
"tenant.register",
{"resource_type": "tenant", "resource_id": tenant_id},
)
self._assert_same_tenant(context, tenant_id)
with self.store.lock:
if tenant_id in self.store.tenants:
raise ControlPlaneError(f"tenant already exists: {tenant_id}")
tenant = TenantRecord(
tenant_id=tenant_id,
display_name=display_name.strip(),
metadata=copy.deepcopy(dict(metadata or {})),
)
self.store.tenants[tenant_id] = tenant
self._audit(
context,
action="tenant.register",
resource_type="tenant",
resource_id=tenant_id,
outcome="allowed",
)
return tenant
def get_tenant(self, context: RequestContext, tenant_id: str | None = None) -> TenantRecord:
tenant_id = tenant_id or context.tenant_id
self._authorize(
context,
"tenant.read",
{"resource_type": "tenant", "resource_id": tenant_id},
)
self._assert_same_tenant(context, tenant_id)
with self.store.lock:
tenant = self.store.tenants.get(tenant_id)
if tenant is None:
raise NotFound("tenant not found")
return tenant
def register_agent(
self,
context: RequestContext,
*,
agent_id: str,
display_name: str,
runtime: str,
capabilities: Iterable[str] = (),
metadata: Mapping[str, Any] | None = None,
) -> AgentRecord:
self._authorize(
context,
"agent.register",
{"resource_type": "agent", "resource_id": agent_id},
)
tenant = self.get_tenant(context)
if tenant.status != "active":
raise ControlPlaneError("tenant is not active")
normalized_capabilities = tuple(
sorted({str(item).strip() for item in capabilities if str(item).strip()})
)
if not agent_id.strip() or not display_name.strip() or not runtime.strip():
raise ValueError("agent_id, display_name and runtime are required")
with self.store.lock:
existing = self.store.agents.get(agent_id)
if existing is not None and existing.tenant_id != context.tenant_id:
raise TenantIsolationError("agent id belongs to another tenant")
if existing is not None:
raise ControlPlaneError(f"agent already exists: {agent_id}")
agent = AgentRecord(
agent_id=agent_id.strip(),
tenant_id=context.tenant_id,
display_name=display_name.strip(),
runtime=runtime.strip(),
capabilities=normalized_capabilities,
metadata=copy.deepcopy(dict(metadata or {})),
)
self.store.agents[agent.agent_id] = agent
self._audit(
context,
action="agent.register",
resource_type="agent",
resource_id=agent.agent_id,
outcome="allowed",
details={"runtime": agent.runtime},
)
return agent
def get_agent(self, context: RequestContext, agent_id: str) -> AgentRecord:
self._authorize(
context,
"agent.read",
{"resource_type": "agent", "resource_id": agent_id},
)
with self.store.lock:
agent = self.store.agents.get(agent_id)
if agent is None:
raise NotFound("agent not found")
self._assert_same_tenant(context, agent.tenant_id)
return agent
def list_agents(self, context: RequestContext) -> list[AgentRecord]:
self._authorize(
context,
"agent.read",
{"resource_type": "agent_collection", "resource_id": context.tenant_id},
)
with self.store.lock:
return [
agent
for agent in self.store.agents.values()
if agent.tenant_id == context.tenant_id
]
def create_task(
self,
context: RequestContext,
*,
agent_id: str,
action: str,
payload: Mapping[str, Any] | None = None,
idempotency_key: str | None = None,
) -> TaskRecord:
self._authorize(
context,
"task.create",
{"resource_type": "task", "resource_id": idempotency_key or "new"},
)
agent = self.get_agent(context, agent_id)
if not agent.enabled:
raise ControlPlaneError("agent is disabled")
if action not in agent.capabilities and "*" not in agent.capabilities:
raise PolicyDenied(f"agent does not advertise capability: {action}")
normalized_payload = copy.deepcopy(dict(payload or {}))
request_digest = _canonical_digest(
{"agent_id": agent_id, "action": action, "payload": normalized_payload}
)
normalized_key = idempotency_key.strip() if idempotency_key else None
with self.store.lock:
if normalized_key:
idempotency_ref = (context.tenant_id, normalized_key)
existing = self.store.idempotency.get(idempotency_ref)
if existing is not None:
existing_task_id, existing_digest = existing
if existing_digest != request_digest:
raise IdempotencyConflict(
"idempotency key was reused with a different request"
)
return self.store.tasks[existing_task_id]
task = TaskRecord(
task_id=_new_id("task"),
tenant_id=context.tenant_id,
agent_id=agent_id,
action=action,
payload=normalized_payload,
idempotency_key=normalized_key,
request_digest=request_digest,
created_by=context.principal_id,
)
self.store.tasks[task.task_id] = task
if normalized_key:
self.store.idempotency[(context.tenant_id, normalized_key)] = (
task.task_id,
request_digest,
)
self._audit(
context,
action="task.create",
resource_type="task",
resource_id=task.task_id,
outcome="allowed",
details={"agent_id": agent_id, "action": action},
)
return task
def get_task(self, context: RequestContext, task_id: str) -> TaskRecord:
self._authorize(
context,
"task.read",
{"resource_type": "task", "resource_id": task_id},
)
with self.store.lock:
task = self.store.tasks.get(task_id)
if task is None:
raise NotFound("task not found")
self._assert_same_tenant(context, task.tenant_id)
return task
def list_tasks(
self,
context: RequestContext,
*,
states: Iterable[TaskState | str] | None = None,
) -> list[TaskRecord]:
self._authorize(
context,
"task.read",
{"resource_type": "task_collection", "resource_id": context.tenant_id},
)
state_filter = (
{TaskState(item) for item in states}
if states is not None
else None
)
with self.store.lock:
return [
task
for task in self.store.tasks.values()
if task.tenant_id == context.tenant_id
and (state_filter is None or task.state in state_filter)
]
def transition_task(
self,
context: RequestContext,
*,
task_id: str,
target_state: TaskState | str,
expected_revision: int | None = None,
result: Mapping[str, Any] | None = None,
error: Mapping[str, Any] | None = None,
) -> TaskRecord:
target = TaskState(target_state)
action_name = {
TaskState.APPROVED: "task.approve",
TaskState.RUNNING: "task.start",
TaskState.COMPLETED: "task.complete",
TaskState.FAILED: "task.fail",
TaskState.CANCELLED: "task.cancel",
}.get(target)
if action_name is None:
raise InvalidTransition(f"cannot transition to {target.value}")
self._authorize(
context,
action_name,
{"resource_type": "task", "resource_id": task_id},
)
with self.store.lock:
current = self.store.tasks.get(task_id)
if current is None:
raise NotFound("task not found")
self._assert_same_tenant(context, current.tenant_id)
if expected_revision is not None and current.revision != expected_revision:
raise RevisionConflict(
f"expected revision {expected_revision}, current {current.revision}"
)
if target not in ALLOWED_TASK_TRANSITIONS[current.state]:
raise InvalidTransition(
f"invalid transition {current.state.value} -> {target.value}"
)
if target == TaskState.COMPLETED and result is None:
raise ValueError("completed task requires result")
if target == TaskState.FAILED and error is None:
raise ValueError("failed task requires error")
updated = TaskRecord(
task_id=current.task_id,
tenant_id=current.tenant_id,
agent_id=current.agent_id,
action=current.action,
payload=current.payload,
state=target,
revision=current.revision + 1,
idempotency_key=current.idempotency_key,
request_digest=current.request_digest,
result=copy.deepcopy(dict(result)) if result is not None else current.result,
error=copy.deepcopy(dict(error)) if error is not None else current.error,
created_by=current.created_by,
approved_by=(
context.principal_id
if target == TaskState.APPROVED
else current.approved_by
),
created_at=current.created_at,
updated_at=_utc_now(),
)
self.store.tasks[task_id] = updated
self._audit(
context,
action=action_name,
resource_type="task",
resource_id=task_id,
outcome="allowed",
details={
"from": current.state.value,
"to": target.value,
"revision": updated.revision,
},
)
return updated
def list_audit_events(
self,
context: RequestContext,
*,
resource_id: str | None = None,
) -> list[AuditEvent]:
self._authorize(
context,
"audit.read",
{"resource_type": "audit", "resource_id": resource_id or context.tenant_id},
)
with self.store.lock:
return [
event
for event in self.store.audit_events
if event.tenant_id == context.tenant_id
and (resource_id is None or event.resource_id == resource_id)
]
@@ -0,0 +1,379 @@
"""AIOS V2 持久化 Dispatch 记录。
Dispatch 是 AIOS Task 与 Runtime command/job 之间的唯一可信绑定。所有结果回写
必须匹配 tenant/task/trace/runtime_execution/provenance/run/principal。
"""
from __future__ import annotations
import json
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass, replace
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Protocol
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
class DispatchState(str, Enum):
PREPARED = "PREPARED"
RUNNING = "RUNNING"
QUEUED = "QUEUED"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
TERMINAL_DISPATCH_STATES = frozenset(
{DispatchState.COMPLETED, DispatchState.FAILED, DispatchState.CANCELLED}
)
@dataclass(frozen=True)
class DispatchRecord:
dispatch_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
runtime: str
runtime_execution_id: str
trace_id: str
provenance_id: str
run_id: str
authorization_id: str
state: DispatchState = DispatchState.PREPARED
expected_worker_id: str | None = None
result_digest: str | None = None
created_at: str = ""
updated_at: str = ""
completed_at: str | None = None
def __post_init__(self) -> None:
for name in (
"dispatch_id",
"tenant_id",
"task_id",
"agent_id",
"principal_id",
"runtime",
"runtime_execution_id",
"trace_id",
"provenance_id",
"run_id",
"authorization_id",
):
object.__setattr__(self, name, _required(getattr(self, name), name))
object.__setattr__(self, "state", DispatchState(self.state))
now = utc_now()
object.__setattr__(self, "created_at", self.created_at or now)
object.__setattr__(self, "updated_at", self.updated_at or now)
if self.expected_worker_id is not None:
object.__setattr__(
self,
"expected_worker_id",
_required(self.expected_worker_id, "expected_worker_id"),
)
@classmethod
def create(
cls,
*,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
runtime: str,
runtime_execution_id: str,
trace_id: str,
provenance_id: str,
run_id: str,
authorization_id: str,
) -> "DispatchRecord":
return cls(
dispatch_id=f"dispatch_{uuid.uuid4().hex}",
tenant_id=tenant_id,
task_id=task_id,
agent_id=agent_id,
principal_id=principal_id,
runtime=runtime,
runtime_execution_id=runtime_execution_id,
trace_id=trace_id,
provenance_id=provenance_id,
run_id=run_id,
authorization_id=authorization_id,
)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value["state"] = self.state.value
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "DispatchRecord":
return cls(**dict(value))
class DispatchConflict(RuntimeError):
pass
class DispatchNotFound(RuntimeError):
pass
class DispatchRepository(Protocol):
def create(self, record: DispatchRecord) -> DispatchRecord: ...
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord: ...
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None: ...
def get_by_runtime_execution(
self, runtime_execution_id: str, *, tenant_id: str
) -> DispatchRecord | None: ...
def update(
self,
dispatch_id: str,
*,
tenant_id: str,
state: DispatchState | str | None = None,
expected_worker_id: str | None = None,
result_digest: str | None = None,
) -> DispatchRecord: ...
def list(self, *, tenant_id: str) -> list[DispatchRecord]: ...
class InMemoryDispatchRepository:
def __init__(self) -> None:
self._lock = threading.RLock()
self._records: dict[str, DispatchRecord] = {}
self._by_task: dict[tuple[str, str], str] = {}
self._by_runtime: dict[tuple[str, str], str] = {}
def create(self, record: DispatchRecord) -> DispatchRecord:
with self._lock:
task_ref = (record.tenant_id, record.task_id)
runtime_ref = (record.tenant_id, record.runtime_execution_id)
if task_ref in self._by_task or runtime_ref in self._by_runtime:
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
if existing == record:
return record
raise DispatchConflict("task or runtime execution already has a dispatch")
self._records[record.dispatch_id] = record
self._by_task[task_ref] = record.dispatch_id
self._by_runtime[runtime_ref] = record.dispatch_id
return record
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
with self._lock:
record = self._records.get(dispatch_id)
if record is None or record.tenant_id != tenant_id:
raise DispatchNotFound("dispatch not found")
return record
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
with self._lock:
dispatch_id = self._by_task.get((tenant_id, task_id))
return self._records.get(dispatch_id) if dispatch_id else None
def get_by_runtime_execution(
self, runtime_execution_id: str, *, tenant_id: str
) -> DispatchRecord | None:
with self._lock:
dispatch_id = self._by_runtime.get((tenant_id, runtime_execution_id))
return self._records.get(dispatch_id) if dispatch_id else None
def update(
self,
dispatch_id: str,
*,
tenant_id: str,
state: DispatchState | str | None = None,
expected_worker_id: str | None = None,
result_digest: str | None = None,
) -> DispatchRecord:
with self._lock:
current = self.get(dispatch_id, tenant_id=tenant_id)
target = DispatchState(state) if state is not None else current.state
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
raise DispatchConflict("terminal dispatch cannot transition")
updated = replace(
current,
state=target,
expected_worker_id=(
expected_worker_id
if expected_worker_id is not None
else current.expected_worker_id
),
result_digest=result_digest or current.result_digest,
updated_at=utc_now(),
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
)
self._records[dispatch_id] = updated
return updated
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
with self._lock:
return sorted(
[item for item in self._records.values() if item.tenant_id == tenant_id],
key=lambda item: (item.created_at, item.dispatch_id),
)
class SQLiteDispatchRepository:
SCHEMA_VERSION = 1
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS dispatch_records (
dispatch_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
task_id TEXT NOT NULL,
runtime_execution_id TEXT NOT NULL,
payload_json TEXT NOT NULL,
state TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(tenant_id, task_id),
UNIQUE(tenant_id, runtime_execution_id)
);
CREATE INDEX IF NOT EXISTS idx_dispatch_tenant_state
ON dispatch_records(tenant_id, state, updated_at);
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(str(self.path), timeout=30.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
return connection
@staticmethod
def _encode(record: DispatchRecord) -> str:
return json.dumps(
record.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
@staticmethod
def _decode(row: sqlite3.Row) -> DispatchRecord:
value = json.loads(row["payload_json"])
if not isinstance(value, Mapping):
raise RuntimeError("invalid dispatch record")
return DispatchRecord.from_dict(value)
def create(self, record: DispatchRecord) -> DispatchRecord:
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
connection.execute(
"INSERT INTO dispatch_records VALUES (?, ?, ?, ?, ?, ?, ?)",
(
record.dispatch_id,
record.tenant_id,
record.task_id,
record.runtime_execution_id,
self._encode(record),
record.state.value,
record.updated_at,
),
)
connection.commit()
return record
except sqlite3.IntegrityError as exc:
connection.rollback()
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
if existing is not None and existing == record:
return existing
raise DispatchConflict("task or runtime execution already has a dispatch") from exc
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT payload_json FROM dispatch_records WHERE dispatch_id = ? AND tenant_id = ?",
(_required(dispatch_id, "dispatch_id"), _required(tenant_id, "tenant_id")),
).fetchone()
if row is None:
raise DispatchNotFound("dispatch not found")
return self._decode(row)
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT payload_json FROM dispatch_records WHERE task_id = ? AND tenant_id = ?",
(_required(task_id, "task_id"), _required(tenant_id, "tenant_id")),
).fetchone()
return self._decode(row) if row is not None else None
def get_by_runtime_execution(
self, runtime_execution_id: str, *, tenant_id: str
) -> DispatchRecord | None:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT payload_json FROM dispatch_records WHERE runtime_execution_id = ? AND tenant_id = ?",
(
_required(runtime_execution_id, "runtime_execution_id"),
_required(tenant_id, "tenant_id"),
),
).fetchone()
return self._decode(row) if row is not None else None
def update(
self,
dispatch_id: str,
*,
tenant_id: str,
state: DispatchState | str | None = None,
expected_worker_id: str | None = None,
result_digest: str | None = None,
) -> DispatchRecord:
current = self.get(dispatch_id, tenant_id=tenant_id)
target = DispatchState(state) if state is not None else current.state
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
raise DispatchConflict("terminal dispatch cannot transition")
updated = replace(
current,
state=target,
expected_worker_id=(
expected_worker_id if expected_worker_id is not None else current.expected_worker_id
),
result_digest=result_digest or current.result_digest,
updated_at=utc_now(),
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"UPDATE dispatch_records SET payload_json = ?, state = ?, updated_at = ? WHERE dispatch_id = ? AND tenant_id = ?",
(
self._encode(updated),
updated.state.value,
updated.updated_at,
updated.dispatch_id,
updated.tenant_id,
),
)
connection.commit()
return updated
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
with self._lock, self._connect() as connection:
rows = connection.execute(
"SELECT payload_json FROM dispatch_records WHERE tenant_id = ? ORDER BY updated_at, dispatch_id",
(_required(tenant_id, "tenant_id"),),
).fetchall()
return [self._decode(row) for row in rows]
@@ -0,0 +1,323 @@
"""AIOS V2 Agent 执行证据。
Evidence 按租户隔离并使用原子 JSON 写入。生产模式下,claim、heartbeat
和 complete 在落盘前必须通过最小权限 Evidence Grant 授权。
"""
from __future__ import annotations
import json
import os
import threading
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Protocol
from .provenance import ProvenanceDenied, digest_payload
class EvidenceAuthorizationWriter(Protocol):
def authorize_evidence_write(self, **values: Any) -> Any: ...
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def _safe(value: Mapping[str, Any] | None) -> dict[str, Any]:
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, default=str))
@dataclass(frozen=True)
class ExecutionEvidence:
execution_id: str
tenant_id: str
task_id: str
agent_id: str
executor: str
branch: str
status: str = "claimed"
progress_completed: int = 0
progress_total: int = 1
current_step: str = "claim"
last_commit: str | None = None
test_status: str = "not_run"
test_summary: Mapping[str, Any] = field(default_factory=dict)
result_summary: Mapping[str, Any] = field(default_factory=dict)
claimed_at: str = field(default_factory=utc_now)
heartbeat_at: str = field(default_factory=utc_now)
completed_at: str | None = None
provenance_id: str | None = None
run_id: str | None = None
last_authorization_id: str | None = None
def __post_init__(self) -> None:
for name in (
"execution_id",
"tenant_id",
"task_id",
"agent_id",
"executor",
"branch",
"status",
"current_step",
"claimed_at",
"heartbeat_at",
):
object.__setattr__(self, name, _required(getattr(self, name), name))
if self.progress_completed < 0 or self.progress_total < 1:
raise ValueError("invalid progress values")
if self.progress_completed > self.progress_total:
raise ValueError("progress_completed cannot exceed progress_total")
object.__setattr__(self, "test_summary", _safe(self.test_summary))
object.__setattr__(self, "result_summary", _safe(self.result_summary))
for optional in ("provenance_id", "run_id", "last_authorization_id"):
value = getattr(self, optional)
object.__setattr__(self, optional, str(value).strip() if value else None)
class ExecutionTracker:
"""每个租户、每次执行持久保存一份证据记录。"""
def __init__(
self,
root: str | Path,
*,
provenance_writer: EvidenceAuthorizationWriter | None = None,
require_provenance: bool = False,
) -> None:
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self.provenance_writer = provenance_writer
self.require_provenance = bool(require_provenance)
if self.require_provenance and self.provenance_writer is None:
raise ValueError("production evidence tracking requires a provenance writer")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = _required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ValueError("invalid evidence path component")
return normalized
def _path(self, tenant_id: str, execution_id: str) -> Path:
tenant = self._component(tenant_id)
execution = self._component(execution_id)
return self.root / tenant / f"{execution}.json"
def _authorize(
self,
evidence: ExecutionEvidence,
*,
operation: str,
principal_id: str | None,
provenance_id: str | None,
run_id: str | None,
changes: Mapping[str, Any],
) -> str | None:
if not self.require_provenance:
return None
if self.provenance_writer is None:
raise RuntimeError("Run Provenance writer is not configured")
effective_provenance = provenance_id or evidence.provenance_id
effective_run = run_id or evidence.run_id
if not effective_provenance or not effective_run or not principal_id:
raise RuntimeError(
"production evidence write requires provenance_id, run_id and principal_id"
)
request_digest = digest_payload(
{
"operation": operation,
"execution_id": evidence.execution_id,
"tenant_id": evidence.tenant_id,
"task_id": evidence.task_id,
"agent_id": evidence.agent_id,
"executor": evidence.executor,
"branch": evidence.branch,
"changes": dict(changes),
}
)
try:
authorization = self.provenance_writer.authorize_evidence_write(
provenance_id=effective_provenance,
run_id=effective_run,
tenant_id=evidence.tenant_id,
task_id=evidence.task_id,
agent_id=evidence.agent_id,
principal_id=principal_id,
request_digest=request_digest,
)
except ProvenanceDenied as exc:
raise RuntimeError(f"Run Provenance denied evidence write: {exc}") from exc
return authorization.authorization_id
def claim(
self,
evidence: ExecutionEvidence,
*,
principal_id: str | None = None,
provenance_id: str | None = None,
run_id: str | None = None,
) -> ExecutionEvidence:
path = self._path(evidence.tenant_id, evidence.execution_id)
with self._lock:
if path.exists():
current = self._read(path)
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
raise RuntimeError("execution_id already belongs to another task")
if self.require_provenance and (
current.provenance_id != (provenance_id or evidence.provenance_id)
or current.run_id != (run_id or evidence.run_id)
):
raise RuntimeError(
"execution_id already belongs to another provenance context"
)
return current
effective_provenance = provenance_id or evidence.provenance_id
effective_run = run_id or evidence.run_id
authorization_id = self._authorize(
evidence,
operation="claim",
principal_id=principal_id,
provenance_id=effective_provenance,
run_id=effective_run,
changes={"status": evidence.status, "current_step": evidence.current_step},
)
persisted = replace(
evidence,
provenance_id=effective_provenance,
run_id=effective_run,
last_authorization_id=authorization_id,
)
self._write(path, persisted)
return persisted
def heartbeat(
self,
*,
tenant_id: str,
execution_id: str,
status: str | None = None,
current_step: str | None = None,
progress_completed: int | None = None,
progress_total: int | None = None,
last_commit: str | None = None,
principal_id: str | None = None,
provenance_id: str | None = None,
run_id: str | None = None,
) -> ExecutionEvidence:
path = self._path(tenant_id, execution_id)
with self._lock:
current = self._read(path)
changes = {
"status": status or current.status,
"current_step": current_step or current.current_step,
"progress_completed": (
progress_completed
if progress_completed is not None
else current.progress_completed
),
"progress_total": (
progress_total if progress_total is not None else current.progress_total
),
"last_commit": last_commit or current.last_commit,
}
authorization_id = self._authorize(
current,
operation="heartbeat",
principal_id=principal_id,
provenance_id=provenance_id,
run_id=run_id,
changes=changes,
)
updated = replace(
current,
**changes,
last_authorization_id=authorization_id or current.last_authorization_id,
heartbeat_at=utc_now(),
)
self._write(path, updated)
return updated
def complete(
self,
*,
tenant_id: str,
execution_id: str,
status: str,
test_status: str,
test_summary: Mapping[str, Any] | None = None,
result_summary: Mapping[str, Any] | None = None,
last_commit: str | None = None,
principal_id: str | None = None,
provenance_id: str | None = None,
run_id: str | None = None,
) -> ExecutionEvidence:
path = self._path(tenant_id, execution_id)
with self._lock:
current = self._read(path)
changes = {
"status": _required(status, "status"),
"current_step": "complete",
"progress_completed": current.progress_total,
"last_commit": last_commit or current.last_commit,
"test_status": _required(test_status, "test_status"),
"test_summary": _safe(test_summary),
"result_summary": _safe(result_summary),
}
authorization_id = self._authorize(
current,
operation="complete",
principal_id=principal_id,
provenance_id=provenance_id,
run_id=run_id,
changes=changes,
)
updated = replace(
current,
**changes,
last_authorization_id=authorization_id or current.last_authorization_id,
heartbeat_at=utc_now(),
completed_at=utc_now(),
)
self._write(path, updated)
return updated
def get(self, *, tenant_id: str, execution_id: str) -> ExecutionEvidence:
return self._read(self._path(tenant_id, execution_id))
def list(self, *, tenant_id: str) -> list[ExecutionEvidence]:
tenant_dir = self.root / self._component(tenant_id)
if not tenant_dir.exists():
return []
records = [self._read(path) for path in tenant_dir.glob("*.json")]
return sorted(records, key=lambda item: (item.heartbeat_at, item.execution_id), reverse=True)
@staticmethod
def _read(path: Path) -> ExecutionEvidence:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise KeyError("execution evidence not found") from exc
if not isinstance(value, Mapping):
raise RuntimeError("invalid execution evidence file")
return ExecutionEvidence(**dict(value))
@staticmethod
def _write(path: Path, evidence: ExecutionEvidence) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, path)
+353
View File
@@ -0,0 +1,353 @@
"""HTTP/JSON gateway for the AIOS V2 control plane.
The public gateway owns user-facing control operations only. Runtime lifecycle
transitions are deliberately absent: external callers cannot directly move a
task to RUNNING, COMPLETED or FAILED. Those transitions are available only
through the provenance-protected orchestrator and internal reconciliation API.
"""
from __future__ import annotations
import dataclasses
import json
import os
import re
import urllib.parse
from enum import Enum
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Mapping
from aios_hermes import HermesConflict, HermesNotFound
from .control_plane import (
ControlPlaneError,
ControlPlaneService,
IdempotencyConflict,
InvalidTransition,
NotFound,
PolicyDenied,
RequestContext,
RevisionConflict,
TaskState,
TenantIsolationError,
Unauthorized,
)
from .dispatch_store import DispatchConflict, DispatchNotFound
from .orchestrator import OrchestrationError
from .provenance import ProvenanceDenied, ProvenanceReplay
TENANT_HEADER = "X-AIOS-Tenant-ID"
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
ROLES_HEADER = "X-AIOS-Roles"
TRACE_HEADER = "X-AIOS-Trace-ID"
SESSION_HEADER = "X-AIOS-Session-ID"
POLICY_HEADER = "X-AIOS-Policy-Version"
AUTHENTICATED_HEADER = "X-AIOS-Authenticated"
_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
def _json_value(value: Any) -> Any:
if dataclasses.is_dataclass(value):
return {
field.name: _json_value(getattr(value, field.name))
for field in dataclasses.fields(value)
}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _json_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_value(item) for item in value]
return value
def _require_mapping(value: Any, *, label: str = "JSON body") -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ValueError(f"{label} must be an object")
return value
def _require_resource_id(value: Any, *, label: str) -> str:
normalized = str(value or "").strip()
if not _RESOURCE_ID_RE.fullmatch(normalized):
raise ValueError(f"invalid {label}")
return normalized
class GatewayApplication:
"""Pure user-facing request router that can be tested without a socket."""
def __init__(self, service: ControlPlaneService | None = None) -> None:
self.service = service or ControlPlaneService()
@staticmethod
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
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":
return HTTPStatus.OK, {"tenant": _json_value(self.service.get_tenant(context))}
if method == "POST" and path == "/api/v2/agents":
agent = self.service.register_agent(
context,
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
display_name=str(payload.get("display_name", "")),
runtime=str(payload.get("runtime", "")),
capabilities=payload.get("capabilities", ()),
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
)
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
if method == "GET" and path == "/api/v2/agents":
return HTTPStatus.OK, {"agents": _json_value(self.service.list_agents(context))}
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
if method == "GET" and agent_match:
agent_id = _require_resource_id(
urllib.parse.unquote(agent_match.group(1)), label="agent_id"
)
return HTTPStatus.OK, {
"agent": _json_value(self.service.get_agent(context, agent_id))
}
if method == "POST" and path == "/api/v2/tasks":
task = self.service.create_task(
context,
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
action=str(payload.get("action", "")).strip(),
payload=_require_mapping(payload.get("payload", {}), label="payload"),
idempotency_key=(
str(payload["idempotency_key"]).strip()
if payload.get("idempotency_key") is not None
else None
),
)
return HTTPStatus.CREATED, {"task": _json_value(task)}
if method == "GET" and path == "/api/v2/tasks":
tasks = self.service.list_tasks(context, states=query.get("state"))
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
if method == "GET" and task_match:
task_id = _require_resource_id(
urllib.parse.unquote(task_match.group(1)), label="task_id"
)
return HTTPStatus.OK, {
"task": _json_value(self.service.get_task(context, task_id))
}
bypass_match = re.fullmatch(
r"/api/v2/tasks/([^/]+)/(start|complete|fail)", path
)
if method == "POST" and bypass_match:
return HTTPStatus.FORBIDDEN, {
"error": "RuntimeLifecycleBypassDenied",
"message": (
"RUNNING, COMPLETED and FAILED transitions are internal; "
"use the provenance-protected dispatch/reconcile flow"
),
}
transition_match = re.fullmatch(
r"/api/v2/tasks/([^/]+)/(approve|cancel)", path
)
if method == "POST" and transition_match:
task_id = _require_resource_id(
urllib.parse.unquote(transition_match.group(1)), label="task_id"
)
target = (
TaskState.APPROVED
if transition_match.group(2) == "approve"
else TaskState.CANCELLED
)
expected_revision = payload.get("expected_revision")
task = self.service.transition_task(
context,
task_id=task_id,
target_state=target,
expected_revision=(
int(expected_revision) if expected_revision is not None else None
),
)
return HTTPStatus.OK, {"task": _json_value(task)}
if method == "GET" and path == "/api/v2/audit":
resource_id = query.get("resource_id", [None])[0]
events = self.service.list_audit_events(
context,
resource_id=(
_require_resource_id(resource_id, label="resource_id")
if resource_id
else None
),
)
return HTTPStatus.OK, {"events": _json_value(events)}
return HTTPStatus.NOT_FOUND, {
"error": "not_found",
"message": "route not found",
}
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
(Unauthorized, HTTPStatus.UNAUTHORIZED),
(PolicyDenied, HTTPStatus.FORBIDDEN),
(ProvenanceDenied, HTTPStatus.FORBIDDEN),
(TenantIsolationError, HTTPStatus.NOT_FOUND),
(NotFound, HTTPStatus.NOT_FOUND),
(DispatchNotFound, HTTPStatus.NOT_FOUND),
(HermesNotFound, HTTPStatus.NOT_FOUND),
(ProvenanceReplay, HTTPStatus.CONFLICT),
(IdempotencyConflict, HTTPStatus.CONFLICT),
(RevisionConflict, HTTPStatus.CONFLICT),
(InvalidTransition, HTTPStatus.CONFLICT),
(DispatchConflict, HTTPStatus.CONFLICT),
(HermesConflict, HTTPStatus.CONFLICT),
(OrchestrationError, HTTPStatus.CONFLICT),
(ValueError, HTTPStatus.BAD_REQUEST),
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
)
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
for error_type, status in _ERROR_STATUS:
if isinstance(exc, error_type):
return status, {"error": error_type.__name__, "message": str(exc)}
return HTTPStatus.INTERNAL_SERVER_ERROR, {
"error": "InternalServerError",
"message": "internal server error",
}
class GatewayRequestHandler(BaseHTTPRequestHandler):
server_version = "AIOSV2Gateway/1.0"
max_body_bytes = 1_048_576
@property
def application(self) -> GatewayApplication:
return self.server.application # type: ignore[attr-defined]
def _read_body(self) -> Any:
length = int(self.headers.get("Content-Length", "0") or "0")
if length < 0 or length > self.max_body_bytes:
raise ValueError("request body is too large")
if length == 0:
return {}
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
if content_type != "application/json":
raise ValueError("Content-Type must be application/json")
return json.loads(self.rfile.read(length).decode("utf-8"))
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(
_json_value(payload), ensure_ascii=False, separators=(",", ":")
).encode("utf-8")
self.send_response(int(status))
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(encoded)
def _dispatch(self, method: str) -> None:
try:
body = self._read_body() if method in {"POST", "PUT", "PATCH"} else {}
status, payload = self.application.handle(
method,
self.path,
{key: value for key, value in self.headers.items()},
body,
)
except Exception as exc:
status, payload = error_response(exc)
self._send_json(status, payload)
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
def log_message(self, format: str, *args: Any) -> None:
super().log_message(format, *args)
class GatewayHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(
self,
server_address: tuple[str, int],
application: GatewayApplication,
) -> None:
self.application = application
super().__init__(server_address, GatewayRequestHandler)
def main() -> None:
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
server = GatewayHTTPServer((host, port), GatewayApplication())
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
server.serve_forever()
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
"""Sanitized GitOps runtime status for the AIOS Web control plane."""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Mapping
@dataclass(frozen=True)
class GitOpsStatus:
available: bool
service_status: str
workspace_mode: str
workspace_branch: str | None
workspace_ref: str | None
workspace_dirty: bool | None
candidate_branch: str
release_channel: str
release_version: str
sync_status: str
details: Mapping[str, Any]
def as_dict(self) -> dict[str, Any]:
return asdict(self)
def _run_git(worktree: Path, *args: str) -> tuple[int, str, str]:
process = subprocess.run(
["git", "-C", str(worktree), *args],
text=True,
capture_output=True,
timeout=5,
)
return process.returncode, process.stdout.strip(), process.stderr.strip()
def collect_gitops_status() -> GitOpsStatus:
"""Return only non-sensitive Git/workspace metadata.
Remote URLs, environment values, credentials, filesystem contents and
command stderr are deliberately excluded from the Web response.
"""
candidate_branch = os.getenv(
"AIOS_CANDIDATE_BRANCH",
"integration/aios-v2r1-cto-production-candidate-20260720",
)
release_channel = os.getenv("AIOS_RELEASE_CHANNEL", "candidate")
release_version = os.getenv("AIOS_RELEASE_VERSION", "V2R1C0-candidate")
sync_status = os.getenv("AIOS_GITOPS_SYNC_STATUS", "unknown")
worktree = Path(
os.getenv(
"AIOS_GITOPS_CONTROLLER_WORKTREE",
"/data/aios-gitops/worktrees/controller",
)
)
if not worktree.is_dir():
return GitOpsStatus(
available=False,
service_status="unknown",
workspace_mode="unavailable",
workspace_branch=None,
workspace_ref=None,
workspace_dirty=None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={"reason": "controller_worktree_unavailable"},
)
inside_code, inside, _ = _run_git(worktree, "rev-parse", "--is-inside-work-tree")
if inside_code != 0 or inside != "true":
return GitOpsStatus(
available=False,
service_status="unknown",
workspace_mode="invalid",
workspace_branch=None,
workspace_ref=None,
workspace_dirty=None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={"reason": "controller_worktree_invalid"},
)
branch_code, branch, _ = _run_git(worktree, "branch", "--show-current")
ref_code, ref_value, _ = _run_git(worktree, "rev-parse", "HEAD")
dirty_code, dirty_value, _ = _run_git(worktree, "status", "--porcelain=v1")
service_status = "unknown"
try:
process = subprocess.run(
["systemctl", "is-active", "aios-gitops-model-api"],
text=True,
capture_output=True,
timeout=3,
)
service_status = process.stdout.strip() or "inactive"
except (FileNotFoundError, subprocess.TimeoutExpired):
service_status = "unavailable"
branch_value = branch if branch_code == 0 and branch else None
return GitOpsStatus(
available=True,
service_status=service_status,
workspace_mode="branch" if branch_value else "detached",
workspace_branch=branch_value,
workspace_ref=ref_value if ref_code == 0 else None,
workspace_dirty=bool(dirty_value) if dirty_code == 0 else None,
candidate_branch=candidate_branch,
release_channel=release_channel,
release_version=release_version,
sync_status=sync_status,
details={
"controller_clean": dirty_code == 0 and not dirty_value,
"controller_detached": branch_code == 0 and not branch,
},
)
+599
View File
@@ -0,0 +1,599 @@
"""AIOS V2 execution orchestrator.
The orchestrator is the only component allowed to translate an approved AIOS
Task into an OpenClaw command or Hermes job. Every dispatch is persisted before
Runtime side effects. Runtime results must bind to the exact dispatch,
command/job, tenant, task, trace, principal and provenance context.
"""
from __future__ import annotations
from typing import Any, Mapping, Protocol
from aios_hermes import HermesAdapter, HermesJob, HermesJobState, HermesResult
from aios_openclaw import (
CommandState,
OpenClawAdapter,
OpenClawCommand,
OpenClawResult,
sign_command,
)
from .control_plane import (
AgentRecord,
ControlPlaneService,
RequestContext,
TaskRecord,
TaskState,
)
from .dispatch_store import (
DispatchConflict,
DispatchRecord,
DispatchRepository,
DispatchState,
InMemoryDispatchRepository,
TERMINAL_DISPATCH_STATES,
)
from .provenance import ProvenanceDenied, digest_payload
class RuntimeAuthorizationWriter(Protocol):
def authorize_runtime_execute(self, **values: Any) -> Any: ...
class OrchestrationError(RuntimeError):
"""AIOS failed to dispatch or reconcile a runtime execution."""
class ExecutionOrchestrator:
"""Dispatch approved AIOS tasks and strictly reconcile Runtime results."""
def __init__(
self,
control_plane: ControlPlaneService,
*,
openclaw: OpenClawAdapter | None = None,
hermes: HermesAdapter | None = None,
openclaw_signing_material_resolver=None,
openclaw_key_id: str = "default",
default_cell_id: str = "default",
default_profile_id: str = "default",
provenance_writer: RuntimeAuthorizationWriter | None = None,
require_provenance: bool = False,
dispatch_repository: DispatchRepository | None = None,
) -> None:
self.control_plane = control_plane
self.openclaw = openclaw
self.hermes = hermes
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
self.openclaw_key_id = str(openclaw_key_id or "").strip() or "default"
self.default_cell_id = default_cell_id
self.default_profile_id = default_profile_id
self.provenance_writer = provenance_writer
self.require_provenance = bool(require_provenance)
self.dispatch_repository = dispatch_repository or InMemoryDispatchRepository()
if self.require_provenance and self.provenance_writer is None:
raise ValueError("production orchestration requires a provenance writer")
@staticmethod
def _record_provenance(value: str | None, *, required: bool, label: str) -> str:
normalized = str(value or "").strip()
if required and not normalized:
raise OrchestrationError(f"production dispatch requires {label}")
return normalized or f"{label}-not-required"
def _authorize_dispatch(
self,
context: RequestContext,
*,
task: TaskRecord,
agent: AgentRecord,
provenance_id: str | None,
run_id: str | None,
timeout_seconds: float | None,
) -> str:
if not self.require_provenance:
return "authorization-not-required"
if self.provenance_writer is None:
raise OrchestrationError("Run Provenance writer is not configured")
if not provenance_id or not run_id:
raise OrchestrationError(
"production dispatch requires provenance_id and run_id"
)
request_digest = digest_payload(
{
"operation": "dispatch",
"tenant_id": task.tenant_id,
"task_id": task.task_id,
"agent_id": agent.agent_id,
"principal_id": context.principal_id,
"runtime": agent.runtime.strip().lower(),
"action": task.action,
"payload": task.payload,
"trace_id": context.trace_id,
"timeout_seconds": timeout_seconds,
"task_revision": task.revision,
}
)
try:
authorization = self.provenance_writer.authorize_runtime_execute(
provenance_id=provenance_id,
run_id=run_id,
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
principal_id=context.principal_id,
request_digest=request_digest,
)
return authorization.authorization_id
except ProvenanceDenied as exc:
raise OrchestrationError(f"Run Provenance denied dispatch: {exc}") from exc
def dispatch(
self,
context: RequestContext,
*,
task_id: str,
timeout_seconds: float | None = None,
provenance_id: str | None = None,
run_id: str | None = None,
) -> DispatchRecord:
task = self.control_plane.get_task(context, task_id)
agent = self.control_plane.get_agent(context, task.agent_id)
persisted_provenance = self._record_provenance(
provenance_id, required=self.require_provenance, label="provenance_id"
)
persisted_run = self._record_provenance(
run_id, required=self.require_provenance, label="run_id"
)
existing = self.dispatch_repository.get_by_task(
task.task_id, tenant_id=task.tenant_id
)
if existing is not None:
if (
existing.provenance_id != persisted_provenance
or existing.run_id != persisted_run
or existing.principal_id != context.principal_id
):
raise OrchestrationError(
"existing dispatch belongs to another provenance or principal context"
)
return existing
if task.state != TaskState.APPROVED:
raise OrchestrationError("only APPROVED tasks may be dispatched")
authorization_id = self._authorize_dispatch(
context,
task=task,
agent=agent,
provenance_id=provenance_id,
run_id=run_id,
timeout_seconds=timeout_seconds,
)
runtime = agent.runtime.strip().lower()
prepared: DispatchRecord | None = None
try:
if runtime == "openclaw":
command = self._create_openclaw_command(context, task)
prepared = self.dispatch_repository.create(
DispatchRecord.create(
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
principal_id=context.principal_id,
runtime="openclaw",
runtime_execution_id=command.command_id,
trace_id=context.trace_id,
provenance_id=persisted_provenance,
run_id=persisted_run,
authorization_id=authorization_id,
)
)
self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.RUNNING,
expected_revision=task.revision,
)
self.dispatch_repository.update(
prepared.dispatch_id,
tenant_id=prepared.tenant_id,
state=DispatchState.RUNNING,
)
if self.openclaw is None:
raise OrchestrationError("OpenClaw runtime is not configured")
result = self.openclaw.execute(
command, timeout_seconds=timeout_seconds
)
self.reconcile_openclaw(
context,
result,
dispatch_id=prepared.dispatch_id,
provenance_id=persisted_provenance,
run_id=persisted_run,
)
return self.dispatch_repository.get(
prepared.dispatch_id, tenant_id=prepared.tenant_id
)
if runtime == "hermes":
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
job = self._create_hermes_job(context, agent, task)
prepared = self.dispatch_repository.create(
DispatchRecord.create(
tenant_id=task.tenant_id,
task_id=task.task_id,
agent_id=agent.agent_id,
principal_id=context.principal_id,
runtime="hermes",
runtime_execution_id=job.job_id,
trace_id=context.trace_id,
provenance_id=persisted_provenance,
run_id=persisted_run,
authorization_id=authorization_id,
)
)
self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.RUNNING,
expected_revision=task.revision,
)
queued = self.hermes.enqueue(job)
return self.dispatch_repository.update(
prepared.dispatch_id,
tenant_id=prepared.tenant_id,
state=DispatchState.QUEUED,
result_digest=digest_payload(
{
"queued_job_id": queued.job_id,
"state": queued.state.value,
"revision": queued.revision,
}
),
)
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
except Exception as exc:
if prepared is not None:
try:
self.dispatch_repository.update(
prepared.dispatch_id,
tenant_id=prepared.tenant_id,
state=DispatchState.FAILED,
result_digest=digest_payload(
{
"code": "dispatch_failed",
"error_type": type(exc).__name__,
"message": str(exc),
}
),
)
except Exception:
pass
current = self.control_plane.get_task(context, task.task_id)
if current.state == TaskState.RUNNING:
self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=TaskState.FAILED,
expected_revision=current.revision,
error={"code": "dispatch_failed", "message": str(exc)},
)
raise
def _create_openclaw_command(
self,
context: RequestContext,
task: TaskRecord,
) -> OpenClawCommand:
if self.openclaw_signing_material_resolver is None:
raise OrchestrationError("OpenClaw signing material is not configured")
key_material = self.openclaw_signing_material_resolver(
task.tenant_id,
self.openclaw_key_id,
)
command = OpenClawCommand.create(
tenant_id=task.tenant_id,
task_id=task.task_id,
trace_id=context.trace_id,
action=task.action,
payload=task.payload,
idempotency_key=task.idempotency_key or task.task_id,
key_id=self.openclaw_key_id,
)
return sign_command(command, key_material)
def _create_hermes_job(
self,
context: RequestContext,
agent: AgentRecord,
task: TaskRecord,
) -> HermesJob:
metadata = dict(agent.metadata)
return HermesJob.create(
tenant_id=task.tenant_id,
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
task_id=task.task_id,
trace_id=context.trace_id,
action=task.action,
payload=task.payload,
idempotency_key=task.idempotency_key or task.task_id,
priority=int(metadata.get("priority", 100)),
)
@staticmethod
def _assert_dispatch_context(
dispatch: DispatchRecord,
*,
runtime: str,
runtime_execution_id: str,
tenant_id: str,
task_id: str,
trace_id: str,
principal_id: str,
provenance_id: str,
run_id: str,
) -> None:
expected = {
"runtime": runtime,
"runtime_execution_id": runtime_execution_id,
"tenant_id": tenant_id,
"task_id": task_id,
"trace_id": trace_id,
"principal_id": principal_id,
"provenance_id": provenance_id,
"run_id": run_id,
}
for field_name, expected_value in expected.items():
if getattr(dispatch, field_name) != expected_value:
raise OrchestrationError(f"dispatch {field_name} mismatch")
def hermes_lifecycle_context(
self,
context: RequestContext,
*,
dispatch_id: str,
job_id: str,
tenant_id: str,
task_id: str,
trace_id: str,
principal_id: str,
provenance_id: str,
run_id: str,
) -> tuple[DispatchRecord, HermesJob]:
"""Resolve a runtime lifecycle request against its durable dispatch."""
dispatch = self.dispatch_repository.get(
dispatch_id, tenant_id=context.tenant_id
)
self._assert_dispatch_context(
dispatch,
runtime="hermes",
runtime_execution_id=job_id,
tenant_id=tenant_id,
task_id=task_id,
trace_id=trace_id,
principal_id=principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if context.tenant_id != tenant_id:
raise OrchestrationError("runtime context tenant_id mismatch")
if context.principal_id != principal_id:
raise OrchestrationError("runtime context principal_id mismatch")
if context.trace_id != trace_id:
raise OrchestrationError("runtime context trace_id mismatch")
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
job = self.hermes.get(job_id, tenant_id=tenant_id)
if job.task_id != task_id or job.trace_id != trace_id:
raise OrchestrationError("Hermes job identity mismatch")
return dispatch, job
def bind_hermes_worker(
self,
context: RequestContext,
job: HermesJob,
*,
dispatch_id: str,
provenance_id: str,
run_id: str,
worker_id: str,
) -> DispatchRecord:
dispatch, current = self.hermes_lifecycle_context(
context,
dispatch_id=dispatch_id,
job_id=job.job_id,
tenant_id=job.tenant_id,
task_id=job.task_id,
trace_id=job.trace_id,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if current.assigned_worker_id != worker_id:
raise OrchestrationError("Hermes worker does not own claimed job")
return self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
expected_worker_id=worker_id,
)
def reconcile_openclaw(
self,
context: RequestContext,
result: OpenClawResult,
*,
dispatch_id: str,
provenance_id: str,
run_id: str,
) -> TaskRecord:
dispatch = self.dispatch_repository.get(
dispatch_id, tenant_id=context.tenant_id
)
self._assert_dispatch_context(
dispatch,
runtime="openclaw",
runtime_execution_id=result.command_id,
tenant_id=result.tenant_id,
task_id=result.task_id,
trace_id=result.trace_id,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
result_digest = digest_payload(
{
"command_id": result.command_id,
"state": result.state.value,
"output": result.output,
"error": result.error,
"runtime_id": result.runtime_id,
"attempts": result.attempts,
"finished_at": result.finished_at,
}
)
if dispatch.state in TERMINAL_DISPATCH_STATES:
if dispatch.result_digest != result_digest:
raise DispatchConflict("terminal dispatch received a different result")
return self.control_plane.get_task(context, dispatch.task_id)
task = self.control_plane.get_task(context, dispatch.task_id)
if task.state != TaskState.RUNNING:
raise OrchestrationError("task is not RUNNING for active dispatch")
if result.state == CommandState.COMPLETED:
target = TaskState.COMPLETED
values = {"result": result.output or {}}
dispatch_state = DispatchState.COMPLETED
elif result.state in {
CommandState.FAILED,
CommandState.TIMED_OUT,
CommandState.REJECTED,
}:
target = TaskState.FAILED
values = {
"error": result.error or {"code": result.state.value.lower()}
}
dispatch_state = DispatchState.FAILED
elif result.state == CommandState.CANCELLED:
target = TaskState.CANCELLED
values = {}
dispatch_state = DispatchState.CANCELLED
else:
self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
state=DispatchState.RUNNING,
result_digest=result_digest,
)
return task
updated_task = self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=target,
expected_revision=task.revision,
**values,
)
self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
state=dispatch_state,
result_digest=result_digest,
)
return updated_task
def reconcile_hermes(
self,
context: RequestContext,
result: HermesResult,
*,
dispatch_id: str,
provenance_id: str,
run_id: str,
) -> TaskRecord:
dispatch = self.dispatch_repository.get(
dispatch_id, tenant_id=context.tenant_id
)
self._assert_dispatch_context(
dispatch,
runtime="hermes",
runtime_execution_id=result.job_id,
tenant_id=result.tenant_id,
task_id=result.task_id,
trace_id=result.trace_id,
principal_id=context.principal_id,
provenance_id=provenance_id,
run_id=run_id,
)
if self.hermes is None:
raise OrchestrationError("Hermes runtime is not configured")
job = self.hermes.get(result.job_id, tenant_id=result.tenant_id)
if result.state in {HermesJobState.COMPLETED, HermesJobState.FAILED}:
if not result.worker_id:
raise OrchestrationError("terminal Hermes result requires worker_id")
if job.assigned_worker_id != result.worker_id:
raise OrchestrationError("Hermes result worker does not own job lease")
if (
dispatch.expected_worker_id is not None
and dispatch.expected_worker_id != result.worker_id
):
raise OrchestrationError("Hermes result worker mismatch")
result_digest = digest_payload(
{
"job_id": result.job_id,
"worker_id": result.worker_id,
"state": result.state.value,
"output": result.output,
"error": result.error,
"finished_at": result.finished_at,
}
)
if dispatch.state in TERMINAL_DISPATCH_STATES:
if dispatch.result_digest != result_digest:
raise DispatchConflict("terminal dispatch received a different result")
return self.control_plane.get_task(context, dispatch.task_id)
task = self.control_plane.get_task(context, dispatch.task_id)
if task.state != TaskState.RUNNING:
raise OrchestrationError("task is not RUNNING for active dispatch")
if result.state == HermesJobState.COMPLETED:
target = TaskState.COMPLETED
values = {"result": result.output or {}}
dispatch_state = DispatchState.COMPLETED
elif result.state == HermesJobState.FAILED:
target = TaskState.FAILED
values = {"error": result.error or {"code": "hermes_failed"}}
dispatch_state = DispatchState.FAILED
elif result.state == HermesJobState.CANCELLED:
target = TaskState.CANCELLED
values = {}
dispatch_state = DispatchState.CANCELLED
else:
raise OrchestrationError("Hermes reconcile only accepts terminal results")
updated_task = self.control_plane.transition_task(
context,
task_id=task.task_id,
target_state=target,
expected_revision=task.revision,
**values,
)
self.dispatch_repository.update(
dispatch.dispatch_id,
tenant_id=dispatch.tenant_id,
state=dispatch_state,
expected_worker_id=result.worker_id,
result_digest=result_digest,
)
return updated_task
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
return {
item.task_id: item
for item in self.dispatch_repository.list(tenant_id=tenant_id)
}
+181
View File
@@ -0,0 +1,181 @@
"""Durable single-process persistence for the AIOS V2 control plane.
The candidate uses an atomic SQLite snapshot so a gateway restart does not lose
tenants, agents, task state, idempotency records, or audit events. The domain
service remains storage-agnostic; a later HA backend can replace this module
without changing API or runtime-adapter contracts.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from dataclasses import asdict
from pathlib import Path
from typing import Any, Callable
from .control_plane import (
AgentRecord,
AuditEvent,
ControlPlaneService,
InMemoryControlPlaneStore,
TaskRecord,
TaskState,
TenantRecord,
)
class SQLiteSnapshotRepository:
"""Store one versioned control-plane snapshot in SQLite."""
SCHEMA_VERSION = 1
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=30.0)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS control_plane_snapshot (
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
schema_version INTEGER NOT NULL,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def load(self) -> dict[str, Any] | None:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT schema_version, payload_json FROM control_plane_snapshot WHERE singleton_id = 1"
).fetchone()
if row is None:
return None
schema_version, payload_json = row
if int(schema_version) != self.SCHEMA_VERSION:
raise RuntimeError(
f"unsupported control-plane snapshot version: {schema_version}"
)
decoded = json.loads(str(payload_json))
if not isinstance(decoded, dict):
raise RuntimeError("invalid control-plane snapshot")
return decoded
def save(self, payload: dict[str, Any]) -> None:
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"""
INSERT INTO control_plane_snapshot (
singleton_id, schema_version, payload_json, updated_at
) VALUES (1, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(singleton_id) DO UPDATE SET
schema_version = excluded.schema_version,
payload_json = excluded.payload_json,
updated_at = CURRENT_TIMESTAMP
""",
(self.SCHEMA_VERSION, encoded),
)
connection.commit()
class DurableControlPlaneService(ControlPlaneService):
"""Control-plane service that persists after every successful mutation."""
def __init__(
self,
repository: SQLiteSnapshotRepository,
*,
policy_evaluator=None,
) -> None:
self.repository = repository
store = InMemoryControlPlaneStore()
self._restore(store, repository.load())
super().__init__(store, policy_evaluator=policy_evaluator)
@staticmethod
def _restore(
store: InMemoryControlPlaneStore,
snapshot: dict[str, Any] | None,
) -> None:
if not snapshot:
return
for item in snapshot.get("tenants", []):
record = TenantRecord(**item)
store.tenants[record.tenant_id] = record
for item in snapshot.get("agents", []):
normalized = dict(item)
normalized["capabilities"] = tuple(normalized.get("capabilities", ()))
record = AgentRecord(**normalized)
store.agents[record.agent_id] = record
for item in snapshot.get("tasks", []):
normalized = dict(item)
normalized["state"] = TaskState(normalized["state"])
record = TaskRecord(**normalized)
store.tasks[record.task_id] = record
store.audit_events.extend(AuditEvent(**item) for item in snapshot.get("audit", []))
for item in snapshot.get("idempotency", []):
store.idempotency[(item["tenant_id"], item["key"])] = (
item["task_id"],
item["digest"],
)
def _snapshot(self) -> dict[str, Any]:
with self.store.lock:
return {
"schema_version": self.repository.SCHEMA_VERSION,
"tenants": [asdict(item) for item in self.store.tenants.values()],
"agents": [asdict(item) for item in self.store.agents.values()],
"tasks": [
{**asdict(item), "state": item.state.value}
for item in self.store.tasks.values()
],
"audit": [asdict(item) for item in self.store.audit_events],
"idempotency": [
{
"tenant_id": tenant_id,
"key": key,
"task_id": task_id,
"digest": digest,
}
for (tenant_id, key), (task_id, digest)
in self.store.idempotency.items()
],
}
def _mutate(self, operation: Callable[[], Any]) -> Any:
with self.store.lock:
result = operation()
self.repository.save(self._snapshot())
return result
def register_tenant(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).register_tenant(*args, **kwargs))
def register_agent(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).register_agent(*args, **kwargs))
def create_task(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).create_task(*args, **kwargs))
def transition_task(self, *args, **kwargs):
return self._mutate(lambda: super(DurableControlPlaneService, self).transition_task(*args, **kwargs))
+831
View File
@@ -0,0 +1,831 @@
"""AIOS V2 可信 Run Provenance 签发、持久化与受控写入。
生产策略要求 Provenance 同时覆盖七类路径:代码变更、Git 操作、部署、
Runtime 执行、Evidence 写入、回滚和审计。任何缺失、篡改、过期、撤销、
重放或上下文不匹配都默认拒绝。
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import sqlite3
import stat
import threading
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
PROVENANCE_VERSION = "aios.v2.run-provenance.v1"
PRODUCTION_POLICY_VERSION = "aios.v2.production.v1"
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def parse_timestamp(value: str) -> datetime:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("timestamp is required")
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp must include timezone")
return parsed.astimezone(timezone.utc)
def required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
return json.dumps(
dict(value),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
def digest_payload(value: Mapping[str, Any]) -> str:
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
def _validate_digest(value: str) -> str:
normalized = required(value, "request_digest").lower()
if len(normalized) != 64 or any(ch not in "0123456789abcdef" for ch in normalized):
raise ValueError("request_digest must be a SHA-256 hexadecimal digest")
return normalized
class ExecutionPath(str, Enum):
CODE_CHANGE = "code_change"
GIT_OPERATION = "git_operation"
DEPLOYMENT = "deployment"
RUNTIME_EXECUTE = "runtime_execute"
EVIDENCE_WRITE = "evidence_write"
ROLLBACK = "rollback"
AUDIT = "audit"
REQUIRED_PRODUCTION_PATHS = frozenset(item.value for item in ExecutionPath)
class GitOperation(str, Enum):
READ_REF = "read_ref"
FETCH = "fetch"
CREATE_BRANCH = "create_branch"
COMMIT = "commit"
MERGE = "merge"
PUSH = "push"
UPDATE_EVIDENCE = "update_evidence"
class ProvenanceStatus(str, Enum):
ISSUED = "issued"
VERIFIED = "verified"
USED = "used"
EXPIRED = "expired"
REVOKED = "revoked"
class ProvenanceError(RuntimeError):
"""Run Provenance 基础错误。"""
class ProvenanceDenied(ProvenanceError):
"""策略拒绝当前操作。"""
class ProvenanceNotFound(ProvenanceDenied):
"""未找到 Provenance。"""
class ProvenanceSignatureError(ProvenanceDenied):
"""签名无效。"""
class ProvenanceExpired(ProvenanceDenied):
"""Provenance 已过期。"""
class ProvenanceRevoked(ProvenanceDenied):
"""Provenance 已撤销。"""
class ProvenanceReplay(ProvenanceDenied):
"""检测到相同授权摘要重放。"""
class Signer(Protocol):
key_id: str
def sign(self, payload: bytes) -> str: ...
class SignatureVerifier(Protocol):
key_id: str
fingerprint: str
def verify(self, payload: bytes, signature: str) -> bool: ...
def _load_crypto() -> tuple[Any, Any, Any]:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
except ImportError as exc: # pragma: no cover - depends on deployment image
raise RuntimeError(
"cryptography package with Ed25519 support is required"
) from exc
return serialization, Ed25519PrivateKey, Ed25519PublicKey
def _check_private_key_file(path: Path) -> bytes:
resolved = path.resolve()
metadata = resolved.stat()
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("private key reference is not a regular file")
if metadata.st_mode & 0o077:
raise RuntimeError("private key file permissions must be 0600 or stricter")
return resolved.read_bytes()
def _check_public_key_file(path: Path) -> bytes:
resolved = path.resolve()
metadata = resolved.stat()
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("public key reference is not a regular file")
if metadata.st_mode & 0o022:
raise RuntimeError("public key file must not be group/world writable")
return resolved.read_bytes()
class Ed25519PrivateKeySigner:
"""Ed25519 Owner/Control Plane 签发器。"""
def __init__(self, private_key: Any, *, key_id: str) -> None:
self.private_key = private_key
self.key_id = required(key_id, "key_id")
@classmethod
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PrivateKeySigner":
serialization, private_cls, _ = _load_crypto()
value = _check_private_key_file(Path(path))
if value.startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(value, password=None)
elif len(value) == 32:
key = private_cls.from_private_bytes(value)
else:
raise RuntimeError("private key must be PEM or a raw 32-byte Ed25519 seed")
if not isinstance(key, private_cls):
raise RuntimeError("private key is not Ed25519")
return cls(key, key_id=key_id)
def sign(self, payload: bytes) -> str:
return base64.b64encode(self.private_key.sign(bytes(payload))).decode("ascii")
def public_verifier(self) -> "Ed25519PublicKeyVerifier":
return Ed25519PublicKeyVerifier(self.private_key.public_key(), key_id=self.key_id)
class Ed25519PublicKeyVerifier:
"""Ed25519 签名验证器。"""
def __init__(self, public_key: Any, *, key_id: str) -> None:
serialization, _, public_cls = _load_crypto()
if not isinstance(public_key, public_cls):
raise RuntimeError("public key is not Ed25519")
self.public_key = public_key
self.key_id = required(key_id, "key_id")
raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
self.fingerprint = hashlib.sha256(raw).hexdigest()
@classmethod
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PublicKeyVerifier":
serialization, _, public_cls = _load_crypto()
value = _check_public_key_file(Path(path))
if value.startswith(b"-----BEGIN"):
key = serialization.load_pem_public_key(value)
elif len(value) == 32:
key = public_cls.from_public_bytes(value)
else:
raise RuntimeError("public key must be PEM or raw 32-byte Ed25519 key")
if not isinstance(key, public_cls):
raise RuntimeError("public key is not Ed25519")
return cls(key, key_id=key_id)
def verify(self, payload: bytes, signature: str) -> bool:
try:
decoded = base64.b64decode(required(signature, "signature"), validate=True)
self.public_key.verify(decoded, bytes(payload))
return True
except Exception:
return False
@dataclass(frozen=True)
class RunProvenance:
provenance_id: str
run_id: str
issuer: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
allowed_paths: Sequence[str]
allowed_git_operations: Sequence[str]
issued_at: str
expires_at: str
policy_version: str
key_id: str
nonce: str
signature: str = ""
schema_version: str = PROVENANCE_VERSION
def __post_init__(self) -> None:
for name in (
"provenance_id",
"run_id",
"issuer",
"tenant_id",
"task_id",
"agent_id",
"principal_id",
"issued_at",
"expires_at",
"policy_version",
"key_id",
"nonce",
"schema_version",
):
object.__setattr__(self, name, required(getattr(self, name), name))
if self.schema_version != PROVENANCE_VERSION:
raise ValueError(f"unsupported schema_version: {self.schema_version}")
issued = parse_timestamp(self.issued_at)
expires = parse_timestamp(self.expires_at)
if expires <= issued:
raise ValueError("expires_at must be later than issued_at")
paths = tuple(sorted({ExecutionPath(item).value for item in self.allowed_paths}))
operations = tuple(
sorted({GitOperation(item).value for item in self.allowed_git_operations})
)
object.__setattr__(self, "allowed_paths", paths)
object.__setattr__(self, "allowed_git_operations", operations)
object.__setattr__(self, "signature", str(self.signature or "").strip())
def unsigned_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"provenance_id": self.provenance_id,
"run_id": self.run_id,
"issuer": self.issuer,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"agent_id": self.agent_id,
"principal_id": self.principal_id,
"allowed_paths": list(self.allowed_paths),
"allowed_git_operations": list(self.allowed_git_operations),
"issued_at": self.issued_at,
"expires_at": self.expires_at,
"policy_version": self.policy_version,
"key_id": self.key_id,
"nonce": self.nonce,
}
def canonical_bytes(self) -> bytes:
return canonical_json_bytes(self.unsigned_dict())
def as_dict(self) -> dict[str, Any]:
value = self.unsigned_dict()
value["signature"] = self.signature
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "RunProvenance":
return cls(**dict(value))
class ProvenanceIssuer:
"""只由 Owner 或 AIOS Control Plane 持有的签发服务。"""
def __init__(self, signer: Signer, store: "SQLiteProvenanceStore") -> None:
self.signer = signer
self.store = store
def issue(
self,
*,
run_id: str,
issuer: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
allowed_paths: Sequence[str] = tuple(REQUIRED_PRODUCTION_PATHS),
allowed_git_operations: Sequence[str] = tuple(item.value for item in GitOperation),
ttl_seconds: int = 3600,
policy_version: str = PRODUCTION_POLICY_VERSION,
provenance_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> RunProvenance:
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be positive")
normalized_paths = frozenset(ExecutionPath(item).value for item in allowed_paths)
if policy_version == PRODUCTION_POLICY_VERSION and normalized_paths != REQUIRED_PRODUCTION_PATHS:
missing = sorted(REQUIRED_PRODUCTION_PATHS - normalized_paths)
extra = sorted(normalized_paths - REQUIRED_PRODUCTION_PATHS)
raise ProvenanceDenied(
f"production provenance must cover exactly seven paths; missing={missing}, extra={extra}"
)
normalized_operations = tuple(
sorted({GitOperation(item).value for item in allowed_git_operations})
)
if ExecutionPath.GIT_OPERATION.value in normalized_paths and not normalized_operations:
raise ProvenanceDenied("git_operation path requires explicit allowed Git operations")
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
unsigned = RunProvenance(
provenance_id=provenance_id or f"prov_{uuid.uuid4().hex}",
run_id=run_id,
issuer=issuer,
tenant_id=tenant_id,
task_id=task_id,
agent_id=agent_id,
principal_id=principal_id,
allowed_paths=tuple(normalized_paths),
allowed_git_operations=normalized_operations,
issued_at=issued.isoformat().replace("+00:00", "Z"),
expires_at=(issued + timedelta(seconds=ttl_seconds))
.isoformat()
.replace("+00:00", "Z"),
policy_version=policy_version,
key_id=self.signer.key_id,
nonce=nonce or uuid.uuid4().hex,
)
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
self.store.save_issued(signed)
return signed
@dataclass(frozen=True)
class ProvenanceAuthorization:
authorization_id: str
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
git_operation: str | None
request_digest: str
policy_version: str
key_id: str
public_key_fingerprint: str
authorized_at: str = field(default_factory=utc_now)
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class SQLiteProvenanceStore:
"""持久化 Provenance、状态、授权使用和不可变事件。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._connection = sqlite3.connect(
str(self.path), timeout=30.0, check_same_thread=False
)
self._connection.row_factory = sqlite3.Row
with self._lock:
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA synchronous=FULL")
self._connection.execute("PRAGMA foreign_keys=ON")
self._connection.executescript(
"""
CREATE TABLE IF NOT EXISTS provenance_records (
provenance_id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
signature_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS provenance_usage (
authorization_id TEXT PRIMARY KEY,
provenance_id TEXT NOT NULL,
execution_path TEXT NOT NULL,
git_operation TEXT NOT NULL DEFAULT '',
request_digest TEXT NOT NULL,
authorized_at TEXT NOT NULL,
receipt_json TEXT NOT NULL,
UNIQUE(provenance_id, execution_path, git_operation, request_digest),
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
);
CREATE TABLE IF NOT EXISTS provenance_events (
event_id TEXT PRIMARY KEY,
provenance_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
);
CREATE INDEX IF NOT EXISTS idx_provenance_usage_id
ON provenance_usage(provenance_id, authorized_at);
CREATE INDEX IF NOT EXISTS idx_provenance_events_id
ON provenance_events(provenance_id, created_at);
"""
)
self._connection.commit()
def close(self) -> None:
with self._lock:
self._connection.close()
def _event(
self,
provenance_id: str,
event_type: str,
details: Mapping[str, Any] | None = None,
) -> None:
self._connection.execute(
"INSERT INTO provenance_events VALUES (?, ?, ?, ?, ?)",
(
f"pevt_{uuid.uuid4().hex}",
provenance_id,
event_type,
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
utc_now(),
),
)
def save_issued(self, provenance: RunProvenance) -> None:
payload = json.dumps(
provenance.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
now = utc_now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
prior = self._connection.execute(
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
(provenance.provenance_id,),
).fetchone()
if prior is not None:
if prior["payload_json"] != payload:
raise ProvenanceDenied("provenance_id reused with different payload")
self._connection.rollback()
return
self._connection.execute(
"INSERT INTO provenance_records VALUES (?, ?, ?, 0, ?, ?)",
(
provenance.provenance_id,
payload,
ProvenanceStatus.ISSUED.value,
now,
now,
),
)
self._event(provenance.provenance_id, "issued")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def get(self, provenance_id: str) -> RunProvenance:
with self._lock:
row = self._connection.execute(
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance not found")
value = json.loads(row["payload_json"])
if not isinstance(value, Mapping):
raise ProvenanceError("stored provenance payload is invalid")
return RunProvenance.from_dict(value)
def status(self, provenance_id: str) -> ProvenanceStatus:
with self._lock:
row = self._connection.execute(
"SELECT status FROM provenance_records WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance not found")
return ProvenanceStatus(row["status"])
def mark_verified(self, provenance_id: str, *, fingerprint: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(provenance_id)
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
raise ProvenanceDenied(f"cannot verify provenance in state {current.value}")
next_status = (
ProvenanceStatus.USED.value
if current == ProvenanceStatus.USED
else ProvenanceStatus.VERIFIED.value
)
self._connection.execute(
"UPDATE provenance_records SET status = ?, signature_verified = 1, updated_at = ? WHERE provenance_id = ?",
(next_status, utc_now(), provenance_id),
)
self._event(
provenance_id,
"verified",
{"public_key_fingerprint": fingerprint},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def mark_expired(self, provenance_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(provenance_id)
if current == ProvenanceStatus.REVOKED:
self._connection.rollback()
return
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(ProvenanceStatus.EXPIRED.value, utc_now(), provenance_id),
)
self._event(provenance_id, "expired")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def revoke(self, provenance_id: str, *, reason: str, principal_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self.get(provenance_id)
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(ProvenanceStatus.REVOKED.value, utc_now(), provenance_id),
)
self._event(
provenance_id,
"revoked",
{
"reason": required(reason, "reason"),
"principal_id": required(principal_id, "principal_id"),
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def record_authorization(self, authorization: ProvenanceAuthorization) -> None:
receipt = json.dumps(
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(authorization.provenance_id)
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
raise ProvenanceDenied(f"provenance state is {current.value}")
try:
self._connection.execute(
"INSERT INTO provenance_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
(
authorization.authorization_id,
authorization.provenance_id,
authorization.execution_path,
authorization.git_operation or "",
authorization.request_digest,
authorization.authorized_at,
receipt,
),
)
except sqlite3.IntegrityError as exc:
raise ProvenanceReplay(
"identical provenance path/operation/request digest already used"
) from exc
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(
ProvenanceStatus.USED.value,
authorization.authorized_at,
authorization.provenance_id,
),
)
self._event(
authorization.provenance_id,
"used",
{
"authorization_id": authorization.authorization_id,
"execution_path": authorization.execution_path,
"git_operation": authorization.git_operation,
"request_digest": authorization.request_digest,
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def authorization_count(self, provenance_id: str) -> int:
with self._lock:
row = self._connection.execute(
"SELECT COUNT(*) AS total FROM provenance_usage WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
return int(row["total"])
def events(self, provenance_id: str) -> list[dict[str, Any]]:
with self._lock:
rows = self._connection.execute(
"SELECT event_type, details_json, created_at FROM provenance_events WHERE provenance_id = ? ORDER BY created_at, event_id",
(required(provenance_id, "provenance_id"),),
).fetchall()
return [
{
"event_type": row["event_type"],
"details": json.loads(row["details_json"]),
"created_at": row["created_at"],
}
for row in rows
]
class ProvenanceVerifier:
"""验证 Owner 签名、上下文、路径、Git 操作、时效和重放。"""
def __init__(
self,
store: SQLiteProvenanceStore,
verifiers: Mapping[str, SignatureVerifier],
) -> None:
if not verifiers:
raise ValueError("at least one public key verifier is required")
self.store = store
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
def authorize(
self,
*,
provenance_id: str,
run_id: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
execution_path: str,
request_digest: str,
git_operation: str | None = None,
now: datetime | None = None,
) -> ProvenanceAuthorization:
provenance = self.store.get(provenance_id)
status = self.store.status(provenance.provenance_id)
if status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("provenance is revoked")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
if current >= parse_timestamp(provenance.expires_at):
self.store.mark_expired(provenance.provenance_id)
raise ProvenanceExpired("provenance is expired")
verifier = self.verifiers.get(provenance.key_id)
if verifier is None:
raise ProvenanceSignatureError("no trusted public key for provenance key_id")
if not provenance.signature or not verifier.verify(
provenance.canonical_bytes(), provenance.signature
):
raise ProvenanceSignatureError("invalid provenance signature")
expected = {
"run_id": required(run_id, "run_id"),
"tenant_id": required(tenant_id, "tenant_id"),
"task_id": required(task_id, "task_id"),
"agent_id": required(agent_id, "agent_id"),
"principal_id": required(principal_id, "principal_id"),
}
for field_name, expected_value in expected.items():
if getattr(provenance, field_name) != expected_value:
raise ProvenanceDenied(f"provenance {field_name} mismatch")
path = ExecutionPath(execution_path).value
if path not in provenance.allowed_paths:
raise ProvenanceDenied(f"execution path is not allowed: {path}")
operation: str | None = None
if path == ExecutionPath.GIT_OPERATION.value:
if git_operation is None:
raise ProvenanceDenied("git operation is required for git_operation path")
operation = GitOperation(git_operation).value
if operation not in provenance.allowed_git_operations:
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
elif git_operation is not None:
raise ProvenanceDenied("git_operation is only valid for git_operation path")
digest = _validate_digest(request_digest)
self.store.mark_verified(
provenance.provenance_id, fingerprint=verifier.fingerprint
)
authorization = ProvenanceAuthorization(
authorization_id=f"pauth_{uuid.uuid4().hex}",
provenance_id=provenance.provenance_id,
run_id=provenance.run_id,
tenant_id=provenance.tenant_id,
task_id=provenance.task_id,
agent_id=provenance.agent_id,
principal_id=provenance.principal_id,
execution_path=path,
git_operation=operation,
request_digest=digest,
policy_version=provenance.policy_version,
key_id=provenance.key_id,
public_key_fingerprint=verifier.fingerprint,
)
self.store.record_authorization(authorization)
return authorization
class ProvenanceControlledWriter:
"""七路径统一策略入口并持久写入授权回执。"""
def __init__(
self,
verifier: ProvenanceVerifier,
receipt_root: str | Path,
*,
writer_id: str = "aios-v2-provenance-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def authorize(self, **values: Any) -> ProvenanceAuthorization:
authorization = self.verifier.authorize(**values)
tenant = self._component(authorization.tenant_id)
provenance_id = self._component(authorization.provenance_id)
target_dir = self.receipt_root / tenant / provenance_id
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> ProvenanceAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
def authorize_runtime_execute(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
def authorize_evidence_write(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
def authorize_rollback(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
def authorize_audit(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""AIOS V2 Run Provenance 与最小权限 Grant 运维 CLI。
私钥只用于签发;授权命令只加载一个与 ``--path`` 对应的 Grant。所有输出均为
不含 Secret 的 JSON。旧 ``issue`` 仅为候选兼容,生产必须使用
``issue-workflow`` 与 ``issue-grant``。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import (
Ed25519PrivateKeySigner,
Ed25519PublicKeyVerifier,
ExecutionPath,
GitOperation,
ProvenanceIssuer,
SQLiteProvenanceStore,
)
from .provenance_grants import (
GrantIssuer,
GrantType,
GrantVerifier,
SQLiteGrantStore,
WorkflowProvenanceIssuer,
)
from .provenance_operations import (
OperationContext,
ProvenanceOperationGate,
context_from_environment,
load_request_file,
)
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
def _json_object(value: str | None, file_path: str | None) -> Mapping[str, Any]:
if value and file_path:
raise ValueError("request-json and request-file are mutually exclusive")
if file_path:
return load_request_file(file_path)
parsed = json.loads(value or "{}")
if not isinstance(parsed, Mapping):
raise ValueError("request JSON must be an object")
return dict(parsed)
def _csv(value: str | None) -> tuple[str, ...]:
return tuple(item.strip() for item in str(value or "").split(",") if item.strip())
def _write_json(path: str | None, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
if path:
target = Path(path).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(encoded, encoding="utf-8")
os.chmod(temporary, 0o640)
os.replace(temporary, target)
print(encoded, end="")
def _parent_store(args: argparse.Namespace) -> SQLiteProvenanceStore:
return SQLiteProvenanceStore(args.store_file)
def _grant_store(args: argparse.Namespace) -> SQLiteGrantStore:
return SQLiteGrantStore(args.store_file)
def _context(args: argparse.Namespace) -> OperationContext:
if args.context_from_env:
return context_from_environment()
values = {
"provenance_id": args.provenance_id,
"run_id": args.run_id,
"tenant_id": args.tenant_id,
"task_id": args.task_id,
"agent_id": args.agent_id,
"principal_id": args.principal_id,
}
missing = [name for name, value in values.items() if not str(value or "").strip()]
if missing:
raise ValueError(f"missing context arguments: {missing}")
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
def command_issue_legacy(args: argparse.Namespace) -> int:
store = _parent_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
provenance = ProvenanceIssuer(signer, store).issue(
run_id=args.run_id,
issuer=args.issuer,
tenant_id=args.tenant_id,
task_id=args.task_id,
agent_id=args.agent_id,
principal_id=args.principal_id,
allowed_paths=_csv(args.allowed_paths) or tuple(item.value for item in ExecutionPath),
allowed_git_operations=_csv(args.allowed_git_operations)
or tuple(item.value for item in GitOperation),
ttl_seconds=args.ttl_seconds,
policy_version=args.policy_version,
)
_write_json(
args.output,
{
"mode": "legacy-full-rights-candidate-only",
"provenance": provenance.as_dict(),
"status": store.status(provenance.provenance_id).value,
"secret_value_stored": False,
},
)
return 0
def command_issue_workflow(args: argparse.Namespace) -> int:
store = _parent_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
workflow = WorkflowProvenanceIssuer(signer, store).issue(
run_id=args.run_id,
issuer=args.issuer,
tenant_id=args.tenant_id,
task_id=args.task_id,
agent_id=args.agent_id,
principal_id=args.principal_id,
ttl_seconds=args.ttl_seconds,
)
_write_json(
args.output,
{
"mode": "least-privilege-workflow",
"workflow_provenance": workflow.as_dict(),
"status": store.status(workflow.provenance_id).value,
"secret_value_stored": False,
},
)
return 0
def command_issue_grant(args: argparse.Namespace) -> int:
parent_store = _parent_store(args)
grant_store = _grant_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
grant = GrantIssuer(signer, parent_store, grant_store).issue(
parent_provenance_id=args.parent_provenance_id,
grant_type=GrantType(args.grant_type),
ttl_seconds=args.ttl_seconds,
allowed_git_operations=_csv(args.allowed_git_operations),
)
_write_json(
args.output,
{
"grant": grant.as_dict(),
"status": grant_store.status(grant.grant_id).value,
"secret_value_stored": False,
},
)
return 0
def _single_scope_writer(args: argparse.Namespace) -> ScopedLeastPrivilegeProvenanceWriter:
path = ExecutionPath(args.path).value
parent_store = _parent_store(args)
grant_store = _grant_store(args)
verifier = Ed25519PublicKeyVerifier.from_file(args.public_key_file, key_id=args.key_id)
trusted = GrantVerifier(parent_store, grant_store, {args.key_id: verifier})
return ScopedLeastPrivilegeProvenanceWriter(
trusted,
args.receipt_dir,
{path: args.grant_id},
required_paths=(path,),
writer_id=f"aios-v2-cli-{path}",
)
def command_authorize(args: argparse.Namespace) -> int:
writer = _single_scope_writer(args)
context = _context(args)
receipt = ProvenanceOperationGate(writer).authorize(
context,
execution_path=args.path,
operation=args.operation,
request=_json_object(args.request_json, args.request_file),
)
_write_json(args.output, {"authorization": receipt.as_dict()})
return 0
def command_revoke(args: argparse.Namespace) -> int:
store = _parent_store(args)
store.revoke(args.provenance_id, reason=args.reason, principal_id=args.principal_id)
_write_json(
args.output,
{"provenance_id": args.provenance_id, "status": store.status(args.provenance_id).value},
)
return 0
def command_status(args: argparse.Namespace) -> int:
store = _parent_store(args)
provenance = store.get(args.provenance_id)
grant_store = _grant_store(args)
_write_json(
args.output,
{
"provenance": provenance.as_dict(),
"status": store.status(args.provenance_id).value,
"authorization_count": store.authorization_count(args.provenance_id),
"events": store.events(args.provenance_id),
"grants": [item.as_dict() for item in grant_store.list_for_parent(args.provenance_id)],
},
)
return 0
def command_grant_revoke(args: argparse.Namespace) -> int:
store = _grant_store(args)
store.revoke(args.grant_id, reason=args.reason, principal_id=args.principal_id)
_write_json(
args.output,
{"grant_id": args.grant_id, "status": store.status(args.grant_id).value},
)
return 0
def command_grant_status(args: argparse.Namespace) -> int:
store = _grant_store(args)
grant = store.get(args.grant_id)
_write_json(
args.output,
{"grant": grant.as_dict(), "status": store.status(args.grant_id).value},
)
return 0
def add_common_store(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--store-file",
default=os.getenv(
"AIOS_PROVENANCE_STORE_FILE",
"/var/lib/aios-v2/run-provenance.sqlite3",
),
)
parser.add_argument("--output", default=None)
def add_signing_context(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--private-key-file", required=True)
parser.add_argument("--key-id", required=True)
def add_workflow_identity(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--run-id", required=True)
parser.add_argument("--issuer", required=True)
parser.add_argument("--tenant-id", required=True)
parser.add_argument("--task-id", required=True)
parser.add_argument("--agent-id", required=True)
parser.add_argument("--principal-id", required=True)
def add_context(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--context-from-env", action="store_true")
parser.add_argument("--provenance-id")
parser.add_argument("--run-id")
parser.add_argument("--tenant-id")
parser.add_argument("--task-id")
parser.add_argument("--agent-id")
parser.add_argument("--principal-id")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AIOS V2 Run Provenance CLI")
subcommands = parser.add_subparsers(dest="command", required=True)
issue = subcommands.add_parser("issue", help="候选兼容:签发全权限 Provenance;生产禁止")
add_common_store(issue)
add_signing_context(issue)
add_workflow_identity(issue)
issue.add_argument("--allowed-paths")
issue.add_argument("--allowed-git-operations")
issue.add_argument("--ttl-seconds", type=int, default=3600)
issue.add_argument("--policy-version", default="aios.v2.candidate.legacy")
issue.set_defaults(handler=command_issue_legacy)
workflow = subcommands.add_parser(
"issue-workflow", help="签发不含操作权限的父 Workflow Provenance"
)
add_common_store(workflow)
add_signing_context(workflow)
add_workflow_identity(workflow)
workflow.add_argument("--ttl-seconds", type=int, default=14400)
workflow.set_defaults(handler=command_issue_workflow)
grant = subcommands.add_parser("issue-grant", help="签发单一路径最小权限 Grant")
add_common_store(grant)
add_signing_context(grant)
grant.add_argument("--parent-provenance-id", required=True)
grant.add_argument("--grant-type", choices=[item.value for item in GrantType], required=True)
grant.add_argument("--allowed-git-operations")
grant.add_argument("--ttl-seconds", type=int, default=900)
grant.set_defaults(handler=command_issue_grant)
authorize = subcommands.add_parser("authorize", help="使用单一子 Grant 授权一次运维操作")
add_common_store(authorize)
add_context(authorize)
authorize.add_argument("--public-key-file", required=True)
authorize.add_argument("--key-id", required=True)
authorize.add_argument("--grant-id", required=True)
authorize.add_argument(
"--receipt-dir",
default=os.getenv(
"AIOS_PROVENANCE_RECEIPT_DIR",
"/var/lib/aios-v2/provenance-receipts",
),
)
authorize.add_argument(
"--path",
required=True,
choices=[
ExecutionPath.CODE_CHANGE.value,
ExecutionPath.DEPLOYMENT.value,
ExecutionPath.RUNTIME_EXECUTE.value,
ExecutionPath.EVIDENCE_WRITE.value,
ExecutionPath.ROLLBACK.value,
ExecutionPath.AUDIT.value,
],
)
authorize.add_argument("--operation", required=True)
request_group = authorize.add_mutually_exclusive_group()
request_group.add_argument("--request-json")
request_group.add_argument("--request-file")
authorize.set_defaults(handler=command_authorize)
revoke = subcommands.add_parser("revoke", help="撤销父 Workflow Provenance")
add_common_store(revoke)
revoke.add_argument("--provenance-id", required=True)
revoke.add_argument("--reason", required=True)
revoke.add_argument("--principal-id", required=True)
revoke.set_defaults(handler=command_revoke)
status = subcommands.add_parser("status", help="查询父 Workflow 与子 Grant")
add_common_store(status)
status.add_argument("--provenance-id", required=True)
status.set_defaults(handler=command_status)
grant_revoke = subcommands.add_parser("grant-revoke", help="单独撤销子 Grant")
add_common_store(grant_revoke)
grant_revoke.add_argument("--grant-id", required=True)
grant_revoke.add_argument("--reason", required=True)
grant_revoke.add_argument("--principal-id", required=True)
grant_revoke.set_defaults(handler=command_grant_revoke)
grant_status = subcommands.add_parser("grant-status", help="查询子 Grant 状态")
add_common_store(grant_status)
grant_status.add_argument("--grant-id", required=True)
grant_status.set_defaults(handler=command_grant_status)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return int(args.handler(args))
except Exception as exc:
print(
json.dumps(
{"status": "denied", "error": type(exc).__name__, "message": str(exc)},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,416 @@
"""受最小权限 Run Provenance Grant 约束的 Git 写入器。
所有 Git 操作都使用参数数组执行。对于 Commit 和 Evidence Commit,先计算
工作树内容摘要并完成 Provenance 授权,之后才允许执行 ``git add``,避免未授权
请求改变 Git Index。
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
from .provenance import GitOperation, ProvenanceDenied, digest_payload, utc_now
_BRANCH_PATTERN = re.compile(r"^(?![-./])(?!.*\.\.)(?!.*//)[A-Za-z0-9._/-]{1,240}$")
_SECRET_URL = re.compile(r"(?i)(https?://)([^/@\s]+)@")
_TOKENISH = re.compile(r"(?i)(token|password|secret|authorization)([=:]\s*)([^\s]+)")
class GitAuthorizationWriter(Protocol):
def authorize_code_change(self, **values: Any) -> Any: ...
def authorize_git_operation(self, *, git_operation: str, **values: Any) -> Any: ...
class ProvenanceGitError(RuntimeError):
"""Git 操作被拒绝或执行失败。"""
@dataclass(frozen=True)
class GitExecutionContext:
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
@dataclass(frozen=True)
class GitCommandResult:
operation_id: str
operation: str
authorization_ids: Sequence[str]
command: Sequence[str]
exit_code: int
stdout: str
stderr: str
head_before: str | None
head_after: str | None
started_at: str
completed_at: str
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class ProvenanceGitWriter:
"""执行经过单路径 Grant 授权的窄范围 Git 操作。"""
def __init__(
self,
repo: str | Path,
writer: GitAuthorizationWriter,
*,
timeout_seconds: float = 120.0,
output_limit: int = 16000,
) -> None:
self.repo = Path(repo).resolve()
self.writer = writer
self.timeout_seconds = float(timeout_seconds)
self.output_limit = int(output_limit)
if self.timeout_seconds <= 0 or self.output_limit < 1024:
raise ValueError("invalid Git writer limits")
self._run_raw(["rev-parse", "--is-inside-work-tree"], check=True)
@staticmethod
def _validate_branch(branch: str) -> str:
normalized = str(branch or "").strip()
if not _BRANCH_PATTERN.fullmatch(normalized) or normalized.endswith((".", "/")):
raise ProvenanceGitError("invalid Git branch name")
if normalized in {"main", "master"}:
raise ProvenanceGitError("direct main/master operation is prohibited")
return normalized
def _validate_path(self, value: str | Path) -> str:
raw = Path(value)
candidate = (self.repo / raw).resolve() if not raw.is_absolute() else raw.resolve()
try:
relative = candidate.relative_to(self.repo)
except ValueError as exc:
raise ProvenanceGitError("Git path escapes repository") from exc
if str(relative) in {"", "."}:
raise ProvenanceGitError("repository root is not a valid file path")
return relative.as_posix()
@staticmethod
def _redact(value: str, limit: int) -> str:
text = _SECRET_URL.sub(r"\1***@", str(value or ""))
text = _TOKENISH.sub(r"\1\2***", text)
if len(text) > limit:
return text[:limit] + "\n...[truncated]"
return text
def _head(self) -> str | None:
process = self._run_raw(["rev-parse", "HEAD"], check=False)
return process.stdout.strip() if process.returncode == 0 else None
def _run_raw(
self,
args: Sequence[str],
*,
check: bool,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
command = ["git", "-C", str(self.repo), *[str(item) for item in args]]
safe_env = {
**os.environ,
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_NOSYSTEM": os.environ.get("GIT_CONFIG_NOSYSTEM", "1"),
}
if env:
safe_env.update({str(key): str(value) for key, value in env.items()})
process = subprocess.run(
command,
text=True,
capture_output=True,
timeout=self.timeout_seconds,
env=safe_env,
)
if check and process.returncode != 0:
raise ProvenanceGitError(
self._redact(process.stderr or process.stdout, self.output_limit)
or f"Git command failed with exit code {process.returncode}"
)
return process
def _path_digest(self, relative: str) -> str:
target = self.repo / relative
if target.exists():
if not target.is_file():
raise ProvenanceGitError("Git writer accepts file paths only")
hasher = hashlib.sha256()
with target.open("rb") as handle:
while True:
block = handle.read(65536)
if not block:
break
hasher.update(block)
return hasher.hexdigest()
tracked = self._run_raw(["ls-files", "--error-unmatch", "--", relative], check=False)
if tracked.returncode != 0:
raise ProvenanceGitError(f"path does not exist and is not tracked: {relative}")
return "deleted"
def _content_manifest(self, paths: Sequence[str]) -> Mapping[str, str]:
return {path: self._path_digest(path) for path in sorted(set(paths))}
def _authorize(
self,
context: GitExecutionContext,
*,
operation: GitOperation,
request: Mapping[str, Any],
code_change: bool,
) -> list[str]:
request_digest = digest_payload(
{
"operation": operation.value,
"repo": self.repo.name,
"request": dict(request),
"head": self._head(),
}
)
ids: list[str] = []
try:
if code_change:
code_authorization = self.writer.authorize_code_change(
provenance_id=context.provenance_id,
run_id=context.run_id,
tenant_id=context.tenant_id,
task_id=context.task_id,
agent_id=context.agent_id,
principal_id=context.principal_id,
request_digest=digest_payload(
{
"git_operation": operation.value,
"request_digest": request_digest,
}
),
)
ids.append(code_authorization.authorization_id)
git_authorization = self.writer.authorize_git_operation(
provenance_id=context.provenance_id,
run_id=context.run_id,
tenant_id=context.tenant_id,
task_id=context.task_id,
agent_id=context.agent_id,
principal_id=context.principal_id,
git_operation=operation.value,
request_digest=request_digest,
)
ids.append(git_authorization.authorization_id)
return ids
except ProvenanceDenied as exc:
raise ProvenanceGitError(f"Run Provenance denied Git operation: {exc}") from exc
def _run_authorized(
self,
*,
operation: GitOperation,
args: Sequence[str],
authorization_ids: Sequence[str],
) -> GitCommandResult:
started = utc_now()
before = self._head()
process = self._run_raw(args, check=False)
after = self._head()
result = GitCommandResult(
operation_id=f"gitop_{uuid.uuid4().hex}",
operation=operation.value,
authorization_ids=tuple(authorization_ids),
command=tuple(["git", "-C", "<repo>", *[str(item) for item in args]]),
exit_code=int(process.returncode),
stdout=self._redact(process.stdout, self.output_limit),
stderr=self._redact(process.stderr, self.output_limit),
head_before=before,
head_after=after,
started_at=started,
completed_at=utc_now(),
)
if process.returncode != 0:
raise ProvenanceGitError(
result.stderr or result.stdout or f"Git {operation.value} failed"
)
return result
def _execute(
self,
context: GitExecutionContext,
*,
operation: GitOperation,
args: Sequence[str],
request: Mapping[str, Any],
code_change: bool,
) -> GitCommandResult:
authorization_ids = self._authorize(
context,
operation=operation,
request=request,
code_change=code_change,
)
return self._run_authorized(
operation=operation,
args=args,
authorization_ids=authorization_ids,
)
def read_ref(self, context: GitExecutionContext, ref: str) -> GitCommandResult:
normalized = str(ref or "").strip()
if not normalized or any(ch.isspace() for ch in normalized):
raise ProvenanceGitError("invalid Git ref")
return self._execute(
context,
operation=GitOperation.READ_REF,
args=["rev-parse", "--verify", normalized],
request={"ref": normalized},
code_change=False,
)
def fetch(
self,
context: GitExecutionContext,
*,
remote: str,
refspecs: Sequence[str],
) -> GitCommandResult:
normalized_remote = str(remote or "").strip()
normalized_refspecs = tuple(str(item).strip() for item in refspecs if str(item).strip())
if not normalized_remote or not normalized_refspecs:
raise ProvenanceGitError("remote and refspecs are required")
if any(item.startswith("+") and ":refs/heads/" in item for item in normalized_refspecs):
raise ProvenanceGitError(
"fetching directly into local branch refs is prohibited; use remote-tracking refs"
)
return self._execute(
context,
operation=GitOperation.FETCH,
args=["fetch", "--prune", normalized_remote, *normalized_refspecs],
request={"remote": normalized_remote, "refspecs": normalized_refspecs},
code_change=False,
)
def create_branch(
self,
context: GitExecutionContext,
*,
branch: str,
start_point: str,
) -> GitCommandResult:
normalized = self._validate_branch(branch)
start = str(start_point or "").strip()
if not start:
raise ProvenanceGitError("start_point is required")
return self._execute(
context,
operation=GitOperation.CREATE_BRANCH,
args=["branch", normalized, start],
request={"branch": normalized, "start_point": start},
code_change=False,
)
def commit(
self,
context: GitExecutionContext,
*,
paths: Sequence[str | Path],
message: str,
) -> GitCommandResult:
normalized_paths = tuple(self._validate_path(item) for item in paths)
normalized_message = str(message or "").strip()
if not normalized_paths or not normalized_message:
raise ProvenanceGitError("commit paths and message are required")
if len(normalized_message) > 500:
raise ProvenanceGitError("commit message is too long")
request = {
"paths": normalized_paths,
"message": normalized_message,
"content_manifest": self._content_manifest(normalized_paths),
}
authorization_ids = self._authorize(
context,
operation=GitOperation.COMMIT,
request=request,
code_change=True,
)
self._run_raw(["add", "--", *normalized_paths], check=True)
return self._run_authorized(
operation=GitOperation.COMMIT,
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
authorization_ids=authorization_ids,
)
def merge(
self,
context: GitExecutionContext,
*,
source_ref: str,
message: str,
) -> GitCommandResult:
source = str(source_ref or "").strip()
normalized_message = str(message or "").strip()
if not source or not normalized_message:
raise ProvenanceGitError("source_ref and message are required")
return self._execute(
context,
operation=GitOperation.MERGE,
args=["merge", "--no-ff", "--no-edit", "-m", normalized_message, source],
request={"source_ref": source, "message": normalized_message},
code_change=True,
)
def push(
self,
context: GitExecutionContext,
*,
remote: str,
branch: str,
) -> GitCommandResult:
normalized_remote = str(remote or "").strip()
normalized_branch = self._validate_branch(branch)
if not normalized_remote:
raise ProvenanceGitError("remote is required")
return self._execute(
context,
operation=GitOperation.PUSH,
args=["push", normalized_remote, f"refs/heads/{normalized_branch}:refs/heads/{normalized_branch}"],
request={"remote": normalized_remote, "branch": normalized_branch},
code_change=False,
)
def update_evidence(
self,
context: GitExecutionContext,
*,
paths: Sequence[str | Path],
message: str,
) -> GitCommandResult:
normalized_paths = tuple(self._validate_path(item) for item in paths)
normalized_message = str(message or "").strip()
if not normalized_paths or not normalized_message:
raise ProvenanceGitError("evidence paths and message are required")
if any(not path.startswith(".agent/") for path in normalized_paths):
raise ProvenanceGitError("UPDATE_EVIDENCE may only commit .agent/ paths")
request = {
"paths": normalized_paths,
"message": normalized_message,
"content_manifest": self._content_manifest(normalized_paths),
}
authorization_ids = self._authorize(
context,
operation=GitOperation.UPDATE_EVIDENCE,
request=request,
code_change=False,
)
self._run_raw(["add", "--", *normalized_paths], check=True)
return self._run_authorized(
operation=GitOperation.UPDATE_EVIDENCE,
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
authorization_ids=authorization_ids,
)
@@ -0,0 +1,744 @@
"""AIOS V2 最小权限 Run Provenance 子 Grant。
父 Workflow Provenance 只绑定 run/tenant/task/agent/principal 身份,不直接授予
操作权限。每个子 Grant 只允许一个 ExecutionPath;Git Grant 还必须限定具体
GitOperation。子 Grant 具备独立 TTL、nonce、状态、撤销和重放防护。
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import (
ExecutionPath,
GitOperation,
ProvenanceDenied,
ProvenanceExpired,
ProvenanceIssuer,
ProvenanceNotFound,
ProvenanceReplay,
ProvenanceRevoked,
ProvenanceSignatureError,
ProvenanceStatus,
ProvenanceVerifier,
RunProvenance,
SQLiteProvenanceStore,
Signer,
SignatureVerifier,
_validate_digest,
canonical_json_bytes,
parse_timestamp,
required,
utc_now,
)
WORKFLOW_POLICY_VERSION = "aios.v2.workflow.v1"
GRANT_SCHEMA_VERSION = "aios.v2.provenance-grant.v1"
class GrantType(str, Enum):
CODE_CHANGE = "code_change"
GIT = "git"
DEPLOYMENT = "deployment"
RUNTIME = "runtime"
EVIDENCE = "evidence"
ROLLBACK = "rollback"
AUDIT = "audit"
GRANT_PATHS: Mapping[GrantType, ExecutionPath] = {
GrantType.CODE_CHANGE: ExecutionPath.CODE_CHANGE,
GrantType.GIT: ExecutionPath.GIT_OPERATION,
GrantType.DEPLOYMENT: ExecutionPath.DEPLOYMENT,
GrantType.RUNTIME: ExecutionPath.RUNTIME_EXECUTE,
GrantType.EVIDENCE: ExecutionPath.EVIDENCE_WRITE,
GrantType.ROLLBACK: ExecutionPath.ROLLBACK,
GrantType.AUDIT: ExecutionPath.AUDIT,
}
class GrantStatus(str, Enum):
ISSUED = "issued"
VERIFIED = "verified"
USED = "used"
EXPIRED = "expired"
REVOKED = "revoked"
@dataclass(frozen=True)
class ProvenanceGrant:
grant_id: str
parent_provenance_id: str
grant_type: GrantType
execution_path: ExecutionPath
run_id: str
issuer: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
allowed_git_operations: Sequence[str]
issued_at: str
expires_at: str
policy_version: str
key_id: str
nonce: str
signature: str = ""
schema_version: str = GRANT_SCHEMA_VERSION
def __post_init__(self) -> None:
for name in (
"grant_id",
"parent_provenance_id",
"run_id",
"issuer",
"tenant_id",
"task_id",
"agent_id",
"principal_id",
"issued_at",
"expires_at",
"policy_version",
"key_id",
"nonce",
"schema_version",
):
object.__setattr__(self, name, required(getattr(self, name), name))
if self.schema_version != GRANT_SCHEMA_VERSION:
raise ValueError(f"unsupported grant schema: {self.schema_version}")
grant_type = GrantType(self.grant_type)
path = ExecutionPath(self.execution_path)
if GRANT_PATHS[grant_type] != path:
raise ValueError("grant_type does not match execution_path")
object.__setattr__(self, "grant_type", grant_type)
object.__setattr__(self, "execution_path", path)
operations = tuple(
sorted({GitOperation(item).value for item in self.allowed_git_operations})
)
if grant_type == GrantType.GIT and not operations:
raise ValueError("Git Grant requires explicit Git operations")
if grant_type != GrantType.GIT and operations:
raise ValueError("only Git Grant may contain Git operations")
object.__setattr__(self, "allowed_git_operations", operations)
issued = parse_timestamp(self.issued_at)
expires = parse_timestamp(self.expires_at)
if expires <= issued:
raise ValueError("grant expires_at must be later than issued_at")
object.__setattr__(self, "signature", str(self.signature or "").strip())
def unsigned_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"grant_id": self.grant_id,
"parent_provenance_id": self.parent_provenance_id,
"grant_type": self.grant_type.value,
"execution_path": self.execution_path.value,
"run_id": self.run_id,
"issuer": self.issuer,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"agent_id": self.agent_id,
"principal_id": self.principal_id,
"allowed_git_operations": list(self.allowed_git_operations),
"issued_at": self.issued_at,
"expires_at": self.expires_at,
"policy_version": self.policy_version,
"key_id": self.key_id,
"nonce": self.nonce,
}
def canonical_bytes(self) -> bytes:
return canonical_json_bytes(self.unsigned_dict())
def as_dict(self) -> dict[str, Any]:
value = self.unsigned_dict()
value["signature"] = self.signature
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ProvenanceGrant":
return cls(**dict(value))
@dataclass(frozen=True)
class GrantAuthorization:
authorization_id: str
grant_id: str
parent_provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
git_operation: str | None
request_digest: str
policy_version: str
key_id: str
public_key_fingerprint: str
authorized_at: str = field(default_factory=utc_now)
@property
def provenance_id(self) -> str:
return self.parent_provenance_id
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class WorkflowProvenanceIssuer:
"""签发不携带操作权限的父 Workflow Provenance。"""
def __init__(self, signer: Signer, store: SQLiteProvenanceStore) -> None:
self._issuer = ProvenanceIssuer(signer, store)
def issue(
self,
*,
run_id: str,
issuer: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
ttl_seconds: int = 14400,
provenance_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> RunProvenance:
return self._issuer.issue(
run_id=run_id,
issuer=issuer,
tenant_id=tenant_id,
task_id=task_id,
agent_id=agent_id,
principal_id=principal_id,
allowed_paths=(),
allowed_git_operations=(),
ttl_seconds=ttl_seconds,
policy_version=WORKFLOW_POLICY_VERSION,
provenance_id=provenance_id,
nonce=nonce,
now=now,
)
class SQLiteGrantStore:
"""在 Provenance SQLite 数据库中持久化子 Grant 与使用记录。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._connection = sqlite3.connect(
str(self.path), timeout=30.0, check_same_thread=False
)
self._connection.row_factory = sqlite3.Row
with self._lock:
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA synchronous=FULL")
self._connection.execute("PRAGMA foreign_keys=ON")
self._connection.executescript(
"""
CREATE TABLE IF NOT EXISTS provenance_grants (
grant_id TEXT PRIMARY KEY,
parent_provenance_id TEXT NOT NULL,
grant_type TEXT NOT NULL,
execution_path TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
signature_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_grants_parent
ON provenance_grants(parent_provenance_id, grant_type, status);
CREATE TABLE IF NOT EXISTS provenance_grant_usage (
authorization_id TEXT PRIMARY KEY,
grant_id TEXT NOT NULL,
execution_path TEXT NOT NULL,
git_operation TEXT NOT NULL DEFAULT '',
request_digest TEXT NOT NULL,
authorized_at TEXT NOT NULL,
receipt_json TEXT NOT NULL,
UNIQUE(grant_id, execution_path, git_operation, request_digest),
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
);
CREATE TABLE IF NOT EXISTS provenance_grant_events (
event_id TEXT PRIMARY KEY,
grant_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
);
"""
)
self._connection.commit()
def close(self) -> None:
with self._lock:
self._connection.close()
def _event(
self,
grant_id: str,
event_type: str,
details: Mapping[str, Any] | None = None,
) -> None:
self._connection.execute(
"INSERT INTO provenance_grant_events VALUES (?, ?, ?, ?, ?)",
(
f"gevt_{uuid.uuid4().hex}",
grant_id,
event_type,
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
utc_now(),
),
)
def save_issued(self, grant: ProvenanceGrant) -> None:
payload = json.dumps(
grant.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
now = utc_now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self._connection.execute(
"INSERT INTO provenance_grants VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
(
grant.grant_id,
grant.parent_provenance_id,
grant.grant_type.value,
grant.execution_path.value,
payload,
GrantStatus.ISSUED.value,
now,
now,
),
)
self._event(grant.grant_id, "issued")
self._connection.commit()
except sqlite3.IntegrityError as exc:
self._connection.rollback()
raise ProvenanceDenied("grant_id already exists") from exc
except Exception:
self._connection.rollback()
raise
def get(self, grant_id: str) -> ProvenanceGrant:
with self._lock:
row = self._connection.execute(
"SELECT payload_json FROM provenance_grants WHERE grant_id = ?",
(required(grant_id, "grant_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance grant not found")
value = json.loads(row["payload_json"])
if not isinstance(value, Mapping):
raise ProvenanceDenied("stored grant is invalid")
return ProvenanceGrant.from_dict(value)
def status(self, grant_id: str) -> GrantStatus:
with self._lock:
row = self._connection.execute(
"SELECT status FROM provenance_grants WHERE grant_id = ?",
(required(grant_id, "grant_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance grant not found")
return GrantStatus(row["status"])
def mark_verified(self, grant_id: str, *, fingerprint: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(grant_id)
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
raise ProvenanceDenied(f"cannot verify grant in state {current.value}")
next_status = (
GrantStatus.USED.value
if current == GrantStatus.USED
else GrantStatus.VERIFIED.value
)
self._connection.execute(
"UPDATE provenance_grants SET status = ?, signature_verified = 1, updated_at = ? WHERE grant_id = ?",
(next_status, utc_now(), grant_id),
)
self._event(
grant_id,
"verified",
{"public_key_fingerprint": fingerprint},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def mark_expired(self, grant_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
if self.status(grant_id) == GrantStatus.REVOKED:
self._connection.rollback()
return
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.EXPIRED.value, utc_now(), grant_id),
)
self._event(grant_id, "expired")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def revoke(self, grant_id: str, *, reason: str, principal_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self.get(grant_id)
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.REVOKED.value, utc_now(), grant_id),
)
self._event(
grant_id,
"revoked",
{
"reason": required(reason, "reason"),
"principal_id": required(principal_id, "principal_id"),
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def record_authorization(self, authorization: GrantAuthorization) -> None:
receipt = json.dumps(
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(authorization.grant_id)
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
raise ProvenanceDenied(f"grant state is {current.value}")
try:
self._connection.execute(
"INSERT INTO provenance_grant_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
(
authorization.authorization_id,
authorization.grant_id,
authorization.execution_path,
authorization.git_operation or "",
authorization.request_digest,
authorization.authorized_at,
receipt,
),
)
except sqlite3.IntegrityError as exc:
raise ProvenanceReplay(
"identical grant path/operation/request digest already used"
) from exc
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.USED.value, authorization.authorized_at, authorization.grant_id),
)
self._event(
authorization.grant_id,
"used",
{
"authorization_id": authorization.authorization_id,
"execution_path": authorization.execution_path,
"git_operation": authorization.git_operation,
"request_digest": authorization.request_digest,
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def list_for_parent(self, parent_provenance_id: str) -> list[ProvenanceGrant]:
with self._lock:
rows = self._connection.execute(
"SELECT payload_json FROM provenance_grants WHERE parent_provenance_id = ? ORDER BY grant_type, grant_id",
(required(parent_provenance_id, "parent_provenance_id"),),
).fetchall()
return [ProvenanceGrant.from_dict(json.loads(row["payload_json"])) for row in rows]
class GrantIssuer:
"""从父 Workflow Provenance 派生单一路径最小权限 Grant。"""
def __init__(
self,
signer: Signer,
parent_store: SQLiteProvenanceStore,
grant_store: SQLiteGrantStore,
) -> None:
self.signer = signer
self.parent_store = parent_store
self.grant_store = grant_store
def issue(
self,
*,
parent_provenance_id: str,
grant_type: GrantType | str,
ttl_seconds: int = 900,
allowed_git_operations: Sequence[str] = (),
grant_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> ProvenanceGrant:
if ttl_seconds <= 0:
raise ValueError("grant ttl_seconds must be positive")
parent = self.parent_store.get(parent_provenance_id)
if parent.policy_version != WORKFLOW_POLICY_VERSION:
raise ProvenanceDenied("child grants require a workflow provenance parent")
if parent.allowed_paths or parent.allowed_git_operations:
raise ProvenanceDenied("workflow provenance parent must not contain operation rights")
parent_status = self.parent_store.status(parent.provenance_id)
if parent_status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("workflow provenance is revoked")
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
parent_expiry = parse_timestamp(parent.expires_at)
expires = min(issued + timedelta(seconds=ttl_seconds), parent_expiry)
if expires <= issued:
raise ProvenanceExpired("workflow provenance has expired")
normalized_type = GrantType(grant_type)
operations = tuple(allowed_git_operations)
unsigned = ProvenanceGrant(
grant_id=grant_id or f"grant_{uuid.uuid4().hex}",
parent_provenance_id=parent.provenance_id,
grant_type=normalized_type,
execution_path=GRANT_PATHS[normalized_type],
run_id=parent.run_id,
issuer=parent.issuer,
tenant_id=parent.tenant_id,
task_id=parent.task_id,
agent_id=parent.agent_id,
principal_id=parent.principal_id,
allowed_git_operations=operations,
issued_at=issued.isoformat().replace("+00:00", "Z"),
expires_at=expires.isoformat().replace("+00:00", "Z"),
policy_version=f"{WORKFLOW_POLICY_VERSION}.{normalized_type.value}",
key_id=self.signer.key_id,
nonce=nonce or uuid.uuid4().hex,
)
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
self.grant_store.save_issued(signed)
return signed
class GrantVerifier:
"""验证父 Workflow 与子 Grant,确保单一路径最小权限。"""
def __init__(
self,
parent_store: SQLiteProvenanceStore,
grant_store: SQLiteGrantStore,
verifiers: Mapping[str, SignatureVerifier],
) -> None:
if not verifiers:
raise ValueError("at least one trusted verifier is required")
self.parent_store = parent_store
self.grant_store = grant_store
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
def _verify_parent(self, parent: RunProvenance, *, now: datetime) -> SignatureVerifier:
status = self.parent_store.status(parent.provenance_id)
if status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("workflow provenance is revoked")
if now >= parse_timestamp(parent.expires_at):
self.parent_store.mark_expired(parent.provenance_id)
raise ProvenanceExpired("workflow provenance is expired")
verifier = self.verifiers.get(parent.key_id)
if verifier is None or not parent.signature or not verifier.verify(
parent.canonical_bytes(), parent.signature
):
raise ProvenanceSignatureError("invalid workflow provenance signature")
if parent.policy_version != WORKFLOW_POLICY_VERSION:
raise ProvenanceDenied("parent is not a workflow provenance")
if parent.allowed_paths or parent.allowed_git_operations:
raise ProvenanceDenied("workflow provenance must not contain operation rights")
return verifier
def authorize(
self,
*,
grant_id: str,
parent_provenance_id: str,
run_id: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
execution_path: str,
request_digest: str,
git_operation: str | None = None,
now: datetime | None = None,
) -> GrantAuthorization:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
parent = self.parent_store.get(parent_provenance_id)
self._verify_parent(parent, now=current)
grant = self.grant_store.get(grant_id)
grant_status = self.grant_store.status(grant.grant_id)
if grant_status == GrantStatus.REVOKED:
raise ProvenanceRevoked("provenance grant is revoked")
if current >= parse_timestamp(grant.expires_at):
self.grant_store.mark_expired(grant.grant_id)
raise ProvenanceExpired("provenance grant is expired")
verifier = self.verifiers.get(grant.key_id)
if verifier is None or not grant.signature or not verifier.verify(
grant.canonical_bytes(), grant.signature
):
raise ProvenanceSignatureError("invalid provenance grant signature")
expected = {
"parent_provenance_id": parent.provenance_id,
"run_id": required(run_id, "run_id"),
"tenant_id": required(tenant_id, "tenant_id"),
"task_id": required(task_id, "task_id"),
"agent_id": required(agent_id, "agent_id"),
"principal_id": required(principal_id, "principal_id"),
}
for field_name, expected_value in expected.items():
if getattr(grant, field_name) != expected_value:
raise ProvenanceDenied(f"grant {field_name} mismatch")
if field_name != "parent_provenance_id" and getattr(parent, field_name) != expected_value:
raise ProvenanceDenied(f"workflow {field_name} mismatch")
path = ExecutionPath(execution_path)
if grant.execution_path != path:
raise ProvenanceDenied(
f"grant permits {grant.execution_path.value}, not {path.value}"
)
operation: str | None = None
if path == ExecutionPath.GIT_OPERATION:
if git_operation is None:
raise ProvenanceDenied("Git operation is required")
operation = GitOperation(git_operation).value
if operation not in grant.allowed_git_operations:
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
elif git_operation is not None:
raise ProvenanceDenied("git_operation is only valid for a Git Grant")
digest = _validate_digest(request_digest)
self.grant_store.mark_verified(grant.grant_id, fingerprint=verifier.fingerprint)
authorization = GrantAuthorization(
authorization_id=f"gauth_{uuid.uuid4().hex}",
grant_id=grant.grant_id,
parent_provenance_id=parent.provenance_id,
run_id=grant.run_id,
tenant_id=grant.tenant_id,
task_id=grant.task_id,
agent_id=grant.agent_id,
principal_id=grant.principal_id,
execution_path=path.value,
git_operation=operation,
request_digest=digest,
policy_version=grant.policy_version,
key_id=grant.key_id,
public_key_fingerprint=verifier.fingerprint,
)
self.grant_store.record_authorization(authorization)
return authorization
class LeastPrivilegeProvenanceWriter:
"""按路径选择独立子 Grant 的生产 Writer。
``provenance_id`` 参数始终指父 Workflow Provenance;Grant ID 来自受保护
配置映射,调用方无法通过请求体临时扩大权限。
"""
def __init__(
self,
verifier: GrantVerifier,
receipt_root: str | Path,
grant_ids: Mapping[str, str],
*,
writer_id: str = "aios-v2-least-privilege-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
self.grant_ids = {
ExecutionPath(path).value: required(grant_id, f"grant_id[{path}]")
for path, grant_id in grant_ids.items()
}
missing = sorted(path.value for path in ExecutionPath if path.value not in self.grant_ids)
if missing:
raise ValueError(f"missing least-privilege Grant mappings: {missing}")
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def authorize(self, **values: Any) -> GrantAuthorization:
path = ExecutionPath(values["execution_path"]).value
grant_id = self.grant_ids[path]
parent_provenance_id = values.pop("provenance_id")
authorization = self.verifier.authorize(
grant_id=grant_id,
parent_provenance_id=parent_provenance_id,
**values,
)
tenant = self._component(authorization.tenant_id)
parent = self._component(authorization.parent_provenance_id)
grant = self._component(authorization.grant_id)
target_dir = self.receipt_root / tenant / parent / grant
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
def authorize_audit(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
@@ -0,0 +1,265 @@
"""AIOS V2 运维操作 Provenance Gate 与服务级 Scoped Writer 工厂。"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
from .provenance import (
Ed25519PublicKeyVerifier,
ExecutionPath,
ProvenanceAuthorization,
ProvenanceControlledWriter,
ProvenanceDenied,
ProvenanceVerifier,
SQLiteProvenanceStore,
digest_payload,
)
from .provenance_grants import GrantAuthorization, GrantVerifier, SQLiteGrantStore
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
APPLICATION_PROVENANCE_SCOPE = (
ExecutionPath.RUNTIME_EXECUTE.value,
ExecutionPath.EVIDENCE_WRITE.value,
ExecutionPath.AUDIT.value,
)
class AuthorizationWriter(Protocol):
def authorize_deployment(self, **values: Any) -> Any: ...
def authorize_rollback(self, **values: Any) -> Any: ...
def authorize_audit(self, **values: Any) -> Any: ...
def authorize_code_change(self, **values: Any) -> Any: ...
def authorize_evidence_write(self, **values: Any) -> Any: ...
def authorize_runtime_execute(self, **values: Any) -> Any: ...
@dataclass(frozen=True)
class OperationContext:
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
def as_dict(self) -> dict[str, str]:
return asdict(self)
@dataclass(frozen=True)
class OperationReceipt:
operation: str
request_digest: str
authorization_id: str
provenance_id: str
grant_id: str | None
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
authorized_at: str
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class ProvenanceOperationGate:
"""部署、回滚、审计和运行操作的统一授权入口。"""
def __init__(self, writer: AuthorizationWriter) -> None:
self.writer = writer
def authorize(
self,
context: OperationContext,
*,
execution_path: str,
operation: str,
request: Mapping[str, Any],
) -> OperationReceipt:
path = ExecutionPath(execution_path).value
normalized_operation = str(operation or "").strip()
if not normalized_operation:
raise ValueError("operation is required")
digest = digest_payload(
{
"operation": normalized_operation,
"execution_path": path,
"request": dict(request),
}
)
values = {
"provenance_id": context.provenance_id,
"run_id": context.run_id,
"tenant_id": context.tenant_id,
"task_id": context.task_id,
"agent_id": context.agent_id,
"principal_id": context.principal_id,
"request_digest": digest,
}
if path == ExecutionPath.DEPLOYMENT.value:
authorization = self.writer.authorize_deployment(**values)
elif path == ExecutionPath.ROLLBACK.value:
authorization = self.writer.authorize_rollback(**values)
elif path == ExecutionPath.AUDIT.value:
authorization = self.writer.authorize_audit(**values)
elif path == ExecutionPath.CODE_CHANGE.value:
authorization = self.writer.authorize_code_change(**values)
elif path == ExecutionPath.EVIDENCE_WRITE.value:
authorization = self.writer.authorize_evidence_write(**values)
elif path == ExecutionPath.RUNTIME_EXECUTE.value:
authorization = self.writer.authorize_runtime_execute(**values)
else:
raise ProvenanceDenied(
"generic operation gate does not authorize git_operation; use ProvenanceGitWriter"
)
return self._receipt(normalized_operation, digest, authorization)
@staticmethod
def _receipt(
operation: str,
request_digest: str,
authorization: ProvenanceAuthorization | GrantAuthorization | Any,
) -> OperationReceipt:
return OperationReceipt(
operation=operation,
request_digest=request_digest,
authorization_id=authorization.authorization_id,
provenance_id=authorization.provenance_id,
grant_id=getattr(authorization, "grant_id", None),
run_id=authorization.run_id,
tenant_id=authorization.tenant_id,
task_id=authorization.task_id,
agent_id=authorization.agent_id,
principal_id=authorization.principal_id,
execution_path=authorization.execution_path,
authorized_at=authorization.authorized_at,
)
_GRANT_ENV_BY_PATH: Mapping[str, str] = {
ExecutionPath.CODE_CHANGE.value: "AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID",
ExecutionPath.GIT_OPERATION.value: "AIOS_PROVENANCE_GRANT_GIT_ID",
ExecutionPath.DEPLOYMENT.value: "AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID",
ExecutionPath.RUNTIME_EXECUTE.value: "AIOS_PROVENANCE_GRANT_RUNTIME_ID",
ExecutionPath.EVIDENCE_WRITE.value: "AIOS_PROVENANCE_GRANT_EVIDENCE_ID",
ExecutionPath.ROLLBACK.value: "AIOS_PROVENANCE_GRANT_ROLLBACK_ID",
ExecutionPath.AUDIT.value: "AIOS_PROVENANCE_GRANT_AUDIT_ID",
}
def grant_ids_from_environment(
*,
required_paths: Sequence[str] | None = None,
) -> dict[str, str]:
paths = tuple(
sorted(
{
ExecutionPath(path).value
for path in (required_paths or tuple(item.value for item in ExecutionPath))
}
)
)
values = {path: os.getenv(_GRANT_ENV_BY_PATH[path], "").strip() for path in paths}
missing = [_GRANT_ENV_BY_PATH[path] for path, value in values.items() if not value]
if missing:
raise RuntimeError(f"missing scoped Provenance Grant configuration: {missing}")
return values
def build_provenance_writer_from_env(
*,
state_dir: str | Path | None = None,
required: bool | None = None,
required_paths: Sequence[str] | None = None,
writer_id: str = "aios-v2-application-grant-writer",
) -> AuthorizationWriter | None:
"""从环境构建服务级最小权限 Writer。
生产应用默认只加载 Runtime、Evidence、Audit Grant。Git、部署和回滚进程
必须显式传入自己的 ``required_paths``,不能继承应用默认范围。旧全权限 Writer
只允许在显式关闭强制模式的候选/测试环境中使用。
"""
require_value = (
bool(required)
if required is not None
else os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0").strip().lower()
in {"1", "true", "yes", "on"}
)
public_key_file = os.getenv("AIOS_PROVENANCE_PUBLIC_KEY_FILE", "").strip()
key_id = os.getenv("AIOS_PROVENANCE_KEY_ID", "").strip()
base = Path(state_dir or os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
store_file = os.getenv(
"AIOS_PROVENANCE_STORE_FILE", str(base / "run-provenance.sqlite3")
).strip()
receipt_root = os.getenv(
"AIOS_PROVENANCE_RECEIPT_DIR", str(base / "provenance-receipts")
).strip()
configured = bool(public_key_file and key_id)
if not configured:
if require_value:
raise RuntimeError(
"Run Provenance is required but AIOS_PROVENANCE_PUBLIC_KEY_FILE or AIOS_PROVENANCE_KEY_ID is missing"
)
return None
verifier = Ed25519PublicKeyVerifier.from_file(public_key_file, key_id=key_id)
parent_store = SQLiteProvenanceStore(store_file)
normalized_paths = tuple(
sorted(
{
ExecutionPath(path).value
for path in (
required_paths
if required_paths is not None
else APPLICATION_PROVENANCE_SCOPE
)
}
)
)
if require_value:
if not normalized_paths:
raise RuntimeError("production Run Provenance requires a non-empty service scope")
grant_ids = grant_ids_from_environment(required_paths=normalized_paths)
grant_store = SQLiteGrantStore(store_file)
trusted_grants = GrantVerifier(parent_store, grant_store, {key_id: verifier})
return ScopedLeastPrivilegeProvenanceWriter(
trusted_grants,
receipt_root,
grant_ids,
required_paths=normalized_paths,
writer_id=writer_id,
)
legacy = ProvenanceVerifier(parent_store, {key_id: verifier})
return ProvenanceControlledWriter(legacy, receipt_root)
def context_from_environment() -> OperationContext:
values = {
"provenance_id": os.getenv("AIOS_PROVENANCE_ID", ""),
"run_id": os.getenv("AIOS_RUN_ID", ""),
"tenant_id": os.getenv("AIOS_TENANT_ID", ""),
"task_id": os.getenv("AIOS_TASK_ID", ""),
"agent_id": os.getenv("AIOS_AGENT_ID", ""),
"principal_id": os.getenv("AIOS_PRINCIPAL_ID", ""),
}
missing = [name for name, value in values.items() if not str(value).strip()]
if missing:
raise RuntimeError(f"missing Provenance context environment variables: {missing}")
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
def load_request_file(path: str | Path) -> Mapping[str, Any]:
value = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("request file must contain a JSON object")
return dict(value)
@@ -0,0 +1,131 @@
"""按服务范围加载的最小权限 Provenance Writer。
该 Writer 只持有调用进程需要的 Grant ID。未配置路径的方法即使被调用也会
默认拒绝,避免应用进程获得 Git、部署或回滚权限。
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import ExecutionPath, ProvenanceDenied, required
from .provenance_grants import GrantAuthorization, GrantVerifier
class ScopedLeastPrivilegeProvenanceWriter:
"""只暴露指定 ExecutionPath 的最小权限 Writer。"""
def __init__(
self,
verifier: GrantVerifier,
receipt_root: str | Path,
grant_ids: Mapping[str, str],
*,
required_paths: Sequence[str],
writer_id: str = "aios-v2-scoped-grant-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
normalized_required = tuple(
sorted({ExecutionPath(path).value for path in required_paths})
)
if not normalized_required:
raise ValueError("required_paths must not be empty")
self.required_paths = frozenset(normalized_required)
self.grant_ids = {
path: required(grant_ids.get(path, ""), f"grant_id[{path}]")
for path in normalized_required
}
unexpected = sorted(set(grant_ids) - self.required_paths)
if unexpected:
raise ValueError(
f"scoped writer received unnecessary Grant mappings: {unexpected}"
)
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def _grant_for(self, path: str) -> str:
normalized = ExecutionPath(path).value
grant_id = self.grant_ids.get(normalized)
if grant_id is None:
raise ProvenanceDenied(
f"this process is not allowed to authorize {normalized}"
)
return grant_id
def authorize(self, **values: Any) -> GrantAuthorization:
path = ExecutionPath(values["execution_path"]).value
grant_id = self._grant_for(path)
parent_provenance_id = values.pop("provenance_id")
authorization = self.verifier.authorize(
grant_id=grant_id,
parent_provenance_id=parent_provenance_id,
**values,
)
tenant = self._component(authorization.tenant_id)
parent = self._component(authorization.parent_provenance_id)
grant = self._component(authorization.grant_id)
target_dir = self.receipt_root / tenant / parent / grant
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
payload["scope"] = sorted(self.required_paths)
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
+ "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.CODE_CHANGE.value, **values
)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.DEPLOYMENT.value, **values
)
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values
)
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values
)
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.ROLLBACK.value, **values
)
def authorize_audit(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
-3
View File
@@ -1,3 +0,0 @@
"""Canonical AIOS V2R1 runtime package."""
__version__ = "2.1.0-rc.1"
-119
View File
@@ -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)
-81
View File
@@ -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())
-106
View File
@@ -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")
-158
View File
@@ -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)
-336
View File
@@ -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
-136
View File
@@ -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",
)
-17
View File
@@ -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
View File
@@ -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)
-39
View File
@@ -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.
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_ROOT="${AIOS_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
BASE_REF="${AIOS_DOCS_BASE_REF:-}"
RUN_LIVE_HEALTHCHECK="${AIOS_RUN_LIVE_HEALTHCHECK:-0}"
RESULT_DIR="${AIOS_ACCEPTANCE_RESULT_DIR:-$REPO_ROOT/.aios-evidence/acceptance}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_DIR="$RESULT_DIR/$STAMP"
SUMMARY="$RUN_DIR/summary.json"
install -d -m 0750 "$RUN_DIR"
pass=0
failed=0
warnings=0
steps=()
record() {
local name="$1" status="$2" code="$3" log="$4"
steps+=("$name|$status|$code|$log")
case "$status" in
通过) pass=$((pass + 1)) ;;
失败) failed=$((failed + 1)) ;;
警告) warnings=$((warnings + 1)) ;;
esac
printf '%s:%s(退出码 %s)\n' "$status" "$name" "$code"
}
run_step() {
local name="$1"
shift
local safe_name
safe_name="$(printf '%s' "$name" | tr ' /:' '___')"
local log="$RUN_DIR/${safe_name}.log"
set +e
(cd "$REPO_ROOT" && "$@") >"$log" 2>&1
local code=$?
set -e
if [[ "$code" -eq 0 ]]; then
record "$name" "通过" "$code" "$log"
else
record "$name" "失败" "$code" "$log"
fi
}
run_optional_step() {
local name="$1"
shift
local safe_name
safe_name="$(printf '%s' "$name" | tr ' /:' '___')"
local log="$RUN_DIR/${safe_name}.log"
set +e
(cd "$REPO_ROOT" && "$@") >"$log" 2>&1
local code=$?
set -e
if [[ "$code" -eq 0 ]]; then
record "$name" "通过" "$code" "$log"
else
record "$name" "警告" "$code" "$log"
fi
}
printf 'AIOS V2R1 Candidate 全量验收开始\n'
printf '仓库:%s\n' "$REPO_ROOT"
printf '结果目录:%s\n' "$RUN_DIR"
[[ -d "$REPO_ROOT" ]] || { echo "错误:仓库不存在" >&2; exit 2; }
DOCS_ARGS=(python3 tools/aios_v2_docs_code_check.py --repo "$REPO_ROOT")
if [[ -n "$BASE_REF" ]]; then
DOCS_ARGS+=(--base-ref "$BASE_REF")
fi
run_step "文档与代码匹配" "${DOCS_ARGS[@]}"
run_step "Python 语法编译" python3 -m compileall -q \
active-runtime/app/aios_v2 \
active-runtime/app/aios_openclaw \
active-runtime/app/aios_hermes
run_step "候选单元与集成测试" python3 -m unittest discover \
-s tests/aios_v2_candidate \
-p 'test_*.py' \
-v
run_step "Shell 脚本语法" bash -c '
set -Eeuo pipefail
while IFS= read -r -d "" path; do
bash -n "$path"
done < <(find deploy/aios-v2 -type f -name "*.sh" -print0)
'
run_step "文档 Git 冲突标记" bash -c '
set -Eeuo pipefail
if grep -RInE "^(<<<<<<<|=======|>>>>>>>)" docs/aios-v2; then
exit 1
fi
'
run_step "明文密钥基础扫描" python3 -c '
from pathlib import Path
import re
import sys
roots = [Path("active-runtime/app"), Path("deploy/aios-v2"), Path("docs/aios-v2"), Path("tasks")]
patterns = [
re.compile(r"(?i)(api[_-]?key|password|private[_-]?key|access[_-]?token)\s*[:=]\s*[\"\x27][^\"\x27\n]{12,}[\"\x27]"),
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
]
violations = []
for root in roots:
if not root.exists():
continue
for path in root.rglob("*"):
if not path.is_file() or path.suffix in {".pyc", ".db", ".sqlite3"}:
continue
text = path.read_text(encoding="utf-8", errors="ignore")
if any(pattern.search(text) for pattern in patterns):
violations.append(str(path))
if violations:
print("疑似明文密钥文件:", *violations, sep="\n", file=sys.stderr)
raise SystemExit(1)
'
if command -v systemd-analyze >/dev/null 2>&1; then
run_optional_step "systemd 单元验证" systemd-analyze verify \
deploy/aios-v2/systemd/aios-v2.service
else
record "systemd 单元验证" "警告" "127" "未安装 systemd-analyze"
fi
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
run_optional_step "Docker Compose 配置验证" docker compose \
-f deploy/aios-v2/docker/compose.yaml config
else
record "Docker Compose 配置验证" "警告" "127" "未安装 Docker Compose"
fi
if [[ "$RUN_LIVE_HEALTHCHECK" == "1" ]]; then
run_step "在线健康检查" bash deploy/aios-v2/healthcheck/check.sh
else
record "在线健康检查" "警告" "0" "未启用 AIOS_RUN_LIVE_HEALTHCHECK=1"
fi
branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)"
commit="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || true)"
dirty="$(git -C "$REPO_ROOT" status --porcelain=v1 2>/dev/null || true)"
python3 - "$SUMMARY" "$branch" "$commit" "$dirty" "$pass" "$failed" "$warnings" "${steps[@]}" <<'PY'
import json
import pathlib
import sys
from datetime import datetime, timezone
summary_path = pathlib.Path(sys.argv[1])
branch, commit, dirty = sys.argv[2:5]
passed, failed, warnings = map(int, sys.argv[5:8])
steps = []
for item in sys.argv[8:]:
name, status, code, log = item.split("|", 3)
steps.append({"name": name, "status": status, "exit_code": int(code), "log": log})
payload = {
"schema": "aios.v2.acceptance.v1",
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"branch": branch or None,
"commit": commit or None,
"workspace_dirty": bool(dirty),
"passed": passed,
"failed": failed,
"warnings": warnings,
"steps": steps,
"final_status": "passed" if failed == 0 else "failed",
}
summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
printf '验收结果:通过 %s,失败 %s,警告 %s\n' "$pass" "$failed" "$warnings"
printf '证据:%s\n' "$SUMMARY"
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/opt/aios/app \
AIOS_V2_HOST=0.0.0.0 \
AIOS_V2_PORT=18920 \
AIOS_V2_STATE_DIR=/var/lib/aios-v2
RUN groupadd --system --gid 12001 aios \
&& useradd --system --uid 12001 --gid aios --home-dir /nonexistent --shell /usr/sbin/nologin aios \
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2 \
&& python3 -m pip install --no-cache-dir 'cryptography>=42'
WORKDIR /opt/aios/app
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
USER aios:aios
EXPOSE 18920
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
CMD ["python3", "-m", "aios_v2.app"]
+44
View File
@@ -0,0 +1,44 @@
services:
aios-v2:
build:
context: ../../..
dockerfile: deploy/aios-v2/docker/Dockerfile
image: aios-v2:V2R1C0-candidate
init: true
restart: unless-stopped
ports:
- "127.0.0.1:${AIOS_V2_PORT:-18920}:18920"
environment:
AIOS_V2_HOST: 0.0.0.0
AIOS_V2_PORT: 18920
AIOS_V2_STATE_DIR: /var/lib/aios-v2
AIOS_OPENCLAW_URL: ${AIOS_OPENCLAW_URL:-}
AIOS_OPENCLAW_CREDENTIAL_REF: ${AIOS_OPENCLAW_CREDENTIAL_REF:-}
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE: ${AIOS_OPENCLAW_SIGNING_MATERIAL_FILE:-}
AIOS_RELEASE_CHANNEL: candidate
AIOS_RELEASE_VERSION: V2R1C0-candidate
volumes:
- aios-v2-state:/var/lib/aios-v2
# Mount runtime signing material explicitly when OpenClaw is enabled.
# The host file must be mode 0600; never store it in this repository.
# - /etc/aios-v2/openclaw-signing-material:/run/secrets/openclaw-signing-material:ro
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test:
- CMD
- python3
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
interval: 15s
timeout: 5s
retries: 4
start_period: 10s
volumes:
aios-v2-state:
+45
View File
@@ -0,0 +1,45 @@
# AIOS V2R1 Candidate 环境模板
# 正式环境禁止保存明文 Secret。
# Provenance/OpenClaw 私钥只用于签发端,不进入应用容器或 Git。
# Runtime
AIOS_V2_HOST=127.0.0.1
AIOS_V2_PORT=18920
AIOS_V2_STATE_DIR=/var/lib/aios-v2
# OpenClaw Ed25519 Command v2
AIOS_OPENCLAW_URL=
AIOS_OPENCLAW_CREDENTIAL_REF=
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=/run/credentials/aios-v2.service/openclaw-command-private-key
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
# Parent Workflow Provenance
AIOS_REQUIRE_RUN_PROVENANCE=true
AIOS_PROVENANCE_PUBLIC_KEY_FILE=/etc/aios-v2/provenance/public-key.pem
AIOS_PROVENANCE_KEY_ID=openclaw-v2r1-candidate-ed25519
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2/run-provenance.sqlite3
AIOS_PROVENANCE_RECEIPT_DIR=/var/lib/aios-v2/provenance-receipts
# Least-privilege child Grants: production requires all seven IDs.
AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID=
AIOS_PROVENANCE_GRANT_GIT_ID=
AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID=
AIOS_PROVENANCE_GRANT_RUNTIME_ID=
AIOS_PROVENANCE_GRANT_EVIDENCE_ID=
AIOS_PROVENANCE_GRANT_ROLLBACK_ID=
AIOS_PROVENANCE_GRANT_AUDIT_ID=
# Per-operation context. Deployment/rollback wrappers populate these from the
# approved task execution context; do not hard-code shared production values.
AIOS_PROVENANCE_ID=
AIOS_RUN_ID=
AIOS_TENANT_ID=
AIOS_TASK_ID=
AIOS_AGENT_ID=
AIOS_PRINCIPAL_ID=
# 禁止:
# AIOS_PROVENANCE_PRIVATE_KEY_FILE
# AIOS_OPENCLAW_TOKEN
# API_KEY=value
# PASSWORD=value
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -Eeuo pipefail
BASE_URL="${AIOS_V2_BASE_URL:-http://127.0.0.1:18920}"
STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
CONTROLLER_WT="${AIOS_GITOPS_CONTROLLER_WORKTREE:-/data/aios-gitops/worktrees/controller}"
SERVICE="${AIOS_V2_SERVICE:-aios-v2}"
fail() {
echo "FAIL: $*" >&2
exit 1
}
pass() {
echo "PASS: $*"
}
command -v python3 >/dev/null || fail "python3 not installed"
if command -v systemctl >/dev/null && systemctl list-unit-files "$SERVICE.service" >/dev/null 2>&1; then
systemctl is-active --quiet "$SERVICE" || fail "$SERVICE is not active"
pass "$SERVICE is active"
fi
python3 - "$BASE_URL" <<'PY'
import json
import sys
import urllib.request
base_url = sys.argv[1].rstrip("/")
with urllib.request.urlopen(f"{base_url}/api/v2/health", timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
if payload.get("status") not in {"ok", "healthy", "ready"}:
raise SystemExit(f"unexpected health payload: {payload}")
print(f"PASS: HTTP health status={payload.get('status')}")
PY
[[ -d "$STATE_DIR" ]] || fail "state directory missing: $STATE_DIR"
[[ -w "$STATE_DIR" ]] || fail "state directory not writable: $STATE_DIR"
pass "state directory writable"
if [[ -e "$STATE_DIR/control-plane.sqlite3" ]]; then
python3 - "$STATE_DIR/control-plane.sqlite3" <<'PY'
import sqlite3
import sys
path = sys.argv[1]
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
try:
row = connection.execute("PRAGMA quick_check").fetchone()
finally:
connection.close()
if not row or row[0] != "ok":
raise SystemExit(f"SQLite quick_check failed: {row}")
print("PASS: SQLite quick_check=ok")
PY
else
echo "WARN: control-plane.sqlite3 not created yet"
fi
if [[ -d "$CONTROLLER_WT" ]] && git -C "$CONTROLLER_WT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
branch="$(git -C "$CONTROLLER_WT" branch --show-current)"
dirty="$(git -C "$CONTROLLER_WT" status --porcelain=v1)"
[[ -z "$branch" ]] || fail "GitOps controller attached to branch: $branch"
[[ -z "$dirty" ]] || fail "GitOps controller workspace is dirty"
pass "GitOps controller detached and clean"
fi
python3 - "$STATE_DIR/executions" <<'PY'
import json
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
if not root.exists():
print("WARN: execution evidence directory not created yet")
raise SystemExit(0)
for path in root.glob("*/*.json"):
value = json.loads(path.read_text(encoding="utf-8"))
for key in ("execution_id", "tenant_id", "task_id", "executor", "status"):
if not value.get(key):
raise SystemExit(f"invalid execution evidence {path}: missing {key}")
print("PASS: execution evidence files valid")
PY
pass "AIOS V2 candidate healthcheck complete"
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -Eeuo pipefail
OPERATION_PATH="${1:-}"
OPERATION_NAME="${2:-}"
REQUEST_FILE="${3:-}"
fail() {
echo "PROVENANCE_DENIED: $*" >&2
exit 1
}
[[ -n "$OPERATION_PATH" ]] || fail "usage: $0 <deployment|rollback|audit|code_change|runtime_execute|evidence_write> <operation> <request-json-file>"
[[ -n "$OPERATION_NAME" ]] || fail "operation name is required"
[[ -n "$REQUEST_FILE" && -f "$REQUEST_FILE" ]] || fail "request JSON file is required"
: "${AIOS_PROVENANCE_PUBLIC_KEY_FILE:?missing AIOS_PROVENANCE_PUBLIC_KEY_FILE}"
: "${AIOS_PROVENANCE_KEY_ID:?missing AIOS_PROVENANCE_KEY_ID}"
: "${AIOS_PROVENANCE_STORE_FILE:?missing AIOS_PROVENANCE_STORE_FILE}"
: "${AIOS_PROVENANCE_RECEIPT_DIR:?missing AIOS_PROVENANCE_RECEIPT_DIR}"
: "${AIOS_PROVENANCE_ID:?missing AIOS_PROVENANCE_ID}"
: "${AIOS_RUN_ID:?missing AIOS_RUN_ID}"
: "${AIOS_TENANT_ID:?missing AIOS_TENANT_ID}"
: "${AIOS_TASK_ID:?missing AIOS_TASK_ID}"
: "${AIOS_AGENT_ID:?missing AIOS_AGENT_ID}"
: "${AIOS_PRINCIPAL_ID:?missing AIOS_PRINCIPAL_ID}"
PYTHONPATH_VALUE="${AIOS_V2_PYTHONPATH:-/opt/aios/current/active-runtime/app}"
PYTHONPATH="$PYTHONPATH_VALUE${PYTHONPATH:+:$PYTHONPATH}" \
python3 -m aios_v2.provenance_cli authorize \
--context-from-env \
--store-file "$AIOS_PROVENANCE_STORE_FILE" \
--public-key-file "$AIOS_PROVENANCE_PUBLIC_KEY_FILE" \
--key-id "$AIOS_PROVENANCE_KEY_ID" \
--receipt-dir "$AIOS_PROVENANCE_RECEIPT_DIR" \
--path "$OPERATION_PATH" \
--operation "$OPERATION_NAME" \
--request-file "$REQUEST_FILE"
+98
View File
@@ -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"
+37
View File
@@ -0,0 +1,37 @@
[Unit]
Description=AIOS V2 Control Plane Candidate
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=aios
Group=aios
WorkingDirectory=/opt/aios/current/active-runtime/app
EnvironmentFile=-/etc/aios-v2/aios-v2.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 -m aios_v2.app
Restart=on-failure
RestartSec=3
TimeoutStartSec=30
TimeoutStopSec=30
KillSignal=SIGTERM
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
ReadWritePaths=/var/lib/aios-v2
StateDirectory=aios-v2
StateDirectoryMode=0750
UMask=0027
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
printf 'AIOS V2R1C0 RC1 health check\n'
python3 - <<'PY'
from pathlib import Path
required = [
'active-runtime/app/aios_v2r1/tenant_context.py',
'active-runtime/app/aios_v2r1/router.py',
'active-runtime/app/aios_v2r1/hermes_fabric.py',
'openclaw/plugins/aios-tenant-boundary/openclaw.plugin.json',
]
missing=[p for p in required if not Path(p).exists()]
if missing:
raise SystemExit('missing: '+','.join(missing))
print('artifact presence PASS')
PY
echo 'runtime health requires OpenClaw environment probe'
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
EXPECTED_BRANCH="release/v2r1c0-tenant-cell-fabric-20260712"
if [[ "$(git -C "$ROOT" branch --show-current)" != "$EXPECTED_BRANCH" ]]; then
echo "wrong release branch"
exit 1
fi
COMMIT=$(git -C "$ROOT" rev-parse HEAD)
echo "Installing AIOS V2R1C0 RC1 commit=$COMMIT"
python3 - <<'PY'
import compileall
if not compileall.compile_dir('active-runtime/app/aios_v2r1', quiet=1):
raise SystemExit(1)
print('python compile PASS')
PY
if [[ -f openclaw/plugins/aios-tenant-boundary/package.json ]]; then
echo "plugin package PASS"
else
echo "plugin package missing"
exit 1
fi
echo "Installation preflight PASS"
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
MODE=${1:---check}
case "$MODE" in
--check)
echo "V2R1C0 RC1 validation mode"
;;
--install)
echo "Install requires approved OpenClaw Cell target"
;;
--rollback)
echo "Rollback requires recorded Cell backup"
;;
*)
echo "Usage: $0 [--check|--install|--rollback]"
exit 2
;;
esac
python3 - <<'PY'
import compileall
raise SystemExit(0 if compileall.compile_dir('active-runtime/app/aios_v2r1', quiet=1) else 1)
PY
echo "python runtime syntax: PASS"
+342
View File
@@ -0,0 +1,342 @@
# AIOS V2 用户需求规格
- 文档版本:`V2R1-hardening-candidate`
- 状态:加固候选版本需求基线
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、Run Provenance、Dispatch、审计、部署与回滚
## 1. 背景与目标
现有系统已经具备 OpenClaw、Hermes、Agent Router、GitOps、局部 WebUI、审计和多租户候选能力。AIOS V2 的目标不是推倒重写,而是将这些分散能力收敛到统一、可审计、可 Web 化、可多租户隔离的控制面。
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录、执行证据、Run Provenance 和 Dispatch。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
## 2. 用户角色
| 角色 | 主要诉求 |
|---|---|
| 平台管理员 | 管理租户、运行时、发布、GitOps 状态和全局策略 |
| 租户管理员 | 管理本租户 Agent、任务、审批、审计和成员权限 |
| 操作员 | 创建任务、发起执行、取消任务、查看运行状态 |
| 审批人 | 审批高风险任务,查看策略判定依据 |
| 审计员 | 查询本租户完整审计链和执行证据 |
| Runtime Worker | 领取已批准任务、心跳、返回执行结果 |
| 开发与运维人员 | 通过 GitOps 提交、测试、部署、回滚和修复 Bug |
## 3. 核心用户需求
### UR-001 唯一控制面
所有任务创建、审批、取消、状态变化、策略判定和审计必须由 AIOS Control Plane 统一处理。
验收:
- Web Console、API、Codex、OpenClaw 和 Hermes 均不得绕过 AIOS 创建或修改任务生命周期。
- OpenClaw/Hermes 的公开接口仅限执行、状态、取消、心跳和结果回传。
### UR-002 复用现有能力
系统不得重写已经稳定的 OpenClaw 执行能力、Hermes Runtime、Agent Router 或 GitOps 基础设施。
验收:
- 通过 Adapter/Boundary 接入现有能力。
- 替换控制逻辑时保留原执行接口和可迁移数据。
### UR-003 OpenClaw 与 Hermes 解耦
OpenClaw 与 Hermes 必须从策略、租户、审批和任务生命周期中逐步解耦。
验收:
- OpenClaw 仅接收已签名、已批准、带租户上下文的命令。
- Hermes 仅接收已批准的租户分区 Job。
- 二者均不得直接写 Control Plane 权威状态。
### UR-004 全面 Web 化
所有面向用户和运维的能力必须通过 Web Console 或稳定 API 访问。
Web Console 至少展示:
- Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence、GitOps Status。
- 所有写操作由 AIOS API 完成,浏览器不得直接调用 OpenClaw/Hermes。
### UR-005 多租户隔离
所有模块必须支持租户隔离,且默认拒绝跨租户访问。
隔离范围:
- API 与会话
- Tenant/Agent/Task 数据
- OpenClaw 命令
- Hermes Queue、Cell、Profile、Worker Lease
- Memory、Cache、Artifact、SecretRef
- Audit、Execution Evidence、日志和指标
验收:
- 资源记录的 `tenant_id` 不可为空。
- 跨租户访问返回拒绝或“资源不存在”,不得泄露存在性。
- 必须有跨租户负向测试。
### UR-006 权威任务生命周期
任务必须遵循统一状态机:
```text
CREATED -> APPROVED -> RUNNING -> COMPLETED
| | |
+----------+----------+-> CANCELLED
+-> FAILED
```
验收:
- 禁止越级变化,例如 `CREATED -> COMPLETED`。
- 每次变化包含 revision、principal、trace、时间和审计事件。
- 公共 API 不允许直接调用 `start`、`complete` 或 `fail` 推进权威状态。
### UR-007 策略与审批
所有有副作用任务必须经过策略判定;高风险任务必须支持人工审批。
验收:
- Policy Decision 包含 allow/deny、reason 和 policy version。
- 拒绝事件进入审计。
- Runtime 不得自行放宽策略。
### UR-008 幂等与并发安全
任务和 Runtime 执行必须支持幂等与乐观并发控制。
验收:
- 相同租户与幂等键、相同内容返回同一任务/执行。
- 相同幂等键不同内容返回冲突。
- revision 或 branch lease 冲突不得静默覆盖。
### UR-009 可观测与执行证据
平台必须能够确认 Codex、OpenClaw、Hermes 等执行器是否领取任务、当前进度、最近提交、测试结果和完成状态。
验收:
- 存在 claim、heartbeat、progress、result 和 evidence 记录。
- Web/API 可按租户查询。
- 执行证据不得包含明文密钥。
### UR-010 GitOps 唯一变更流程
所有代码、配置、文档和部署资产修改必须进入非 `main` 分支,通过 commit、测试、审查和受控提升。
验收:
- Controller worktree 必须 detached 且 clean。
- 本地 bare repo 与 Gitea 目标分支 OID 一致。
- 不允许直接 push `main`、直接创建生产 Tag 或绕过审批部署。
### UR-011 持久化与恢复
租户、Agent、Task、Audit、Idempotency、Dispatch、Run Provenance、Grant 使用记录和 Execution Evidence 必须在服务重启后恢复。
验收:
- 候选版支持 SQLite WAL 或等价原子持久化。
- 有 Control Plane 与 Dispatch restart recovery 测试。
- 生产版需进一步提供迁移、备份、恢复、HA 和容量证据。
### UR-012 审计与合规
所有控制与执行动作必须生成不可混租户的审计事件。
至少记录:
- tenant、principal、trace、action、resource、outcome、policy version、timestamp。
- OpenClaw/Hermes Runtime 生命周期。
- GitOps、部署和回滚动作。
- Provenance Grant、Scope、Authorization Receipt 和 Dispatch 结果摘要。
### UR-013 密钥安全
Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key、密码、Token 或私钥。
验收:
- Git 只存 `secret_ref`、`credential_ref`、`key_id` 或受保护文件路径。
- Secret 文件权限为 `0600` 或更严格。
- Secret Scanner 默认拒绝可疑提交。
- OpenClaw Command 和 Run Provenance 使用 Ed25519;生产应用只读取公钥。
### UR-014 部署与回滚
系统必须提供 systemd、Docker/Compose、健康检查和受保护回滚入口。
验收:
- 服务以非 root 权限运行。
- 状态目录独立持久化。
- 回滚前自动备份状态。
- 默认只回滚代码,状态恢复需要显式授权。
### UR-015 文档与代码同步
任何影响用户行为、架构边界、API、数据模型、状态机、安全策略、部署或运维方式的变更,必须在同一分支同步更新 `docs/aios-v2/`。
验收:
- 架构/功能代码变化但无文档变化时,文档同步门禁失败。
- Traceability Matrix 必须指向当前代码和测试。
- 新架构决策必须增加或更新 ADR。
### UR-016 Workflow Run Provenance
每次生产 Run 必须具有一个可验证、可撤销、可过期的 Workflow Provenance,用于绑定 Run、Tenant、Task、Agent 与 Principal,但父 Workflow 本身不得授予操作权限。
验收:
- Workflow Provenance 使用 Ed25519 签名。
- `allowed_paths` 与 `allowed_git_operations` 为空。
- 上下文、签名、TTL、Nonce、Key ID 不合法时默认拒绝。
- 撤销父 Workflow 后全部子 Grant 失效。
### UR-017 最小权限子 Grant
所有生产副作用必须通过与单一 ExecutionPath 对应的子 Grant 授权。
验收:
- Runtime、Evidence、Git、Deployment、Rollback、Audit 和 Code Change 使用独立 Grant。
- 一个 Grant 只能包含一个路径。
- Git Grant 必须限定具体 GitOperation。
- 子 Grant 具有独立 TTL、撤销、状态和重放防护。
### UR-018 Runtime Result 信任绑定
OpenClaw/Hermes 结果只有在与持久 Dispatch 的身份和执行上下文完全匹配时,才能更新权威 Task。
验收:
- 至少校验 tenant、task、agent、principal、trace、runtime、runtime execution、provenance 和 run。
- 不同终态结果摘要不得覆盖已有终态。
- Hermes 终态结果必须来自持有 Job Lease 的 Worker。
- Hermes Worker 生命周期验收入口必须是默认关闭的内部接口,仅接受可信
`runtime` 或 `platform_admin` 身份;普通 Operator、浏览器和公共 API 不得直接调用。
- 每次 Claim/Start/Heartbeat/Complete/Fail/Cancel 必须绑定
tenant、task、trace、principal、provenance、run、dispatch、job、cell 和 profile。
- Complete/Fail/Cancel 必须通过 `ExecutionOrchestrator.reconcile_hermes` 更新权威
Task/Dispatch,并同步生成 Audit/Event 与 Execution Evidence。
- Lease 到期必须持久化清除旧 Owner、递增 revision 并允许安全重领;旧 Worker
后续 Result 必须拒绝。
- Runtime callback 审计不得绕过 Orchestrator Reconcile。
### UR-019 Dispatch 持久化与恢复
Runtime 副作用之前必须持久化唯一 Dispatch,重启后能够恢复并继续严格 Reconcile。
验收:
- 同租户 Task 和 Runtime Execution 各自唯一绑定一个 Dispatch。
- Dispatch 支持按 ID、Task 与 Runtime Execution 查询。
- 终态 Dispatch 不得迁移到不同终态。
- Hermes Job、Result、Lease、幂等键和 Worker heartbeat 必须与 Dispatch 一样在
服务重启后恢复;不得以只存在于进程内的 Map 作为在线验收依据。
- 有 SQLite Repository 重启恢复测试。
### UR-020 OpenClaw Ed25519 命令合同
OpenClaw 生产命令必须使用版本化 Ed25519 合同,不得继续使用 HMAC 作为新命令信任根。
验收:
- 合同版本为 `aios.openclaw.command.v2`。
- 签名覆盖 Key ID、算法、公钥 Fingerprint、Tenant、Task、Trace、Payload、Idempotency 与时间字段。
- Key ID/Fingerprint/签名/过期或时钟偏差不合法时拒绝。
- Fingerprint 使用恒定时间比较。
### UR-021 服务级权限隔离
每个生产服务或 Worker 只能加载其职责所需的 Grant,不得加载全部 Grant 备用。
验收:
- 默认 AIOS Application 仅加载 Runtime、Evidence 与 Audit Grant。
- Git、Deployment、Rollback 与 Code Change 由独立 Scope 加载。
- 空 Scope、多余 Grant 映射和未声明路径调用全部拒绝。
- Authorization Receipt 记录 Grant ID、Writer ID 和 Scope。
### UR-022 CLI 单 Grant 授权
运维 CLI 的一次授权操作只能加载一个与请求路径一致的 Grant。
验收:
- `authorize` 使用一个 `--path` 和一个 `--grant-id`。
- Grant 类型与 Path 不一致时拒绝。
- CLI 不提供一次加载七个生产 Grant 的授权入口。
- 旧全权限 `issue` 仅允许候选兼容,不得进入生产流程。
## 4. 非功能需求
### NFR-001 安全
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计、可撤销 Provenance 和 Runtime 结果绑定。
### NFR-002 可靠性
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、Dispatch 恢复、健康检查和回滚。
### NFR-003 可维护性
- 模块边界稳定,Control Plane 与 Runtime 解耦;代码、文档、测试和部署可追踪。
### NFR-004 可扩展性
- Store、Transport、Policy、EventPublisher、Runtime Adapter、Provenance Verifier 与 Writer 均通过接口扩展。
### NFR-005 可运维性
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告、Grant 状态和 Receipt。
### NFR-006 性能候选指标
候选版上线前至少验证:
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间、Grant 授权延迟和 Dispatch 恢复时间。
- 具体 SLO 在预发布基准测试后固化,禁止无证据承诺。
## 5. 本候选版本范围
### 已纳入代码
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、Run Provenance、Least-Privilege Grant、Scoped Writer、Dispatch Repository、部署和回滚资产。
### 已纳入测试代码
- Control Plane/Runtime 候选流程。
- OpenClaw Ed25519 签名与验证。
- Hermes Worker Lease 与严格 Reconcile。
- Dispatch SQLite 重启恢复。
- Workflow/Grant 撤销、过期、重放和服务 Scope 隔离。
### 尚需环境验证
- 完整 Acceptance 实际执行证据。
- OpenClaw 真实 Runtime 联调。
- 生产数据库/队列替换或容量验证。
- 预发布性能、安全、重启和回滚演练。
- Owner 对 merge、Tag 和生产部署的批准。
## 6. 用户验收场景
1. 平台管理员创建租户并注册 OpenClaw/Hermes Agent。
2. 操作员创建任务,审批人批准。
3. Owner 或受控签发端签发 Workflow Provenance 与所需最小权限 Grant。
4. AIOS Orchestrator 持久化 Dispatch,并创建 Ed25519 命令或租户分区 Job。
5. Runtime 执行并返回与 Dispatch 完全匹配的结果。
6. AIOS 更新 Task、Audit、Authorization Receipt 和 Execution Evidence。
7. 租户用户在 Web Console 查看完整链路。
8. 跨租户、跨 Principal、跨 Grant Path 和重放请求被拒绝。
9. 服务重启后 Control Plane、Dispatch 和 Provenance 状态恢复。
10. 候选部署健康检查、Acceptance 和回滚演练实际执行并保存证据。
+461
View File
@@ -0,0 +1,461 @@
# AIOS V2 目标架构
- 文档版本:`V2R1C0-candidate`
- 架构状态:目标架构已实现为候选代码,待预发布验证
- 适用范围:Control Plane、Agent Router、OpenClaw、Hermes、Web Console、GitOps、审计、部署与运维
## 1. 架构目标
AIOS V2 的核心目标是把分散在 OpenClaw、Hermes、Agent Router、局部 WebUI 和 GitOps 中的控制能力收敛为一个权威控制面,同时保留现有执行能力,避免推倒重写。
目标状态:
```text
用户 / Web Console / Public API
|
v
AIOS Control Plane
Tenant | Identity | Agent | Task | Policy | Approval | Audit
|
v
Execution Orchestrator / Agent Router
/ \
v v
OpenClaw Adapter Hermes Boundary
| |
v v
OpenClaw Runtime Hermes Workers
\ /
v v
Result Reconciliation + Execution Evidence
```
设计结论:
- AIOS 是唯一控制面。
- Agent Router 是调度平面,不是第二控制面。
- OpenClaw 是执行适配与 Runtime。
- Hermes 是队列、Worker、Profile 与执行 Runtime。
- Web Console 只调用 AIOS API。
- GitOps 是代码、配置、文档和发布变更的唯一入口。
## 2. 从现有架构到目标架构
### 2.1 现有能力
现有系统已经具备:
- OpenClaw 执行与 Fleet 能力。
- Hermes Worker、Profile、队列和工作区能力。
- Agent Router 路由经验和部分调度接口。
- GitOps 分支、提交、审批、回滚和审计能力。
- Tenant Context、Tenant Router、Memory Scope、SecretRef、Message Ledger 和审计候选实现。
- 局部 WebUI、Gateway 和自动任务领取能力。
### 2.2 现有问题
- 控制逻辑分散,可能存在多个任务事实源。
- OpenClaw/Hermes 内部仍可能保存租户、策略或任务状态。
- 候选能力分散在多个分支,主线缺少权威实现。
- WebUI 与 API 入口不统一。
- 分支名称、代码、测试和部署证据之间缺少映射。
- 多租户隔离多为逻辑约束,尚缺全链路生产证据。
### 2.3 演进原则
- 先加边界和适配器,再迁移控制权。
- 先复用接口,再替换内部实现。
- 先在候选分支验证,再受控提升。
- 不允许同时存在两套权威任务生命周期。
- 不以“大分支存在”代替验收证据。
## 3. 逻辑分层
### 3.1 Web 与 API 层
职责:
- 提供 Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence 和 GitOps 页面/API。
- 依赖可信身份代理注入 `X-AIOS-*` 身份头。
- 不允许浏览器直接访问 OpenClaw 或 Hermes。
- 不允许客户端自行选择或覆盖 `tenant_id`。
候选实现:
- `active-runtime/app/aios_v2/app.py`
- `active-runtime/app/aios_v2/gateway.py`
- `active-runtime/app/aios_v2/console.py`
### 3.2 AIOS Control Plane
权威职责:
- Tenant Registry
- Agent Registry
- Task Lifecycle
- Policy Decision
- Approval
- Audit Event
- Runtime Inventory
- Execution Evidence
- Deployment/GitOps Status
不负责:
- OpenClaw 内部执行细节。
- Hermes Worker 内部执行细节。
- 直接持有 Runtime 凭据明文。
候选实现:
- `active-runtime/app/aios_v2/control_plane.py`
- `active-runtime/app/aios_v2/persistence.py`
- `active-runtime/app/aios_v2/execution_tracking.py`
- `active-runtime/app/aios_v2/gitops_status.py`
### 3.3 Execution Orchestrator / Agent Router
职责:
- 只接受 `APPROVED` 任务。
- 根据 Agent Registry 和 Policy Result 选择 Runtime。
- 生成 OpenClawCommand 或 HermesJob。
- 传播 tenant、task、trace、idempotency 和 policy version。
- 将 Runtime Result 回写 AIOS Task。
不负责:
- 创建租户。
- 修改策略。
- 绕过审批。
- 保存第二套任务状态机。
候选实现:
- `active-runtime/app/aios_v2/orchestrator.py`
### 3.4 OpenClaw Adapter
职责:
- 验证签名命令。
- 执行、查询、取消、健康检查和回调。
- 支持幂等、超时和租户隔离。
- 把 Runtime 生命周期转换为 AIOS 审计事件。
不负责:
- Tenant、Policy、Approval、Task Lifecycle。
- 向浏览器暴露公网控制接口。
候选实现:
- `active-runtime/app/aios_openclaw/contract.py`
- `active-runtime/app/aios_openclaw/adapter.py`
- `active-runtime/app/aios_openclaw/transport.py`
- `active-runtime/app/aios_openclaw/health.py`
- `active-runtime/app/aios_openclaw/callback.py`
### 3.5 Hermes Runtime Boundary
职责:
- Tenant/Cell/Profile 分区队列。
- Worker claim、start、heartbeat、complete、fail、cancel。
- Lease 超时和自动回队列。
- Result 回传和 Runtime Health。
不负责:
- Policy、Approval、Tenant Registry、权威 Task Lifecycle。
候选实现:
- `active-runtime/app/aios_hermes/contract.py`
- `active-runtime/app/aios_hermes/adapter.py`
- `active-runtime/app/aios_hermes/worker.py`
### 3.6 Persistence
候选实现使用 SQLite WAL 原子快照,持久化:
- Tenant
- Agent
- Task
- Audit
- Idempotency
- Execution Evidence 文件
生产演进:
- Store 接口保持稳定。
- 可替换为 PostgreSQL、对象存储或分布式事件存储。
- 生产替换必须提供迁移、备份、恢复、HA、容量和租户隔离证据。
## 4. 多租户架构
### 4.1 权威租户上下文
每次请求和执行至少传播:
```text
tenant_id
principal_id
session_id
trace_id
task_id
policy_version
```
Runtime 还传播:
```text
command_id / job_id
cell_id
profile_id
idempotency_key
worker_id
```
### 4.2 隔离层级
| 层级 | 隔离要求 |
|---|---|
| HTTP/API | 可信身份头、租户不可由浏览器覆盖 |
| Control Plane | 所有资源带 `tenant_id`,跨租户默认拒绝 |
| OpenClaw | 签名命令绑定 tenant/task/trace |
| Hermes | tenant/cell/profile 三层分区 |
| Storage | 候选为显式租户字段;生产需 RLS 或物理隔离策略 |
| Evidence/Audit | 仅按当前租户查询 |
| Cache/Memory | key 必须包含 tenant namespace |
| Secret | 只保存引用,不保存明文 |
| Logs/Metrics | 必须包含 tenant/task/trace 标签,并限制查询范围 |
### 4.3 失败行为
- 跨租户资源访问返回拒绝或“未找到”。
- 不泄露其他租户资源存在性。
- 缺少 tenant context 时 fail closed。
- Runtime 返回的 tenant/task/trace 不匹配时拒绝回写。
## 5. 任务与执行数据流
### 5.1 任务创建与审批
```text
User/API
|
v
Trusted Identity
|
v
AIOS Gateway
|
v
Policy Gate
|
v
Task CREATED
|
v
Approval
|
v
Task APPROVED
```
### 5.2 OpenClaw 执行
```text
APPROVED Task
|
v
Execution Orchestrator
|
v
Signed OpenClawCommand
|
v
OpenClaw Adapter
|
v
Runtime Result
|
v
Task Reconciliation + Audit + Evidence
```
### 5.3 Hermes 执行
```text
APPROVED Task
|
v
Execution Orchestrator
|
v
Tenant/Cell/Profile HermesJob
|
v
Worker Claim + Lease + Heartbeat
|
v
HermesResult
|
v
Task Reconciliation + Audit + Evidence
```
## 6. 安全架构
### 6.1 身份边界
- Public API 位于可信反向代理之后。
- 反向代理完成登录、会话、MFA 和身份头注入。
- 候选应用拒绝未认证身份断言。
- 生产不得允许客户端直接伪造 `X-AIOS-*`。
### 6.2 Runtime 命令完整性
OpenClawCommand 使用规范化序列化和 HMAC-SHA256:
- 签名覆盖 tenant、task、trace、action、payload、idempotency 和时间。
- 支持 key ID、到期时间和时钟偏差。
- Runtime 密钥通过受保护文件或外部 Secret Provider 解析。
### 6.3 Secret 边界
Git 允许:
```text
secret_ref
credential_ref
key_id
受保护文件路径
```
Git 禁止:
```text
API Key
密码
Token
私钥
签名材料
```
### 6.4 Web 安全
候选应用设置:
- no-store
- nosniff
- DENY frame
- no-referrer
- CSP
- 请求体大小限制
生产还需验证:
- CSRF/CORS
- Rate Limit
- Session Timeout
- CSP Nonce/Hash
- WAF 和反向代理访问策略
## 7. 可靠性架构
### 7.1 幂等
- Control Plane:`tenant_id + idempotency_key + request_digest`。
- OpenClaw:`tenant_id + idempotency_key + signature`。
- Hermes:`tenant_id + idempotency_key + job_digest`。
### 7.2 并发
- Task 使用 revision。
- Hermes 使用 revision + worker lease。
- GitOps 使用 detached controller + branch lease。
- 持久化使用 SQLite `BEGIN IMMEDIATE` 和原子快照。
### 7.3 恢复
- Controller restart:detached guard。
- AIOS restart:SQLite restore。
- Worker crash:lease 到期后 Job 回到 QUEUED。
- Release failure:健康检查失败自动恢复 previous release。
## 8. 可观测架构
统一关联键:
```text
tenant_id
principal_id
trace_id
task_id
agent_id
command_id / job_id
worker_id
commit
```
Web Console 展示:
- Tasks
- Agents
- Runtime Health
- Audit
- Execution Evidence
- GitOps Status
生产还需接入:
- 结构化日志
- Metrics
- Distributed Tracing
- Alerting
- SLO Dashboard
## 9. 部署架构
支持两种候选部署:
### systemd
- 非 root `aios` 用户。
- `ProtectSystem=strict`、`NoNewPrivileges` 等安全选项。
- 状态目录 `/var/lib/aios-v2`。
### Docker/Compose
- 非 root UID/GID。
- Read-only root filesystem。
- Drop all capabilities。
- State volume。
- HTTP healthcheck。
## 10. 架构约束
以下行为视为架构违规:
1. OpenClaw/Hermes 直接创建或批准 AIOS Task。
2. Web Console 直接调用 Runtime。
3. 新模块不携带 `tenant_id`。
4. 新增第二套任务状态机或租户事实源。
5. 在 Git 中提交明文凭据。
6. 绕过 GitOps 修改生产代码/配置。
7. 架构或功能变化未同步更新 `docs/aios-v2/`。
8. 未通过测试和预发布门禁直接提升候选版本。
## 11. 目标架构完成判定
目标架构只有在以下证据齐全时才视为完成:
- 文档与代码映射完整。
- Candidate tests 对精确 commit 执行通过。
- OpenClaw/Hermes 实际 Runtime 联调通过。
- 多租户负向测试通过。
- 重启恢复和回滚演练通过。
- GitOps 本地/Gitea OID 一致。
- Owner 批准 merge、Tag 和生产部署。
+313
View File
@@ -0,0 +1,313 @@
# AIOS V2 迁移与交付策略
- 文档版本:`V2R1C0-candidate`
- 交付原则:增量迁移、能力复用、控制权收敛、GitOps 唯一变更入口
## 1. 迁移目标
AIOS V2 不通过一次性重写替换现有系统,而是在保留 OpenClaw、Hermes、Agent Router、GitOps 和既有运行能力的前提下,将租户、策略、任务、审批、审计和发布治理收敛到 AIOS Control Plane。
迁移完成的标志不是“新代码存在”,而是:
- AIOS 成为唯一权威控制面;
- OpenClaw/Hermes 不再保存第二套控制状态;
- 所有控制功能 Web/API 化;
- 所有模块通过租户隔离、测试和预发布门禁;
- 本地 Git、Gitea、测试、部署和回滚证据可追踪。
## 2. 为什么采用增量迁移
### 2.1 避免能力回退
OpenClaw、Hermes 和 Agent Router 已经包含大量运行经验、边界处理和执行能力。整体重写会扩大风险面,并导致业务能力、操作流程和历史数据回退。
### 2.2 保持可回滚
每个迁移步骤必须:
- 独立分支;
- 小范围提交;
- 独立测试;
- 可单独回滚;
- 不要求立即关闭旧执行路径。
### 2.3 先收敛控制权,再优化内部实现
迁移优先级:
```text
唯一入口
-> 权威租户/任务/策略
-> Runtime Adapter
-> 数据层生产化
-> Web 与可观测
-> 性能与 HA
```
## 3. 分阶段迁移路线
### Phase 0:GitOps 与架构事实源
目标:建立可靠变更链和权威设计基线。
已完成候选能力:
- Controller detached guard;
- 避免 fetch 到已检出本地分支;
- Gitea SSH 镜像;
- `docs/aios-v2/` 架构事实源;
- 候选分支、部署和回滚资产。
退出条件:
- Controller detached 且 clean;
- 本地/Gitea OID 一致;
- 架构与需求文档纳入版本。
### Phase 1:Control Plane 权威化
目标:将 Tenant、Agent、Task、Policy、Approval 和 Audit 收敛到 AIOS。
候选实现:
- `aios_v2/control_plane.py`
- `aios_v2/persistence.py`
- `aios_v2/gateway.py`
- `aios_v2/app.py`
退出条件:
- 任务状态机统一;
- 幂等、revision、审计和跨租户拒绝通过测试;
- 服务重启后权威状态恢复。
### Phase 2:OpenClaw 解耦
目标:OpenClaw 从控制逻辑转为执行适配器。
步骤:
1. 定义签名命令合同;
2. 引入 Adapter;
3. 将直接调用迁移到 AIOS Orchestrator;
4. Runtime 只返回执行结果和健康状态;
5. 关闭或网络隔离旧公共控制入口。
退出条件:
- 未批准任务无法生成有效命令;
- 命令绑定 tenant/task/trace/idempotency;
- 旧控制接口冻结并记录替代路径。
### Phase 3:Hermes 解耦
目标:Hermes 只承担租户分区队列和 Worker Runtime。
步骤:
1. 定义 HermesJob/HermesResult;
2. 强制 tenant/cell/profile 分区;
3. 引入 claim/lease/heartbeat;
4. 将结果统一回写 AIOS;
5. 移除 Hermes 内部策略和任务事实源。
退出条件:
- Worker 无权创建 AIOS Task;
- 租约过期后 Job 可恢复;
- 跨租户 claim/read/result 被拒绝。
### Phase 4:Web 与运维统一
目标:所有用户与运维功能通过 AIOS Web/API。
范围:
- Tenant、Agent、Task、Runtime、Audit、Execution Evidence、GitOps;
- systemd/Docker/Compose;
- healthcheck、rollback、release report。
退出条件:
- 浏览器不直接调用 Runtime;
- 生产身份代理和权限模型通过验证;
- 部署、重启和回滚演练通过。
### Phase 5:生产数据层与 HA
目标:将候选 SQLite/内存运行状态替换或强化为生产级后端。
要求:
- 数据迁移;
- RLS 或物理隔离;
- 备份恢复;
- HA 与容量;
- 多实例并发;
- 灾难恢复目标。
该阶段不得改变上层合同,避免 Runtime 和 Web 再次耦合存储实现。
## 4. 分支与提交策略
### 4.1 分支类型
| 类型 | 用途 |
|---|---|
| `task/*` | 可执行任务合同与主体实现 |
| `patch/*` | 独立修复或窄范围增强 |
| `integration/*` | 多模块候选收敛 |
| `result/*` | 自动执行结果与证据 |
| `release/*` | 受控发布候选 |
### 4.2 规则
- 禁止直接修改 `main`;
- 一个提交应可独立审阅和回滚;
- 大文件和大范围变更应拆分;
- 所有行为变化必须同步文档和测试;
- 生产 Tag、部署和回滚需要 Owner 批准。
## 5. GitOps 交付流程
```text
需求/架构更新
|
v
任务分支 + 文档 + 测试
|
v
代码提交 + Secret Gate
|
v
本地 bare repo 同步
|
v
Gitea 镜像与 OID 校验
|
v
Candidate 集成
|
v
自动测试与预发布
|
v
Owner 审批
|
v
Merge / Tag / Production Deploy
```
### 5.1 同步状态
GitOps 返回必须区分:
- local sync;
- Gitea sync;
- branch OID;
- workspace mode;
- dirty state。
仅返回 `common_git_dir synced` 不足以证明 Web 远端可见。
## 6. 发布门禁
### Gate A:设计完整性
- 用户需求、目标架构、模块契约、ADR 和追踪矩阵同步;
- 无冲突标记、断链或过期路径。
### Gate B:代码质量
- 包可导入;
- 静态编译通过;
- Secret Scanner 通过;
- 无已知越权旁路。
### Gate C:测试
- 单元、集成、多租户负向、重启恢复、Runtime 边界测试通过;
- 记录 commit、命令、exit code、pass/fail 和 artifact digest。
### Gate D:预发布
- systemd 或 Compose 启动成功;
- 健康检查通过;
- OpenClaw/Hermes 真实联调通过;
- 备份、重启和回滚演练通过;
- 日志中无敏感信息。
### Gate E:生产批准
- Owner 批准 merge、Tag 和生产部署;
- 发布窗口、回滚负责人和观察指标明确;
- 生产变更通过 GitOps 记录。
## 7. 回滚策略
### 7.1 代码回滚
- 使用 `/opt/aios/current` 原子 symlink 切换;
- 回滚前备份状态;
- 健康检查失败自动恢复 previous release。
### 7.2 状态回滚
默认不自动回滚状态,避免覆盖候选运行期间新增的 Task、Audit 和 Evidence。
状态恢复只有在:
- 明确确认 schema 不兼容;
- 数据备份可验证;
- Owner 显式授权;
时启用。
### 7.3 Runtime 回滚
OpenClaw/Hermes Adapter 采用稳定合同。若候选执行层失败,可恢复旧 Runtime 版本,但不得恢复旧控制权旁路。
## 8. Bug 修复流程
生产或预发布 Bug:
1. 创建 `patch/bugfix/*`;
2. 添加可复现测试;
3. 修复代码;
4. 更新相关需求/架构/契约/ADR;
5. 运行候选验收;
6. 进入新的 release candidate;
7. Owner 批准部署。
禁止在生产目录直接编辑文件后不回写 Git。
## 9. 文档同步规则
以下变化必须更新文档:
- 用户行为或页面;
- API 路径、Header、请求/响应;
- 数据模型、状态机、策略;
- OpenClaw/Hermes/Router 边界;
- 多租户与安全模型;
- 部署、健康检查、回滚;
- 测试或发布门禁。
对应关系:
- 需求变化:更新 `01-USER-REQUIREMENTS.md`;
- 架构变化:更新 `02-TARGET-ARCHITECTURE.md` 和 ADR;
- 迁移/发布变化:更新本文件;
- 合同变化:更新 `04-MODULE-CONTRACTS.md`;
- 代码/测试路径变化:更新 `05-TRACEABILITY-MATRIX.md`。
## 10. 当前候选后续步骤
1. 完成文档同步门禁和 Acceptance Runner;
2. 在精确 Candidate commit 执行测试;
3. 推送 Gitea 并校验 OID;
4. 部署隔离预发布环境;
5. 联调真实 OpenClaw/Hermes;
6. 执行重启、性能、安全和回滚演练;
7. 生成最终 evidence;
8. 请求 Owner 的 merge/Tag/production approval。
+608
View File
@@ -0,0 +1,608 @@
# AIOS V2 模块契约
- 文档版本:`V2R1-hardening-candidate`
- 状态:加固候选契约基线
- 原则:Control Plane 权威、Runtime 仅执行、默认拒绝、服务级最小权限、全链路租户隔离、所有权威状态可审计和恢复
核心模型名称与实现保持逐字同步:`RequestContext` 承载可信租户、主体、角色、trace、
session 与 policy version;`TaskState` 是 AIOS 权威 Task 生命周期枚举,Runtime Worker
不得直接写入或复制该状态机。
## 1. 通用可信上下文契约
所有控制与执行请求必须携带以下上下文:
| 字段 | 必填 | 说明 |
|---|---:|---|
| `tenant_id` | 是 | 权威租户标识 |
| `principal_id` | 控制和 Reconcile 是 | 已认证主体 |
| `trace_id` | 是 | 全链路追踪 |
| `task_id` | 执行阶段是 | AIOS 权威任务 |
| `agent_id` | 执行和 Provenance 是 | 任务绑定 Agent |
| `provenance_id` | 生产副作用操作是 | 父 Workflow Provenance |
| `run_id` | 生产副作用操作是 | 本次执行 Run |
| `dispatch_id` | Runtime Result 是 | 持久化 Dispatch 记录 |
| `policy_version` | 控制请求是 | 策略版本 |
| `idempotency_key` | 创建/执行是 | 幂等键 |
规则:
- 缺少必填字段时 fail closed。
- 客户端不得覆盖可信代理或 AIOS 生成的租户、身份和追踪字段。
- Runtime 返回的 tenant/task/trace/runtime execution 必须与持久 Dispatch 完全匹配。
- Runtime Result 还必须匹配 Principal、Parent Provenance 和 Run。
- 所有持久化键、缓存键、队列分区、Receipt 和 Evidence 路径必须包含租户命名空间。
## 2. Tenant Contract
### 2.1 TenantRecord
```text
tenant_id
display_name
status
metadata
created_at
updated_at
```
约束:
- `tenant_id` 不可为空且不可变。
- Tenant 只由 AIOS Control Plane 创建和维护。
- Runtime 不得创建或修改租户。
- 跨租户读取默认返回拒绝或资源不存在,不得泄露存在性。
实现:`active-runtime/app/aios_v2/control_plane.py`
## 3. Agent Contract
### 3.1 AgentRecord
```text
agent_id
tenant_id
display_name
runtime
capabilities
enabled
metadata
created_at
updated_at
```
约束:
- Agent 属于唯一租户。
- `runtime` 由 Registry 决定,可取 `openclaw`、`hermes` 或后续注册 Runtime。
- `capabilities` 是允许调度的动作集合。
- `metadata` 仅保存非敏感引用,不得保存明文凭据。
## 4. Task Contract
### 4.1 TaskRecord
```text
task_id
tenant_id
agent_id
action
payload
state
revision
idempotency_key
request_digest
result
error
created_by
approved_by
created_at
updated_at
```
### 4.2 权威状态机
```text
CREATED -> APPROVED -> RUNNING -> COMPLETED
| | |
+----------+----------+-> CANCELLED
+-> FAILED
```
允许转换:
| 当前 | 允许目标 | 权威入口 |
|---|---|---|
| CREATED | APPROVED, CANCELLED | Control Plane Approval/Cancel |
| APPROVED | RUNNING, CANCELLED | Orchestrator Dispatch / Control Plane Cancel |
| RUNNING | COMPLETED, FAILED, CANCELLED | Orchestrator Reconcile / Control Plane Cancel |
| COMPLETED | 无 | 终态 |
| FAILED | 无 | 终态 |
| CANCELLED | 无 | 终态 |
约束:
- 每次转换必须校验 `revision`。
- COMPLETED 必须带 `result`;FAILED 必须带 `error`。
- 每次转换生成 AuditEvent。
- Runtime 不得直接写 Task Store。
- 公共 API 不得直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`。
## 5. Policy Contract
### 5.1 PolicyDecision
```text
allowed
reason
policy_version
```
约束:
- Policy 默认拒绝。
- Runtime 不得覆盖 PolicyDecision。
- 拒绝必须审计。
- 高风险动作必须经过 Approval。
## 6. Audit Contract
### 6.1 AuditEvent
```text
event_id
tenant_id
principal_id
trace_id
action
resource_type
resource_id
outcome
policy_version
details
created_at
```
约束:
- 按租户写入和查询。
- `details` 不得包含明文凭据、私钥或完整敏感 Payload。
- Runtime callback 可以生成非权威审计事件,但不得单独触发 Task 状态变化。
- Task 状态变化必须经过 Orchestrator 严格 Reconcile。
- 生产版需增加不可变外部固化和完整性验证。
## 7. Workflow Provenance Contract
### 7.1 RunProvenance
父 Workflow Provenance 只绑定身份与 Run,不授予操作权限:
```text
provenance_id
run_id
issuer
tenant_id
task_id
agent_id
principal_id
allowed_paths = []
allowed_git_operations = []
issued_at
expires_at
policy_version
key_id
nonce
signature
schema_version
```
约束:
- 使用 Ed25519 签名。
- `allowed_paths` 和 `allowed_git_operations` 必须为空。
- 撤销或过期父 Workflow 后,全部子 Grant 自动失效。
- 私钥只存在签发端,生产应用只读取公钥。
实现:`active-runtime/app/aios_v2/provenance.py`
## 8. Provenance Grant Contract
### 8.1 ProvenanceGrant
```text
grant_id
parent_provenance_id
grant_type
execution_path
run_id
issuer
tenant_id
task_id
agent_id
principal_id
allowed_git_operations
issued_at
expires_at
policy_version
key_id
nonce
signature
schema_version
```
Grant 类型与唯一允许路径:
| GrantType | ExecutionPath |
|---|---|
| `code_change` | `code_change` |
| `git` | `git_operation` |
| `deployment` | `deployment` |
| `runtime` | `runtime_execute` |
| `evidence` | `evidence_write` |
| `rollback` | `rollback` |
| `audit` | `audit` |
约束:
- 一个 Grant 只允许一个路径。
- Git Grant 必须显式声明具体 GitOperation;非 Git Grant 不得包含 Git 操作。
- 子 Grant 身份字段必须与父 Workflow 完全一致。
- 子 Grant TTL 不得超过父 Workflow。
- `(grant_id, path, git_operation, request_digest)` 重复使用时拒绝。
- Grant 支持独立 `issued/verified/used/expired/revoked` 生命周期。
实现:
- `active-runtime/app/aios_v2/provenance_grants.py`
- `active-runtime/app/aios_v2/provenance_scoped.py`
## 9. Service Scoped Writer Contract
生产进程只加载职责所需 Grant:
- AIOS Application:`runtime_execute`、`evidence_write`、`audit`。
- Git Worker:`git_operation`,代码提交场景另需独立 `code_change` Grant。
- Deployment Worker:`deployment`。
- Rollback Worker:`rollback`。
- CLI 单次授权:一个 `--path` + 一个 `--grant-id`。
约束:
- 空 Scope 拒绝。
- 多余 Grant 映射拒绝。
- 未配置路径的方法调用拒绝。
- Receipt 必须记录 `grant_id`、`writer_id` 和服务 Scope。
## 10. Dispatch Contract
### 10.1 DispatchRecord
```text
dispatch_id
tenant_id
task_id
agent_id
principal_id
runtime
runtime_execution_id
trace_id
provenance_id
run_id
authorization_id
state
expected_worker_id
result_digest
created_at
updated_at
completed_at
```
状态:
```text
PREPARED
RUNNING
QUEUED
COMPLETED
FAILED
CANCELLED
```
约束:
- Dispatch 必须在 Runtime 副作用之前持久化。
- 同一租户的 Task 和 Runtime Execution 各只能绑定一个 Dispatch。
- 终态 Dispatch 不得跳转到不同终态。
- 相同终态结果允许幂等读取,不同结果摘要拒绝。
- SQLite Repository 必须支持按 Task 和 Runtime Execution 恢复。
实现:`active-runtime/app/aios_v2/dispatch_store.py`
## 11. OpenClaw Contract
### 11.1 OpenClawCommand v2
```text
contract_version = aios.openclaw.command.v2
signature_algorithm = Ed25519
command_id
tenant_id
task_id
trace_id
action
payload
idempotency_key
created_at
expires_at
key_id
public_key_fingerprint
signature
```
签名覆盖全部未签名字段,包括算法、Key ID 和公钥 Fingerprint。
约束:
- 使用规范化 JSON 与 Ed25519。
- HMAC-SHA256 不再是生产命令合同。
- 只接受由已批准任务和已授权 Dispatch 生成的命令。
- 过期、未来时间超差、签名错误、Key ID 或 Fingerprint 不匹配时拒绝。
- Fingerprint 使用恒定时间比较。
- `key_id` 只引用密钥,不包含密钥材料。
- Candidate 组合层必须把 `AIOS_OPENCLAW_SIGNING_KEY_ID` 原样传入 Orchestrator,
不得在已有批准 Key ID 时回落为默认值。
### 11.2 OpenClawResult
```text
command_id
tenant_id
task_id
trace_id
state
output
error
started_at
finished_at
runtime_id
attempts
```
终态:`COMPLETED`、`FAILED`、`CANCELLED`、`TIMED_OUT`、`REJECTED`。
约束:
- Result 必须匹配持久 Dispatch 的 command、tenant、task、trace、principal、provenance 和 run。
- 只有 Orchestrator 可以更新 AIOS Task。
- callback 审计不是权威 Reconcile。
实现:`active-runtime/app/aios_openclaw/`
## 12. Hermes Contract
### 12.0 内部生命周期入口
- 路径:`/api/v2/internal/runtime/hermes/lifecycle/{claim,start,heartbeat,complete,fail,cancel,status}`。
- `AIOS_HERMES_LIFECYCLE_API_ENABLED` 默认关闭;关闭时按不存在的路由 fail closed。
- 仅可信反向代理注入且具备 `runtime` 或 `platform_admin` 角色的身份可调用。
- 所有写请求必须携带并严格匹配 tenant、task、trace、principal、provenance、run、
dispatch、job、cell、profile;Claim 还必须指定精确 job,不能从分区中误领其他 Job。
- Worker 不得直接修改 Task Store。Complete、Fail 和 Cancel 产生的 HermesResult
必须经持久 Dispatch 校验并由 `ExecutionOrchestrator.reconcile_hermes` 终态化。
- 每次成功生命周期写入同时落盘 Hermes 状态、Audit/Event 和 Execution Evidence。
### 12.1 HermesJob
```text
job_id
tenant_id
cell_id
profile_id
task_id
trace_id
action
payload
idempotency_key
priority
state
revision
assigned_worker_id
lease_expires_at
created_at
updated_at
```
约束:
- `tenant_id + cell_id + profile_id` 形成执行分区。
- claim 只能领取同分区 Job。
- Lease 到期 Job 回到 QUEUED。
- Hermes Store 使用 `AIOS_V2_STATE_DIR/hermes.sqlite3` 保存 Job、Result、Lease、
Idempotency 和 Worker heartbeat;Adapter 仍是唯一 Queue/Lease 状态机。
- Lease 到期重排必须清空 `assigned_worker_id`、清空 `lease_expires_at`、递增 revision
并持久化。重启后由显式 lifecycle status/claim 或健康检查安全触发重排。
- revision 冲突不得覆盖。
### 12.2 HermesResult
```text
job_id
tenant_id
task_id
trace_id
state
worker_id
output
error
started_at
finished_at
```
约束:
- 终态 Result 必须携带 Worker ID。
- Worker 必须持有 Job Lease。
- Result 必须匹配 Dispatch、Principal、Parent Provenance 和 Run。
- 相同 Worker、相同终态和相同 output/error 的重复提交返回原 Result;不同终态或
不同结果内容返回 Conflict,不得覆盖已持久化终态。
- 只允许内部入口 `POST /api/v2/internal/runtime/hermes/reconcile` 处理。
- 旧入口 `POST /api/v2/hermes/results/reconcile` 返回 `410 Gone`。
实现:`active-runtime/app/aios_hermes/`
## 13. Execution Evidence Contract
### 13.1 ExecutionEvidence
```text
execution_id
tenant_id
task_id
agent_id
executor
branch
status
progress_completed
progress_total
current_step
last_commit
test_status
test_summary
result_summary
claimed_at
heartbeat_at
completed_at
provenance_id
run_id
last_authorization_id
```
约束:
- 按租户存储和查询。
- 生产 `claim/heartbeat/complete` 落盘前必须通过 Evidence Grant。
- Evidence ID 不得被不同 Task、Agent 或 Provenance Context 复用。
- 不得包含 Secret 或完整敏感 Payload。
实现:`active-runtime/app/aios_v2/execution_tracking.py`
## 14. Git Writer Contract
允许操作:`READ_REF`、`FETCH`、`CREATE_BRANCH`、`COMMIT`、`MERGE`、`PUSH`、`UPDATE_EVIDENCE`,具体能力由 Git Grant 限定。
约束:
- 禁止直接操作 `main` 或 `master`。
- 所有命令使用参数数组,禁止 Shell 字符串拼接。
- Commit 和 Evidence Commit 必须先计算文件内容摘要并完成 Grant 授权,再执行 `git add`。
- `UPDATE_EVIDENCE` 只允许 `.agent/` 路径。
- 输出必须脱敏并截断。
实现:`active-runtime/app/aios_v2/provenance_git.py`
## 15. Web/API Contract
### 15.1 可信身份头
```text
X-AIOS-Authenticated
X-AIOS-Tenant-ID
X-AIOS-Principal-ID
X-AIOS-Roles
X-AIOS-Trace-ID
X-AIOS-Session-ID
X-AIOS-Policy-Version
X-AIOS-Provenance-ID
X-AIOS-Run-ID
```
生产约束:
- 可信代理必须删除客户端同名头后重新注入。
- 应用不得直接暴露公网端口。
- Header 与 Body 中重复的 Provenance/Run 值必须一致。
### 15.2 核心 API
```text
GET /api/v2/health
POST /api/v2/tenants
GET /api/v2/tenants/current
POST /api/v2/agents
GET /api/v2/agents
POST /api/v2/tasks
GET /api/v2/tasks
GET /api/v2/tasks/{id}
POST /api/v2/tasks/{id}/approve
POST /api/v2/tasks/{id}/cancel
POST /api/v2/tasks/{id}/dispatch
GET /api/v2/audit
GET /api/v2/runtime
GET /api/v2/executions
GET /api/v2/gitops
POST /api/v2/internal/runtime/hermes/reconcile
```
公共生命周期旁路:
```text
POST /api/v2/tasks/{id}/start -> 403
POST /api/v2/tasks/{id}/complete -> 403
POST /api/v2/tasks/{id}/fail -> 403
```
## 16. Persistence Contract
候选版:
- Control Plane:SQLite Snapshot。
- Dispatch:独立 SQLite Repository。
- Provenance/Grant/Usage/Event:SQLite WAL。
- Evidence/Receipt:租户隔离原子文件写入。
- 写事务使用原子替换或 `BEGIN IMMEDIATE`。
生产替换要求:
- 不改变 Domain/API Contract。
- 提供 migration、backup、restore、HA、隔离和容量测试。
- 恢复时保留已使用 Grant 与终态 Dispatch,禁止旧授权重放。
## 17. Deployment Contract
### systemd
- 非 root `aios` 用户。
- 状态目录 `/var/lib/aios-v2`。
- 私钥通过受保护 credential 注入,不写入 Git 或普通环境文件。
- 私钥文件必须是 regular file、禁止 symlink、权限 `0600` 或更严格。
### Docker
- 非 root UID/GID。
- read-only root filesystem。
- drop all capabilities。
- 独立状态 volume 和 Secret mount。
### Rollback
- 回滚前备份状态。
- 默认只回滚代码。
- 状态恢复需独立 Rollback Grant 和显式批准。
- 回滚后必须执行 Healthcheck 与恢复验证。
- Release Archive 必须保留 `rollback.sh` 与 `healthcheck/check.sh` 的 executable bit;
验收测试必须阻止非执行模式进入候选包。
- `systemctl start` 后的回滚健康检查必须执行有界 readiness 重试,默认最多 30 次、
每次间隔 1 秒;不得因服务尚未监听的一次瞬时拒绝误判目标 Release 失败。
## 18. 契约变更规则
以下变化必须在同一分支更新本文档、追踪矩阵和相关 ADR:
- 字段、状态、Header、API、错误语义;
- 租户、安全和 Provenance 约束;
- OpenClaw/Hermes 边界;
- Dispatch、Persistence、Deployment、Rollback Contract;
- Evidence、Audit、Receipt 格式;
- 服务 Scope 或 Grant 类型。
不兼容变化必须新增 ADR 和版本号,不得静默修改。正式 Gate A 结论必须基于实际执行 Acceptance 和保存的证据,不得仅凭测试文件存在宣布通过。
+79
View File
@@ -0,0 +1,79 @@
# AIOS V2 需求—架构—代码—测试—部署追踪矩阵
- 文档版本:`V2R1-hardening-candidate`
- 状态:实现与测试代码已提交;测试执行和 Acceptance 证据待完成
- 基线需求:`UR-001`~`UR-022`
- 判定规则:测试文件存在不等于测试通过;只有实际执行并保存证据后,状态才能改为“已验证”
## 1. 状态定义
| 状态 | 含义 |
|---|---|
| 已实现 | 代码、配置或文档已经提交到当前加固分支 |
| 测试已提交 | 自动测试代码已经提交,但本报告没有实际执行结果 |
| 待验收 | 需要运行 `deploy/aios-v2/acceptance/run-all.sh` 或对应预发布检查并保存证据 |
| 未批准 | 尚未获得 Owner 的 Merge、Tag 或生产部署批准 |
## 2. 用户需求追踪
| 需求 | 架构/决策 | 主要实现 | 测试代码 | 部署/验收入口 | 当前状态 |
|---|---|---|---|---|---|
| UR-001 唯一控制面 | ADR-001;模块契约第 2~6 节 | `active-runtime/app/aios_v2/control_plane.py`、`gateway.py`、`orchestrator.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/acceptance/run-all.sh`;公共生命周期旁路负向检查 | 已实现;测试已提交;待验收 |
| UR-002 复用现有能力 | ADR-002 | `active-runtime/app/aios_openclaw/`、`active-runtime/app/aios_hermes/`、`active-runtime/app/aios_v2/orchestrator.py` | `test_candidate_flow.py` | OpenClaw/Hermes 联调 | 已实现;真实 Runtime 联调待验收 |
| UR-003 OpenClaw/Hermes 解耦 | ADR-002、ADR-010;模块契约第 11、12 节 | OpenClaw Adapter、Hermes Adapter/Worker、内部 Lifecycle/Reconcile | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | 默认关闭的内部 Runtime API;Runtime audit/evidence 检查 | 已实现;Hermes 专项 17/17 |
| UR-004 全面 Web 化 | 目标架构;模块契约第 15 节 | `active-runtime/app/aios_v2/app.py`、`console.py`、`gateway.py` | `test_candidate_flow.py` | Web/API 冒烟、Healthcheck | 已实现;UI/浏览器验收待执行 |
| UR-005 多租户隔离 | ADR-004 | `control_plane.py`、`persistence.py`、`execution_tracking.py`、`dispatch_store.py` | `test_candidate_flow.py::test_cross_tenant_task_read_is_denied` | 跨租户负向 Acceptance | 已实现;测试已提交;待验收 |
| UR-006 权威任务生命周期 | ADR-001;模块契约第 4 节 | `control_plane.py`、`gateway.py`、`orchestrator.py` | `test_candidate_flow.py` | `/start`、`/complete`、`/fail` 应返回拒绝;Runtime 严格 Reconcile | 已实现;测试/接口验收待执行 |
| UR-007 策略与审批 | ADR-001 | `control_plane.py`、`gateway.py` | `test_candidate_flow.py` | Approval、deny audit、policy version 检查 | 已实现;策略场景验收待执行 |
| UR-008 幂等与并发安全 | 模块契约第 4、10、12 节 | Task idempotency/revision、Hermes lease/revision、Dispatch 唯一约束 | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | Lease 到期重排、换主、相同终态幂等、不同终态冲突 | 已实现;专项测试通过 |
| UR-009 可观测与执行证据 | 模块契约第 6、13 节 | `execution_tracking.py`、`gitops_status.py`、`console.py` | `test_candidate_flow.py::test_execution_tracking_persists_claim_heartbeat_and_result` | `/api/v2/executions`、Evidence Receipt 检查 | 已实现;测试已提交;待验收 |
| UR-010 GitOps 唯一变更流程 | ADR-003 | `active-runtime/app/aios_v2/provenance_git.py`、GitOps 工具与任务资产 | `test_provenance.py`、Grant 隔离测试 | Branch/Commit/Push 门禁、Controller clean、OID 同步 | 已实现候选控制;环境 GitOps 验收待执行 |
| UR-011 持久化与恢复 | ADR-006、ADR-010;模块契约第 10、12、16 节 | `persistence.py`、`dispatch_store.py`、`aios_hermes/adapter.py::SQLiteHermesStore`、Provenance/Grant Store | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | 运行中 Job 重启恢复、重启后 Reconcile、SQLite quick_check | Hermes/Dispatch/Task 恢复测试通过;生产 HA/容量待验收 |
| UR-012 审计与合规 | ADR-001、ADR-004、ADR-009 | `control_plane.py` AuditEvent、`app.py` callback audit、Provenance/Grant Event/Receipt | `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` | `/api/v2/audit`、Receipt/Event 完整性检查 | 已实现候选审计;不可变外部固化待预发布 |
| UR-013 密钥安全 | ADR-009;模块契约第 6、11、17 节 | Ed25519 Signer/Verifier、protected file reader、SecretRef 配置 | `test_provenance.py`、`test_candidate_flow.py` | Secret scanner、权限检查、容器/systemd credential 检查 | 已实现;环境密钥注入验收待执行 |
| UR-014 部署与回滚 | 迁移与交付文档;模块契约第 17 节 | `deploy/aios-v2/systemd/`、`docker/`、`healthcheck/`、`rollback/` | Acceptance 脚本与候选测试 | systemd/Docker 安装、Healthcheck、备份与回滚演练 | 资产已提交;实际部署/回滚未执行 |
| UR-015 文档与代码同步 | ADR-005 | `tools/aios_v2_docs_code_check.py`、`docs/aios-v2/` | 文档门禁脚本 | Acceptance Runner 调用文档门禁 | 已实现;门禁尚未实际执行 |
| UR-016 Workflow Run Provenance | ADR-009;模块契约第 7 节 | `active-runtime/app/aios_v2/provenance.py`、`provenance_grants.py::WorkflowProvenanceIssuer` | `test_provenance.py`、`test_provenance_grant_isolation.py::test_parent_workflow_has_no_operational_rights` | CLI `issue-workflow`、状态/撤销检查 | 已实现;测试已提交;待验收 |
| UR-017 最小权限子 Grant | ADR-009;模块契约第 8 节 | `provenance_grants.py`、`provenance_scoped.py` | `test_provenance_grant_isolation.py` | `issue-grant`、Grant status/revoke、Receipt 检查 | 已实现;专项测试已提交;待验收 |
| UR-018 Runtime Result 信任绑定 | ADR-009、ADR-010;模块契约第 10~12 节 | `orchestrator.py`、`dispatch_store.py`、内部 Hermes Lifecycle/Reconcile | `test_hermes_live_lifecycle.py` | tenant/task/trace/principal/provenance/run/dispatch/job/cell/profile/worker 负向矩阵 | 已实现;专项 17/17 |
| UR-019 Dispatch 持久化与恢复 | ADR-009、ADR-010;模块契约第 10、12 节 | `dispatch_store.py`、`orchestrator.py`、`app.py`、`SQLiteHermesStore` | Dispatch recovery 与 Hermes restart-reconcile 测试 | 进程重启后读取 Dispatch/Job 并完成 Reconcile | 已实现;测试通过 |
| UR-020 OpenClaw Ed25519 命令合同 | ADR-009;模块契约第 11 节 | `active-runtime/app/aios_openclaw/contract.py`、`adapter.py`、`app.py` | `test_candidate_flow.py`、`test_provenance.py` | 真实 OpenClaw v2 合同联调、Key rotation、clock skew/expiry 负向验收 | 已实现;测试代码已升级;真实联调待执行 |
| UR-021 服务级权限隔离 | ADR-009;模块契约第 9 节 | `provenance_scoped.py`、`provenance_operations.py`、`app.py` | `test_provenance_grant_isolation.py` | Application 仅加载 Runtime/Evidence/Audit;Git/Deploy/Rollback 单独 Scope | 已实现;专项测试已提交;配置验收待执行 |
| UR-022 CLI 单 Grant 授权 | ADR-009;模块契约第 9 节 | `provenance_cli.py` | `test_provenance_grant_isolation.py` 提供 Writer/Grant 基础验证;CLI 端到端待补执行证据 | `authorize --path ... --grant-id ...`;旧批量 Grant 参数应不存在 | 已实现;CLI 端到端验收待执行 |
## 3. P0 加固项追踪
| P0 | 风险 | 实现 | 测试/证据 | 状态 |
|---|---|---|---|---|
| P0-1 公共生命周期旁路 | 客户端直接推进 RUNNING/COMPLETED/FAILED | `gateway.py` 公共入口拒绝;`orchestrator.py` 权威推进 | API 负向 Acceptance | 代码完成;待执行 |
| P0-2 Dispatch 持久化 | 重启丢失 Runtime 绑定 | `dispatch_store.py` SQLite Repository;`app.py` 接线 | Dispatch restart recovery 测试 | 代码/测试已提交;待执行 |
| P0-3 Result 严格绑定 | 伪造或错配 Runtime Result 更新 Task | `orchestrator.py` 校验 Runtime Execution、Tenant、Task、Trace、Principal、Provenance、Run | Runtime 正向恢复测试;负向 Acceptance | 代码完成;完整验收待执行 |
| P0-4 OpenClaw Ed25519 | HMAC 共享密钥和合同不一致 | `aios_openclaw/contract.py` v2 Ed25519;安全文件读取 | 候选 Flow 测试;真实 Runtime 联调 | 代码/测试已提交;联调待执行 |
| P0-5 最小权限 Grant | 单凭证拥有全部生产路径 | Workflow + Child Grant + Scoped Writer + 单 Grant CLI | Grant isolation 专项测试 | 代码/测试已提交;待执行 |
## 4. Acceptance 证据要求
完整 Acceptance 至少应保存:
1. 测试命令、开始/结束时间、退出码和完整日志。
2. `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` 的实际结果。
3. 文档—代码同步门禁实际结果。
4. Secret scanner、依赖和容器/systemd 安全检查结果。
5. OpenClaw Ed25519 v2 真实联调结果。
6. Hermes 内部 Reconcile 正向与伪造上下文负向结果。
7. Control Plane、Dispatch、Provenance/Grant 的重启恢复结果。
8. 部署、Healthcheck、备份和回滚演练结果。
9. 分支、Commit、Controller clean、远端 OID 同步证据。
10. Owner 对 Merge、Tag 和生产部署的独立批准。
## 5. 当前发布门禁
| Gate | 状态 | 说明 |
|---|---|---|
| 实现 | 完成 | 五项 P0 代码与配置已提交当前加固分支 |
| 测试代码 | 完成 | 回归、Dispatch recovery、Grant isolation 测试已提交 |
| 测试执行 | 未完成 | 当前没有可验证的实际运行日志和退出码 |
| 文档同步 | 已提交 | 需求、契约、ADR、追踪矩阵已更新;门禁尚未执行 |
| Acceptance | 未完成 | `run-all.sh` 尚无本轮实际执行证据 |
| Gate A | 未批准 | 只能在 Acceptance 通过并完成独立审查后批准 |
| Merge/Tag/生产部署 | 未批准 | 需要 AIOS Owner 明确批准 |
+272
View File
@@ -0,0 +1,272 @@
# AIOS V2 文档治理规范
- 文档版本:`V2R1C0-candidate`
- 适用范围:`docs/aios-v2/`、架构决策记录、生产候选报告、在线任务合同、部署与验收说明
- 状态:已采纳
## 1. 目的
本规范用于保证 AIOS V2 的需求、架构、代码、测试、部署和运行事实始终保持一致,避免出现以下问题:
- 代码已经变化,但架构文档仍描述旧边界;
- API、字段或状态机变化后,测试与追踪矩阵未同步;
- OpenClaw、Hermes 或 Agent Router 的控制职责发生漂移;
- 候选分支存在实现,但版本无法解释为什么这样设计;
- 部署、回滚或安全策略发生变化,却没有形成可审计文档;
- 归档包只包含摘要或占位符,无法作为正式交付材料。
## 2. 文档语言要求
所有面向用户、产品、架构、运维、审计和发布的正式文档统一使用中文。
以下内容保留原始英文技术标识:
- 类名、函数名、字段名、环境变量;
- API 路径、HTTP 方法和 Header;
- Git 分支、Commit、Tag;
- OpenClaw、Hermes、Agent Router、GitOps 等专有名称;
- systemd、Docker、SQLite、HMAC、HTTP、JSON 等标准术语。
禁止在同一文档中无必要地切换中英文叙述。
## 3. 文档完整性要求
每份正式文档至少包含:
1. 文档目的;
2. 适用范围;
3. 当前版本和状态;
4. 完整正文;
5. 与代码、测试或部署的映射;
6. 验收规则;
7. 已知限制;
8. 后续演进方向;
9. 更新触发条件。
正式归档包禁止使用以下内容代替正文:
- “后续补充”;
- “待完善”;
- 只有标题没有说明;
- 只包含目录摘要;
- 与候选分支代码不一致的旧文档。
确实尚未验证的项目,应明确标记为“已实现但尚未验证”,并说明验证命令、责任方和进入下一状态的条件。
## 4. 文档分类与维护责任
| 文档 | 主要内容 | 触发更新的变化 |
|---|---|---|
| `README.md` | 文档索引、版本状态、实现地图 | 新增模块、目录、版本或交付物 |
| `01-USER-REQUIREMENTS.md` | 用户目标、功能需求、非功能需求、验收场景 | 用户行为、产品范围、验收标准变化 |
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户、部署架构 | 控制权、Runtime 边界、数据流或安全模型变化 |
| `03-MIGRATION-AND-DELIVERY.md` | 迁移阶段、GitOps、发布门禁、回滚、Bug 修复 | 迁移路线、发布流程、门禁或回滚方式变化 |
| `04-MODULE-CONTRACTS.md` | 数据模型、API、状态机、错误语义、Runtime Contract | 字段、接口、状态机、Header、错误行为变化 |
| `05-TRACEABILITY-MATRIX.md` | 需求到代码、测试、部署、验收的映射 | 代码、测试、部署路径或状态变化 |
| `adr/*` | 跨模块、长期或不可逆架构决策 | 关键决策新增、替换或废弃 |
| `AIOS-V2R1-PRODUCTION-CANDIDATE.md` | 候选版本总览、测试、部署、风险和发布结论 | Candidate SHA、测试证据、部署或风险变化 |
| `tasks/*` | OpenClaw、Codex 等在线执行任务 | 职责、边界、阻塞、验收或执行流程变化 |
## 5. 代码变化与文档同步规则
以下代码或配置路径发生变化时,必须同步更新 `docs/aios-v2/`:
### 5.1 Control Plane
触发路径:
```text
active-runtime/app/aios_v2/control_plane.py
active-runtime/app/aios_v2/persistence.py
active-runtime/app/aios_v2/gateway.py
active-runtime/app/aios_v2/app.py
active-runtime/app/aios_v2/orchestrator.py
active-runtime/app/aios_v2/execution_tracking.py
active-runtime/app/aios_v2/gitops_status.py
```
至少检查:
- 用户需求;
- 目标架构;
- 模块契约;
- 追踪矩阵;
- 必要时新增或更新 ADR。
### 5.2 OpenClaw
触发路径:
```text
active-runtime/app/aios_openclaw/
```
至少检查:
- OpenClawCommand、OpenClawResult、RuntimeHealth;
- 签名、幂等、取消、超时和审计边界;
- ADR-002;
- 真实 Runtime 联调要求。
### 5.3 Hermes
触发路径:
```text
active-runtime/app/aios_hermes/
```
至少检查:
- HermesJob、HermesResult、HermesHealth;
- tenant/cell/profile 分区;
- Worker Lease、Heartbeat、Recovery;
- ADR-002、ADR-004。
### 5.4 部署与运维
触发路径:
```text
deploy/aios-v2/
```
至少检查:
- 迁移与交付策略;
- 部署契约;
- 健康检查;
- 回滚;
- 候选报告。
### 5.5 API、Web 与用户行为
任何 API 路径、Header、请求/响应、页面或用户操作变化,必须同步:
- `01-USER-REQUIREMENTS.md`;
- `02-TARGET-ARCHITECTURE.md`;
- `04-MODULE-CONTRACTS.md`;
- `05-TRACEABILITY-MATRIX.md`。
## 6. ADR 治理
必须新增或更新 ADR 的场景:
- AIOS、OpenClaw、Hermes、Agent Router 的控制边界变化;
- 多租户隔离模型变化;
- 持久化、消息、审计、密钥或部署出现跨模块变化;
- 不兼容 API、字段或状态机变化;
- GitOps、文档门禁或发布策略变化;
- 替换核心基础设施或引入新的权威事实源。
ADR 状态:
- `已提出`:尚未正式采纳;
- `已采纳`:当前有效;
- `已替代`:被新 ADR 替代;
- `已废弃`:保留历史但禁止新实现采用。
任何 ADR 替代必须说明:
- 被替代的 ADR;
- 迁移影响;
- 兼容性和回滚方案;
- 受影响代码、测试和文档。
## 7. 文档—代码匹配门禁
验收必须执行:
```text
python3 tools/aios_v2_docs_code_check.py
```
门禁至少检查:
1. 必需文档存在且非空;
2. 文档语言以中文为主;
3. 文档引用的关键代码、测试和部署路径存在;
4. 文档列出的核心 API 能在代码中找到;
5. 文档列出的核心模型和字段能在代码中找到;
6. Traceability Matrix 覆盖所有 `UR-*` 需求;
7. ADR 索引引用的文件存在;
8. 架构或功能代码变化时,当前交付范围包含 `docs/aios-v2/` 变化;
9. 文档中不存在未解决 Git 冲突标记;
10. 正式文档中不存在摘要占位符或空章节。
门禁失败时:
- Candidate 不得标记为 `Test-verified`;
- OpenClaw 不得继续生产部署;
- Codex 必须登记 blocker 并给出修复建议;
- 修复必须进入 Git 分支并统一审视。
## 8. 在线任务文档要求
OpenClaw 和 Codex 任务必须使用中文说明,并保留可执行字段名。
每个任务至少包含:
- 任务目标;
- 责任范围;
- 禁止事项;
- 输入和输出;
- 分支;
- 执行状态;
- 验收命令;
- claim、heartbeat、progress、blocker、commit、test_status、result 证据要求;
- 阻塞升级路径。
## 9. 归档包要求
正式归档包必须包含完整正文:
```text
docs/aios-v2/
tasks/openclaw-production-deploy-task.yaml
tasks/codex-production-supervisor-task.yaml
deploy/aios-v2/
AIOS-V2R1-PRODUCTION-CANDIDATE.md
```
归档包必须同时生成清单:
- 文件路径;
- 文件大小;
- SHA-256;
- Candidate branch;
- Candidate commit;
- 生成时间。
禁止只打包摘要副本。
## 10. 审查职责
### OpenClaw
- 部署和运行修复时同步相关部署、运维和已知问题文档;
- 不得修改架构边界;
- 发现架构影响时升级给 Codex。
### Codex
- 监督需求、架构、契约、追踪和 ADR 一致性;
- 解决阻塞;
- 拒绝缺少文档或证据的补丁;
- 输出统一发布建议。
### AIOS Owner
- 审批合并、Tag 和生产部署;
- 对重大架构决策和风险接受做最终确认。
## 11. 验收状态
文档完成状态只有在以下条件全部满足时才标记为“完整”:
- 所有必需文档和 ADR 已提交;
- 文档—代码匹配检查通过;
- 归档包包含仓库完整正文;
- 任务合同为中文完整版本;
- Candidate commit、清单和 SHA-256 已记录;
- Codex 监督结论不存在未解决阻塞。
+224
View File
@@ -0,0 +1,224 @@
# AIOS V2R1 监督状态与阻塞处置清单
- 文档版本:`V2R1C0-candidate`
- 快照时间:`2026-07-20 17:03:53 CST`
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
- 候选 OID:`634f6b164f5b20372ab9ec8144bbf76df97de335`
- 当前决策:`STOP_PREPRODUCTION`
- 当前状态:`BLOCKED`
## 1. 执行摘要
AIOS V2R1 的核心代码、完整中文文档、任务合同、部署资产和 GitOps 证据机制已经形成。OpenClaw 与 Codex 已经领取任务并向 Gitea 提交监督证据,三个关键分支的本地 OID 与 Gitea OID 已一致。
当前仍不允许进入预发布,原因不是主体功能缺失,而是安全、签名材料、Candidate 合并、执行来源证明和 Controller 健康检查五项高风险门禁未通过。
当前禁止:
- 进入 Gate A–D;
- 执行在线 Runtime 联调;
- 执行重启恢复和回滚演练;
- 合并 `main`;
- 创建 Tag;
- 正式部署或切换流量。
## 2. 权威仓库与执行状态
| 项目 | 当前值 |
|---|---|
| 权威 Bare Repo | `/data/aios-gitops/repo.git` |
| OpenClaw Worktree | 独立且 clean |
| Codex Worktree | 独立且 clean |
| Gitea Remote | `https://g.cnnetmkt.top/git/aios/aios.git` |
| Candidate OID | `634f6b164f5b20372ab9ec8144bbf76df97de335` |
| OpenClaw Task OID | `bd2784015d40c328d158cd52ebd8683b6e56b3ad` |
| Codex Task OID | `e28245f411dd85ff9384ed595dacc53aab8a91ba` |
| 三分支 Gitea 同步 | `local_oid == gitea_oid` |
| OpenClaw 状态 | `blocked` |
| OpenClaw Heartbeat | 有记录,但 `valid_for_running=false` |
| Codex 状态 | `blocked` |
| Codex Heartbeat | 新鲜 |
| Codex 发布建议 | `STOP_PREPRODUCTION` |
## 3. 已完成的安全与运行修复
### 3.1 Gitea 凭据轮换
已完成:
- 旧 `owner/openclaw-deploy-cli`、`scope=all` 凭据 live count 为 `0`;
- 新凭据:`gitops-bot/openclaw-deploy-cli-v2-20260720`;
- 新凭据作用域:`write:repository`;
- 凭据文件权限:`root:gitops 0640`;
- 令牌值未进入 Git、日志、Evidence 或报告。
### 3.2 Gitea 与执行服务
已完成:
- 验证 Gitea SSH 指纹;
- 备份并修复 `known_hosts`;
- 恢复 `aios-agent-command-watcher`;
- 恢复 `aios-autonomous-controller`;
- 两项服务均为 `active`;
- OpenClaw/One API 真实模型调用返回 HTTP 200;
- OpenClaw/Codex claim、heartbeat、progress、blockers、result、status 已提交并同步 Gitea。
## 4. 当前开放阻塞
### 4.1 BLK-OPENCLAW-PLAINTEXT-SECRETS
- 严重级别:高
- 状态:开放
- 现象:OpenClaw 受管配置仍发现 3 处 `PLAINTEXT_FOUND`。
- 风险:明文 Secret 可能进入配置、日志、命令参数或执行证据。
- 解除条件:
1. 三处明文内容全部迁移为 SecretRef、systemd Credential、只读 Secret Volume 或仓库外受保护文件;
2. 旧值完成撤销和轮换;
3. Secret Scanner 复验通过;
4. Git 历史、日志和 Evidence 不包含明文。
### 4.2 BLK-AIOS-V2-SIGNING-MATERIAL-MISSING
- 严重级别:高
- 状态:开放
- 现象:`/etc/aios-v2/aios-v2.env` 已存在且权限合规,但 `/etc/aios-v2/openclaw-signing-material.bin` 不存在。
- 风险:无法验证 OpenClaw 签名命令、Key ID 与真实 Runtime 身份链。
- 外部受信输入:批准的签名材料。
- 解除条件:
1. 签名材料由批准的 Secret/Key 交付链安装到仓库外;
2. 权限为 `0600` 或更严格;
3. 运行用户可读;
4. Key ID 与材料匹配;
5. execute/status 路径完成真实签名验证;
6. 不输出材料内容、Hash 或长度特征。
### 4.3 BLK-CANDIDATE-NOT-MERGED
- 严重级别:高
- 状态:开放
- 现象:OpenClaw Task 尚未包含当前 Candidate,`merge-base --is-ancestor` 未通过。
- 风险:后续测试和部署不能证明当前 Candidate 可用。
- 解除条件:
1. 保护已有 `.agent/` Evidence;
2. 普通 Merge 当前 Candidate 到 OpenClaw Task;
3. 解决冲突并由 Codex 审查;
4. 更新所有 Evidence 中的 `candidate_commit`;
5. 本地与 Gitea OID 再次一致。
### 4.4 BLK-OPENCLAW-RUN-PROVENANCE-MISSING
- 严重级别:高
- 状态:开放
- 现象:OpenClaw 写工具被 `run_provenance_missing` 策略拒绝。
- 风险:无法证明写操作来自已领取、已授权、可审计的执行会话。
- 外部受信输入:允许七个 `.agent` JSON 写入的 OpenClaw Provenance 授权。
- 解除条件:
1. 创建带任务 ID、执行器实例、Claim OID、Candidate OID 和授权主体的 Run Provenance;
2. Provenance 与七个 `.agent` JSON 写入范围绑定;
3. 权限仅覆盖当前任务分支和 Evidence 路径;
4. 由 Owner 或批准策略签发;
5. 写入、Commit、Push 和审计链验证通过。
### 4.5 BLK-CONTROLLER-LEGACY-GITEA-CHECK
- 严重级别:高
- 状态:开放
- 现象:Controller 健康检查仍硬编码旧 `aios-v23`,并返回 `gitea=false`、`branch_sync=false`。
- 风险:健康状态与当前 AIOS V2R1 仓库不一致,可能错误阻止或错误放行发布。
- 解除条件:
1. 移除旧仓库硬编码;
2. 从配置读取权威仓库、Remote 和目标分支;
3. 健康检查分别报告 `local_sync`、`gitea_sync` 和 `oid_match`;
4. 不再把本地 Bare Repo 更新当作完全同步;
5. 增加回归测试;
6. 更新中文文档和追踪矩阵。
## 5. 模型审查策略修正
`gpt-5.3-codex-spark` 是子 Agent 审查的优先推荐模型,不是强制依赖。
执行规则:
1. Spark 可用时优先使用;
2. Spark 不可用时,使用模型注册表中已批准、已启用且优先级最高的合适模型;
3. 必须记录实际 `model_id`、模型名称和审查结果;
4. 禁止冒充使用不存在的模型;
5. 只有完全没有可用审查模型时,模型能力才构成阻塞。
当前五个 Spark 角色的独立结论一致为“禁止生产放行”,该结论作为已有审查证据保留;后续复验可使用任何满足上述规则的批准模型。
## 6. Gate 状态
| Gate | 当前状态 | 说明 |
|---|---|---|
| Gate A:文档与设计 | 未运行 | Candidate 未合并到任务,阻塞未解除 |
| Gate B:代码与测试 | 未运行 | 不能基于不完整任务分支测试 |
| Gate C:预发布 | 禁止 | Secret、签名、Provenance、Controller Health 未通过 |
| Gate D:发布审查 | `STOP_PREPRODUCTION` | Codex 明确停止预发布 |
| Gate E:生产发布 | 禁止 | 未满足任何生产 Gate |
## 7. 阻塞解除顺序
必须按以下顺序处理,避免重复测试和错误放行:
```text
1. 清除 3 处明文 Secret
↓
2. 安装批准的签名材料
↓
3. 签发最小范围 Run Provenance
↓
4. 修复 Controller 旧仓库健康检查
↓
5. Merge 当前 Candidate 到 OpenClaw Task
↓
6. 校验 local_oid == gitea_oid
↓
7. 执行 Gate A:文档—代码检查
↓
8. 执行 Gate B:单元与集成测试
↓
9. 执行离线全量验收
↓
10. 执行在线 Runtime 联调
↓
11. 执行重启恢复与回滚
↓
12. Codex 重新审查并给出发布建议
```
## 8. 外部受信输入
目前仅有两项确实需要外部受信输入:
1. **批准的签名材料**
- 不得通过聊天、Git 或普通日志传递;
- 必须通过批准的 Secret/Key 交付链安装。
2. **OpenClaw Run Provenance 授权**
- 仅允许当前任务所需的七个 `.agent` JSON 写入;
- 必须绑定任务、执行器实例、Claim、Candidate 和分支;
- 不得授予通用生产写权限。
其余阻塞均可由 OpenClaw、Codex、Windows ChatGPT Owner 和 GitOps 运维流程处理。
## 9. 监督结论
当前状态保持:
```text
BLOCKED
STOP_PREPRODUCTION
```
虽然三分支已经同步 Gitea、凭据已经最小权限轮换、执行服务已经恢复,但当前尚无足够证据允许执行 Gate A–D 或进入预发布。
任何后续状态更新必须同步:
- OpenClaw `.agent/status.json`、`blockers.json`、`progress.json`、`result.json`;
- Codex 对应监督 Evidence;
- 本文档;
- `05-TRACEABILITY-MATRIX.md`;
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md`;
- 如涉及架构或发布语义,更新 ADR。
@@ -0,0 +1,104 @@
# AIOS V2R1 Hermes 生命周期最终验证报告
- 报告版本:`V2R1-hermes-lifecycle-20260721.3`
- 日期:`2026-07-21`
- 分支:`patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
- 固定验证 OID:`95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
- 总体状态:`PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
- 发布决策:`STOP_PREPRODUCTION`
- 生产发布:`NOT_APPROVED`
## 1. 执行结论
`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING` 的 Hermes 功能与证据子条件已在固定 OID 上全部通过。候选实现复用现有 Hermes Adapter、Queue、Lease、Task 与 Orchestrator 状态机,新增持久 SQLite Store 和默认关闭的受保护内部生命周期接口。Hermes 完整生命周期 `17/17`、候选测试 `49/49`、Gate A、Gate B `10/10`、离线 Acceptance `8/0/1`、真实 OpenClaw HTTP、重启恢复、双向回滚和最终健康检查均通过。
但 Owner 指令规定只有全部 Acceptance 条件同时满足时才允许正式解除该 Blocker。由于“所有 Worktree clean”失败,本报告不得把它标记为正式 `RESOLVED`;准确状态是 `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`。
最终发布门禁仍失败:权威 Bare Repo 登记的 88 个 worktree 中有 10 个脏 worktree。候选 worktree 和活动 Controller worktree 均 clean,且无 locked/prunable;但 Owner 的验收条件要求所有 worktree clean,因此不得准备 RC、不得发起 Tag Approval Request、不得进入预生产评审。
## 2. 关键实现
- `SQLiteHermesStore` 持久保存 Job、Result、Idempotency 与 Worker Heartbeat。
- 现有 `HermesAdapter` 保持唯一 Queue/Lease 状态机,支持指定 Job claim、租约到期 requeue、重启恢复与严格终态冲突。
- 生命周期身份绑定覆盖 tenant、task、trace、principal、provenance、run、dispatch、job、cell、profile。
- start/heartbeat/complete/fail 强制 Lease Owner;complete/fail/cancel 通过 `ExecutionOrchestrator.reconcile_hermes` 更新 Task 与 Dispatch。
- cancel 仅允许同时具备 `runtime` 与 `platform_admin` 的 AIOS Control Plane,要求 `cancellation_source=aios-control-plane`,并拒绝携带 `worker_id`,Worker 只能观察取消。
- 所有成功生命周期操作写入 Audit/Event/Execution Evidence;接口由 `AIOS_HERMES_LIFECYCLE_API_ENABLED` 控制,默认关闭。
## 3. 固定 OID 验证
| 项目 | 结果 |
|---|---|
| Python syntax/import | PASS |
| 全部 candidate tests | `49/49` |
| Grant isolation | `11/11` |
| Dispatch recovery | `1/1` |
| Hermes lifecycle | `17/17` |
| Docs code gate / Gate A | `0 errors / 0 warnings` |
| Secret scan | `547 tracked files / 0 violations` |
| SQLite quick_check | `2/2 ok` |
| Gate B | `10/10` |
| Offline Acceptance | `8 passed / 0 failed / 1 expected warning` |
| OpenClaw real HTTP | `COMPLETED` |
| Hermes full live rerun | `17/17` |
| Restart recovery | `2/2` |
| Previous → Current rollback/forward | PASS |
| State preservation | `STATE_PRESERVED_EXACTLY=PASS` |
| Corrected final health (`16R`) | PASS,三服务 active |
原 `16.log` 由非服务用户执行健康脚本,打印 state-dir 权限失败后被外层脚本错误覆盖为退出码 0,因此不作为通过证据。纠错后的 `16R.log` 使用受控 `sudo env`,子 shell 保留失败码,所有健康项实际 PASS 且 `EXIT_CODE=0`。原文件保留用于审计。
回滚日志中的短暂 connection-refused 发生于 systemd 重启窗口内,受 `AIOS_V2_ROLLBACK_HEALTH_RETRIES=30` 与 1 秒间隔限制;前后两次均在界限内达到健康、输出 `ROLLBACK_COMPLETE`,并完成状态文件精确哈希对账。
## 4. Evidence
- 根目录:`/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
- `steps.tsv` SHA256:`b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf`
- `final-live-gate.log` SHA256:`82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa`
- `16R.log` SHA256:`641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf`
- `15.log` SHA256:`b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e`
- `13.log` SHA256:`b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710`
- `12R2.log` SHA256:`0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67`
证据只记录身份、状态、命令、时间、退出码与 Hash,不记录 Secret 值。
## 5. Git 与运行态门禁
| 门禁 | 当前值 | 结果 |
|---|---|---|
| Bare/Gitea/Candidate OID | 全部 `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4` | PASS |
| Candidate worktree | clean | PASS |
| Active Controller | `6188da26453505dacde9d6d298191437164c8d27`,detached、clean | PASS |
| Locked worktree | `0` | PASS |
| Prunable worktree | `0` | PASS |
| 全局 worktree clean | `10 dirty / 88 total` | FAIL |
| 当前隔离 Release | `b158041b8633e658c34ce182f3c8aa97b4bf5f9a` | 已恢复 |
| AIOS/OpenClaw/Controller services | active | PASS |
10 个脏 worktree 均不属于本任务允许清理的范围:
1. `chatgpt/TASK-V2R1-PERSONAL-WEIXIN-GOLDEN-CONTRACT-R2-20260715`(36)
2. `chatgpt/TASK-V2R1-PERSONAL-WEIXIN-PORTAL-ADAPTER-20260715`(2)
3. `chatgpt/TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8`(2)
4. `codex`(56)
5. `codex/TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8`(6)
6. `controller`(11)
7. `openclaw`(5)
8. `openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8`(3)
9. `openclaw/TASK-V2R1-OPENCLAW-WEIXIN-WEB-LOGIN-CONTRACT-20260715`(20)
10. `openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8`(3)
## 6. Reviewer
独立 Spark Reviewer 首先发现 cancel 权限边界不够明确;该问题已在 `95d2e52` 修复并新增三条 HTTP 断言。纠错后 Reviewer 确认代码安全边界、16 步证据与有界回滚均 PASS。对发布门禁的最终布尔复核为:`DIRTY_WORKTREE_COUNT=10` 且 `GLOBAL_WORKTREE_CLEAN=false`,所以全局门禁 FAIL,不能进入预生产评审。
## 7. Blocker 与发布决策
- 功能与证据子条件通过、正式关闭受阻:`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING`
- 直接阻断条件:`BLK-GLOBAL-WORKTREE-CLEANLINESS`
- 状态:`BLOCKED_EVIDENCE_REQUIRED`
- 决策:`STOP_PREPRODUCTION`
本轮未修改 `main`、未创建 Tag、未 Force Push、未切换生产流量、未部署新 RC、未输出 Secret。
下一步唯一允许动作:由各 worktree Owner 审查并提交、归档或可信清理上述 10 个脏 worktree,然后重新执行全局 Git 门禁。只有全局 dirty 数量降为 0 后,才允许准备 RC 与 Tag Approval Request。

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