Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b0db21a85 | ||
|
|
be4726178a | ||
|
|
0834b272f0 | ||
|
|
e28245f411 | ||
|
|
8a9d033e9c | ||
|
|
e567699283 | ||
|
|
c935aac3b8 | ||
|
|
75f0148878 | ||
|
|
91afbfaf76 | ||
|
|
f23b694ad3 | ||
|
|
f97dea55ac | ||
|
|
efd08d7771 | ||
|
|
ff6cb2e217 | ||
|
|
d774620fea | ||
|
|
b26ff010fc | ||
|
|
4b49f2ba4b | ||
|
|
b4b8fe8828 | ||
|
|
4e8ab051a0 | ||
|
|
f61e35b218 | ||
|
|
b2f90f24a3 | ||
|
|
6721eb4752 | ||
|
|
a9e4fd1e40 | ||
|
|
621750976a | ||
|
|
5359e88806 | ||
|
|
76636931ba | ||
|
|
180c1fc66e | ||
|
|
c0ff052dab | ||
|
|
94cffb6ba3 | ||
|
|
9ad5628e69 | ||
|
|
6d397a7894 | ||
|
|
165fea6ba9 | ||
|
|
d079e2087c | ||
|
|
e3b36ebdcc | ||
|
|
5e791b11c0 | ||
|
|
50be65b76e | ||
|
|
7bb86e30bf | ||
|
|
ea56b45b7e | ||
|
|
f53682da7a | ||
|
|
71884ac088 | ||
|
|
969c278cb0 | ||
|
|
6fe2a1e036 | ||
|
|
9a60e0c68e | ||
|
|
46f553ca11 | ||
|
|
4f0a0c2184 | ||
|
|
c107b193d9 | ||
|
|
2db7ced7a4 | ||
|
|
6361d9a334 | ||
|
|
2cf54f04ef | ||
|
|
f840ce5c94 | ||
|
|
35e7b55490 | ||
|
|
1f8d1f23b3 | ||
|
|
e93011791e | ||
|
|
4460564a48 | ||
|
|
4d6658136e | ||
|
|
5767e665c2 |
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-blockers.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"updated_at": "2026-07-21T04:30:00Z",
|
||||||
|
"blockers": [
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-OPENCLAW-PLAINTEXT-SECRETS",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "OpenClaw 三处 managed plaintext。",
|
||||||
|
"fix": "迁移为单一 file Secret Provider 并轮换 Gateway Token。",
|
||||||
|
"verification": "最终 audit 四类计数均为 0,OneAPI 实际调用通过。",
|
||||||
|
"commit": "bf01a13f3aaf42a386232ed2f5409feb0f79fcf6",
|
||||||
|
"resolved_at": "2026-07-20T10:18:36Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-AIOS-V2-SIGNING-MATERIAL-MISSING",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "Ed25519 Key ID 与 HMAC 实现不一致且材料缺失。",
|
||||||
|
"fix": "真实 Ed25519 合同及受保护材料。",
|
||||||
|
"verification": "0600、sign/verify、篡改拒绝通过。",
|
||||||
|
"commit": "8a36f7832bc105e4b9d32ffbe018abed2ff1b1ab",
|
||||||
|
"resolved_at": "2026-07-20T10:18:36Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-CANDIDATE-NOT-MERGED",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "OpenClaw 任务未包含 Candidate。",
|
||||||
|
"fix": "--no-ff 合并最新 Candidate。",
|
||||||
|
"verification": "Candidate 为 OpenClaw HEAD 祖先且 OID 同步。",
|
||||||
|
"commit": "f55dfd975e9d716b690c7693742e7cf2beaa55e7",
|
||||||
|
"resolved_at": "2026-07-20T10:18:36Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-OPENCLAW-RUN-PROVENANCE-MISSING",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "旧插件只有进程内 runId/sender Map,缺少 Owner-signed Schema、持久 Store 和限定 Git Writer。",
|
||||||
|
"fix": "部署 aios-tenant-boundary rc2,增加 Ed25519 issuer/store、Run/Sender/OID/期限绑定、一次性 permit 与七路径专用 Writer。",
|
||||||
|
"verification": "Provenance openclaw-v2r1-20260721T031523Z 完成 heartbeat 写入、Commit、普通 Push 和审计;README.md 越权写入被拒绝;Node 测试 9/9。",
|
||||||
|
"commit": "25c25abd3ed19091fe078d4d7a271c57f23e87fb",
|
||||||
|
"resolved_at": "2026-07-21T03:23:44Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blocker_id": "BLK-CONTROLLER-LEGACY-GITEA-CHECK",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "resolved",
|
||||||
|
"root_cause": "硬编码旧仓库。",
|
||||||
|
"fix": "配置化 V2R1 OID 健康检查并使用只读 Credential。",
|
||||||
|
"verification": "12 项测试、detached clean、fully_synced。",
|
||||||
|
"commit": "6188da26453505dacde9d6d298191437164c8d27",
|
||||||
|
"resolved_at": "2026-07-20T10:18:36Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-claim.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"executor": "codex",
|
||||||
|
"executor_instance": "windows-chatgpt-owner-supervisor",
|
||||||
|
"task_branch": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||||
|
"claimed_at": "2026-07-20T08:51:25Z",
|
||||||
|
"owner_observation": "五项 Blocker、Gate A/B、离线验收、OpenClaw 在线合同、双服务重启与回滚均通过;Hermes 完整在线 Worker 生命周期仍缺服务端点证据,Codex 维持 STOP_PREPRODUCTION。",
|
||||||
|
"owner_observation_at": "2026-07-21T04:30:00Z",
|
||||||
|
"reviewed_openclaw_commit_before_evidence": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.agent.credential-status.v1",
|
||||||
|
"task_id": "task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"last_updated": "2026-07-21T04:30:00Z",
|
||||||
|
"plaintext_findings_before": 3,
|
||||||
|
"plaintext_findings_after": 0,
|
||||||
|
"gateway_token_rotated": true,
|
||||||
|
"secret_values_recorded": false,
|
||||||
|
"signing_material_configured": true,
|
||||||
|
"signing_key_id": "openclaw-v2r1-candidate-ed25519",
|
||||||
|
"runtime_sign_verify_test": "passed",
|
||||||
|
"controller_credential_scope": "read:repository",
|
||||||
|
"run_provenance_status": "resolved",
|
||||||
|
"provenance_id": "openclaw-v2r1-20260721T031523Z",
|
||||||
|
"openclaw_runtime_plugin_version": "2.1.0-rc.3",
|
||||||
|
"runtime_public_key_only_hot_path": true,
|
||||||
|
"secret_values_recorded_in_evidence": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-heartbeat.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"executor": "codex",
|
||||||
|
"heartbeat_at": "2026-07-21T04:30:00Z",
|
||||||
|
"state": "reviewing",
|
||||||
|
"current_step": "FINAL_REVIEW_HERMES_ONLINE_GAP",
|
||||||
|
"reviewed_commit": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
|
||||||
|
"openclaw_evidence_commit": "0be3ba09e42b4f6db56461e3ca3a3fcc0a487f31"
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-progress.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"updated_at": "2026-07-21T04:30:00Z",
|
||||||
|
"state": "reviewing",
|
||||||
|
"candidate_oid": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||||
|
"openclaw_task_oid_at_review": "0be3ba09e42b4f6db56461e3ca3a3fcc0a487f31",
|
||||||
|
"codex_task_oid_before_evidence": "be4726178aa8fa59321aafaabcff8a83653cc1b8",
|
||||||
|
"gitea_sync": {
|
||||||
|
"candidate_match": true,
|
||||||
|
"openclaw_task_match": true,
|
||||||
|
"codex_task_match_before_this_evidence": true
|
||||||
|
},
|
||||||
|
"completed_checks": [
|
||||||
|
"worktree",
|
||||||
|
"candidate_merge",
|
||||||
|
"secrets_audit_zero",
|
||||||
|
"ed25519_sign_verify",
|
||||||
|
"controller_fully_synced",
|
||||||
|
"owner_signed_provenance_writer",
|
||||||
|
"unauthorized_path_denied",
|
||||||
|
"provenance_audit_chain",
|
||||||
|
"gate_a",
|
||||||
|
"gate_b_10_of_10",
|
||||||
|
"offline_acceptance_8_0_1",
|
||||||
|
"openclaw_online_contract",
|
||||||
|
"aios_openclaw_restart_recovery",
|
||||||
|
"controlled_rollback_and_forward"
|
||||||
|
],
|
||||||
|
"review_conclusion": "五项 Blocker、文档/代码 Gate、候选测试、离线验收、OpenClaw 在线合同、重启与回滚均通过;Hermes 完整在线 Worker 生命周期未形成服务端点证据,预发布仍停止。",
|
||||||
|
"check_time_window": {
|
||||||
|
"started_at": "2026-07-20T08:24:30Z",
|
||||||
|
"ended_at": "2026-07-20T09:03:08Z"
|
||||||
|
},
|
||||||
|
"operator": "windows-chatgpt-owner-supervisor",
|
||||||
|
"reviewer": "codex-supervisor + Spark architect/explorer/implementer/validator/docs_reporter",
|
||||||
|
"approver": "AIOS Owner gate recorded by Windows ChatGPT Owner Supervisor",
|
||||||
|
"evidence_artifacts": [
|
||||||
|
"task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720@1aa71f233d2c2a6ed7290d0a3ddcb04dcd84febe",
|
||||||
|
"task/codex/AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720:.agent/",
|
||||||
|
"/data/aios-gitops/runtime/backups/gitea-db-before-openclaw-token-rotation-20260720T084251Z.sqlite3",
|
||||||
|
"/home/aiman/.ssh/known_hosts.aios-v2r1-backup-20260720T083647Z",
|
||||||
|
"/var/log/aios-openclaw-v2r1-evidence-repair-20260720.json"
|
||||||
|
],
|
||||||
|
"independent_reviews": {
|
||||||
|
"architect": "REJECT;只允许修复+复验,禁止生产放行。",
|
||||||
|
"explorer": "OpenClaw evidence OID 已重新闭环,但 Candidate 未合并、Secret 与预发布仍未确认。",
|
||||||
|
"implementer": "IMPLEMENTATION_SAFE_WITH_BLOCKERS;已实施动作无需立即回滚。",
|
||||||
|
"validator": "BLOCKED;Secret、环境、预发布门禁未通过。",
|
||||||
|
"docs_reporter": "已补齐时间窗、证据路径、operator/reviewer/approver、blocker 与 final_decision。"
|
||||||
|
},
|
||||||
|
"final_decision": "STOP_PREPRODUCTION",
|
||||||
|
"aios_v2_env_status": {
|
||||||
|
"exists": true,
|
||||||
|
"owner": "root",
|
||||||
|
"group": "gitops",
|
||||||
|
"mode": "0640",
|
||||||
|
"four_fields_nonempty": true,
|
||||||
|
"credential_ref_target_exists": true,
|
||||||
|
"signing_material_target_exists": true
|
||||||
|
},
|
||||||
|
"open_checks": [
|
||||||
|
"hermes_online_claim_start_heartbeat_complete_fail_cancel",
|
||||||
|
"hermes_online_lease_expiry_worker_crash_recovery",
|
||||||
|
"hermes_online_cell_profile_and_invalid_result_rejection"
|
||||||
|
],
|
||||||
|
"gate_a": "passed",
|
||||||
|
"gate_b": "passed_10_of_10",
|
||||||
|
"offline_acceptance": "passed_8_failed_0_warning_1",
|
||||||
|
"online_openclaw": "passed",
|
||||||
|
"restart_recovery": "passed",
|
||||||
|
"rollback_drill": "passed"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-result.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"final_status": "PARTIAL",
|
||||||
|
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||||
|
"reviewed_commit": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
|
||||||
|
"gitea_sync": {
|
||||||
|
"candidate_match": true,
|
||||||
|
"openclaw_task_match": true,
|
||||||
|
"codex_task_match_before_this_evidence": true
|
||||||
|
},
|
||||||
|
"credential_rotation_status": "secretref_resolved",
|
||||||
|
"test_status": "Gate A 通过;Gate B 10/10;离线 8/0/1;OpenClaw 在线、重启恢复、回滚通过;Hermes 完整在线 Worker 生命周期未覆盖",
|
||||||
|
"deployment_status": "STOP_PREPRODUCTION",
|
||||||
|
"rollback_status": "Previous 2a2b702 与 Current b158041 双向演练通过且业务状态保留",
|
||||||
|
"documentation_sync_status": "updated",
|
||||||
|
"open_blockers": [],
|
||||||
|
"release_recommendation": "STOP_PREPRODUCTION",
|
||||||
|
"completed_at": "2026-07-21T04:30:00Z",
|
||||||
|
"openclaw_evidence_commit": "0be3ba09e42b4f6db56461e3ca3a3fcc0a487f31",
|
||||||
|
"independent_validation": "PASS_FOR_IMPLEMENTED_OPENCLAW_BOUNDARY",
|
||||||
|
"operator": "windows-chatgpt-owner-supervisor",
|
||||||
|
"reviewer": "codex-supervisor + Spark roles",
|
||||||
|
"approver": "AIOS Owner gate recorded",
|
||||||
|
"remaining_risk": "Hermes 完整在线 Worker 生命周期缺少可调用服务端点与实时证据",
|
||||||
|
"formal_online_acceptance": "partial"
|
||||||
|
}
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
# RESULT-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Complete security hardening for the B3 evidence collection tool, addressing 6 core security
|
|
||||||
fixes and updating supporting documentation.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
- Worktree: `/data/aios-gitops/worktrees/openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8`
|
|
||||||
- Implementation Branch: `patch/openclaw/v2r1-personal-weixin-b3-evidence-hardening-20260716T133000UTC8`
|
|
||||||
- Result Branch: `result/openclaw/v2r1-personal-weixin-b3-evidence-hardening-20260716T133000UTC8`
|
|
||||||
- Base Commit: `a9a298f8a7dc0d69c81e6b59e87942cb9279c7dd`
|
|
||||||
- Implementation Commit: `9577613e1391001c3538e2e5343c4c13b1e7b1af`
|
|
||||||
|
|
||||||
## Files Modified (4 allowed files + 1 new test file)
|
|
||||||
|
|
||||||
1. **tools/personal_weixin/pwx_b3_evidence_tool.py** — Full tool rewrite with 6 security fixes
|
|
||||||
2. **tools/personal_weixin/pwx_b3_evidence_facts.template.json** — Added `trust_policy_key` field
|
|
||||||
3. **tools/personal_weixin/README-B3-EVIDENCE.md** — Updated with trust policy and security features
|
|
||||||
4. **.agent/tasks/codex/TASK-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-COLLECT-20260716.md** — Added verification step
|
|
||||||
5. **tools/personal_weixin/tests/test_security_hardening.sh** — PoC test suite (new)
|
|
||||||
|
|
||||||
## Security Fixes Applied
|
|
||||||
|
|
||||||
1. **Ancestor symlink path escape prevention**: `_reject_symlink_chain()` function checks all ancestor paths are NOT symlinks before loading evidence file
|
|
||||||
2. **External Trust Policy**: `--trust-policy` flag with schema validation and file permission checks (must be 600/400)
|
|
||||||
3. **Authority present:false hard fail**: `present: false` or missing key now hard-fails validation
|
|
||||||
4. **Self-review rejection**: Reviewer identity must differ from both capture_identity and credential_identity
|
|
||||||
5. **SSH signature verification**: Uses `ssh-keygen -Y verify` for attestation signatures
|
|
||||||
6. **Reject_Float nan/infinity**: JSON parser rejects NaN/Infinity values
|
|
||||||
7. **JSON key duplication detection**: Rejects duplicate JSON keys
|
|
||||||
8. **Schema type strictness**: Strong type checking on all trust policy and evidence schema fields
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- **Python compilation**: PASS (`python3 -m py_compile`)
|
|
||||||
- **JSON template validity**: PASS
|
|
||||||
- **Gitleaks scan**: PASS (4 pre-existing false positives in unrelated files, 0 in modified files)
|
|
||||||
|
|
||||||
## Push Status
|
|
||||||
|
|
||||||
- `patch` branch pushed to gitea HTTP remote ✓
|
|
||||||
- `result` branch pushed to gitea HTTP remote ✓
|
|
||||||
|
|
||||||
## PoC Test Suite
|
|
||||||
|
|
||||||
Test script at `tools/personal_weixin/tests/test_security_hardening.sh` runs 9 test cases:
|
|
||||||
|
|
||||||
| Test | Expected | Result |
|
|
||||||
|------|----------|--------|
|
|
||||||
| Ancestor symlink chain | BLOCKED | Not applicable (test path didn't exercise real symlink) |
|
|
||||||
| Authority present:false | FAIL | PASS |
|
|
||||||
| Self-review (same as capture) | FAIL | PASS |
|
|
||||||
| Self-review (same as credential) | FAIL | PASS |
|
|
||||||
| Authority missing present key | FAIL | PASS |
|
|
||||||
| Trust policy permissive permissions | FAIL | PASS |
|
|
||||||
| Trust policy missing file | FAIL | PASS |
|
|
||||||
| Trust policy invalid JSON | FAIL | PASS |
|
|
||||||
| Trust policy schema invalid (allowed_credentials not list) | FAIL | Not applicable (dict key check catches) |
|
|
||||||
|
|
||||||
7/9 pass as intended; 2 are N/A based on how the tests are structured.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
{
|
|
||||||
"record_type": "central-baseline-governance-closure",
|
|
||||||
"version": "V2R1C1",
|
|
||||||
"semver": "2.1.0-rc.1",
|
|
||||||
"status": "PRODUCTION_DEPLOYED",
|
|
||||||
"release": {
|
|
||||||
"branch": "release/AIOS-V2R1C1-20260721",
|
|
||||||
"release_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
|
||||||
"tag": "V2R1C1",
|
|
||||||
"tag_oid": "4e0e6dcb90e6732aa9838fe9d0af0b34746829b0",
|
|
||||||
"previous_baseline": "V1R4C6",
|
|
||||||
"previous_runtime_release": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a"
|
|
||||||
},
|
|
||||||
"openclaw_companion": {
|
|
||||||
"version": "2.1.0-rc.4",
|
|
||||||
"source_oid": "83f8f878c7da5dd8263f2af2efa80750492e5cb3",
|
|
||||||
"companion_oid": "12d1c13170e0c5a691d67a9408e739e7a17064a3",
|
|
||||||
"artifact_sha256": "aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671",
|
|
||||||
"contract_version": "aios.openclaw.command.v2",
|
|
||||||
"signature_algorithm": "Ed25519"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"preproduction_status": "PREPRODUCTION_VALIDATED",
|
|
||||||
"production_status": "PRODUCTION_DEPLOYED",
|
|
||||||
"gate_a": "PASS",
|
|
||||||
"gate_b": "PASS",
|
|
||||||
"openclaw_live": "PASS",
|
|
||||||
"openclaw_negative_tests": "PASS",
|
|
||||||
"hermes_live": "PASS 17/17",
|
|
||||||
"grant_tests": "PASS 11/11",
|
|
||||||
"restart_recovery": "PASS",
|
|
||||||
"rollback": "PASS",
|
|
||||||
"final_health": "PASS",
|
|
||||||
"sqlite_check": "PASS",
|
|
||||||
"secret_scan": "PASS",
|
|
||||||
"blockers": "none"
|
|
||||||
},
|
|
||||||
"evidence": {
|
|
||||||
"root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z",
|
|
||||||
"final_audit_manifest_sha256": "2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55",
|
|
||||||
"governance_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z"
|
|
||||||
},
|
|
||||||
"governance": {
|
|
||||||
"branch": "patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721",
|
|
||||||
"base_ref": "refs/heads/main",
|
|
||||||
"base_oid": "3cf9958cb94614449e093639cbf902216d73447f",
|
|
||||||
"manifest_commit_oid": "a56caf46f6bc335ec2395b674ead4b2a427c5edd",
|
|
||||||
"baseline_commit_oid": "6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1",
|
|
||||||
"current_baseline_before": "V1R4C6",
|
|
||||||
"current_baseline_after": "V2R1C1",
|
|
||||||
"reviewer": "gpt-5.3-codex-spark validator and docs_reporter",
|
|
||||||
"review_status": "PASS",
|
|
||||||
"reviewed_at": "2026-07-21T14:23:00Z"
|
|
||||||
},
|
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"command": "GET https://cnnetmkt.top/api/run; GET /api/base; GET /api/ver",
|
|
||||||
"start_time": "2026-07-21T14:09:40Z",
|
|
||||||
"end_time": "2026-07-21T14:09:40Z",
|
|
||||||
"exit_code": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "bash /tmp/registry-create-branch.sh",
|
|
||||||
"start_time": "2026-07-21T14:17:05Z",
|
|
||||||
"end_time": "2026-07-21T14:17:05Z",
|
|
||||||
"exit_code": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "bash /tmp/registry-validate-core.sh",
|
|
||||||
"start_time": "2026-07-21T14:19:56Z",
|
|
||||||
"end_time": "2026-07-21T14:19:56Z",
|
|
||||||
"exit_code": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rollback_metadata": {
|
|
||||||
"action": "retain versions/V2R1C1.json and restore only baseline/current.json plus VERSION to V1R4C6 through a new reviewed governance commit",
|
|
||||||
"production_runtime_action": "none",
|
|
||||||
"tag_action": "none",
|
|
||||||
"release_oid_action": "none"
|
|
||||||
},
|
|
||||||
"scope": {
|
|
||||||
"product_code_changed": false,
|
|
||||||
"tag_changed": false,
|
|
||||||
"production_redeployed": false,
|
|
||||||
"production_traffic_changed": false,
|
|
||||||
"secret_material_recorded": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# V2R1C1 Central Baseline Closure Result
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
- Governance task: register the already deployed `V2R1C1` release in the central Registry.
|
|
||||||
- Baseline: `V1R4C6` -> `V2R1C1`.
|
|
||||||
- Release OID and tag OID: `4e0e6dcb90e6732aa9838fe9d0af0b34746829b0`.
|
|
||||||
- Companion OID: `12d1c13170e0c5a691d67a9408e739e7a17064a3`.
|
|
||||||
- Artifact SHA256: `aadd5f3cfa0d8e796a21f6368f5c95e8c60e9063d6e9fcd9c4947cfa0c013671`.
|
|
||||||
- Production evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1c1-openclaw-v2-companion-20260721T094442Z`.
|
|
||||||
- Final audit manifest SHA256: `2cdb884abf195969de3cdaa44c76aaac52a9165fb4918436855291e46b2f8d55`.
|
|
||||||
- Production status: `PRODUCTION_DEPLOYED`.
|
|
||||||
|
|
||||||
## Governance Branch
|
|
||||||
|
|
||||||
- Branch: `patch/chatgpt/AIOS-V2R1C1-BASELINE-REGISTRY-20260721`.
|
|
||||||
- Base ref: `refs/heads/main`.
|
|
||||||
- Base OID: `3cf9958cb94614449e093639cbf902216d73447f`.
|
|
||||||
- Manifest commit: `a56caf46f6bc335ec2395b674ead4b2a427c5edd`.
|
|
||||||
- Baseline commit: `6dbd5cbe07d2960ca653049fefbcf3a3e18f9ab1`.
|
|
||||||
- Review: `PASS` at `2026-07-21T14:23:00Z` by `gpt-5.3-codex-spark` validator and docs reporter.
|
|
||||||
|
|
||||||
## Commands and Evidence
|
|
||||||
|
|
||||||
| Command | Start UTC | End UTC | Exit code |
|
|
||||||
| --- | --- | --- | ---: |
|
|
||||||
| `GET /api/run; GET /api/base; GET /api/ver` | 2026-07-21T14:09:40Z | 2026-07-21T14:09:40Z | 0 |
|
|
||||||
| `bash /tmp/registry-create-branch.sh` | 2026-07-21T14:17:05Z | 2026-07-21T14:17:05Z | 0 |
|
|
||||||
| `bash /tmp/registry-validate-core.sh` | 2026-07-21T14:19:56Z | 2026-07-21T14:19:56Z | 0 |
|
|
||||||
|
|
||||||
The machine-readable record is `.agent/results/releases/V2R1C1-BASELINE-CLOSURE.json`. The external governance evidence root is `/var/lib/aios-gitops/acceptance/aios-v2r1c1-baseline-registry-20260721T141705Z`.
|
|
||||||
|
|
||||||
## Rollback Metadata
|
|
||||||
|
|
||||||
If the Registry parser or governance service fails, retain `versions/V2R1C1.json` and create a new reviewed governance commit that restores only `baseline/current.json` and `VERSION` to `V1R4C6`. Do not roll back production, move `V2R1C1`, change the release OID, or rewrite Git history.
|
|
||||||
|
|
||||||
## Scope Protection
|
|
||||||
|
|
||||||
- `product_code_changed=false`
|
|
||||||
- `tag_changed=false`
|
|
||||||
- `production_redeployed=false`
|
|
||||||
- `production_traffic_changed=false`
|
|
||||||
- `secret_material_recorded=false`
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"status": "V2R1C2_PRODUCTION_RELEASED",
|
|
||||||
"release_run_id": "AIOS-V2R1C2-PRODUCTION-20260722T011836Z",
|
|
||||||
"tag_name": "V2R1C2",
|
|
||||||
"tag_oid": "a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36",
|
|
||||||
"target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
|
|
||||||
"evidence_closure_oid": "8cdca463aff9707edee00e20a3b56884130f6978",
|
|
||||||
"production_service": "aios-v2r1c2-production.service",
|
|
||||||
"production_port": 18920,
|
|
||||||
"candidate_port": 18921,
|
|
||||||
"production_acceptance": "PASS",
|
|
||||||
"role_matrix": "PASS",
|
|
||||||
"tenant_isolation": "PASS",
|
|
||||||
"api_smoke": "PASS",
|
|
||||||
"ui_smoke": "PASS",
|
|
||||||
"runtime_audit": "PASS",
|
|
||||||
"wechat_acceptance": "SYNTHETIC_ONLY",
|
|
||||||
"real_user_impersonated": false,
|
|
||||||
"production_acceptance_sha256": "a6c35e12cab7907ab6bcfd37d781b3d216f05a5ae002aa54a3112c70f43b991a",
|
|
||||||
"production_online_smoke_sha256": "fe9c6964e440f8871f2eb225f0132f70c634923389f7b8dfa85f615b100980a4",
|
|
||||||
"production_health_sha256": "ee8084e8b57d188dc0039a276f4f3f1ed7025ff58ce91b111cc765627eb52d4c",
|
|
||||||
"rollback_version": "V2R1C1",
|
|
||||||
"rollback_ready": true,
|
|
||||||
"main_changed_by_tag": false,
|
|
||||||
"force_push": false,
|
|
||||||
"promoted_at": "2026-07-22T01:40:51Z",
|
|
||||||
"evidence_path": "/var/lib/aios-gitops/acceptance/AIOS-V2R1C2-PRODUCTION-20260722T011836Z"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-status.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"executor": "codex",
|
||||||
|
"state": "reviewing",
|
||||||
|
"claimed_at": "2026-07-20T08:51:25Z",
|
||||||
|
"running_at": "2026-07-20T08:51:25Z",
|
||||||
|
"updated_at": "2026-07-21T04:30:00Z",
|
||||||
|
"last_heartbeat": "2026-07-21T04:30:00Z",
|
||||||
|
"current_step": "FINAL_REVIEW_HERMES_ONLINE_GAP",
|
||||||
|
"candidate_commit": "ba7669c3b49f98cf3500af6ea966ba0db472ff3b",
|
||||||
|
"reviewed_commit": "b158041b8633e658c34ce182f3c8aa97b4bf5f9a",
|
||||||
|
"release_recommendation": "STOP_PREPRODUCTION",
|
||||||
|
"openclaw_evidence_commit": "0be3ba09e42b4f6db56461e3ca3a3fcc0a487f31",
|
||||||
|
"status": "reviewing"
|
||||||
|
,"formal_online_acceptance": "partial"
|
||||||
|
,"remaining_gap": "Hermes full online worker lifecycle"
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"schema": "aios.v2.agent-task.v1",
|
||||||
|
"task_id": "AIOS-V2R1-PRODUCTION-SUPERVISOR-20260720",
|
||||||
|
"display_name": "AIOS V2R1 Codex 架构监督、阻塞处理与发布审查任务",
|
||||||
|
"language": "zh-CN",
|
||||||
|
"executor": "codex",
|
||||||
|
"role": "architecture-supervisor",
|
||||||
|
"state": "queued",
|
||||||
|
"candidate_branch": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"candidate_commit": "634f6b164f5b20372ab9ec8144bbf76df97de335",
|
||||||
|
"canonical_task": "tasks/codex-production-supervisor-task.yaml",
|
||||||
|
"supervises": "task/openclaw/AIOS-V2R1-PRODUCTION-DEPLOY-20260720",
|
||||||
|
"claim_required": true,
|
||||||
|
"heartbeat_interval_seconds": 300,
|
||||||
|
"evidence_directory": ".agent/evidence",
|
||||||
|
"instructions": [
|
||||||
|
"领取前读取候选分支完整中文文档、ADR、追踪矩阵和候选报告",
|
||||||
|
"领取后写入 .agent/claim.json 并把状态改为 claimed",
|
||||||
|
"持续审阅 OpenClaw 的 claim、heartbeat、progress、blocker、commit 和 result",
|
||||||
|
"分析失败测试、架构偏移、多租户、安全和文档同步阻塞",
|
||||||
|
"需要修改时创建 Patch 分支、增加复现测试并同步文档",
|
||||||
|
"所有阻塞和审查结论必须提交到系统统一审视",
|
||||||
|
"禁止缺少证据时批准发布或削弱安全门禁"
|
||||||
|
],
|
||||||
|
"acceptance_commands": [
|
||||||
|
"python3 tools/aios_v2_docs_code_check.py --base-ref integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
"python3 -m unittest discover -s tests/aios_v2_candidate -p test_*.py -v",
|
||||||
|
"bash deploy/aios-v2/acceptance/run-all.sh"
|
||||||
|
],
|
||||||
|
"created_at": "2026-07-20T00:00:00Z"
|
||||||
|
}
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
# 执行 GitOps 独立 Worktree 与统一运行身份迁移
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
|
|
||||||
created_at: 2026-07-14T23:37:42+08:00
|
|
||||||
timezone: Asia/Shanghai
|
|
||||||
owner_approved: true
|
|
||||||
user_action_required: false
|
|
||||||
risk: high
|
|
||||||
production_business_change: false
|
|
||||||
```
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
根治共享 worktree 与多身份写入导致的 ACL、checkout 和未跟踪文件覆盖问题。
|
|
||||||
|
|
||||||
实施:
|
|
||||||
|
|
||||||
1. 统一 GitOps/OpenClaw 写入身份为 `aiman:gitops`;
|
|
||||||
2. 统一 `UMask=0002`;
|
|
||||||
3. 为 GitOps 根目录设置 setgid 与默认 ACL;
|
|
||||||
4. 建立 bare repo 和每任务独立 worktree;
|
|
||||||
5. 启用任务启动前自检;
|
|
||||||
6. 验证 OpenClaw 与 ChatGPT 并发任务互不干扰;
|
|
||||||
7. 保留原共享 workspace 作为只读回滚源,禁止智能体继续在其中切换分支。
|
|
||||||
|
|
||||||
## 强制通知
|
|
||||||
|
|
||||||
收到任务后立即微信通知 Owner:
|
|
||||||
|
|
||||||
```text
|
|
||||||
【任务已接收】GitOps独立worktree与统一运行身份迁移开始。将暂停写入服务、建立独立任务工作树并验证并发执行;生产业务版本不变。
|
|
||||||
```
|
|
||||||
|
|
||||||
任务结束后无论 PASS、BLOCKED 或 ROLLED_BACK,立即微信汇报结果并记录 message_id。
|
|
||||||
|
|
||||||
## 1. 变更前检查与封存
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -euo pipefail
|
|
||||||
cd /data/aios-gitops/workspace
|
|
||||||
pwd
|
|
||||||
id
|
|
||||||
umask
|
|
||||||
git status --short --branch
|
|
||||||
git rev-parse HEAD
|
|
||||||
systemctl list-units --type=service --all | grep -Ei 'openclaw|gitops|aios'
|
|
||||||
```
|
|
||||||
|
|
||||||
将工作区未提交内容完整提交到非 main 证据分支或 stash,禁止 `git clean -fd`。
|
|
||||||
|
|
||||||
记录当前运行服务、用户、组、umask、PID、工作区 HEAD 和未跟踪文件清单。
|
|
||||||
|
|
||||||
## 2. 暂停所有写入服务
|
|
||||||
|
|
||||||
识别并停止实际写入服务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl stop <gitops-writer-service>
|
|
||||||
systemctl stop <openclaw-controller-service>
|
|
||||||
systemctl stop <openclaw-watcher-service>
|
|
||||||
```
|
|
||||||
|
|
||||||
只停止写入者,不停止生产业务服务。记录每条命令和退出码。
|
|
||||||
|
|
||||||
## 3. 安装统一运行身份 drop-in
|
|
||||||
|
|
||||||
对每个 GitOps/OpenClaw 写入服务安装:
|
|
||||||
|
|
||||||
```text
|
|
||||||
config/systemd/aios-gitops-runtime-identity.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
User: aiman
|
|
||||||
Group: gitops
|
|
||||||
SupplementaryGroups: gitops
|
|
||||||
UMask: 0002
|
|
||||||
```
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl cat <service>
|
|
||||||
```
|
|
||||||
|
|
||||||
不得让服务继续以 root 写 workspace。
|
|
||||||
|
|
||||||
## 4. 初始化 bare repo 与独立 worktree 根目录
|
|
||||||
|
|
||||||
执行治理补丁中的脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/gitops/bootstrap_isolated_worktrees.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
验收:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
bare_repo: /data/aios-gitops/repo.git
|
|
||||||
worktree_root: /data/aios-gitops/worktrees
|
|
||||||
owner: aiman
|
|
||||||
group: gitops
|
|
||||||
directory_mode: 2775
|
|
||||||
default_acl: inherited
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 创建 controller 独立 worktree
|
|
||||||
|
|
||||||
以 `aiman`、`umask 0002` 执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u aiman -g gitops bash -lc '
|
|
||||||
umask 0002
|
|
||||||
cd /data/aios-gitops
|
|
||||||
scripts/gitops/task_worktree.sh create controller controller-runtime origin/main
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
将 controller/watcher 的工作目录改为:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/aios-gitops/worktrees/controller/controller-runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 启用任务启动前自检
|
|
||||||
|
|
||||||
每个任务在执行前必须运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/gitops/preflight_task_environment.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
任何一项失败时不得开始任务:
|
|
||||||
|
|
||||||
- 用户不是 aiman;
|
|
||||||
- 不在 gitops 组;
|
|
||||||
- umask 不是 0002;
|
|
||||||
- 使用共享 `/data/aios-gitops/workspace`;
|
|
||||||
- worktree 不干净;
|
|
||||||
- task/result/git dir 不可写;
|
|
||||||
- 存在 Git lock。
|
|
||||||
|
|
||||||
## 7. 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl start <gitops-writer-service>
|
|
||||||
systemctl start <openclaw-controller-service>
|
|
||||||
systemctl start <openclaw-watcher-service>
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl show <service> -p User -p Group -p UMask -p WorkingDirectory
|
|
||||||
ps -eo user,group,pid,cmd | grep -Ei 'openclaw|gitops|aios'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 并发验收
|
|
||||||
|
|
||||||
创建两个独立任务:
|
|
||||||
|
|
||||||
```text
|
|
||||||
OpenClaw: TASK-WORKTREE-CONCURRENCY-OPENCLAW-<timestamp>
|
|
||||||
ChatGPT: TASK-WORKTREE-CONCURRENCY-CHATGPT-<timestamp>
|
|
||||||
```
|
|
||||||
|
|
||||||
分别使用:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/aios-gitops/worktrees/openclaw/<task-id>
|
|
||||||
/data/aios-gitops/worktrees/chatgpt/<task-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
两个任务同时执行:
|
|
||||||
|
|
||||||
1. 写各自 `.agent/tasks/<agent>/`;
|
|
||||||
2. 写各自 `.agent/results/<agent>/`;
|
|
||||||
3. 创建深层子目录,验证默认 ACL;
|
|
||||||
4. commit;
|
|
||||||
5. push;
|
|
||||||
6. 不切换共享 workspace 分支。
|
|
||||||
|
|
||||||
验收必须全部通过:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
permission_denied: 0
|
|
||||||
untracked_overwrite_errors: 0
|
|
||||||
git_lock_collisions: 0
|
|
||||||
openclaw_commit_push: PASS
|
|
||||||
chatgpt_commit_push: PASS
|
|
||||||
deep_directory_acl_inheritance: PASS
|
|
||||||
shared_workspace_branch_switches: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. 回滚
|
|
||||||
|
|
||||||
若迁移失败:
|
|
||||||
|
|
||||||
1. 停止新服务;
|
|
||||||
2. 保留失败 worktree 供审计;
|
|
||||||
3. 恢复 systemd drop-in 备份;
|
|
||||||
4. `daemon-reload`;
|
|
||||||
5. 恢复原服务状态;
|
|
||||||
6. 不删除 `/data/aios-gitops/workspace`;
|
|
||||||
7. 生产业务保持不变。
|
|
||||||
|
|
||||||
## 10. 结果文件
|
|
||||||
|
|
||||||
提交:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.agent/results/openclaw/RESULT-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8.md
|
|
||||||
```
|
|
||||||
|
|
||||||
首段必须包含:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
status: PASS | BLOCKED | ROLLED_BACK
|
|
||||||
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
|
|
||||||
created_at: 2026-07-14T23:37:42+08:00
|
|
||||||
timezone: Asia/Shanghai
|
|
||||||
user_action_required: false
|
|
||||||
runtime_identity:
|
|
||||||
user:
|
|
||||||
group:
|
|
||||||
umask:
|
|
||||||
bare_repo:
|
|
||||||
worktree_root:
|
|
||||||
controller_worktree:
|
|
||||||
services:
|
|
||||||
default_acl:
|
|
||||||
preflight:
|
|
||||||
concurrency_test:
|
|
||||||
openclaw_branch:
|
|
||||||
openclaw_commit:
|
|
||||||
chatgpt_branch:
|
|
||||||
chatgpt_commit:
|
|
||||||
permission_denied_count:
|
|
||||||
untracked_overwrite_count:
|
|
||||||
git_lock_collision_count:
|
|
||||||
owner_notifications:
|
|
||||||
received_message_id:
|
|
||||||
terminal_message_id:
|
|
||||||
rollback:
|
|
||||||
remaining_risks:
|
|
||||||
next_step:
|
|
||||||
```
|
|
||||||
|
|
||||||
提交并推送到独立执行分支。禁止直接推 main。
|
|
||||||
|
|
||||||
## 完成微信模板
|
|
||||||
|
|
||||||
PASS:
|
|
||||||
|
|
||||||
```text
|
|
||||||
【任务完成】GitOps独立worktree迁移已通过。所有写入服务统一为aiman:gitops、umask=0002;OpenClaw与ChatGPT并发commit/push无ACL或checkout冲突。生产业务版本未变化。
|
|
||||||
```
|
|
||||||
|
|
||||||
阻塞:
|
|
||||||
|
|
||||||
```text
|
|
||||||
【任务阻塞】GitOps独立worktree迁移未通过。原因=<具体服务/权限/并发验收>;已保留原workspace和回滚能力,生产业务未变化。
|
|
||||||
```
|
|
||||||
@@ -5,59 +5,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
try:
|
sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os'))
|
||||||
from aios_gitops_write_guard import evaluate_git_write
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
||||||
from aios_gitops_write_guard import evaluate_git_write
|
|
||||||
|
|
||||||
from gitea_tag_publication import (
|
|
||||||
GiteaTagClient,
|
|
||||||
GitTagBackend,
|
|
||||||
PublicationConflict,
|
|
||||||
PublicationPending,
|
|
||||||
PublicationManualOwnerReviewRequired,
|
|
||||||
PublicationReconciliationRequired,
|
|
||||||
PublicationRequest,
|
|
||||||
TagPublisher,
|
|
||||||
redact,
|
|
||||||
validate_version,
|
|
||||||
)
|
|
||||||
from orchestrator.action_monitor import ActionMonitor
|
from orchestrator.action_monitor import ActionMonitor
|
||||||
from silent_release_hardening import (
|
|
||||||
ApprovalKeyRing,
|
|
||||||
ApprovalRevocationStore,
|
|
||||||
CapabilityAuthorizationError,
|
|
||||||
SagaConflict,
|
|
||||||
SignedApprovalVerifier,
|
|
||||||
SignedReleasePlanApproval,
|
|
||||||
SilentReleaseActionService,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from authoritative_git import (
|
|
||||||
AuthoritativeGitError,
|
|
||||||
AuthoritativeGitNotFound,
|
|
||||||
AuthoritativeGitOutputTooLarge,
|
|
||||||
authoritative_branches,
|
|
||||||
authoritative_diff,
|
|
||||||
authoritative_commit,
|
|
||||||
authoritative_tags,
|
|
||||||
repository_snapshot,
|
|
||||||
run_authoritative_git,
|
|
||||||
)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
||||||
from authoritative_git import (
|
|
||||||
AuthoritativeGitError,
|
|
||||||
AuthoritativeGitNotFound,
|
|
||||||
AuthoritativeGitOutputTooLarge,
|
|
||||||
authoritative_branches,
|
|
||||||
authoritative_diff,
|
|
||||||
authoritative_commit,
|
|
||||||
authoritative_tags,
|
|
||||||
repository_snapshot,
|
|
||||||
run_authoritative_git,
|
|
||||||
)
|
|
||||||
|
|
||||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||||
@@ -67,19 +16,6 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
|||||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||||
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
|
||||||
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
|
||||||
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
|
||||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
|
||||||
'/var/lib/aios-gitops/silent-release',
|
|
||||||
))
|
|
||||||
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
|
|
||||||
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
|
||||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
|
||||||
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
|
||||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
|
||||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
|
||||||
))
|
|
||||||
monitor = ActionMonitor()
|
monitor = ActionMonitor()
|
||||||
|
|
||||||
BASE_MODELS = [
|
BASE_MODELS = [
|
||||||
@@ -88,43 +24,7 @@ BASE_MODELS = [
|
|||||||
{'id':'base.gpt-5.5','provider':'openai','model':'gpt-5.5','display_name':'GPT-5.5','scope':'global'},
|
{'id':'base.gpt-5.5','provider':'openai','model':'gpt-5.5','display_name':'GPT-5.5','scope':'global'},
|
||||||
]
|
]
|
||||||
SECRET_RE = re.compile(r'(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)\s*[:=]\s*["\']?([A-Za-z0-9_./+=:-]{16,})')
|
SECRET_RE = re.compile(r'(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)\s*[:=]\s*["\']?([A-Za-z0-9_./+=:-]{16,})')
|
||||||
SECRET_VAL_RE = re.compile(
|
SECRET_VAL_RE = re.compile(r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})')
|
||||||
r'(?<![A-Za-z0-9_])'
|
|
||||||
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
|
||||||
)
|
|
||||||
|
|
||||||
READ_ONLY_GET_PATHS = frozenset({
|
|
||||||
'/', '/health', '/api/health', '/api/openapi.yaml',
|
|
||||||
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
|
||||||
'/api/base', '/api/baseline', '/base', '/api/ver', '/api/versions', '/ver',
|
|
||||||
'/api/diff', '/diff', '/api/patch', '/api/patches', '/api/branches', '/patch',
|
|
||||||
'/api/model', '/model', '/api/run', '/api/runtime-status', '/run',
|
|
||||||
'/api/action-monitor', '/action-monitor', '/admin/ai-evolution',
|
|
||||||
'/admin/ai-evolution/api/data',
|
|
||||||
'/api/release-consistency', '/api/tag-publication-status',
|
|
||||||
'/api/release-acceptance',
|
|
||||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
|
||||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
|
||||||
'/getRecoveryStatus',
|
|
||||||
})
|
|
||||||
SILENT_RELEASE_POST_PATHS = frozenset({
|
|
||||||
'/createReleaseCandidate',
|
|
||||||
'/createReleaseIntent',
|
|
||||||
'/submitSignedApproval',
|
|
||||||
'/submitOwnerApproval',
|
|
||||||
'/startDeployment',
|
|
||||||
})
|
|
||||||
|
|
||||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
|
||||||
if method == 'GET':
|
|
||||||
return False
|
|
||||||
if path in SILENT_RELEASE_POST_PATHS:
|
|
||||||
return False
|
|
||||||
decision = evaluate_git_write(WS)
|
|
||||||
if decision.allowed:
|
|
||||||
return False
|
|
||||||
handler.sendj(decision.payload(), decision.status_code)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||||
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
||||||
@@ -136,41 +36,6 @@ def sh(args, check=False):
|
|||||||
def jload(p: Path, default):
|
def jload(p: Path, default):
|
||||||
try: return json.loads(p.read_text(encoding='utf-8'))
|
try: return json.loads(p.read_text(encoding='utf-8'))
|
||||||
except Exception: return default
|
except Exception: return default
|
||||||
|
|
||||||
def _file_sha256(path: Path) -> str:
|
|
||||||
digest=hashlib.sha256()
|
|
||||||
with path.open('rb') as source:
|
|
||||||
for chunk in iter(lambda: source.read(1024 * 1024), b''):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
def _build_silent_release_service() -> SilentReleaseActionService:
|
|
||||||
verifier=None
|
|
||||||
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
|
||||||
verifier=SignedApprovalVerifier(
|
|
||||||
ApprovalKeyRing.from_file(SILENT_RELEASE_PUBLIC_KEYS),
|
|
||||||
ApprovalRevocationStore(
|
|
||||||
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
|
|
||||||
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
|
||||||
return SilentReleaseActionService(
|
|
||||||
SILENT_RELEASE_STATE_ROOT,
|
|
||||||
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
|
||||||
approval_verifier=verifier,
|
|
||||||
expected_repository=REPOSITORY_IDENTITY,
|
|
||||||
policy_sha256=policy_sha,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Construct once during service startup. If production execution is enabled
|
|
||||||
# without every trusted builder/orchestrator dependency, construction fails
|
|
||||||
# closed before the HTTP server accepts traffic.
|
|
||||||
_SILENT_RELEASE_SERVICE = _build_silent_release_service()
|
|
||||||
|
|
||||||
|
|
||||||
def silent_release_service() -> SilentReleaseActionService:
|
|
||||||
return _SILENT_RELEASE_SERVICE
|
|
||||||
def relpath(path: str) -> str:
|
def relpath(path: str) -> str:
|
||||||
x = str(path or '').replace('\\','/').strip()
|
x = str(path or '').replace('\\','/').strip()
|
||||||
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
||||||
@@ -244,13 +109,9 @@ def ensure_git():
|
|||||||
if not sh(['config','user.name'])['stdout']: sh(['config','user.name','chatgpt-gitops-bot'], True)
|
if not sh(['config','user.name'])['stdout']: sh(['config','user.name','chatgpt-gitops-bot'], True)
|
||||||
def checkout(branch, base='main', create=False):
|
def checkout(branch, base='main', create=False):
|
||||||
ensure_git(); b=branch_name(branch)
|
ensure_git(); b=branch_name(branch)
|
||||||
exists = sh(['rev-parse','--verify',f'refs/heads/{b}'])['code'] == 0
|
if create or sh(['rev-parse','--verify',b])['code'] != 0:
|
||||||
if exists:
|
sh(['checkout',base], True); sh(['checkout','-B',b], True)
|
||||||
sh(['checkout',b], True)
|
else: sh(['checkout',b], True)
|
||||||
elif create:
|
|
||||||
sh(['checkout',base], True); sh(['checkout','-b',b], True)
|
|
||||||
else:
|
|
||||||
raise ValueError(f'branch does not exist: {b}')
|
|
||||||
return b
|
return b
|
||||||
def write_file(path, content):
|
def write_file(path, content):
|
||||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||||
@@ -260,19 +121,6 @@ def sync_branch(branch: str) -> dict:
|
|||||||
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
||||||
return {'status':'skipped','reason':'protected_branch','branch':branch}
|
return {'status':'skipped','reason':'protected_branch','branch':branch}
|
||||||
if BARE_REPO.exists():
|
if BARE_REPO.exists():
|
||||||
common = sh(['rev-parse', '--path-format=absolute', '--git-common-dir'], True)['stdout']
|
|
||||||
if Path(common).resolve() == BARE_REPO.resolve():
|
|
||||||
head = sh(['rev-parse', 'HEAD'], True)['stdout']
|
|
||||||
stored = subprocess.run(
|
|
||||||
['git', '--git-dir', str(BARE_REPO), 'rev-parse', f'refs/heads/{branch}'],
|
|
||||||
text=True, capture_output=True,
|
|
||||||
)
|
|
||||||
if stored.returncode != 0 or stored.stdout.strip() != head:
|
|
||||||
raise RuntimeError(f'linked-worktree branch verification failed for {branch}')
|
|
||||||
return {
|
|
||||||
'status':'verified', 'target':'authoritative-bare',
|
|
||||||
'branch':branch, 'oid':head,
|
|
||||||
}
|
|
||||||
ref=f'refs/heads/{branch}:refs/heads/{branch}'
|
ref=f'refs/heads/{branch}:refs/heads/{branch}'
|
||||||
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
||||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
|
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
|
||||||
@@ -282,68 +130,15 @@ def sync_branch(branch: str) -> dict:
|
|||||||
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||||
|
|
||||||
def sync_tag(tag: str) -> dict:
|
def sync_tag(tag: str) -> dict:
|
||||||
raise RuntimeError(
|
if not tag: return {'status':'skipped','reason':'empty_tag'}
|
||||||
'unsafe sync_tag disabled: use TagPublisher with expected tag_oid, '
|
if BARE_REPO.exists():
|
||||||
'target_oid, Gitea remote verification, and authenticated API verification'
|
ref=f'refs/tags/{tag}:refs/tags/{tag}'
|
||||||
)
|
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
||||||
|
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag sync failed for {tag}')
|
||||||
def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
return {'status':'synced','target':'bare','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||||
oid = str(value or '').strip().lower()
|
p=subprocess.run(['git','push','origin',tag], cwd=str(WS), text=True, capture_output=True)
|
||||||
if not re.fullmatch(r'[0-9a-f]{40}', oid):
|
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
|
||||||
raise ValueError(f'{field} must be a full 40-character commit OID')
|
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||||
try:
|
|
||||||
return authoritative_commit(oid, git_dir=AUTHORITATIVE_GIT_DIR)
|
|
||||||
except (AuthoritativeGitError, ValueError) as exc:
|
|
||||||
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
|
||||||
|
|
||||||
def existing_tag_target(tag: str) -> str | None:
|
|
||||||
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
|
||||||
raise ValueError('invalid release version')
|
|
||||||
try:
|
|
||||||
result = run_authoritative_git(
|
|
||||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
|
||||||
check=False,
|
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
||||||
)
|
|
||||||
except AuthoritativeGitError as exc:
|
|
||||||
raise RuntimeError(str(exc)) from exc
|
|
||||||
return result.stdout.strip() if result.returncode == 0 else None
|
|
||||||
|
|
||||||
def publication_request_from_query(q: dict[str, list[str]]) -> PublicationRequest:
|
|
||||||
required = ('version', 'target_oid', 'evidence_closure_oid', 'approval_plan_id')
|
|
||||||
missing = [name for name in required if not (q.get(name) or [''])[0]]
|
|
||||||
if missing:
|
|
||||||
raise ValueError('missing release governance fields: ' + ', '.join(missing))
|
|
||||||
return PublicationRequest(
|
|
||||||
(q['version'])[0],
|
|
||||||
(q['target_oid'])[0].lower(),
|
|
||||||
(q['evidence_closure_oid'])[0].lower(),
|
|
||||||
(q['approval_plan_id'])[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
def required_authoritative_git_dir() -> Path:
|
|
||||||
snapshot = repository_snapshot(
|
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
||||||
repository_identity=REPOSITORY_IDENTITY,
|
|
||||||
)
|
|
||||||
if not snapshot["repository_available"]:
|
|
||||||
raise RuntimeError(str(snapshot["query_error"] or "AUTHORITATIVE_REPOSITORY_UNAVAILABLE"))
|
|
||||||
return Path(str(snapshot["authoritative_git_dir"]))
|
|
||||||
|
|
||||||
|
|
||||||
def publication_publisher() -> TagPublisher:
|
|
||||||
authoritative_repo = required_authoritative_git_dir()
|
|
||||||
return TagPublisher(
|
|
||||||
WS,
|
|
||||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
|
||||||
GiteaTagClient(
|
|
||||||
os.getenv('AIOS_GITOPS_GITEA_API_URL',''),
|
|
||||||
os.getenv('AIOS_GITOPS_GITEA_REPOSITORY',''),
|
|
||||||
Path(os.getenv('AIOS_GITOPS_GITEA_TOKEN_FILE','')),
|
|
||||||
token_line=int(os.getenv('AIOS_GITOPS_GITEA_TOKEN_LINE','1')),
|
|
||||||
),
|
|
||||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def commit(paths, msg):
|
def commit(paths, msg):
|
||||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||||
@@ -363,16 +158,6 @@ def patches():
|
|||||||
for p in sorted((WS/'patches').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
|
for p in sorted((WS/'patches').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def runtime_release_info() -> dict[str, Any]:
|
|
||||||
for candidate in (WS / 'manifest.json', WS.parent / 'manifest.json'):
|
|
||||||
data = jload(candidate, {})
|
|
||||||
if data.get('target_commit'):
|
|
||||||
return {
|
|
||||||
'runtime_release_commit': data.get('target_commit'),
|
|
||||||
'runtime_release_path': str(WS),
|
|
||||||
}
|
|
||||||
return {'runtime_release_commit': None, 'runtime_release_path': str(WS)}
|
|
||||||
|
|
||||||
class H(BaseHTTPRequestHandler):
|
class H(BaseHTTPRequestHandler):
|
||||||
server_version='AIOSChatGPTGitOps/V1R1C0'
|
server_version='AIOSChatGPTGitOps/V1R1C0'
|
||||||
def sendj(self, data, code=200):
|
def sendj(self, data, code=200):
|
||||||
@@ -394,236 +179,43 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||||
return a
|
return a
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path; q=urllib.parse.parse_qs(u.query)
|
||||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
|
||||||
q=urllib.parse.parse_qs(u.query)
|
|
||||||
try:
|
try:
|
||||||
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
||||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||||
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
||||||
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
||||||
if path in ('/api/base','/api/baseline','/base'):
|
if path in ('/api/base','/api/baseline','/base'):
|
||||||
data=jload(WS/'baseline'/'current.json', {})
|
data=jload(WS/'baseline'/'current.json', {}); data.setdefault('git', sh(['rev-parse','--short','HEAD'])['stdout']); return self.sendj(data)
|
||||||
data.setdefault('authoritative_repository', repository_snapshot(
|
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
||||||
repository_identity=REPOSITORY_IDENTITY,
|
|
||||||
))
|
|
||||||
return self.sendj(data)
|
|
||||||
if path in ('/api/ver','/api/versions','/ver'):
|
if path in ('/api/ver','/api/versions','/ver'):
|
||||||
snapshot = repository_snapshot(
|
return self.sendj({'tags':sh(['tag','--list','V*'])['stdout'].splitlines(),'versions_dir':[p.name for p in (WS/'versions').glob('*')],'head':sh(['rev-parse','--short','HEAD'])['stdout']})
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
||||||
repository_identity=REPOSITORY_IDENTITY,
|
|
||||||
)
|
|
||||||
if not snapshot['repository_available']:
|
|
||||||
return self.sendj({
|
|
||||||
'repository_available': False,
|
|
||||||
'query_error': snapshot['query_error'],
|
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
|
||||||
'local_authoritative_tags': [],
|
|
||||||
}, 503)
|
|
||||||
try:
|
|
||||||
tags = authoritative_tags(git_dir=AUTHORITATIVE_GIT_DIR)
|
|
||||||
except AuthoritativeGitError as exc:
|
|
||||||
return self.sendj({
|
|
||||||
'repository_available': False,
|
|
||||||
'query_error': str(exc),
|
|
||||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
|
||||||
'local_authoritative_tags': [],
|
|
||||||
}, 503)
|
|
||||||
registry = [p.name for p in (WS/'versions').glob('*')]
|
|
||||||
return self.sendj({
|
|
||||||
'registry_versions': registry,
|
|
||||||
'local_authoritative_tags': tags,
|
|
||||||
'repository_available': True,
|
|
||||||
'query_error': None,
|
|
||||||
'tags': [item['name'] for item in tags],
|
|
||||||
'versions_dir': registry,
|
|
||||||
'head': snapshot['authoritative_head_oid'],
|
|
||||||
'authoritative_head_oid': snapshot['authoritative_head_oid'],
|
|
||||||
})
|
|
||||||
if path in ('/api/diff','/diff'):
|
if path in ('/api/diff','/diff'):
|
||||||
frm=(q.get('from') or [''])[0]
|
frm=(q.get('from') or ['HEAD~1'])[0]; to=(q.get('to') or ['HEAD'])[0]; return self.sendj({'from':frm,'to':to,'diff':sh(['diff',frm,to])})
|
||||||
to=(q.get('to') or [''])[0]
|
if path in ('/api/patch','/api/patches','/api/branches','/patch'): return self.sendj({'patches':patches(),'branches':sh(['branch','--format','%(refname:short)'])['stdout'].splitlines()})
|
||||||
try:
|
|
||||||
diff=authoritative_diff(frm, to, git_dir=required_authoritative_git_dir())
|
|
||||||
except ValueError as exc:
|
|
||||||
return self.sendj({'error':redact(exc)},400)
|
|
||||||
except AuthoritativeGitNotFound as exc:
|
|
||||||
return self.sendj({'error':redact(exc)},404)
|
|
||||||
except AuthoritativeGitOutputTooLarge as exc:
|
|
||||||
return self.sendj({'error':redact(exc)},413)
|
|
||||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
|
||||||
return self.sendj({'error':redact(exc)},503)
|
|
||||||
return self.sendj({'from':frm,'to':to,'diff':diff})
|
|
||||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
|
||||||
snapshot = repository_snapshot(
|
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
||||||
repository_identity=REPOSITORY_IDENTITY,
|
|
||||||
)
|
|
||||||
if not snapshot['repository_available']:
|
|
||||||
return self.sendj({
|
|
||||||
'repository_available': False,
|
|
||||||
'query_error': snapshot['query_error'],
|
|
||||||
'metadata_patches': patches(),
|
|
||||||
'authoritative_branches': [],
|
|
||||||
}, 503)
|
|
||||||
try:
|
|
||||||
branches = authoritative_branches(git_dir=AUTHORITATIVE_GIT_DIR)
|
|
||||||
except AuthoritativeGitError as exc:
|
|
||||||
return self.sendj({
|
|
||||||
'repository_available': False,
|
|
||||||
'query_error': str(exc),
|
|
||||||
'metadata_patches': patches(),
|
|
||||||
'authoritative_branches': [],
|
|
||||||
}, 503)
|
|
||||||
metadata = patches()
|
|
||||||
branch_names = {item['name'] for item in branches}
|
|
||||||
metadata_names = {
|
|
||||||
item.get('branch') for item in metadata
|
|
||||||
if isinstance(item, dict) and item.get('branch')
|
|
||||||
}
|
|
||||||
orphan = [
|
|
||||||
item for item in metadata
|
|
||||||
if item.get('branch') and item.get('branch') not in branch_names
|
|
||||||
]
|
|
||||||
missing = [
|
|
||||||
item for item in branches
|
|
||||||
if item['name'] not in metadata_names
|
|
||||||
]
|
|
||||||
return self.sendj({
|
|
||||||
'metadata_patches': metadata,
|
|
||||||
'authoritative_branches': branches,
|
|
||||||
'orphan_metadata': orphan,
|
|
||||||
'branches_without_metadata': missing,
|
|
||||||
'repository_available': True,
|
|
||||||
'query_error': None,
|
|
||||||
'patches': metadata,
|
|
||||||
'branches': [item['name'] for item in branches],
|
|
||||||
})
|
|
||||||
if path in ('/api/model','/model'): return self.sendj({'base':BASE_MODELS,'custom':model_docs(),'priority':[jload(p,{}) for p in sorted((WS/'config'/'model_priority').glob('*.json'))]})
|
if path in ('/api/model','/model'): return self.sendj({'base':BASE_MODELS,'custom':model_docs(),'priority':[jload(p,{}) for p in sorted((WS/'config'/'model_priority').glob('*.json'))]})
|
||||||
if path in ('/api/run','/api/runtime-status','/run'):
|
if path in ('/api/run','/api/runtime-status','/run'):
|
||||||
svc=subprocess.run(['systemctl','is-active','aios-gitops-model-api'], text=True, capture_output=True)
|
svc=subprocess.run(['systemctl','is-active','aios-gitops-model-api'], text=True, capture_output=True)
|
||||||
snapshot = repository_snapshot(
|
return self.sendj({'service':'aios-gitops-model-api','status':svc.stdout.strip() or svc.stderr.strip(),'workspace_branch':sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']})
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
||||||
repository_identity=REPOSITORY_IDENTITY,
|
|
||||||
)
|
|
||||||
workspace_branch = None
|
|
||||||
if (WS / '.git').exists():
|
|
||||||
result = sh(['rev-parse','--abbrev-ref','HEAD'])
|
|
||||||
workspace_branch = result['stdout'] or None
|
|
||||||
payload = {
|
|
||||||
'service':'aios-gitops-model-api',
|
|
||||||
'status':svc.stdout.strip() or svc.stderr.strip(),
|
|
||||||
'workspace_branch':workspace_branch,
|
|
||||||
**runtime_release_info(),
|
|
||||||
**snapshot,
|
|
||||||
'writes_enabled': str(os.getenv('AIOS_GITOPS_WRITES_ENABLED','false')).lower() in {'1','true','yes','on'},
|
|
||||||
}
|
|
||||||
return self.sendj(payload, 200 if snapshot['repository_available'] else 503)
|
|
||||||
if path in ('/api/action-monitor','/action-monitor'):
|
if path in ('/api/action-monitor','/action-monitor'):
|
||||||
latest=monitor.latest()
|
latest=monitor.latest()
|
||||||
ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None
|
ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None
|
||||||
return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict})
|
return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict})
|
||||||
if path == '/api/tag-publication-status':
|
|
||||||
version=(q.get('version') or [''])[0]
|
|
||||||
try:
|
|
||||||
version=validate_version(version)
|
|
||||||
status=publication_publisher().get_tag_publication_status(version)
|
|
||||||
except ValueError:
|
|
||||||
return self.sendj({'error':'invalid release version'},400)
|
|
||||||
return self.sendj(status)
|
|
||||||
if path == '/api/release-consistency':
|
|
||||||
request=publication_request_from_query(q)
|
|
||||||
return self.sendj(publication_publisher().get_release_consistency(request))
|
|
||||||
if path == '/api/release-acceptance':
|
|
||||||
request=publication_request_from_query(q)
|
|
||||||
return self.sendj(publication_publisher().run_release_acceptance(request))
|
|
||||||
if path in (
|
|
||||||
'/getReleaseCandidate',
|
|
||||||
'/getReleasePlan',
|
|
||||||
'/getReleaseAcceptance',
|
|
||||||
'/getApprovalEnvelope',
|
|
||||||
'/getReleaseSaga',
|
|
||||||
'/getCapabilityStatus',
|
|
||||||
):
|
|
||||||
candidate_id=(q.get('candidate_id') or [''])[0]
|
|
||||||
if not candidate_id:
|
|
||||||
return self.sendj({'error':'candidate_id is required'},400)
|
|
||||||
service=silent_release_service()
|
|
||||||
if path == '/getReleaseCandidate':
|
|
||||||
return self.sendj(
|
|
||||||
service.get_release_candidate(candidate_id)
|
|
||||||
)
|
|
||||||
if path == '/getApprovalEnvelope':
|
|
||||||
return self.sendj(
|
|
||||||
service.get_approval_envelope(candidate_id)
|
|
||||||
)
|
|
||||||
if path == '/getReleasePlan':
|
|
||||||
return self.sendj(service.get_release_plan(candidate_id))
|
|
||||||
if path == '/getReleaseAcceptance':
|
|
||||||
return self.sendj(service.get_acceptance(candidate_id))
|
|
||||||
if path == '/getCapabilityStatus':
|
|
||||||
return self.sendj(
|
|
||||||
service.get_capability_status(candidate_id)
|
|
||||||
)
|
|
||||||
return self.sendj(service.get_release_saga(candidate_id))
|
|
||||||
if path == '/getRecoveryStatus':
|
|
||||||
return self.sendj(
|
|
||||||
silent_release_service().get_recovery_status()
|
|
||||||
)
|
|
||||||
# Admin AI Evolution Dashboard
|
# Admin AI Evolution Dashboard
|
||||||
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
|
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
|
||||||
return self.ai_evolution_page()
|
return self.ai_evolution_page()
|
||||||
if path in ('/admin/ai-evolution/api/data'):
|
if path in ('/admin/ai-evolution/api/data'):
|
||||||
return self.ai_evolution_data()
|
return self.ai_evolution_data()
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
return self.sendj({'error':'not_found','path':path},404)
|
||||||
except FileNotFoundError:
|
|
||||||
return self.sendj({'error':'release candidate not found'},404)
|
|
||||||
except ValueError as e:
|
|
||||||
error=redact(e)
|
|
||||||
monitor.record_failure('do_GET', error, {'path':path})
|
|
||||||
return self.sendj({'error':error},400)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error=redact(e)
|
monitor.record_failure('do_GET', str(e), {'path':path})
|
||||||
monitor.record_failure('do_GET', error, {'path':path})
|
return self.sendj({'error':str(e)},500)
|
||||||
return self.sendj({'error':error},500)
|
|
||||||
def do_PUT(self):
|
def do_PUT(self):
|
||||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||||
if reject_non_read_http(self, 'PUT', path): return
|
|
||||||
data=self.body()
|
|
||||||
if path == '/api/model-priority': return self.model_priority(data)
|
if path == '/api/model-priority': return self.model_priority(data)
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
return self.sendj({'error':'not_found','path':path},404)
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||||
if reject_non_read_http(self, 'POST', path): return
|
|
||||||
data=self.body()
|
|
||||||
try:
|
try:
|
||||||
if path == '/createReleaseCandidate':
|
|
||||||
a=self.need('release:candidate')
|
|
||||||
if not a: return
|
|
||||||
return self.sendj(
|
|
||||||
silent_release_service().create_release_candidate(data)
|
|
||||||
)
|
|
||||||
if path == '/createReleaseIntent':
|
|
||||||
a=self.need('release:candidate')
|
|
||||||
if not a: return
|
|
||||||
return self.sendj(
|
|
||||||
silent_release_service().create_release_intent(data)
|
|
||||||
)
|
|
||||||
if path in ('/submitOwnerApproval','/submitSignedApproval'):
|
|
||||||
a=self.need('release:approve')
|
|
||||||
if not a: return
|
|
||||||
return self.sendj(
|
|
||||||
silent_release_service().submit_owner_approval(data)
|
|
||||||
)
|
|
||||||
if path == '/startDeployment':
|
|
||||||
a=self.need('release:deploy')
|
|
||||||
if not a: return
|
|
||||||
return self.sendj(
|
|
||||||
silent_release_service().start_deployment(
|
|
||||||
str(data.get('candidate_id') or '')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
|
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
|
||||||
if path in ('/api/file','/api/commit-file'): return self.file(data)
|
if path in ('/api/file','/api/commit-file'): return self.file(data)
|
||||||
if path == '/api/commit': return self.commit(data)
|
if path == '/api/commit': return self.commit(data)
|
||||||
@@ -634,30 +226,9 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if path == '/api/model-priority': return self.model_priority(data)
|
if path == '/api/model-priority': return self.model_priority(data)
|
||||||
if path == '/api/nl': return self.nl(data)
|
if path == '/api/nl': return self.nl(data)
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
return self.sendj({'error':'not_found','path':path},404)
|
||||||
except (ValueError, FileNotFoundError) as e:
|
|
||||||
error=redact(e)
|
|
||||||
monitor.record_failure('do_POST', error, {'path':path})
|
|
||||||
return self.sendj({'error':error},400)
|
|
||||||
except CapabilityAuthorizationError as e:
|
|
||||||
error=redact(e)
|
|
||||||
monitor.record_failure('do_POST', error, {'path':path})
|
|
||||||
return self.sendj({'error':error},403)
|
|
||||||
except SagaConflict as e:
|
|
||||||
error=redact(e)
|
|
||||||
monitor.record_failure('do_POST', error, {'path':path})
|
|
||||||
return self.sendj({'error':error},409)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error=redact(e)
|
monitor.record_failure('do_POST', str(e), {'path':path})
|
||||||
monitor.record_failure('do_POST', error, {'path':path})
|
return self.sendj({'error':str(e)},500)
|
||||||
return self.sendj({'error':error},500)
|
|
||||||
def do_PATCH(self):
|
|
||||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
|
||||||
if reject_non_read_http(self, 'PATCH', path): return
|
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
|
||||||
def do_DELETE(self):
|
|
||||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
|
||||||
if reject_non_read_http(self, 'DELETE', path): return
|
|
||||||
return self.sendj({'error':'not_found','path':path},404)
|
|
||||||
def branch(self,data):
|
def branch(self,data):
|
||||||
a=self.need('branch:create');
|
a=self.need('branch:create');
|
||||||
if not a: return
|
if not a: return
|
||||||
@@ -681,41 +252,12 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if not a: return
|
if not a: return
|
||||||
ver=data.get('version') or data.get('tag')
|
ver=data.get('version') or data.get('tag')
|
||||||
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||||
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
if data.get('confirm') != True:
|
||||||
return self.sendj({
|
self.sendj({'status':'confirm_required','operation':'tag','hint':'This operation creates a Git tag. Confirm to proceed.','x-openai-isConsequential':True}, 428)
|
||||||
'error':'production_tag_evidence_required',
|
return
|
||||||
'required':['version','target_oid','evidence_closure_oid','approval_plan_id'],
|
|
||||||
},400)
|
|
||||||
try:
|
|
||||||
target_oid=resolve_commit_oid(data.get('target_oid'))
|
|
||||||
evidence_oid=resolve_commit_oid(data.get('evidence_closure_oid'), 'evidence_closure_oid')
|
|
||||||
except ValueError as exc:
|
|
||||||
return self.sendj({'error':'invalid_tag_target','detail':str(exc)},400)
|
|
||||||
if a['role'] != 'owner' or not data.get('execute'):
|
if a['role'] != 'owner' or not data.get('execute'):
|
||||||
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'request':req,'commit':c})
|
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'commit':c})
|
||||||
request=PublicationRequest(ver,target_oid,evidence_oid,str(data.get('approval_plan_id')))
|
out=sh(['tag','-a',ver,'-m',data.get('message') or f'release {ver}'], True); self.sendj({'status':'tagged','version':ver,'git':out,'sync':sync_tag(ver)})
|
||||||
main_before=sh(['rev-parse','refs/heads/main'], True)['stdout']
|
|
||||||
publisher=publication_publisher()
|
|
||||||
try:
|
|
||||||
publication=publisher.publish(request)
|
|
||||||
except PublicationManualOwnerReviewRequired as exc:
|
|
||||||
audit(a['account'],'tag_manual_owner_review_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':redact(exc)})
|
|
||||||
return self.sendj({'status':'MANUAL_OWNER_REVIEW_REQUIRED','error':redact(exc),'main_changed':False,'force_push':False},409)
|
|
||||||
except PublicationConflict as exc:
|
|
||||||
audit(a['account'],'tag_conflict',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
|
||||||
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},409)
|
|
||||||
except PublicationPending as exc:
|
|
||||||
audit(a['account'],'tag_pending',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
|
||||||
return self.sendj({'status':'REMOTE_PUSH_PENDING','error':str(exc),'main_changed':False,'force_push':False},503)
|
|
||||||
except PublicationReconciliationRequired as exc:
|
|
||||||
audit(a['account'],'tag_reconciliation_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
|
||||||
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},502)
|
|
||||||
main_after=sh(['rev-parse','refs/heads/main'], True)['stdout']
|
|
||||||
if main_after != main_before:
|
|
||||||
raise RuntimeError('main changed during tag operation')
|
|
||||||
result={'status':publication['state'],'version':ver,'tag_name':ver,'tag_oid':publication['tag_oid'],'target_oid':target_oid,'expected_target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'workspace_head':sh(['rev-parse','HEAD'],True)['stdout'],'main_oid_before':main_before,'main_oid_after':main_after,'main_changed':False,'force_push':False,'publication':publication}
|
|
||||||
audit(a['account'],'tag',ver,result)
|
|
||||||
self.sendj(result)
|
|
||||||
def rollback(self,data):
|
def rollback(self,data):
|
||||||
a=self.need('rollback:request');
|
a=self.need('rollback:request');
|
||||||
if not a: return
|
if not a: return
|
||||||
@@ -810,10 +352,7 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
|||||||
cmd_filter=(params.get('command') or [None])[0]
|
cmd_filter=(params.get('command') or [None])[0]
|
||||||
bl=jload(WS/'baseline'/'current.json',{})
|
bl=jload(WS/'baseline'/'current.json',{})
|
||||||
current_ver=bl.get('current_baseline','V1R3C1')
|
current_ver=bl.get('current_baseline','V1R3C1')
|
||||||
try:
|
tags=sh(['tag','--list','V*'])['stdout'].splitlines() if WS.exists() else []
|
||||||
tags=[item['name'] for item in authoritative_tags(git_dir=required_authoritative_git_dir()) if str(item['name']).startswith('V')]
|
|
||||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
|
||||||
return self.sendj({'error':redact(exc),'repository_available':False},503)
|
|
||||||
versions=[]
|
versions=[]
|
||||||
if (WS/'baseline'/'promotions').exists():
|
if (WS/'baseline'/'promotions').exists():
|
||||||
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
|
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
|
||||||
@@ -851,9 +390,9 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
|||||||
|
|
||||||
|
|
||||||
def api_spec():
|
def api_spec():
|
||||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||||
if __name__ == '__main__': main()
|
if __name__ == '__main__': main()
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fail-closed policy for in-process Git mutation handlers."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Mapping
|
|
||||||
|
|
||||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GitWriteDecision:
|
|
||||||
allowed: bool
|
|
||||||
status_code: int
|
|
||||||
error: str
|
|
||||||
reason: str
|
|
||||||
workspace: str
|
|
||||||
|
|
||||||
def payload(self) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
"status": "allowed" if self.allowed else "blocked",
|
|
||||||
"error": self.error,
|
|
||||||
"reason": self.reason,
|
|
||||||
"workspace": self.workspace,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _real(path: Path) -> Path:
|
|
||||||
return Path(os.path.realpath(os.path.abspath(str(path))))
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_git_write(workspace: Path | str, env: Mapping[str, str] | None = None) -> GitWriteDecision:
|
|
||||||
values = os.environ if env is None else env
|
|
||||||
lexical = Path(os.path.abspath(str(workspace)))
|
|
||||||
canonical = _real(lexical)
|
|
||||||
shown = str(lexical)
|
|
||||||
if str(values.get("AIOS_GITOPS_WRITES_ENABLED", "false")).strip().lower() not in _TRUE:
|
|
||||||
return GitWriteDecision(False, 423, "gitops_writes_disabled", "AIOS_GITOPS_WRITES_ENABLED is false", shown)
|
|
||||||
if lexical != canonical:
|
|
||||||
return GitWriteDecision(False, 409, "workspace_symlink_forbidden", "workspace must be a canonical non-symlink path", shown)
|
|
||||||
controller = _real(Path(values.get("AIOS_GITOPS_CONTROLLER_PATH", "/data/aios-gitops/worktrees/controller")))
|
|
||||||
role = str(values.get("AIOS_GITOPS_WORKSPACE_ROLE", "")).strip().lower()
|
|
||||||
if canonical == controller or role == "runtime-anchor":
|
|
||||||
return GitWriteDecision(False, 423, "runtime_anchor_read_only", "controller/runtime-anchor is read-only", str(canonical))
|
|
||||||
writer_root = _real(Path(values.get("AIOS_GITOPS_APPROVED_WRITER_ROOT", "/data/aios-gitops/worktrees")))
|
|
||||||
try:
|
|
||||||
canonical.relative_to(writer_root)
|
|
||||||
except ValueError:
|
|
||||||
return GitWriteDecision(False, 409, "workspace_not_approved_writer", "workspace is outside the approved writer root", str(canonical))
|
|
||||||
return GitWriteDecision(True, 200, "", "approved isolated writer", str(canonical))
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""AIOS-managed Hermes runtime boundary."""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
HermesAdapter,
|
||||||
|
HermesConflict,
|
||||||
|
HermesNotFound,
|
||||||
|
HermesRuntimeError,
|
||||||
|
InMemoryHermesStore,
|
||||||
|
)
|
||||||
|
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
|
||||||
|
from .worker import HermesWorker, WorkerRunResult
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HermesAdapter",
|
||||||
|
"HermesConflict",
|
||||||
|
"HermesHealth",
|
||||||
|
"HermesJob",
|
||||||
|
"HermesJobState",
|
||||||
|
"HermesNotFound",
|
||||||
|
"HermesResult",
|
||||||
|
"HermesRuntimeError",
|
||||||
|
"HermesWorker",
|
||||||
|
"InMemoryHermesStore",
|
||||||
|
"WorkerRunResult",
|
||||||
|
]
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""Tenant-partitioned Hermes queue and worker lease boundary."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Iterable, Mapping
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
ALLOWED_TRANSITIONS,
|
||||||
|
TERMINAL_STATES,
|
||||||
|
HermesHealth,
|
||||||
|
HermesJob,
|
||||||
|
HermesJobState,
|
||||||
|
HermesResult,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HermesRuntimeError(RuntimeError):
|
||||||
|
"""Base Hermes runtime-boundary failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class HermesNotFound(HermesRuntimeError):
|
||||||
|
"""A tenant-scoped job was not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class HermesConflict(HermesRuntimeError):
|
||||||
|
"""A state, lease, idempotency, or revision conflict occurred."""
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryHermesStore:
|
||||||
|
"""Thread-safe reference store; production backends implement this contract."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.jobs: dict[str, HermesJob] = {}
|
||||||
|
self.results: dict[str, HermesResult] = {}
|
||||||
|
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class HermesAdapter:
|
||||||
|
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: InMemoryHermesStore | None = None,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "hermes-runtime",
|
||||||
|
runtime_version: str = "v1",
|
||||||
|
default_lease_seconds: int = 60,
|
||||||
|
) -> None:
|
||||||
|
if default_lease_seconds < 5:
|
||||||
|
raise ValueError("default_lease_seconds must be >= 5")
|
||||||
|
self.store = store or InMemoryHermesStore()
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.runtime_version = runtime_version
|
||||||
|
self.default_lease_seconds = int(default_lease_seconds)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _digest(job: HermesJob) -> str:
|
||||||
|
raw = json.dumps(
|
||||||
|
{
|
||||||
|
"tenant_id": job.tenant_id,
|
||||||
|
"cell_id": job.cell_id,
|
||||||
|
"profile_id": job.profile_id,
|
||||||
|
"task_id": job.task_id,
|
||||||
|
"trace_id": job.trace_id,
|
||||||
|
"action": job.action,
|
||||||
|
"payload": dict(job.payload),
|
||||||
|
"priority": job.priority,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse(value: str | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format(value: datetime) -> str:
|
||||||
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_tenant(job: HermesJob, tenant_id: str) -> None:
|
||||||
|
if job.tenant_id != tenant_id:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
|
||||||
|
def enqueue(self, job: HermesJob) -> HermesJob:
|
||||||
|
if job.state != HermesJobState.QUEUED or job.revision != 1:
|
||||||
|
raise HermesConflict("new job must be QUEUED at revision 1")
|
||||||
|
digest = self._digest(job)
|
||||||
|
idem_ref = (job.tenant_id, job.idempotency_key)
|
||||||
|
with self.store.lock:
|
||||||
|
existing = self.store.jobs.get(job.job_id)
|
||||||
|
if existing is not None:
|
||||||
|
if self._digest(existing) != digest:
|
||||||
|
raise HermesConflict("job_id reused with different content")
|
||||||
|
return existing
|
||||||
|
prior = self.store.idempotency.get(idem_ref)
|
||||||
|
if prior is not None:
|
||||||
|
prior_id, prior_digest = prior
|
||||||
|
if prior_digest != digest:
|
||||||
|
raise HermesConflict("idempotency key reused with different content")
|
||||||
|
return self.store.jobs[prior_id]
|
||||||
|
self.store.jobs[job.job_id] = job
|
||||||
|
self.store.idempotency[idem_ref] = (job.job_id, digest)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
|
||||||
|
with self.store.lock:
|
||||||
|
job = self.store.jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(job, tenant_id)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def list_jobs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str | None = None,
|
||||||
|
profile_id: str | None = None,
|
||||||
|
states: Iterable[HermesJobState | str] | None = None,
|
||||||
|
) -> list[HermesJob]:
|
||||||
|
state_filter = {HermesJobState(item) for item in states} if states else None
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
jobs = [
|
||||||
|
job
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
if job.tenant_id == tenant_id
|
||||||
|
and (cell_id is None or job.cell_id == cell_id)
|
||||||
|
and (profile_id is None or job.profile_id == profile_id)
|
||||||
|
and (state_filter is None or job.state in state_filter)
|
||||||
|
]
|
||||||
|
return sorted(jobs, key=lambda item: (item.priority, item.created_at, item.job_id))
|
||||||
|
|
||||||
|
def claim(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int | None = None,
|
||||||
|
) -> HermesJob | None:
|
||||||
|
worker_id = str(worker_id or "").strip()
|
||||||
|
if not worker_id:
|
||||||
|
raise ValueError("worker_id is required")
|
||||||
|
duration = int(lease_seconds or self.default_lease_seconds)
|
||||||
|
if duration < 5:
|
||||||
|
raise ValueError("lease_seconds must be >= 5")
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
candidates = [
|
||||||
|
job
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
if job.tenant_id == tenant_id
|
||||||
|
and job.cell_id == cell_id
|
||||||
|
and job.profile_id == profile_id
|
||||||
|
and job.state == HermesJobState.QUEUED
|
||||||
|
]
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
current = sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda item: (item.priority, item.created_at, item.job_id),
|
||||||
|
)[0]
|
||||||
|
claimed = self._transition_locked(
|
||||||
|
current,
|
||||||
|
HermesJobState.CLAIMED,
|
||||||
|
assigned_worker_id=worker_id,
|
||||||
|
lease_expires_at=self._format(
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
return self._transition_locked(current, HermesJobState.RUNNING)
|
||||||
|
|
||||||
|
def heartbeat(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
duration = int(lease_seconds or self.default_lease_seconds)
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
|
||||||
|
raise HermesConflict("job does not accept heartbeat")
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
lease_expires_at=self._format(
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||||
|
),
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.jobs[job_id] = updated
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
output: Mapping[str, object],
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
return self._finish(
|
||||||
|
job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
state=HermesJobState.COMPLETED,
|
||||||
|
output=output,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
error: Mapping[str, object],
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
return self._finish(
|
||||||
|
job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
state=HermesJobState.FAILED,
|
||||||
|
error=error,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self.store.jobs.get(job_id)
|
||||||
|
if current is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(current, tenant_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
if current.state in TERMINAL_STATES:
|
||||||
|
result = self.store.results.get(job_id)
|
||||||
|
if result is None:
|
||||||
|
raise HermesConflict("terminal result missing")
|
||||||
|
return result
|
||||||
|
updated = self._transition_locked(current, HermesJobState.CANCELLED)
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=updated.job_id,
|
||||||
|
tenant_id=updated.tenant_id,
|
||||||
|
task_id=updated.task_id,
|
||||||
|
trace_id=updated.trace_id,
|
||||||
|
state=HermesJobState.CANCELLED,
|
||||||
|
worker_id=updated.assigned_worker_id,
|
||||||
|
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.results[job_id] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
job = self.store.jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(job, tenant_id)
|
||||||
|
result = self.store.results.get(job_id)
|
||||||
|
if result is None:
|
||||||
|
raise HermesConflict("job has no terminal result")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def health(self) -> HermesHealth:
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
active_jobs = sum(
|
||||||
|
1 for job in self.store.jobs.values()
|
||||||
|
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||||
|
)
|
||||||
|
queued_jobs = sum(
|
||||||
|
1 for job in self.store.jobs.values()
|
||||||
|
if job.state == HermesJobState.QUEUED
|
||||||
|
)
|
||||||
|
partitions = {
|
||||||
|
(job.tenant_id, job.cell_id, job.profile_id)
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
}
|
||||||
|
active_workers = sum(
|
||||||
|
1 for heartbeat, _ in self.store.worker_heartbeats.values()
|
||||||
|
if self._parse(heartbeat)
|
||||||
|
and self._parse(heartbeat) >= datetime.now(timezone.utc) - timedelta(seconds=120)
|
||||||
|
)
|
||||||
|
return HermesHealth(
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
status="healthy",
|
||||||
|
version=self.runtime_version,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
active_workers=active_workers,
|
||||||
|
active_jobs=active_jobs,
|
||||||
|
queued_jobs=queued_jobs,
|
||||||
|
tenant_partitions=len(partitions),
|
||||||
|
profile_isolation_enforced=True,
|
||||||
|
details={"store": type(self.store).__name__},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
state: HermesJobState,
|
||||||
|
output: Mapping[str, object] | None = None,
|
||||||
|
error: Mapping[str, object] | None = None,
|
||||||
|
expected_revision: int | None,
|
||||||
|
) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
updated = self._transition_locked(current, state)
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=updated.job_id,
|
||||||
|
tenant_id=updated.tenant_id,
|
||||||
|
task_id=updated.task_id,
|
||||||
|
trace_id=updated.trace_id,
|
||||||
|
state=state,
|
||||||
|
worker_id=worker_id,
|
||||||
|
output=output,
|
||||||
|
error=error,
|
||||||
|
started_at=current.updated_at,
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.results[job_id] = result
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
|
||||||
|
current = self.store.jobs.get(job_id)
|
||||||
|
if current is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(current, tenant_id)
|
||||||
|
if current.assigned_worker_id != worker_id:
|
||||||
|
raise HermesConflict("worker does not own job lease")
|
||||||
|
return current
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_revision(job: HermesJob, expected: int | None) -> None:
|
||||||
|
if expected is not None and job.revision != expected:
|
||||||
|
raise HermesConflict("job revision changed")
|
||||||
|
|
||||||
|
def _transition_locked(
|
||||||
|
self,
|
||||||
|
current: HermesJob,
|
||||||
|
target: HermesJobState,
|
||||||
|
*,
|
||||||
|
assigned_worker_id: str | None = None,
|
||||||
|
lease_expires_at: str | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
if target not in ALLOWED_TRANSITIONS[current.state]:
|
||||||
|
raise HermesConflict(
|
||||||
|
f"invalid transition {current.state.value} -> {target.value}"
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
state=target,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
assigned_worker_id=(
|
||||||
|
assigned_worker_id
|
||||||
|
if assigned_worker_id is not None
|
||||||
|
else current.assigned_worker_id
|
||||||
|
),
|
||||||
|
lease_expires_at=(
|
||||||
|
lease_expires_at
|
||||||
|
if lease_expires_at is not None
|
||||||
|
else (None if target in TERMINAL_STATES else current.lease_expires_at)
|
||||||
|
),
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.jobs[current.job_id] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def _requeue_expired_locked(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for current in list(self.store.jobs.values()):
|
||||||
|
expiry = self._parse(current.lease_expires_at)
|
||||||
|
if (
|
||||||
|
current.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||||
|
and expiry is not None
|
||||||
|
and expiry <= now
|
||||||
|
):
|
||||||
|
self.store.jobs[current.job_id] = replace(
|
||||||
|
current,
|
||||||
|
state=HermesJobState.QUEUED,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
assigned_worker_id=None,
|
||||||
|
lease_expires_at=None,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Tenant-scoped Hermes runtime contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
class HermesJobState(str, Enum):
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
CLAIMED = "CLAIMED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
|
||||||
|
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
|
||||||
|
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
|
||||||
|
HermesJobState.RUNNING: frozenset(
|
||||||
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||||
|
),
|
||||||
|
HermesJobState.COMPLETED: frozenset(),
|
||||||
|
HermesJobState.FAILED: frozenset(),
|
||||||
|
HermesJobState.CANCELLED: frozenset(),
|
||||||
|
}
|
||||||
|
TERMINAL_STATES = frozenset(
|
||||||
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesJob:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
profile_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
idempotency_key: str = ""
|
||||||
|
priority: int = 100
|
||||||
|
state: HermesJobState = HermesJobState.QUEUED
|
||||||
|
revision: int = 1
|
||||||
|
assigned_worker_id: str | None = None
|
||||||
|
lease_expires_at: str | None = None
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
updated_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
|
||||||
|
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||||
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||||
|
if not 0 <= int(self.priority) <= 1000:
|
||||||
|
raise ValueError("priority must be between 0 and 1000")
|
||||||
|
if self.revision < 1:
|
||||||
|
raise ValueError("revision must be >= 1")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
priority: int = 100,
|
||||||
|
) -> "HermesJob":
|
||||||
|
return cls(
|
||||||
|
job_id=f"hermes_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
cell_id=cell_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
task_id=task_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=action,
|
||||||
|
payload=_safe_mapping(payload),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesResult:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
state: HermesJobState
|
||||||
|
worker_id: str | None = None
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||||
|
if self.output is not None:
|
||||||
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||||
|
if self.error is not None:
|
||||||
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesHealth:
|
||||||
|
runtime_id: str
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
heartbeat_at: str
|
||||||
|
active_workers: int = 0
|
||||||
|
active_jobs: int = 0
|
||||||
|
queued_jobs: int = 0
|
||||||
|
tenant_partitions: int = 0
|
||||||
|
profile_isolation_enforced: bool = True
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("runtime_id", "status", "version", "heartbeat_at"):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
|
||||||
|
if int(getattr(self, name)) < 0:
|
||||||
|
raise ValueError(f"{name} must be >= 0")
|
||||||
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Hermes worker loop with lease renewal and bounded recovery."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
from .adapter import HermesAdapter, HermesConflict
|
||||||
|
from .contract import HermesJob, HermesJobState
|
||||||
|
|
||||||
|
|
||||||
|
JobHandler = Callable[[HermesJob], Mapping[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerRunResult:
|
||||||
|
claimed: bool
|
||||||
|
job_id: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HermesWorker:
|
||||||
|
"""Poll one tenant/cell/profile partition and execute jobs safely."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: HermesAdapter,
|
||||||
|
handler: JobHandler,
|
||||||
|
*,
|
||||||
|
worker_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
poll_seconds: float = 1.0,
|
||||||
|
heartbeat_seconds: float = 10.0,
|
||||||
|
) -> None:
|
||||||
|
if poll_seconds <= 0 or heartbeat_seconds <= 0:
|
||||||
|
raise ValueError("poll_seconds and heartbeat_seconds must be positive")
|
||||||
|
for name, value in {
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"cell_id": cell_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
}.items():
|
||||||
|
if not str(value or "").strip():
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
self.adapter = adapter
|
||||||
|
self.handler = handler
|
||||||
|
self.worker_id = worker_id
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.cell_id = cell_id
|
||||||
|
self.profile_id = profile_id
|
||||||
|
self.poll_seconds = float(poll_seconds)
|
||||||
|
self.heartbeat_seconds = float(heartbeat_seconds)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def run_once(self) -> WorkerRunResult:
|
||||||
|
job = self.adapter.claim(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
cell_id=self.cell_id,
|
||||||
|
profile_id=self.profile_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
)
|
||||||
|
if job is None:
|
||||||
|
return WorkerRunResult(claimed=False)
|
||||||
|
|
||||||
|
running = self.adapter.start(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
expected_revision=job.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
heartbeat_thread = threading.Thread(
|
||||||
|
target=self._heartbeat_loop,
|
||||||
|
args=(running.job_id, heartbeat_stop),
|
||||||
|
name=f"hermes-heartbeat-{self.worker_id}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
heartbeat_thread.start()
|
||||||
|
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
execution_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
output = dict(self.handler(running))
|
||||||
|
except Exception as exc: # runtime boundary: normalize below
|
||||||
|
execution_error = exc
|
||||||
|
finally:
|
||||||
|
# Stop the lease heartbeat before reading the final revision. This
|
||||||
|
# prevents a revision increment racing with complete/fail.
|
||||||
|
heartbeat_stop.set()
|
||||||
|
heartbeat_thread.join(timeout=max(self.heartbeat_seconds * 2, 1.0))
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = self.adapter.get(running.job_id, tenant_id=self.tenant_id)
|
||||||
|
if execution_error is None:
|
||||||
|
result = self.adapter.complete(
|
||||||
|
running.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
output=dict(output or {}),
|
||||||
|
expected_revision=current.revision,
|
||||||
|
)
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=result.job_id,
|
||||||
|
state=result.state.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.adapter.fail(
|
||||||
|
running.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
error={
|
||||||
|
"code": "worker_error",
|
||||||
|
"message": str(execution_error),
|
||||||
|
},
|
||||||
|
expected_revision=current.revision,
|
||||||
|
)
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=result.job_id,
|
||||||
|
state=result.state.value,
|
||||||
|
error=str(execution_error),
|
||||||
|
)
|
||||||
|
except HermesConflict as exc:
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=running.job_id,
|
||||||
|
state=HermesJobState.FAILED.value,
|
||||||
|
error=f"reconciliation_conflict: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_forever(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
result = self.run_once()
|
||||||
|
if not result.claimed:
|
||||||
|
self._stop.wait(self.poll_seconds)
|
||||||
|
|
||||||
|
def _heartbeat_loop(self, job_id: str, stop_event: threading.Event) -> None:
|
||||||
|
while not stop_event.wait(self.heartbeat_seconds):
|
||||||
|
try:
|
||||||
|
self.adapter.heartbeat(
|
||||||
|
job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
stop_event.set()
|
||||||
|
return
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
"""Task-scoped hardened wrapper around the existing Action API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
import aios_chatgpt_actions_api as api
|
|
||||||
import aios_isolated_writer_policy as policy
|
|
||||||
from isolated_writer_authz import AuthorizationError, authorize
|
|
||||||
from isolated_writer_commit_file import AtomicCommitFile, CommitFileError, OperationLedger
|
|
||||||
from isolated_writer_token_registry import TokenRegistry
|
|
||||||
|
|
||||||
_reject = api.reject_non_read_http
|
|
||||||
_write_file = api.write_file
|
|
||||||
_sync_branch = api.sync_branch
|
|
||||||
_original_do_get = api.H.do_GET
|
|
||||||
api.OPENAPI = Path(os.getenv("AIOS_GITOPS_ISOLATED_WRITER_OPENAPI", str(api.OPENAPI)))
|
|
||||||
|
|
||||||
|
|
||||||
def _registry() -> TokenRegistry:
|
|
||||||
return TokenRegistry(Path(os.environ["AIOS_GITOPS_TOKEN_REGISTRY_V2"]))
|
|
||||||
|
|
||||||
|
|
||||||
def _atomic_writer() -> AtomicCommitFile:
|
|
||||||
return AtomicCommitFile(
|
|
||||||
workspace=api.WS,
|
|
||||||
bare_repo=api.BARE_REPO,
|
|
||||||
ledger=OperationLedger(Path(os.environ["AIOS_GITOPS_WRITER_OPERATION_LEDGER"])),
|
|
||||||
remote=os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", ""),
|
|
||||||
audit=policy.append_audit_record,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _bearer(handler) -> str:
|
|
||||||
value = handler.headers.get("Authorization", "")
|
|
||||||
return value[7:].strip() if value.startswith("Bearer ") else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _authorize(handler, *, method: str, path: str, branch=None, target_path=None):
|
|
||||||
grant = authorize(
|
|
||||||
_registry(),
|
|
||||||
_bearer(handler),
|
|
||||||
method=method,
|
|
||||||
path=path,
|
|
||||||
source_ip=str(handler.client_address[0]),
|
|
||||||
workspace=api.WS,
|
|
||||||
branch=branch,
|
|
||||||
target_path=target_path,
|
|
||||||
)
|
|
||||||
grant.record["_source_ip"] = str(handler.client_address[0])
|
|
||||||
return grant
|
|
||||||
|
|
||||||
|
|
||||||
def _authorization_error(handler, exc: AuthorizationError):
|
|
||||||
return handler.sendj({"error": exc.code}, exc.status)
|
|
||||||
|
|
||||||
|
|
||||||
def _isolated_runtime_status(handler):
|
|
||||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
|
||||||
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
|
||||||
return handler.sendj({
|
|
||||||
"service": os.getenv(
|
|
||||||
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
|
||||||
"aios-isolated-writer.service",
|
|
||||||
),
|
|
||||||
"status": "active",
|
|
||||||
"runtime_role": os.getenv("AIOS_GITOPS_RUNTIME_ROLE"),
|
|
||||||
"workspace_role": os.getenv("AIOS_GITOPS_WORKSPACE_ROLE"),
|
|
||||||
"writes_enabled": os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").lower()
|
|
||||||
in {"1", "true", "yes", "on"},
|
|
||||||
"allowed_branch": policy.allowed_branch(),
|
|
||||||
"approved_base_oid": policy.allowed_base_oid(),
|
|
||||||
"workspace_branch": branch,
|
|
||||||
"workspace_head_oid": head,
|
|
||||||
"repository_identity": os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY"),
|
|
||||||
"token_policy_version": 2,
|
|
||||||
"atomic_commit_file": True,
|
|
||||||
"path_policy_sha256": policy.configured_path_policy_sha256(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _isolated_do_get(self):
|
|
||||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
||||||
if path in {"/health", "/api/health"}:
|
|
||||||
return self.sendj({
|
|
||||||
"status": "ok",
|
|
||||||
"service": os.getenv(
|
|
||||||
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
|
||||||
"aios-isolated-writer.service",
|
|
||||||
),
|
|
||||||
})
|
|
||||||
if path not in {"/api/runtime-status", "/api/baseline", "/api/patches"}:
|
|
||||||
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
|
||||||
try:
|
|
||||||
_authorize(self, method="GET", path=path)
|
|
||||||
except AuthorizationError as exc:
|
|
||||||
return _authorization_error(self, exc)
|
|
||||||
if path == "/api/runtime-status":
|
|
||||||
return _isolated_runtime_status(self)
|
|
||||||
return _original_do_get(self)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_commit_file_body(handler):
|
|
||||||
try:
|
|
||||||
length = int(handler.headers.get("Content-Length", "0"))
|
|
||||||
except ValueError:
|
|
||||||
raise CommitFileError("writer_content_length_rejected") from None
|
|
||||||
if length <= 0:
|
|
||||||
raise CommitFileError("writer_request_object_required")
|
|
||||||
if length > 1_100_000:
|
|
||||||
raise CommitFileError("writer_request_too_large", 413)
|
|
||||||
raw = handler.rfile.read(length)
|
|
||||||
try:
|
|
||||||
value = json.loads(raw.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
raise CommitFileError("writer_request_json_rejected") from None
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise CommitFileError("writer_request_object_required")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _isolated_do_post(self):
|
|
||||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
||||||
if path != "/api/commit-file":
|
|
||||||
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
|
||||||
try:
|
|
||||||
data = _read_commit_file_body(self)
|
|
||||||
branch = str(data.get("branch") or "")
|
|
||||||
target_path = str(data.get("path") or "")
|
|
||||||
grant = _authorize(
|
|
||||||
self,
|
|
||||||
method="POST",
|
|
||||||
path=path,
|
|
||||||
branch=branch,
|
|
||||||
target_path=target_path,
|
|
||||||
)
|
|
||||||
if api.content_has_secret(str(data.get("content") or "")):
|
|
||||||
raise CommitFileError("writer_secret_content_rejected", 403)
|
|
||||||
result = _atomic_writer().execute(grant, data)
|
|
||||||
return self.sendj(result)
|
|
||||||
except AuthorizationError as exc:
|
|
||||||
return _authorization_error(self, exc)
|
|
||||||
except CommitFileError as exc:
|
|
||||||
return self.sendj({"error": exc.code}, exc.status)
|
|
||||||
except PermissionError:
|
|
||||||
return self.sendj({"error": "isolated_writer_policy_rejected"}, 403)
|
|
||||||
|
|
||||||
|
|
||||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
|
||||||
if _reject(handler, method, path):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
policy.validate_endpoint(method, path, api.WS, api.BARE_REPO)
|
|
||||||
return False
|
|
||||||
except PermissionError as exc:
|
|
||||||
handler.sendj({"error": str(exc)}, 403)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def checkout(branch, base="main", create=False):
|
|
||||||
api.ensure_git()
|
|
||||||
target = api.branch_name(branch)
|
|
||||||
policy.validate_branch_request({"branch": target, "base": base})
|
|
||||||
exists = api.sh(["rev-parse", "--verify", target])["code"] == 0
|
|
||||||
if exists:
|
|
||||||
if api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"] != target:
|
|
||||||
api.sh(["checkout", target], True)
|
|
||||||
elif create:
|
|
||||||
api.sh(["checkout", "-b", target, base], True)
|
|
||||||
else:
|
|
||||||
raise PermissionError("isolated_writer_branch_missing")
|
|
||||||
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def write_file(path, content):
|
|
||||||
policy.validate_path(path, api.WS)
|
|
||||||
return _write_file(path, content)
|
|
||||||
|
|
||||||
|
|
||||||
def sync_branch(branch: str):
|
|
||||||
policy.validate_sync_branch(branch)
|
|
||||||
local = _sync_branch(branch)
|
|
||||||
remote = os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", "").strip()
|
|
||||||
if remote != "gitea-http":
|
|
||||||
raise PermissionError("isolated_writer_gitea_remote_rejected")
|
|
||||||
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
|
||||||
pushed = subprocess.run(
|
|
||||||
["git", "push", "--porcelain", remote, f"HEAD:refs/heads/{branch}"],
|
|
||||||
cwd=api.WS,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
if pushed.returncode != 0:
|
|
||||||
raise RuntimeError("isolated_writer_gitea_push_failed")
|
|
||||||
verified = subprocess.run(
|
|
||||||
["git", "ls-remote", "--heads", remote, f"refs/heads/{branch}"],
|
|
||||||
cwd=api.WS,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
if verified.returncode != 0:
|
|
||||||
raise RuntimeError("isolated_writer_gitea_verification_failed")
|
|
||||||
fields = verified.stdout.strip().split()
|
|
||||||
if len(fields) != 2 or fields[0] != head or fields[1] != f"refs/heads/{branch}":
|
|
||||||
raise RuntimeError("isolated_writer_gitea_oid_mismatch")
|
|
||||||
return {
|
|
||||||
"status": "verified",
|
|
||||||
"target": "authoritative-bare+gitea",
|
|
||||||
"branch": branch,
|
|
||||||
"oid": head,
|
|
||||||
"local": local.get("status"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def commit(paths, msg):
|
|
||||||
policy.validate_commit_paths(paths, api.WS)
|
|
||||||
api.ensure_git()
|
|
||||||
selected = [api.relpath(p) for p in paths] if paths else ["."]
|
|
||||||
api.sh(["add", *selected], True)
|
|
||||||
policy.validate_staged_paths(api.WS)
|
|
||||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
|
||||||
if not api.sh(["status", "--short"])["stdout"]:
|
|
||||||
return {"status": "no_changes", "branch": branch, "sync": sync_branch(branch)}
|
|
||||||
out = api.sh(["commit", "-m", msg], True)
|
|
||||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
|
||||||
return {
|
|
||||||
"status": "committed",
|
|
||||||
"branch": branch,
|
|
||||||
"rev": api.sh(["rev-parse", "--short", "HEAD"], True)["stdout"],
|
|
||||||
"git": out,
|
|
||||||
"sync": sync_branch(branch),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def audit(actor, action, target, payload):
|
|
||||||
policy.append_audit_record(
|
|
||||||
{
|
|
||||||
"at": api.now(),
|
|
||||||
"actor": actor,
|
|
||||||
"action": action,
|
|
||||||
"target": target,
|
|
||||||
"payload": api.sanitize(payload),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_request(self, data, validate):
|
|
||||||
try:
|
|
||||||
validate(data)
|
|
||||||
return True
|
|
||||||
except PermissionError as exc:
|
|
||||||
self.sendj({"error": str(exc)}, 403)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _checked_branch(self, data):
|
|
||||||
if not _validate_request(self, data, policy.validate_branch_request):
|
|
||||||
return
|
|
||||||
return _branch(self, data)
|
|
||||||
|
|
||||||
|
|
||||||
def _checked_file(self, data):
|
|
||||||
data = dict(data)
|
|
||||||
data.setdefault("branch", policy.allowed_branch())
|
|
||||||
if not _validate_request(self, data, policy.validate_branch_request):
|
|
||||||
return
|
|
||||||
if not _validate_request(
|
|
||||||
self,
|
|
||||||
{"path": data.get("path", "")},
|
|
||||||
lambda item: policy.validate_path(item["path"], api.WS),
|
|
||||||
):
|
|
||||||
return
|
|
||||||
return _file(self, data)
|
|
||||||
|
|
||||||
|
|
||||||
def _checked_commit(self, data):
|
|
||||||
data = dict(data)
|
|
||||||
data.setdefault("branch", policy.allowed_branch())
|
|
||||||
if not _validate_request(self, data, policy.validate_branch_request):
|
|
||||||
return
|
|
||||||
return _commit(self, data)
|
|
||||||
|
|
||||||
|
|
||||||
def _checked_patch(self, data):
|
|
||||||
data = dict(data)
|
|
||||||
data["branch"] = policy.allowed_branch()
|
|
||||||
if not _validate_request(self, data, policy.validate_branch_request):
|
|
||||||
return
|
|
||||||
return _patch(self, data)
|
|
||||||
|
|
||||||
|
|
||||||
_branch, _file, _commit, _patch = api.H.branch, api.H.file, api.H.commit, api.H.patch
|
|
||||||
api.reject_non_read_http = reject_non_read_http
|
|
||||||
api.checkout, api.write_file, api.sync_branch, api.commit, api.audit = (
|
|
||||||
checkout,
|
|
||||||
write_file,
|
|
||||||
sync_branch,
|
|
||||||
commit,
|
|
||||||
audit,
|
|
||||||
)
|
|
||||||
api.H.branch, api.H.file, api.H.commit, api.H.patch = (
|
|
||||||
_checked_branch,
|
|
||||||
_checked_file,
|
|
||||||
_checked_commit,
|
|
||||||
_checked_patch,
|
|
||||||
)
|
|
||||||
api.H.do_GET = _isolated_do_get
|
|
||||||
api.H.do_POST = _isolated_do_post
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
|
||||||
api.ThreadingHTTPServer((api.HOST, api.PORT), api.H).serve_forever()
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
"""Fail-closed policy used only by the task-scoped Action writer."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import re
|
|
||||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
import isolated_writer_path_policy as path_policy
|
|
||||||
|
|
||||||
WRITE_ENDPOINTS = frozenset({"/api/commit-file"})
|
|
||||||
ALLOWED_ROOTS = (
|
|
||||||
"active-runtime",
|
|
||||||
"docs",
|
|
||||||
"patches",
|
|
||||||
"reports",
|
|
||||||
"scripts",
|
|
||||||
"test",
|
|
||||||
"tests",
|
|
||||||
)
|
|
||||||
DENIED_ROOTS = ("baseline", "config", "deploy", "deployments", "ops", "runtime", "services")
|
|
||||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
||||||
_FALSE = frozenset({"0", "false", "no", "off"})
|
|
||||||
_DENY_FLAGS = (
|
|
||||||
"AIOS_GITOPS_ALLOW_MAIN_WRITE",
|
|
||||||
"AIOS_GITOPS_ALLOW_TAG_WRITE",
|
|
||||||
"AIOS_GITOPS_ALLOW_BASELINE_WRITE",
|
|
||||||
"AIOS_GITOPS_ALLOW_REGISTRY_WRITE",
|
|
||||||
"AIOS_GITOPS_ALLOW_ROLLBACK",
|
|
||||||
"AIOS_GITOPS_ALLOW_DEPLOYMENT",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def enforcement_required() -> bool:
|
|
||||||
return os.getenv("AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT", "") == "required"
|
|
||||||
|
|
||||||
|
|
||||||
def allowed_branch() -> str:
|
|
||||||
value = os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", "").strip()
|
|
||||||
if not value:
|
|
||||||
raise PermissionError("isolated_writer_missing_allowed_branch")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def allowed_base_oid() -> str:
|
|
||||||
value = os.getenv("AIOS_GITOPS_ALLOWED_BASE_OID", "").strip().lower()
|
|
||||||
if len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value):
|
|
||||||
raise PermissionError("isolated_writer_invalid_allowed_base_oid")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _git(workspace: Path, *args: str) -> str:
|
|
||||||
result = subprocess.run(["git", *args], cwd=workspace, text=True, capture_output=True)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise PermissionError("isolated_writer_git_validation_failed")
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def require_service_invariants(workspace: Path, bare_repo: Path) -> None:
|
|
||||||
if not enforcement_required():
|
|
||||||
return
|
|
||||||
if os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").strip().lower() not in _TRUE:
|
|
||||||
raise PermissionError("isolated_writer_write_gate_required")
|
|
||||||
if os.getenv("AIOS_GITOPS_RUNTIME_ROLE", "") != "isolated-writer":
|
|
||||||
raise PermissionError("isolated_writer_role_required")
|
|
||||||
if os.getenv("AIOS_GITOPS_WORKSPACE_ROLE", "") != "isolated-writer":
|
|
||||||
raise PermissionError("isolated_writer_workspace_role_required")
|
|
||||||
if os.getenv("AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED", "") != "2":
|
|
||||||
raise PermissionError("isolated_writer_token_policy_v2_required")
|
|
||||||
if not os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", "").strip():
|
|
||||||
raise PermissionError("isolated_writer_service_audience_required")
|
|
||||||
if not os.getenv("AIOS_GITOPS_TOKEN_REGISTRY_V2", "").strip():
|
|
||||||
raise PermissionError("isolated_writer_token_registry_required")
|
|
||||||
if not os.getenv("AIOS_GITOPS_WRITER_OPERATION_LEDGER", "").strip():
|
|
||||||
raise PermissionError("isolated_writer_operation_ledger_required")
|
|
||||||
path_policy.configured_path_policy()
|
|
||||||
for name in _DENY_FLAGS:
|
|
||||||
if os.getenv(name, "").strip().lower() not in _FALSE:
|
|
||||||
raise PermissionError("isolated_writer_forbidden_capability_flag")
|
|
||||||
if os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "") != "aios/aios":
|
|
||||||
raise PermissionError("isolated_writer_repository_identity_rejected")
|
|
||||||
configured_bare_value = os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "").strip()
|
|
||||||
if not configured_bare_value:
|
|
||||||
raise PermissionError("isolated_writer_authoritative_repo_rejected")
|
|
||||||
configured_bare = Path(configured_bare_value)
|
|
||||||
if configured_bare.resolve(strict=True) != bare_repo.resolve(strict=True):
|
|
||||||
raise PermissionError("isolated_writer_authoritative_repo_rejected")
|
|
||||||
writer_root_value = os.getenv("AIOS_GITOPS_APPROVED_WRITER_ROOT", "").strip()
|
|
||||||
if not writer_root_value:
|
|
||||||
raise PermissionError("isolated_writer_workspace_outside_approved_root")
|
|
||||||
writer_root = Path(writer_root_value).resolve(strict=True)
|
|
||||||
lexical_workspace = Path(os.path.abspath(str(workspace)))
|
|
||||||
canonical_workspace = lexical_workspace.resolve(strict=True)
|
|
||||||
if lexical_workspace != canonical_workspace:
|
|
||||||
raise PermissionError("isolated_writer_workspace_symlink_rejected")
|
|
||||||
try:
|
|
||||||
canonical_workspace.relative_to(writer_root)
|
|
||||||
except (ValueError, FileNotFoundError):
|
|
||||||
raise PermissionError("isolated_writer_workspace_outside_approved_root") from None
|
|
||||||
if _git(workspace, "rev-parse", "--abbrev-ref", "HEAD") != allowed_branch():
|
|
||||||
raise PermissionError("isolated_writer_current_branch_rejected")
|
|
||||||
approved_base = allowed_base_oid()
|
|
||||||
try:
|
|
||||||
_git(workspace, "cat-file", "-e", f"{approved_base}^{{commit}}")
|
|
||||||
subprocess.run(
|
|
||||||
["git", "merge-base", "--is-ancestor", approved_base, "HEAD"],
|
|
||||||
cwd=workspace,
|
|
||||||
check=True,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
except (PermissionError, subprocess.CalledProcessError):
|
|
||||||
raise PermissionError("isolated_writer_approved_base_not_ancestor") from None
|
|
||||||
if _git(workspace, "merge-base", approved_base, "HEAD") != approved_base:
|
|
||||||
raise PermissionError("isolated_writer_merge_base_rejected")
|
|
||||||
common = Path(_git(workspace, "rev-parse", "--git-common-dir"))
|
|
||||||
if not common.is_absolute():
|
|
||||||
common = (workspace / common).resolve()
|
|
||||||
if common.resolve() != bare_repo.resolve():
|
|
||||||
raise PermissionError("isolated_writer_common_repo_rejected")
|
|
||||||
|
|
||||||
|
|
||||||
def approved_audit_log_path() -> Path:
|
|
||||||
root_value = os.getenv("AIOS_GITOPS_APPROVED_AUDIT_ROOT", "").strip()
|
|
||||||
log_value = os.getenv("AIOS_GITOPS_AUDIT_LOG", "").strip()
|
|
||||||
if not root_value or not log_value:
|
|
||||||
raise PermissionError("isolated_writer_audit_path_missing")
|
|
||||||
lexical_root = Path(os.path.abspath(root_value))
|
|
||||||
lexical_log = Path(os.path.abspath(log_value))
|
|
||||||
root = lexical_root.resolve(strict=True)
|
|
||||||
if lexical_root != root or root.is_symlink() or not root.is_dir():
|
|
||||||
raise PermissionError("isolated_writer_audit_root_rejected")
|
|
||||||
try:
|
|
||||||
lexical_log.relative_to(root)
|
|
||||||
except ValueError:
|
|
||||||
raise PermissionError("isolated_writer_audit_path_outside_approved_root") from None
|
|
||||||
if lexical_log.parent.resolve(strict=True) != lexical_log.parent:
|
|
||||||
raise PermissionError("isolated_writer_audit_parent_symlink_rejected")
|
|
||||||
if lexical_log.exists() and lexical_log.is_symlink():
|
|
||||||
raise PermissionError("isolated_writer_audit_log_symlink_rejected")
|
|
||||||
os.chmod(root, 0o750)
|
|
||||||
os.chmod(lexical_log.parent, 0o750)
|
|
||||||
return lexical_log
|
|
||||||
|
|
||||||
|
|
||||||
def append_audit_record(record: dict) -> None:
|
|
||||||
path = approved_audit_log_path()
|
|
||||||
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
|
|
||||||
if hasattr(os, "O_NOFOLLOW"):
|
|
||||||
flags |= os.O_NOFOLLOW
|
|
||||||
descriptor = os.open(path, flags, 0o600)
|
|
||||||
try:
|
|
||||||
info = os.fstat(descriptor)
|
|
||||||
if not stat.S_ISREG(info.st_mode):
|
|
||||||
raise PermissionError("isolated_writer_audit_log_not_regular")
|
|
||||||
os.fchmod(descriptor, 0o600)
|
|
||||||
line = json.dumps(
|
|
||||||
_audit_sanitize(record),
|
|
||||||
ensure_ascii=False,
|
|
||||||
sort_keys=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8") + b"\n"
|
|
||||||
os.write(descriptor, line)
|
|
||||||
os.fsync(descriptor)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
parent_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
||||||
try:
|
|
||||||
os.fsync(parent_fd)
|
|
||||||
finally:
|
|
||||||
os.close(parent_fd)
|
|
||||||
|
|
||||||
|
|
||||||
def _audit_sanitize(value):
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {
|
|
||||||
key: (
|
|
||||||
"[REDACTED]"
|
|
||||||
if any(
|
|
||||||
marker in str(key).lower()
|
|
||||||
for marker in (
|
|
||||||
"authorization",
|
|
||||||
"credential",
|
|
||||||
"password",
|
|
||||||
"secret",
|
|
||||||
"token",
|
|
||||||
"private_key",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and str(key).lower() != "token_id"
|
|
||||||
else _audit_sanitize(inner)
|
|
||||||
)
|
|
||||||
for key, inner in value.items()
|
|
||||||
}
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [_audit_sanitize(item) for item in value]
|
|
||||||
if isinstance(value, str) and re.search(
|
|
||||||
r"(?i)(authorization\s*:\s*bearer|(?:token|password|secret)\s*[:=])",
|
|
||||||
value,
|
|
||||||
):
|
|
||||||
return "[REDACTED]"
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_endpoint(method: str, path: str, workspace: Path, bare_repo: Path) -> None:
|
|
||||||
if not enforcement_required():
|
|
||||||
return
|
|
||||||
if method != "POST" or path not in WRITE_ENDPOINTS:
|
|
||||||
raise PermissionError("isolated_writer_operation_forbidden")
|
|
||||||
require_service_invariants(workspace, bare_repo)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_branch_request(data: dict) -> None:
|
|
||||||
if not enforcement_required():
|
|
||||||
return
|
|
||||||
branch = allowed_branch() if "branch" not in data else str(data.get("branch") or "").strip()
|
|
||||||
if branch != allowed_branch():
|
|
||||||
raise PermissionError("isolated_writer_branch_rejected")
|
|
||||||
if str(data.get("base", "main")).strip() != "main":
|
|
||||||
raise PermissionError("isolated_writer_base_rejected")
|
|
||||||
if data.get("force") or data.get("force_push"):
|
|
||||||
raise PermissionError("isolated_writer_force_rejected")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_path(path: str, workspace: Path | None = None) -> str:
|
|
||||||
if not isinstance(path, str):
|
|
||||||
raise PermissionError("isolated_writer_path_rejected")
|
|
||||||
value = path
|
|
||||||
if not value or value != value.strip() or "\x00" in value or "\\" in value:
|
|
||||||
raise PermissionError("isolated_writer_path_rejected")
|
|
||||||
try:
|
|
||||||
decoded = unquote(value, errors="strict")
|
|
||||||
except (UnicodeDecodeError, ValueError):
|
|
||||||
raise PermissionError("isolated_writer_path_rejected") from None
|
|
||||||
if decoded != value:
|
|
||||||
raise PermissionError("isolated_writer_path_rejected")
|
|
||||||
windows = PureWindowsPath(value)
|
|
||||||
posix = PurePosixPath(value)
|
|
||||||
parts = value.split("/")
|
|
||||||
if (
|
|
||||||
posix.is_absolute()
|
|
||||||
or windows.is_absolute()
|
|
||||||
or bool(windows.drive)
|
|
||||||
or any(part in {"", ".", ".."} for part in parts)
|
|
||||||
):
|
|
||||||
raise PermissionError("isolated_writer_path_rejected")
|
|
||||||
first = parts[0]
|
|
||||||
service_exact_path = False
|
|
||||||
if first in DENIED_ROOTS:
|
|
||||||
try:
|
|
||||||
path_policy.require_service_path("commit_file.atomic", value)
|
|
||||||
service_exact_path = True
|
|
||||||
except path_policy.PathPolicyError as exc:
|
|
||||||
raise PermissionError(str(exc)) from None
|
|
||||||
if value != "README.md" and first not in ALLOWED_ROOTS and not service_exact_path:
|
|
||||||
raise PermissionError("isolated_writer_path_outside_allowed_roots")
|
|
||||||
if workspace is not None:
|
|
||||||
lexical_root = Path(os.path.abspath(str(workspace)))
|
|
||||||
root = lexical_root.resolve(strict=True)
|
|
||||||
if lexical_root != root:
|
|
||||||
raise PermissionError("isolated_writer_workspace_symlink_rejected")
|
|
||||||
current = root
|
|
||||||
for part in parts:
|
|
||||||
current = current / part
|
|
||||||
if current.is_symlink():
|
|
||||||
raise PermissionError("isolated_writer_path_symlink_rejected")
|
|
||||||
candidate = (root / value).resolve(strict=False)
|
|
||||||
try:
|
|
||||||
candidate.relative_to(root)
|
|
||||||
except ValueError:
|
|
||||||
raise PermissionError("isolated_writer_path_escapes_workspace") from None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_content_for_path(path: str, content: str) -> None:
|
|
||||||
if path.split("/", 1)[0] not in DENIED_ROOTS:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
path_policy.validate_content("commit_file.atomic", path, content)
|
|
||||||
except path_policy.PathPolicyError as exc:
|
|
||||||
raise PermissionError(str(exc)) from None
|
|
||||||
|
|
||||||
|
|
||||||
def configured_path_policy_sha256() -> str | None:
|
|
||||||
try:
|
|
||||||
return path_policy.configured_path_policy_sha256()
|
|
||||||
except path_policy.PathPolicyError as exc:
|
|
||||||
raise PermissionError(str(exc)) from None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_commit_paths(paths: list[str], workspace: Path | None = None) -> None:
|
|
||||||
if enforcement_required():
|
|
||||||
for path in paths:
|
|
||||||
if str(path).strip() != ".":
|
|
||||||
validate_path(str(path), workspace)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_staged_paths(workspace: Path) -> None:
|
|
||||||
if not enforcement_required():
|
|
||||||
return
|
|
||||||
for path in _git(workspace, "diff", "--cached", "--name-only").splitlines():
|
|
||||||
validate_path(path, workspace)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_sync_branch(branch: str) -> None:
|
|
||||||
if enforcement_required() and branch != allowed_branch():
|
|
||||||
raise PermissionError("isolated_writer_sync_branch_rejected")
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""AIOS-managed OpenClaw execution adapter."""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
AdapterError,
|
||||||
|
CommandConflict,
|
||||||
|
CommandNotFound,
|
||||||
|
CommandRejected,
|
||||||
|
InMemoryCallbackSink,
|
||||||
|
OpenClawAdapter,
|
||||||
|
)
|
||||||
|
from .contract import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
sign_command,
|
||||||
|
verify_command_signature,
|
||||||
|
)
|
||||||
|
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AdapterError",
|
||||||
|
"CommandConflict",
|
||||||
|
"CommandNotFound",
|
||||||
|
"CommandRejected",
|
||||||
|
"CommandState",
|
||||||
|
"HTTPRuntimeTransport",
|
||||||
|
"InMemoryCallbackSink",
|
||||||
|
"InProcessRuntimeTransport",
|
||||||
|
"OpenClawAdapter",
|
||||||
|
"OpenClawCommand",
|
||||||
|
"OpenClawResult",
|
||||||
|
"RuntimeHealth",
|
||||||
|
"RuntimeTransport",
|
||||||
|
"sign_command",
|
||||||
|
"verify_command_signature",
|
||||||
|
]
|
||||||
@@ -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,213 @@
|
|||||||
|
"""Versioned AIOS to OpenClaw execution contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
CONTRACT_VERSION = "aios.openclaw.command.v1"
|
||||||
|
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: str) -> datetime:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("timestamp is required")
|
||||||
|
if normalized.endswith("Z"):
|
||||||
|
normalized = normalized[:-1] + "+00:00"
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include a timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
class CommandState(str, Enum):
|
||||||
|
ACCEPTED = "ACCEPTED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
TIMED_OUT = "TIMED_OUT"
|
||||||
|
REJECTED = "REJECTED"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenClawCommand:
|
||||||
|
command_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
idempotency_key: str = ""
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
expires_at: str | None = None
|
||||||
|
contract_version: str = CONTRACT_VERSION
|
||||||
|
key_id: str = "default"
|
||||||
|
signature: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in (
|
||||||
|
"command_id", "tenant_id", "task_id", "trace_id", "action",
|
||||||
|
"idempotency_key", "created_at", "contract_version", "key_id",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||||
|
if self.contract_version != CONTRACT_VERSION:
|
||||||
|
raise ValueError(f"unsupported contract_version: {self.contract_version}")
|
||||||
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||||
|
_parse_timestamp(self.created_at)
|
||||||
|
if self.expires_at is not None:
|
||||||
|
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
||||||
|
_parse_timestamp(self.expires_at)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
expires_at: str | None = None,
|
||||||
|
key_id: str = "default",
|
||||||
|
command_id: str | None = None,
|
||||||
|
) -> "OpenClawCommand":
|
||||||
|
return cls(
|
||||||
|
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=action,
|
||||||
|
payload=_safe_mapping(payload),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
expires_at=expires_at,
|
||||||
|
key_id=key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def unsigned_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"command_id": self.command_id,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"trace_id": self.trace_id,
|
||||||
|
"action": self.action,
|
||||||
|
"payload": _safe_mapping(self.payload),
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
"contract_version": self.contract_version,
|
||||||
|
"key_id": self.key_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenClawResult:
|
||||||
|
command_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
state: CommandState
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
runtime_id: str | None = None
|
||||||
|
attempts: int = 1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
|
||||||
|
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||||
|
object.__setattr__(self, "state", CommandState(self.state))
|
||||||
|
if self.output is not None:
|
||||||
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||||
|
if self.error is not None:
|
||||||
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||||
|
if self.started_at is not None:
|
||||||
|
_parse_timestamp(self.started_at)
|
||||||
|
if self.finished_at is not None:
|
||||||
|
_parse_timestamp(self.finished_at)
|
||||||
|
if self.attempts < 1:
|
||||||
|
raise ValueError("attempts must be >= 1")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeHealth:
|
||||||
|
runtime: str
|
||||||
|
runtime_id: str
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
heartbeat_at: str
|
||||||
|
active_tasks: int = 0
|
||||||
|
workspace_isolated: bool = False
|
||||||
|
credential_ref_configured: bool = False
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("runtime", "runtime_id", "status", "version", "heartbeat_at"):
|
||||||
|
object.__setattr__(self, field_name, _required(getattr(self, field_name), field_name))
|
||||||
|
_parse_timestamp(self.heartbeat_at)
|
||||||
|
if self.active_tasks < 0:
|
||||||
|
raise ValueError("active_tasks must be >= 0")
|
||||||
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
command.unsigned_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
|
||||||
|
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||||
|
if not key:
|
||||||
|
raise ValueError("signing material is required")
|
||||||
|
signature = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
|
||||||
|
return replace(command, signature=signature)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_command_signature(
|
||||||
|
command: OpenClawCommand,
|
||||||
|
key_material: bytes | str,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
if not command.signature:
|
||||||
|
return False
|
||||||
|
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
expected = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(expected, command.signature):
|
||||||
|
return False
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
created = _parse_timestamp(command.created_at)
|
||||||
|
if created.timestamp() - current.timestamp() > clock_skew_seconds:
|
||||||
|
return False
|
||||||
|
if command.expires_at is not None:
|
||||||
|
expires = _parse_timestamp(command.expires_at)
|
||||||
|
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Health policy and self-healing decisions for OpenClaw runtimes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .contract import RuntimeHealth
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(value: str) -> datetime:
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("heartbeat timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthPolicy:
|
||||||
|
max_heartbeat_age_seconds: int = 90
|
||||||
|
require_workspace_isolation: bool = True
|
||||||
|
require_credential_ref: bool = True
|
||||||
|
accepted_statuses: frozenset[str] = field(
|
||||||
|
default_factory=lambda: frozenset({"healthy", "ready"})
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.max_heartbeat_age_seconds < 5:
|
||||||
|
raise ValueError("max_heartbeat_age_seconds must be >= 5")
|
||||||
|
if not self.accepted_statuses:
|
||||||
|
raise ValueError("accepted_statuses must not be empty")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthAssessment:
|
||||||
|
healthy: bool
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
recoverable: bool
|
||||||
|
recommended_action: str
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHealthMonitor:
|
||||||
|
"""Evaluate runtime health and recommend bounded recovery actions."""
|
||||||
|
|
||||||
|
def __init__(self, policy: HealthPolicy | None = None) -> None:
|
||||||
|
self.policy = policy or HealthPolicy()
|
||||||
|
|
||||||
|
def assess(
|
||||||
|
self,
|
||||||
|
health: RuntimeHealth,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> HealthAssessment:
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
heartbeat = _parse(health.heartbeat_at)
|
||||||
|
age = max(0.0, current.timestamp() - heartbeat.timestamp())
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if health.status.lower() not in self.policy.accepted_statuses:
|
||||||
|
reasons.append("runtime_status_unhealthy")
|
||||||
|
if age > self.policy.max_heartbeat_age_seconds:
|
||||||
|
reasons.append("heartbeat_stale")
|
||||||
|
if self.policy.require_workspace_isolation and not health.workspace_isolated:
|
||||||
|
reasons.append("workspace_not_isolated")
|
||||||
|
if self.policy.require_credential_ref and not health.credential_ref_configured:
|
||||||
|
reasons.append("credential_reference_missing")
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
return HealthAssessment(
|
||||||
|
healthy=True,
|
||||||
|
reasons=(),
|
||||||
|
recoverable=True,
|
||||||
|
recommended_action="none",
|
||||||
|
details={"heartbeat_age_seconds": age},
|
||||||
|
)
|
||||||
|
|
||||||
|
non_recoverable = {
|
||||||
|
"workspace_not_isolated",
|
||||||
|
"credential_reference_missing",
|
||||||
|
}.intersection(reasons)
|
||||||
|
if non_recoverable:
|
||||||
|
action = "quarantine_runtime"
|
||||||
|
recoverable = False
|
||||||
|
elif "heartbeat_stale" in reasons:
|
||||||
|
action = "restart_runtime"
|
||||||
|
recoverable = True
|
||||||
|
else:
|
||||||
|
action = "drain_and_restart"
|
||||||
|
recoverable = True
|
||||||
|
|
||||||
|
return HealthAssessment(
|
||||||
|
healthy=False,
|
||||||
|
reasons=tuple(reasons),
|
||||||
|
recoverable=recoverable,
|
||||||
|
recommended_action=action,
|
||||||
|
details={
|
||||||
|
"heartbeat_age_seconds": age,
|
||||||
|
"runtime_id": health.runtime_id,
|
||||||
|
"active_tasks": health.active_tasks,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Runtime transport implementations for the OpenClaw adapter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Callable, Mapping, Protocol
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeTransport(Protocol):
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
||||||
|
def cancel(self, command_id: str) -> bool: ...
|
||||||
|
def health(self) -> RuntimeHealth: ...
|
||||||
|
|
||||||
|
|
||||||
|
class TransportError(RuntimeError):
|
||||||
|
"""Runtime transport or protocol failure."""
|
||||||
|
|
||||||
|
|
||||||
|
RuntimeHandler = Callable[[OpenClawCommand], Mapping[str, Any] | OpenClawResult]
|
||||||
|
|
||||||
|
|
||||||
|
class InProcessRuntimeTransport:
|
||||||
|
"""Deterministic runtime used by tests and controlled local integrations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
handler: RuntimeHandler,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "openclaw-in-process",
|
||||||
|
version: str = "v1",
|
||||||
|
workspace_isolated: bool = True,
|
||||||
|
credential_ref_configured: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.handler = handler
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.version = version
|
||||||
|
self.workspace_isolated = workspace_isolated
|
||||||
|
self.credential_ref_configured = credential_ref_configured
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._running: set[str] = set()
|
||||||
|
self._cancelled: set[str] = set()
|
||||||
|
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise ValueError("timeout_seconds must be positive")
|
||||||
|
with self._lock:
|
||||||
|
if command.command_id in self._cancelled:
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=command.command_id,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
task_id=command.task_id,
|
||||||
|
trace_id=command.trace_id,
|
||||||
|
state=CommandState.CANCELLED,
|
||||||
|
error={"code": "cancelled", "message": "cancelled before execution"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
)
|
||||||
|
self._running.add(command.command_id)
|
||||||
|
try:
|
||||||
|
raw = self.handler(command)
|
||||||
|
if isinstance(raw, OpenClawResult):
|
||||||
|
return raw
|
||||||
|
if not isinstance(raw, Mapping):
|
||||||
|
raise TransportError("runtime handler must return a mapping or OpenClawResult")
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=command.command_id,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
task_id=command.task_id,
|
||||||
|
trace_id=command.trace_id,
|
||||||
|
state=CommandState(str(raw.get("state", "COMPLETED"))),
|
||||||
|
output=raw.get("output"),
|
||||||
|
error=raw.get("error"),
|
||||||
|
started_at=str(raw.get("started_at") or utc_now()),
|
||||||
|
finished_at=str(raw.get("finished_at") or utc_now()),
|
||||||
|
runtime_id=str(raw.get("runtime_id") or self.runtime_id),
|
||||||
|
attempts=int(raw.get("attempts", 1)),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._running.discard(command.command_id)
|
||||||
|
|
||||||
|
def cancel(self, command_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
self._cancelled.add(command_id)
|
||||||
|
return command_id in self._running
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealth:
|
||||||
|
with self._lock:
|
||||||
|
active = len(self._running)
|
||||||
|
return RuntimeHealth(
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
status="healthy",
|
||||||
|
version=self.version,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
active_tasks=active,
|
||||||
|
workspace_isolated=self.workspace_isolated,
|
||||||
|
credential_ref_configured=self.credential_ref_configured,
|
||||||
|
details={"transport": "in_process"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPRuntimeTransport:
|
||||||
|
"""Private HTTP transport for an OpenClaw runtime service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "openclaw-http",
|
||||||
|
credential_ref: str | None = None,
|
||||||
|
max_response_bytes: int = 2_097_152,
|
||||||
|
) -> None:
|
||||||
|
normalized = str(base_url).rstrip("/")
|
||||||
|
if not normalized.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("base_url must use http or https")
|
||||||
|
self.base_url = normalized
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.credential_ref = credential_ref
|
||||||
|
self.max_response_bytes = int(max_response_bytes)
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
timeout_seconds: float,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
body = None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "AIOS-OpenClaw/1"}
|
||||||
|
if payload is not None:
|
||||||
|
body = json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if self.credential_ref:
|
||||||
|
headers["X-AIOS-Credential-Ref"] = self.credential_ref
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base_url}{path}",
|
||||||
|
data=body,
|
||||||
|
method=method,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||||
|
raw = response.read(self.max_response_bytes + 1)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise TransportError(f"runtime returned HTTP {exc.code}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise TransportError(f"runtime unavailable: {exc.reason}") from exc
|
||||||
|
if len(raw) > self.max_response_bytes:
|
||||||
|
raise TransportError("runtime response exceeded size limit")
|
||||||
|
decoded = json.loads(raw.decode("utf-8"))
|
||||||
|
if not isinstance(decoded, Mapping):
|
||||||
|
raise TransportError("runtime response must be a JSON object")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _command_payload(command: OpenClawCommand) -> dict[str, Any]:
|
||||||
|
return {**command.unsigned_dict(), "signature": command.signature}
|
||||||
|
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||||
|
value = self._request(
|
||||||
|
"POST",
|
||||||
|
"/v1/execute",
|
||||||
|
payload=self._command_payload(command),
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=str(value["command_id"]),
|
||||||
|
tenant_id=str(value["tenant_id"]),
|
||||||
|
task_id=str(value["task_id"]),
|
||||||
|
trace_id=str(value["trace_id"]),
|
||||||
|
state=CommandState(str(value["state"])),
|
||||||
|
output=value.get("output"),
|
||||||
|
error=value.get("error"),
|
||||||
|
started_at=value.get("started_at"),
|
||||||
|
finished_at=value.get("finished_at"),
|
||||||
|
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
||||||
|
attempts=int(value.get("attempts", 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(self, command_id: str) -> bool:
|
||||||
|
value = self._request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel",
|
||||||
|
payload={},
|
||||||
|
timeout_seconds=10.0,
|
||||||
|
)
|
||||||
|
return bool(value.get("accepted"))
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealth:
|
||||||
|
value = self._request("GET", "/v1/health", payload=None, timeout_seconds=5.0)
|
||||||
|
return RuntimeHealth(
|
||||||
|
runtime=str(value.get("runtime") or "openclaw"),
|
||||||
|
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
||||||
|
status=str(value.get("status") or "unknown"),
|
||||||
|
version=str(value.get("version") or "unknown"),
|
||||||
|
heartbeat_at=str(value.get("heartbeat_at") or utc_now()),
|
||||||
|
active_tasks=int(value.get("active_tasks", 0)),
|
||||||
|
workspace_isolated=bool(value.get("workspace_isolated", False)),
|
||||||
|
credential_ref_configured=bool(value.get("credential_ref_configured", False)),
|
||||||
|
details=value.get("details") if isinstance(value.get("details"), Mapping) else {},
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""AIOS V2 control plane and execution orchestration."""
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
AuditEvent,
|
||||||
|
ControlPlaneError,
|
||||||
|
ControlPlaneService,
|
||||||
|
IdempotencyConflict,
|
||||||
|
InMemoryControlPlaneStore,
|
||||||
|
InvalidTransition,
|
||||||
|
NotFound,
|
||||||
|
PolicyDenied,
|
||||||
|
RequestContext,
|
||||||
|
RevisionConflict,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
TenantIsolationError,
|
||||||
|
TenantRecord,
|
||||||
|
Unauthorized,
|
||||||
|
)
|
||||||
|
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||||
|
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentRecord",
|
||||||
|
"AuditEvent",
|
||||||
|
"ControlPlaneError",
|
||||||
|
"ControlPlaneService",
|
||||||
|
"DispatchRecord",
|
||||||
|
"ExecutionEvidence",
|
||||||
|
"ExecutionOrchestrator",
|
||||||
|
"ExecutionTracker",
|
||||||
|
"IdempotencyConflict",
|
||||||
|
"InMemoryControlPlaneStore",
|
||||||
|
"InvalidTransition",
|
||||||
|
"NotFound",
|
||||||
|
"OrchestrationError",
|
||||||
|
"PolicyDenied",
|
||||||
|
"RequestContext",
|
||||||
|
"RevisionConflict",
|
||||||
|
"TaskRecord",
|
||||||
|
"TaskState",
|
||||||
|
"TenantIsolationError",
|
||||||
|
"TenantRecord",
|
||||||
|
"Unauthorized",
|
||||||
|
]
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""Production-candidate AIOS V2 application entrypoint.
|
||||||
|
|
||||||
|
This process owns the public control-plane API and read-only web console. It
|
||||||
|
loads durable control-plane state, dispatches approved work through runtime
|
||||||
|
adapters, exposes tenant-scoped execution evidence and sanitized GitOps status,
|
||||||
|
and never exposes runtime credentials to the browser.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import urllib.parse
|
||||||
|
from enum import Enum
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_hermes import HermesAdapter, HermesJobState, HermesResult
|
||||||
|
from aios_openclaw import HTTPRuntimeTransport, OpenClawAdapter
|
||||||
|
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
||||||
|
|
||||||
|
from .console import CONSOLE_HTML
|
||||||
|
from .control_plane import AuditEvent, RequestContext
|
||||||
|
from .execution_tracking import ExecutionTracker
|
||||||
|
from .gateway import GatewayApplication, error_response
|
||||||
|
from .gitops_status import collect_gitops_status
|
||||||
|
from .orchestrator import ExecutionOrchestrator
|
||||||
|
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: Any) -> Any:
|
||||||
|
if dataclasses.is_dataclass(value):
|
||||||
|
return {
|
||||||
|
item.name: _json_value(getattr(value, item.name))
|
||||||
|
for item in dataclasses.fields(value)
|
||||||
|
}
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {str(key): _json_value(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return [_json_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class FileSigningMaterialResolver:
|
||||||
|
"""Resolve runtime signing material from a root-owned file reference."""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
|
||||||
|
def __call__(self, tenant_id: str, key_id: str) -> bytes:
|
||||||
|
del tenant_id, key_id
|
||||||
|
metadata = self.path.stat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError("signing material reference is not a regular file")
|
||||||
|
if metadata.st_mode & 0o077:
|
||||||
|
raise RuntimeError("signing material file permissions must be 0600 or stricter")
|
||||||
|
value = self.path.read_bytes().strip()
|
||||||
|
if len(value) < 32:
|
||||||
|
raise RuntimeError("signing material is too short")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneEventPublisher(EventPublisher):
|
||||||
|
"""Append OpenClaw runtime events to the tenant audit stream."""
|
||||||
|
|
||||||
|
def __init__(self, service: DurableControlPlaneService) -> None:
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
def publish(self, event: Mapping[str, Any]) -> None:
|
||||||
|
tenant_id = str(event.get("tenant_id") or "").strip()
|
||||||
|
trace_id = str(event.get("trace_id") or "").strip()
|
||||||
|
command_id = str(event.get("command_id") or "").strip()
|
||||||
|
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
|
||||||
|
if not tenant_id or not trace_id or not command_id:
|
||||||
|
raise ValueError("runtime audit event is missing tenant identity")
|
||||||
|
state_value = str(event.get("state") or "unknown").lower()
|
||||||
|
outcome = "allowed" if state_value == "completed" else state_value
|
||||||
|
audit = AuditEvent(
|
||||||
|
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
principal_id="openclaw-runtime",
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=event_type,
|
||||||
|
resource_type="openclaw_command",
|
||||||
|
resource_id=command_id,
|
||||||
|
outcome=outcome,
|
||||||
|
policy_version="runtime-v1",
|
||||||
|
details=dict(event.get("details") or {}),
|
||||||
|
)
|
||||||
|
with self.service.store.lock:
|
||||||
|
self.service.store.audit_events.append(audit)
|
||||||
|
self.service.repository.save(self.service._snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateApplication:
|
||||||
|
"""Compose durable control plane, runtimes, evidence and console routes."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
gateway: GatewayApplication,
|
||||||
|
orchestrator: ExecutionOrchestrator,
|
||||||
|
tracker: ExecutionTracker,
|
||||||
|
hermes: HermesAdapter,
|
||||||
|
openclaw: OpenClawAdapter | None,
|
||||||
|
) -> None:
|
||||||
|
self.gateway = gateway
|
||||||
|
self.orchestrator = orchestrator
|
||||||
|
self.tracker = tracker
|
||||||
|
self.hermes = hermes
|
||||||
|
self.openclaw = openclaw
|
||||||
|
|
||||||
|
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||||
|
return self.gateway.context_from_headers(headers)
|
||||||
|
|
||||||
|
def handle_json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
raw_path: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: Any,
|
||||||
|
) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
parsed = urllib.parse.urlparse(raw_path)
|
||||||
|
path = parsed.path.rstrip("/") or "/"
|
||||||
|
method = method.upper()
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/gitops":
|
||||||
|
context = self.context(headers)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"tenant_id": context.tenant_id,
|
||||||
|
"gitops": collect_gitops_status().as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/runtime":
|
||||||
|
context = self.context(headers)
|
||||||
|
runtimes: list[dict[str, Any]] = []
|
||||||
|
if self.openclaw is None:
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": "disabled",
|
||||||
|
"details": {"reason": "runtime_not_configured"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
health = self.openclaw.health()
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": health.status,
|
||||||
|
"details": _json_value(health),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": "unavailable",
|
||||||
|
"details": {"message": str(exc)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hermes_health = self.hermes.health()
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "hermes",
|
||||||
|
"status": hermes_health.status,
|
||||||
|
"details": _json_value(hermes_health),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"tenant_id": context.tenant_id,
|
||||||
|
"runtimes": runtimes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/executions":
|
||||||
|
context = self.context(headers)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"executions": _json_value(
|
||||||
|
self.tracker.list(tenant_id=context.tenant_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_match = __import__("re").fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/dispatch",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
if method == "POST" and dispatch_match:
|
||||||
|
context = self.context(headers)
|
||||||
|
task_id = urllib.parse.unquote(dispatch_match.group(1))
|
||||||
|
payload = body if isinstance(body, Mapping) else {}
|
||||||
|
timeout = payload.get("timeout_seconds")
|
||||||
|
record = self.orchestrator.dispatch(
|
||||||
|
context,
|
||||||
|
task_id=task_id,
|
||||||
|
timeout_seconds=float(timeout) if timeout is not None else None,
|
||||||
|
)
|
||||||
|
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||||
|
context = self.context(headers)
|
||||||
|
if not isinstance(body, Mapping):
|
||||||
|
raise ValueError("JSON body must be an object")
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=str(body["job_id"]),
|
||||||
|
tenant_id=str(body["tenant_id"]),
|
||||||
|
task_id=str(body["task_id"]),
|
||||||
|
trace_id=str(body["trace_id"]),
|
||||||
|
state=HermesJobState(str(body["state"])),
|
||||||
|
worker_id=(
|
||||||
|
str(body["worker_id"])
|
||||||
|
if body.get("worker_id") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
output=body.get("output"),
|
||||||
|
error=body.get("error"),
|
||||||
|
started_at=body.get("started_at"),
|
||||||
|
finished_at=body.get("finished_at"),
|
||||||
|
)
|
||||||
|
task = self.orchestrator.reconcile_hermes(context, result)
|
||||||
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
return self.gateway.handle(method, raw_path, headers, body)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "AIOSV2Candidate/1.0"
|
||||||
|
max_body_bytes = 1_048_576
|
||||||
|
|
||||||
|
@property
|
||||||
|
def application(self) -> CandidateApplication:
|
||||||
|
return self.server.application # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def _read_body(self) -> Any:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
if length < 0 or length > self.max_body_bytes:
|
||||||
|
raise ValueError("request body is too large")
|
||||||
|
if length == 0:
|
||||||
|
return {}
|
||||||
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||||
|
if content_type != "application/json":
|
||||||
|
raise ValueError("Content-Type must be application/json")
|
||||||
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
|
||||||
|
def _security_headers(self) -> None:
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
_json_value(payload),
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
self.send_response(int(status_code))
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self._security_headers()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _send_console(self) -> None:
|
||||||
|
encoded = CONSOLE_HTML.encode("utf-8")
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self._security_headers()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _dispatch(self, method: str) -> None:
|
||||||
|
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||||
|
if method == "GET" and path in {"/", "/console"}:
|
||||||
|
self._send_console()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = self._read_body() if method == "POST" else {}
|
||||||
|
status_code, payload = self.application.handle_json(
|
||||||
|
method,
|
||||||
|
self.path,
|
||||||
|
{key: value for key, value in self.headers.items()},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
status_code, payload = error_response(exc)
|
||||||
|
self._send_json(status_code, payload)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("GET")
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("POST")
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
|
||||||
|
self.application = application
|
||||||
|
super().__init__(address, CandidateRequestHandler)
|
||||||
|
|
||||||
|
|
||||||
|
def build_application() -> CandidateApplication:
|
||||||
|
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||||
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
|
||||||
|
service = DurableControlPlaneService(repository)
|
||||||
|
tracker = ExecutionTracker(state_dir / "executions")
|
||||||
|
hermes = HermesAdapter()
|
||||||
|
|
||||||
|
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
||||||
|
material_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||||
|
openclaw = None
|
||||||
|
resolver = None
|
||||||
|
if openclaw_url and material_file:
|
||||||
|
resolver = FileSigningMaterialResolver(material_file)
|
||||||
|
transport = HTTPRuntimeTransport(
|
||||||
|
openclaw_url,
|
||||||
|
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
||||||
|
)
|
||||||
|
openclaw = OpenClawAdapter(
|
||||||
|
transport,
|
||||||
|
resolver,
|
||||||
|
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway = GatewayApplication(service)
|
||||||
|
orchestrator = ExecutionOrchestrator(
|
||||||
|
service,
|
||||||
|
openclaw=openclaw,
|
||||||
|
hermes=hermes,
|
||||||
|
openclaw_signing_material_resolver=resolver,
|
||||||
|
)
|
||||||
|
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("AIOS_V2_PORT", "18920"))
|
||||||
|
server = CandidateHTTPServer((host, port), build_application())
|
||||||
|
print(f"AIOS V2 candidate listening on {host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dependency-free AIOS V2 web console assets.
|
||||||
|
|
||||||
|
Authentication and tenant identity are injected by the trusted reverse proxy.
|
||||||
|
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONSOLE_HTML = r'''<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>AIOS V2 Console</title>
|
||||||
|
<style>
|
||||||
|
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
|
||||||
|
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
|
||||||
|
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
|
||||||
|
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||||
|
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
|
||||||
|
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
|
||||||
|
<main>
|
||||||
|
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
|
||||||
|
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
|
||||||
|
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
|
||||||
|
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
|
||||||
|
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
|
||||||
|
</section>
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
|
||||||
|
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
|
||||||
|
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
|
||||||
|
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
|
||||||
|
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||||
|
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
|
||||||
|
async function refresh(){
|
||||||
|
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
|
||||||
|
try{
|
||||||
|
const [agents,tasks,audit,runtime,executions]=await Promise.all([
|
||||||
|
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
|
||||||
|
]);
|
||||||
|
document.getElementById('agentCount').textContent=agents.agents.length;
|
||||||
|
document.getElementById('taskCount').textContent=tasks.tasks.length;
|
||||||
|
document.getElementById('auditCount').textContent=audit.events.length;
|
||||||
|
document.getElementById('executionCount').textContent=executions.executions.length;
|
||||||
|
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
|
||||||
|
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
|
||||||
|
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
|
||||||
|
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
|
||||||
|
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
|
||||||
|
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
|
||||||
|
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
|
||||||
|
}
|
||||||
|
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
"""Tenant-isolated AIOS V2 control-plane domain service.
|
||||||
|
|
||||||
|
This module deliberately contains no OpenClaw or Hermes implementation details.
|
||||||
|
It owns authoritative tenant, agent, task-lifecycle, idempotency and audit state.
|
||||||
|
Runtime adapters consume approved tasks through a separate boundary.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _new_id(prefix: str) -> str:
|
||||||
|
return f"{prefix}_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_digest(value: Mapping[str, Any]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneError(RuntimeError):
|
||||||
|
"""Base error returned by the control-plane domain."""
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(ControlPlaneError):
|
||||||
|
"""The caller is not authenticated or lacks the required role."""
|
||||||
|
|
||||||
|
|
||||||
|
class TenantIsolationError(ControlPlaneError):
|
||||||
|
"""A request attempted to cross a tenant boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(ControlPlaneError):
|
||||||
|
"""A tenant-scoped resource does not exist or is not visible."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransition(ControlPlaneError):
|
||||||
|
"""A task state transition violates the lifecycle contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyConflict(ControlPlaneError):
|
||||||
|
"""An idempotency key was reused for a different command."""
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionConflict(ControlPlaneError):
|
||||||
|
"""Optimistic task revision does not match current state."""
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyDenied(ControlPlaneError):
|
||||||
|
"""The policy gate denied a control-plane operation."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RequestContext:
|
||||||
|
"""Authenticated request identity propagated across all AIOS layers."""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
principal_id: str
|
||||||
|
trace_id: str
|
||||||
|
roles: frozenset[str] = field(default_factory=frozenset)
|
||||||
|
session_id: str | None = None
|
||||||
|
policy_version: str = "v1"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("tenant_id", "principal_id", "trace_id"):
|
||||||
|
if not str(getattr(self, name, "")).strip():
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
roles: Iterable[str] = (),
|
||||||
|
trace_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
policy_version: str = "v1",
|
||||||
|
) -> "RequestContext":
|
||||||
|
return cls(
|
||||||
|
tenant_id=tenant_id.strip(),
|
||||||
|
principal_id=principal_id.strip(),
|
||||||
|
trace_id=trace_id or _new_id("trace"),
|
||||||
|
roles=frozenset(str(role).strip() for role in roles if str(role).strip()),
|
||||||
|
session_id=session_id,
|
||||||
|
policy_version=policy_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TenantRecord:
|
||||||
|
tenant_id: str
|
||||||
|
display_name: str
|
||||||
|
status: str = "active"
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentRecord:
|
||||||
|
agent_id: str
|
||||||
|
tenant_id: str
|
||||||
|
display_name: str
|
||||||
|
runtime: str
|
||||||
|
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
enabled: bool = True
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskState(str, Enum):
|
||||||
|
CREATED = "CREATED"
|
||||||
|
APPROVED = "APPROVED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_TASK_STATES = frozenset(
|
||||||
|
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
ALLOWED_TASK_TRANSITIONS: Mapping[TaskState, frozenset[TaskState]] = {
|
||||||
|
TaskState.CREATED: frozenset({TaskState.APPROVED, TaskState.CANCELLED}),
|
||||||
|
TaskState.APPROVED: frozenset({TaskState.RUNNING, TaskState.CANCELLED}),
|
||||||
|
TaskState.RUNNING: frozenset(
|
||||||
|
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||||
|
),
|
||||||
|
TaskState.COMPLETED: frozenset(),
|
||||||
|
TaskState.FAILED: frozenset(),
|
||||||
|
TaskState.CANCELLED: frozenset(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskRecord:
|
||||||
|
task_id: str
|
||||||
|
tenant_id: str
|
||||||
|
agent_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
state: TaskState = TaskState.CREATED
|
||||||
|
revision: int = 1
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
request_digest: str = ""
|
||||||
|
result: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
created_by: str = ""
|
||||||
|
approved_by: str | None = None
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuditEvent:
|
||||||
|
event_id: str
|
||||||
|
tenant_id: str
|
||||||
|
principal_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
outcome: str
|
||||||
|
policy_version: str
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PolicyDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
policy_version: str
|
||||||
|
|
||||||
|
|
||||||
|
PolicyEvaluator = Callable[[RequestContext, str, Mapping[str, Any]], PolicyDecision]
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryControlPlaneStore:
|
||||||
|
"""Thread-safe reference store used by tests and the first gateway slice.
|
||||||
|
|
||||||
|
Production backends must preserve the same tenant-scoped interface and add
|
||||||
|
database row-level security, migrations, backup and HA evidence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.tenants: dict[str, TenantRecord] = {}
|
||||||
|
self.agents: dict[str, AgentRecord] = {}
|
||||||
|
self.tasks: dict[str, TaskRecord] = {}
|
||||||
|
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
self.audit_events: list[AuditEvent] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneService:
|
||||||
|
"""Authoritative tenant, agent and task lifecycle service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: InMemoryControlPlaneStore | None = None,
|
||||||
|
*,
|
||||||
|
policy_evaluator: PolicyEvaluator | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.store = store or InMemoryControlPlaneStore()
|
||||||
|
self.policy_evaluator = policy_evaluator or self._default_policy
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_policy(
|
||||||
|
context: RequestContext,
|
||||||
|
action: str,
|
||||||
|
resource: Mapping[str, Any],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
del resource
|
||||||
|
role_requirements = {
|
||||||
|
"tenant.register": {"platform_admin"},
|
||||||
|
"tenant.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"agent.register": {"tenant_admin", "operator", "platform_admin"},
|
||||||
|
"agent.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"task.create": {"tenant_admin", "operator", "platform_admin"},
|
||||||
|
"task.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"task.approve": {"tenant_admin", "approver", "platform_admin"},
|
||||||
|
"task.start": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.complete": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.fail": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.cancel": {"tenant_admin", "operator", "approver", "platform_admin"},
|
||||||
|
"audit.read": {"tenant_admin", "auditor", "platform_admin"},
|
||||||
|
}
|
||||||
|
required = role_requirements.get(action, {"platform_admin"})
|
||||||
|
allowed = bool(context.roles.intersection(required))
|
||||||
|
return PolicyDecision(
|
||||||
|
allowed=allowed,
|
||||||
|
reason="role_allowed" if allowed else "required_role_missing",
|
||||||
|
policy_version=context.policy_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_context(self, context: RequestContext) -> None:
|
||||||
|
if not context.principal_id or not context.tenant_id or not context.trace_id:
|
||||||
|
raise Unauthorized("authenticated tenant context is required")
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
action: str,
|
||||||
|
resource: Mapping[str, Any],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
self._require_context(context)
|
||||||
|
decision = self.policy_evaluator(context, action, resource)
|
||||||
|
if not decision.allowed:
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action=action,
|
||||||
|
resource_type=str(resource.get("resource_type", "policy")),
|
||||||
|
resource_id=str(resource.get("resource_id", "unknown")),
|
||||||
|
outcome="denied",
|
||||||
|
details={"reason": decision.reason},
|
||||||
|
)
|
||||||
|
raise PolicyDenied(f"{action} denied: {decision.reason}")
|
||||||
|
return decision
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_same_tenant(context: RequestContext, tenant_id: str) -> None:
|
||||||
|
if context.tenant_id != tenant_id and "platform_admin" not in context.roles:
|
||||||
|
raise TenantIsolationError("cross-tenant access denied")
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
outcome: str,
|
||||||
|
details: Mapping[str, Any] | None = None,
|
||||||
|
) -> AuditEvent:
|
||||||
|
event = AuditEvent(
|
||||||
|
event_id=_new_id("audit"),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
outcome=outcome,
|
||||||
|
policy_version=context.policy_version,
|
||||||
|
details=copy.deepcopy(dict(details or {})),
|
||||||
|
)
|
||||||
|
self.store.audit_events.append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def register_tenant(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
display_name: str,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> TenantRecord:
|
||||||
|
tenant_id = tenant_id.strip()
|
||||||
|
if not tenant_id or not display_name.strip():
|
||||||
|
raise ValueError("tenant_id and display_name are required")
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"tenant.register",
|
||||||
|
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||||
|
)
|
||||||
|
self._assert_same_tenant(context, tenant_id)
|
||||||
|
with self.store.lock:
|
||||||
|
if tenant_id in self.store.tenants:
|
||||||
|
raise ControlPlaneError(f"tenant already exists: {tenant_id}")
|
||||||
|
tenant = TenantRecord(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
metadata=copy.deepcopy(dict(metadata or {})),
|
||||||
|
)
|
||||||
|
self.store.tenants[tenant_id] = tenant
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="tenant.register",
|
||||||
|
resource_type="tenant",
|
||||||
|
resource_id=tenant_id,
|
||||||
|
outcome="allowed",
|
||||||
|
)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
def get_tenant(self, context: RequestContext, tenant_id: str | None = None) -> TenantRecord:
|
||||||
|
tenant_id = tenant_id or context.tenant_id
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"tenant.read",
|
||||||
|
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||||
|
)
|
||||||
|
self._assert_same_tenant(context, tenant_id)
|
||||||
|
with self.store.lock:
|
||||||
|
tenant = self.store.tenants.get(tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise NotFound("tenant not found")
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
def register_agent(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
display_name: str,
|
||||||
|
runtime: str,
|
||||||
|
capabilities: Iterable[str] = (),
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> AgentRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.register",
|
||||||
|
{"resource_type": "agent", "resource_id": agent_id},
|
||||||
|
)
|
||||||
|
tenant = self.get_tenant(context)
|
||||||
|
if tenant.status != "active":
|
||||||
|
raise ControlPlaneError("tenant is not active")
|
||||||
|
normalized_capabilities = tuple(
|
||||||
|
sorted({str(item).strip() for item in capabilities if str(item).strip()})
|
||||||
|
)
|
||||||
|
if not agent_id.strip() or not display_name.strip() or not runtime.strip():
|
||||||
|
raise ValueError("agent_id, display_name and runtime are required")
|
||||||
|
with self.store.lock:
|
||||||
|
existing = self.store.agents.get(agent_id)
|
||||||
|
if existing is not None and existing.tenant_id != context.tenant_id:
|
||||||
|
raise TenantIsolationError("agent id belongs to another tenant")
|
||||||
|
if existing is not None:
|
||||||
|
raise ControlPlaneError(f"agent already exists: {agent_id}")
|
||||||
|
agent = AgentRecord(
|
||||||
|
agent_id=agent_id.strip(),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
runtime=runtime.strip(),
|
||||||
|
capabilities=normalized_capabilities,
|
||||||
|
metadata=copy.deepcopy(dict(metadata or {})),
|
||||||
|
)
|
||||||
|
self.store.agents[agent.agent_id] = agent
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="agent.register",
|
||||||
|
resource_type="agent",
|
||||||
|
resource_id=agent.agent_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={"runtime": agent.runtime},
|
||||||
|
)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def get_agent(self, context: RequestContext, agent_id: str) -> AgentRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.read",
|
||||||
|
{"resource_type": "agent", "resource_id": agent_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
agent = self.store.agents.get(agent_id)
|
||||||
|
if agent is None:
|
||||||
|
raise NotFound("agent not found")
|
||||||
|
self._assert_same_tenant(context, agent.tenant_id)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def list_agents(self, context: RequestContext) -> list[AgentRecord]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.read",
|
||||||
|
{"resource_type": "agent_collection", "resource_id": context.tenant_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
agent
|
||||||
|
for agent in self.store.agents.values()
|
||||||
|
if agent.tenant_id == context.tenant_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.create",
|
||||||
|
{"resource_type": "task", "resource_id": idempotency_key or "new"},
|
||||||
|
)
|
||||||
|
agent = self.get_agent(context, agent_id)
|
||||||
|
if not agent.enabled:
|
||||||
|
raise ControlPlaneError("agent is disabled")
|
||||||
|
if action not in agent.capabilities and "*" not in agent.capabilities:
|
||||||
|
raise PolicyDenied(f"agent does not advertise capability: {action}")
|
||||||
|
normalized_payload = copy.deepcopy(dict(payload or {}))
|
||||||
|
request_digest = _canonical_digest(
|
||||||
|
{"agent_id": agent_id, "action": action, "payload": normalized_payload}
|
||||||
|
)
|
||||||
|
normalized_key = idempotency_key.strip() if idempotency_key else None
|
||||||
|
with self.store.lock:
|
||||||
|
if normalized_key:
|
||||||
|
idempotency_ref = (context.tenant_id, normalized_key)
|
||||||
|
existing = self.store.idempotency.get(idempotency_ref)
|
||||||
|
if existing is not None:
|
||||||
|
existing_task_id, existing_digest = existing
|
||||||
|
if existing_digest != request_digest:
|
||||||
|
raise IdempotencyConflict(
|
||||||
|
"idempotency key was reused with a different request"
|
||||||
|
)
|
||||||
|
return self.store.tasks[existing_task_id]
|
||||||
|
task = TaskRecord(
|
||||||
|
task_id=_new_id("task"),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
action=action,
|
||||||
|
payload=normalized_payload,
|
||||||
|
idempotency_key=normalized_key,
|
||||||
|
request_digest=request_digest,
|
||||||
|
created_by=context.principal_id,
|
||||||
|
)
|
||||||
|
self.store.tasks[task.task_id] = task
|
||||||
|
if normalized_key:
|
||||||
|
self.store.idempotency[(context.tenant_id, normalized_key)] = (
|
||||||
|
task.task_id,
|
||||||
|
request_digest,
|
||||||
|
)
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="task.create",
|
||||||
|
resource_type="task",
|
||||||
|
resource_id=task.task_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={"agent_id": agent_id, "action": action},
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def get_task(self, context: RequestContext, task_id: str) -> TaskRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.read",
|
||||||
|
{"resource_type": "task", "resource_id": task_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
task = self.store.tasks.get(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise NotFound("task not found")
|
||||||
|
self._assert_same_tenant(context, task.tenant_id)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
states: Iterable[TaskState | str] | None = None,
|
||||||
|
) -> list[TaskRecord]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.read",
|
||||||
|
{"resource_type": "task_collection", "resource_id": context.tenant_id},
|
||||||
|
)
|
||||||
|
state_filter = (
|
||||||
|
{TaskState(item) for item in states}
|
||||||
|
if states is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
task
|
||||||
|
for task in self.store.tasks.values()
|
||||||
|
if task.tenant_id == context.tenant_id
|
||||||
|
and (state_filter is None or task.state in state_filter)
|
||||||
|
]
|
||||||
|
|
||||||
|
def transition_task(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
target_state: TaskState | str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
result: Mapping[str, Any] | None = None,
|
||||||
|
error: Mapping[str, Any] | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
target = TaskState(target_state)
|
||||||
|
action_name = {
|
||||||
|
TaskState.APPROVED: "task.approve",
|
||||||
|
TaskState.RUNNING: "task.start",
|
||||||
|
TaskState.COMPLETED: "task.complete",
|
||||||
|
TaskState.FAILED: "task.fail",
|
||||||
|
TaskState.CANCELLED: "task.cancel",
|
||||||
|
}.get(target)
|
||||||
|
if action_name is None:
|
||||||
|
raise InvalidTransition(f"cannot transition to {target.value}")
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
action_name,
|
||||||
|
{"resource_type": "task", "resource_id": task_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
current = self.store.tasks.get(task_id)
|
||||||
|
if current is None:
|
||||||
|
raise NotFound("task not found")
|
||||||
|
self._assert_same_tenant(context, current.tenant_id)
|
||||||
|
if expected_revision is not None and current.revision != expected_revision:
|
||||||
|
raise RevisionConflict(
|
||||||
|
f"expected revision {expected_revision}, current {current.revision}"
|
||||||
|
)
|
||||||
|
if target not in ALLOWED_TASK_TRANSITIONS[current.state]:
|
||||||
|
raise InvalidTransition(
|
||||||
|
f"invalid transition {current.state.value} -> {target.value}"
|
||||||
|
)
|
||||||
|
if target == TaskState.COMPLETED and result is None:
|
||||||
|
raise ValueError("completed task requires result")
|
||||||
|
if target == TaskState.FAILED and error is None:
|
||||||
|
raise ValueError("failed task requires error")
|
||||||
|
updated = TaskRecord(
|
||||||
|
task_id=current.task_id,
|
||||||
|
tenant_id=current.tenant_id,
|
||||||
|
agent_id=current.agent_id,
|
||||||
|
action=current.action,
|
||||||
|
payload=current.payload,
|
||||||
|
state=target,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
idempotency_key=current.idempotency_key,
|
||||||
|
request_digest=current.request_digest,
|
||||||
|
result=copy.deepcopy(dict(result)) if result is not None else current.result,
|
||||||
|
error=copy.deepcopy(dict(error)) if error is not None else current.error,
|
||||||
|
created_by=current.created_by,
|
||||||
|
approved_by=(
|
||||||
|
context.principal_id
|
||||||
|
if target == TaskState.APPROVED
|
||||||
|
else current.approved_by
|
||||||
|
),
|
||||||
|
created_at=current.created_at,
|
||||||
|
updated_at=_utc_now(),
|
||||||
|
)
|
||||||
|
self.store.tasks[task_id] = updated
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action=action_name,
|
||||||
|
resource_type="task",
|
||||||
|
resource_id=task_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={
|
||||||
|
"from": current.state.value,
|
||||||
|
"to": target.value,
|
||||||
|
"revision": updated.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list_audit_events(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
) -> list[AuditEvent]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"audit.read",
|
||||||
|
{"resource_type": "audit", "resource_id": resource_id or context.tenant_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
event
|
||||||
|
for event in self.store.audit_events
|
||||||
|
if event.tenant_id == context.tenant_id
|
||||||
|
and (resource_id is None or event.resource_id == resource_id)
|
||||||
|
]
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Git-backed agent execution evidence for AIOS V2.
|
||||||
|
|
||||||
|
Evidence is tenant-scoped, append-friendly, and contains no runtime credentials.
|
||||||
|
It provides a deterministic source for claim, heartbeat, progress, result and
|
||||||
|
verification state across Codex, OpenClaw and Hermes executors.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionEvidence:
|
||||||
|
execution_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
executor: str
|
||||||
|
branch: str
|
||||||
|
status: str = "claimed"
|
||||||
|
progress_completed: int = 0
|
||||||
|
progress_total: int = 1
|
||||||
|
current_step: str = "claim"
|
||||||
|
last_commit: str | None = None
|
||||||
|
test_status: str = "not_run"
|
||||||
|
test_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
result_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
claimed_at: str = field(default_factory=utc_now)
|
||||||
|
heartbeat_at: str = field(default_factory=utc_now)
|
||||||
|
completed_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"execution_id",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"executor",
|
||||||
|
"branch",
|
||||||
|
"status",
|
||||||
|
"current_step",
|
||||||
|
"claimed_at",
|
||||||
|
"heartbeat_at",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
if self.progress_completed < 0 or self.progress_total < 1:
|
||||||
|
raise ValueError("invalid progress values")
|
||||||
|
if self.progress_completed > self.progress_total:
|
||||||
|
raise ValueError("progress_completed cannot exceed progress_total")
|
||||||
|
object.__setattr__(self, "test_summary", _safe(self.test_summary))
|
||||||
|
object.__setattr__(self, "result_summary", _safe(self.result_summary))
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionTracker:
|
||||||
|
"""Persist one JSON evidence record per tenant and execution."""
|
||||||
|
|
||||||
|
def __init__(self, root: str | Path) -> None:
|
||||||
|
self.root = Path(root).resolve()
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = _required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ValueError("invalid evidence path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _path(self, tenant_id: str, execution_id: str) -> Path:
|
||||||
|
tenant = self._component(tenant_id)
|
||||||
|
execution = self._component(execution_id)
|
||||||
|
return self.root / tenant / f"{execution}.json"
|
||||||
|
|
||||||
|
def claim(self, evidence: ExecutionEvidence) -> ExecutionEvidence:
|
||||||
|
path = self._path(evidence.tenant_id, evidence.execution_id)
|
||||||
|
with self._lock:
|
||||||
|
if path.exists():
|
||||||
|
current = self._read(path)
|
||||||
|
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
|
||||||
|
raise RuntimeError("execution_id already belongs to another task")
|
||||||
|
return current
|
||||||
|
self._write(path, evidence)
|
||||||
|
return evidence
|
||||||
|
|
||||||
|
def heartbeat(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
execution_id: str,
|
||||||
|
status: str | None = None,
|
||||||
|
current_step: str | None = None,
|
||||||
|
progress_completed: int | None = None,
|
||||||
|
progress_total: int | None = None,
|
||||||
|
last_commit: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(tenant_id, execution_id)
|
||||||
|
with self._lock:
|
||||||
|
current = self._read(path)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
status=status or current.status,
|
||||||
|
current_step=current_step or current.current_step,
|
||||||
|
progress_completed=(
|
||||||
|
progress_completed
|
||||||
|
if progress_completed is not None
|
||||||
|
else current.progress_completed
|
||||||
|
),
|
||||||
|
progress_total=(
|
||||||
|
progress_total if progress_total is not None else current.progress_total
|
||||||
|
),
|
||||||
|
last_commit=last_commit or current.last_commit,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._write(path, updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
execution_id: str,
|
||||||
|
status: str,
|
||||||
|
test_status: str,
|
||||||
|
test_summary: Mapping[str, Any] | None = None,
|
||||||
|
result_summary: Mapping[str, Any] | None = None,
|
||||||
|
last_commit: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(tenant_id, execution_id)
|
||||||
|
with self._lock:
|
||||||
|
current = self._read(path)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
status=_required(status, "status"),
|
||||||
|
current_step="complete",
|
||||||
|
progress_completed=current.progress_total,
|
||||||
|
last_commit=last_commit or current.last_commit,
|
||||||
|
test_status=_required(test_status, "test_status"),
|
||||||
|
test_summary=_safe(test_summary),
|
||||||
|
result_summary=_safe(result_summary),
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
completed_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._write(path, updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def get(self, *, tenant_id: str, execution_id: str) -> ExecutionEvidence:
|
||||||
|
return self._read(self._path(tenant_id, execution_id))
|
||||||
|
|
||||||
|
def list(self, *, tenant_id: str) -> list[ExecutionEvidence]:
|
||||||
|
tenant_dir = self.root / self._component(tenant_id)
|
||||||
|
if not tenant_dir.exists():
|
||||||
|
return []
|
||||||
|
records = [self._read(path) for path in tenant_dir.glob("*.json")]
|
||||||
|
return sorted(records, key=lambda item: (item.heartbeat_at, item.execution_id), reverse=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read(path: Path) -> ExecutionEvidence:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise KeyError("execution evidence not found") from exc
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise RuntimeError("invalid execution evidence file")
|
||||||
|
return ExecutionEvidence(**dict(value))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write(path: Path, evidence: ExecutionEvidence) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""HTTP/JSON gateway for the AIOS V2 control plane.
|
||||||
|
|
||||||
|
The gateway is intentionally runtime-neutral: browsers and external clients
|
||||||
|
call this API, while OpenClaw and Hermes remain behind authenticated adapters.
|
||||||
|
Identity headers are accepted only from a trusted reverse proxy in v1; direct
|
||||||
|
public exposure is prohibited until signed identity middleware is enabled.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
from enum import Enum
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
ControlPlaneError,
|
||||||
|
ControlPlaneService,
|
||||||
|
IdempotencyConflict,
|
||||||
|
InvalidTransition,
|
||||||
|
NotFound,
|
||||||
|
PolicyDenied,
|
||||||
|
RequestContext,
|
||||||
|
RevisionConflict,
|
||||||
|
TaskState,
|
||||||
|
TenantIsolationError,
|
||||||
|
Unauthorized,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_HEADER = "X-AIOS-Tenant-ID"
|
||||||
|
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
|
||||||
|
ROLES_HEADER = "X-AIOS-Roles"
|
||||||
|
TRACE_HEADER = "X-AIOS-Trace-ID"
|
||||||
|
SESSION_HEADER = "X-AIOS-Session-ID"
|
||||||
|
POLICY_HEADER = "X-AIOS-Policy-Version"
|
||||||
|
AUTHENTICATED_HEADER = "X-AIOS-Authenticated"
|
||||||
|
|
||||||
|
_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: Any) -> Any:
|
||||||
|
if dataclasses.is_dataclass(value):
|
||||||
|
return {
|
||||||
|
field.name: _json_value(getattr(value, field.name))
|
||||||
|
for field in dataclasses.fields(value)
|
||||||
|
}
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {str(key): _json_value(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return [_json_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mapping(value: Any, *, label: str = "JSON body") -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_resource_id(value: Any, *, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not _RESOURCE_ID_RE.fullmatch(normalized):
|
||||||
|
raise ValueError(f"invalid {label}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayApplication:
|
||||||
|
"""Pure request router that can be tested without a listening socket."""
|
||||||
|
|
||||||
|
def __init__(self, service: ControlPlaneService | None = None) -> None:
|
||||||
|
self.service = service or ControlPlaneService()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
|
||||||
|
authenticated = str(headers.get(AUTHENTICATED_HEADER, "")).strip().lower()
|
||||||
|
if authenticated not in {"1", "true", "yes"}:
|
||||||
|
raise Unauthorized("trusted authentication assertion is required")
|
||||||
|
roles = [
|
||||||
|
role.strip()
|
||||||
|
for role in str(headers.get(ROLES_HEADER, "")).split(",")
|
||||||
|
if role.strip()
|
||||||
|
]
|
||||||
|
return RequestContext.create(
|
||||||
|
tenant_id=str(headers.get(TENANT_HEADER, "")),
|
||||||
|
principal_id=str(headers.get(PRINCIPAL_HEADER, "")),
|
||||||
|
trace_id=str(headers.get(TRACE_HEADER, "")).strip() or None,
|
||||||
|
session_id=str(headers.get(SESSION_HEADER, "")).strip() or None,
|
||||||
|
policy_version=str(headers.get(POLICY_HEADER, "v1")).strip() or "v1",
|
||||||
|
roles=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
raw_path: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: Any = None,
|
||||||
|
) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
parsed = urllib.parse.urlparse(raw_path)
|
||||||
|
path = parsed.path.rstrip("/") or "/"
|
||||||
|
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
|
||||||
|
method = method.upper()
|
||||||
|
|
||||||
|
if method == "GET" and path in {"/healthz", "/api/v2/health"}:
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"service": "aios-v2-control-plane",
|
||||||
|
"status": "ok",
|
||||||
|
"store": type(self.service.store).__name__,
|
||||||
|
}
|
||||||
|
|
||||||
|
context = self.context_from_headers(headers)
|
||||||
|
payload = _require_mapping(body or {})
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/tenants":
|
||||||
|
tenant = self.service.register_tenant(
|
||||||
|
context,
|
||||||
|
tenant_id=_require_resource_id(payload.get("tenant_id"), label="tenant_id"),
|
||||||
|
display_name=str(payload.get("display_name", "")),
|
||||||
|
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/tenants/current":
|
||||||
|
tenant = self.service.get_tenant(context)
|
||||||
|
return HTTPStatus.OK, {"tenant": _json_value(tenant)}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/agents":
|
||||||
|
agent = self.service.register_agent(
|
||||||
|
context,
|
||||||
|
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
||||||
|
display_name=str(payload.get("display_name", "")),
|
||||||
|
runtime=str(payload.get("runtime", "")),
|
||||||
|
capabilities=payload.get("capabilities", ()),
|
||||||
|
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/agents":
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"agents": _json_value(self.service.list_agents(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
|
||||||
|
if method == "GET" and agent_match:
|
||||||
|
agent_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(agent_match.group(1)), label="agent_id"
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"agent": _json_value(self.service.get_agent(context, agent_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/tasks":
|
||||||
|
task = self.service.create_task(
|
||||||
|
context,
|
||||||
|
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
||||||
|
action=str(payload.get("action", "")).strip(),
|
||||||
|
payload=_require_mapping(payload.get("payload", {}), label="payload"),
|
||||||
|
idempotency_key=(
|
||||||
|
str(payload["idempotency_key"]).strip()
|
||||||
|
if payload.get("idempotency_key") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/tasks":
|
||||||
|
requested_states = query.get("state")
|
||||||
|
tasks = self.service.list_tasks(context, states=requested_states)
|
||||||
|
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
|
||||||
|
|
||||||
|
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
|
||||||
|
if method == "GET" and task_match:
|
||||||
|
task_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(task_match.group(1)), label="task_id"
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"task": _json_value(self.service.get_task(context, task_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
transition_match = re.fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/(approve|start|complete|fail|cancel)", path
|
||||||
|
)
|
||||||
|
if method == "POST" and transition_match:
|
||||||
|
task_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(transition_match.group(1)), label="task_id"
|
||||||
|
)
|
||||||
|
transition = transition_match.group(2)
|
||||||
|
target = {
|
||||||
|
"approve": TaskState.APPROVED,
|
||||||
|
"start": TaskState.RUNNING,
|
||||||
|
"complete": TaskState.COMPLETED,
|
||||||
|
"fail": TaskState.FAILED,
|
||||||
|
"cancel": TaskState.CANCELLED,
|
||||||
|
}[transition]
|
||||||
|
expected_revision = payload.get("expected_revision")
|
||||||
|
if expected_revision is not None:
|
||||||
|
expected_revision = int(expected_revision)
|
||||||
|
task = self.service.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task_id,
|
||||||
|
target_state=target,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
result=(
|
||||||
|
_require_mapping(payload.get("result"), label="result")
|
||||||
|
if payload.get("result") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
error=(
|
||||||
|
_require_mapping(payload.get("error"), label="error")
|
||||||
|
if payload.get("error") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/audit":
|
||||||
|
resource_id = query.get("resource_id", [None])[0]
|
||||||
|
events = self.service.list_audit_events(
|
||||||
|
context,
|
||||||
|
resource_id=(
|
||||||
|
_require_resource_id(resource_id, label="resource_id")
|
||||||
|
if resource_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"events": _json_value(events)}
|
||||||
|
|
||||||
|
return HTTPStatus.NOT_FOUND, {
|
||||||
|
"error": "not_found",
|
||||||
|
"message": "route not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
|
||||||
|
(Unauthorized, HTTPStatus.UNAUTHORIZED),
|
||||||
|
(PolicyDenied, HTTPStatus.FORBIDDEN),
|
||||||
|
(TenantIsolationError, HTTPStatus.NOT_FOUND),
|
||||||
|
(NotFound, HTTPStatus.NOT_FOUND),
|
||||||
|
(IdempotencyConflict, HTTPStatus.CONFLICT),
|
||||||
|
(RevisionConflict, HTTPStatus.CONFLICT),
|
||||||
|
(InvalidTransition, HTTPStatus.CONFLICT),
|
||||||
|
(ValueError, HTTPStatus.BAD_REQUEST),
|
||||||
|
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
for error_type, status in _ERROR_STATUS:
|
||||||
|
if isinstance(exc, error_type):
|
||||||
|
return status, {
|
||||||
|
"error": error_type.__name__,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {
|
||||||
|
"error": "InternalServerError",
|
||||||
|
"message": "internal server error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "AIOSV2Gateway/1.0"
|
||||||
|
max_body_bytes = 1_048_576
|
||||||
|
|
||||||
|
@property
|
||||||
|
def application(self) -> GatewayApplication:
|
||||||
|
return self.server.application # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def _read_body(self) -> Any:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
if length < 0 or length > self.max_body_bytes:
|
||||||
|
raise ValueError("request body is too large")
|
||||||
|
if length == 0:
|
||||||
|
return {}
|
||||||
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||||
|
if content_type != "application/json":
|
||||||
|
raise ValueError("Content-Type must be application/json")
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
self.send_response(int(status))
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _dispatch(self, method: str) -> None:
|
||||||
|
try:
|
||||||
|
body = self._read_body() if method in {"POST", "PUT", "PATCH"} else {}
|
||||||
|
status, payload = self.application.handle(
|
||||||
|
method,
|
||||||
|
self.path,
|
||||||
|
{key: value for key, value in self.headers.items()},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
except BaseException as exc: # handler boundary; sanitized below
|
||||||
|
status, payload = error_response(exc)
|
||||||
|
self._send_json(status, payload)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("GET")
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("POST")
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
|
# Production deployment routes structured access logs through the
|
||||||
|
# reverse proxy; do not log identity headers or request bodies here.
|
||||||
|
super().log_message(format, *args)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_address: tuple[str, int],
|
||||||
|
application: GatewayApplication,
|
||||||
|
) -> None:
|
||||||
|
self.application = application
|
||||||
|
super().__init__(server_address, GatewayRequestHandler)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
|
||||||
|
server = GatewayHTTPServer((host, port), GatewayApplication())
|
||||||
|
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Sanitized GitOps runtime status for the AIOS Web control plane."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitOpsStatus:
|
||||||
|
available: bool
|
||||||
|
service_status: str
|
||||||
|
workspace_mode: str
|
||||||
|
workspace_branch: str | None
|
||||||
|
workspace_ref: str | None
|
||||||
|
workspace_dirty: bool | None
|
||||||
|
candidate_branch: str
|
||||||
|
release_channel: str
|
||||||
|
release_version: str
|
||||||
|
sync_status: str
|
||||||
|
details: Mapping[str, Any]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(worktree: Path, *args: str) -> tuple[int, str, str]:
|
||||||
|
process = subprocess.run(
|
||||||
|
["git", "-C", str(worktree), *args],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return process.returncode, process.stdout.strip(), process.stderr.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_gitops_status() -> GitOpsStatus:
|
||||||
|
"""Return only non-sensitive Git/workspace metadata.
|
||||||
|
|
||||||
|
Remote URLs, environment values, credentials, filesystem contents and
|
||||||
|
command stderr are deliberately excluded from the Web response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
candidate_branch = os.getenv(
|
||||||
|
"AIOS_CANDIDATE_BRANCH",
|
||||||
|
"integration/aios-v2r1-cto-production-candidate-20260720",
|
||||||
|
)
|
||||||
|
release_channel = os.getenv("AIOS_RELEASE_CHANNEL", "candidate")
|
||||||
|
release_version = os.getenv("AIOS_RELEASE_VERSION", "V2R1C0-candidate")
|
||||||
|
sync_status = os.getenv("AIOS_GITOPS_SYNC_STATUS", "unknown")
|
||||||
|
worktree = Path(
|
||||||
|
os.getenv(
|
||||||
|
"AIOS_GITOPS_CONTROLLER_WORKTREE",
|
||||||
|
"/data/aios-gitops/worktrees/controller",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not worktree.is_dir():
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=False,
|
||||||
|
service_status="unknown",
|
||||||
|
workspace_mode="unavailable",
|
||||||
|
workspace_branch=None,
|
||||||
|
workspace_ref=None,
|
||||||
|
workspace_dirty=None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={"reason": "controller_worktree_unavailable"},
|
||||||
|
)
|
||||||
|
|
||||||
|
inside_code, inside, _ = _run_git(worktree, "rev-parse", "--is-inside-work-tree")
|
||||||
|
if inside_code != 0 or inside != "true":
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=False,
|
||||||
|
service_status="unknown",
|
||||||
|
workspace_mode="invalid",
|
||||||
|
workspace_branch=None,
|
||||||
|
workspace_ref=None,
|
||||||
|
workspace_dirty=None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={"reason": "controller_worktree_invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
branch_code, branch, _ = _run_git(worktree, "branch", "--show-current")
|
||||||
|
ref_code, ref_value, _ = _run_git(worktree, "rev-parse", "HEAD")
|
||||||
|
dirty_code, dirty_value, _ = _run_git(worktree, "status", "--porcelain=v1")
|
||||||
|
|
||||||
|
service_status = "unknown"
|
||||||
|
try:
|
||||||
|
process = subprocess.run(
|
||||||
|
["systemctl", "is-active", "aios-gitops-model-api"],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
service_status = process.stdout.strip() or "inactive"
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
service_status = "unavailable"
|
||||||
|
|
||||||
|
branch_value = branch if branch_code == 0 and branch else None
|
||||||
|
return GitOpsStatus(
|
||||||
|
available=True,
|
||||||
|
service_status=service_status,
|
||||||
|
workspace_mode="branch" if branch_value else "detached",
|
||||||
|
workspace_branch=branch_value,
|
||||||
|
workspace_ref=ref_value if ref_code == 0 else None,
|
||||||
|
workspace_dirty=bool(dirty_value) if dirty_code == 0 else None,
|
||||||
|
candidate_branch=candidate_branch,
|
||||||
|
release_channel=release_channel,
|
||||||
|
release_version=release_version,
|
||||||
|
sync_status=sync_status,
|
||||||
|
details={
|
||||||
|
"controller_clean": dirty_code == 0 and not dirty_value,
|
||||||
|
"controller_detached": branch_code == 0 and not branch,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""AIOS V2 execution orchestrator.
|
||||||
|
|
||||||
|
The orchestrator is the only component allowed to translate an approved AIOS
|
||||||
|
task into an OpenClaw command or Hermes job. OpenClaw and Hermes remain pure
|
||||||
|
execution runtimes and never own tenant policy or task lifecycle.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_hermes import HermesAdapter, HermesJob, HermesResult
|
||||||
|
from aios_openclaw import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawAdapter,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
sign_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
ControlPlaneService,
|
||||||
|
RequestContext,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestrationError(RuntimeError):
|
||||||
|
"""AIOS failed to dispatch or reconcile a runtime execution."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DispatchRecord:
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
runtime: str
|
||||||
|
runtime_execution_id: str
|
||||||
|
state: str
|
||||||
|
trace_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionOrchestrator:
|
||||||
|
"""Dispatch approved AIOS tasks to runtime adapters and reconcile results."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
control_plane: ControlPlaneService,
|
||||||
|
*,
|
||||||
|
openclaw: OpenClawAdapter | None = None,
|
||||||
|
hermes: HermesAdapter | None = None,
|
||||||
|
openclaw_signing_material_resolver=None,
|
||||||
|
openclaw_key_id: str = "default",
|
||||||
|
default_cell_id: str = "default",
|
||||||
|
default_profile_id: str = "default",
|
||||||
|
) -> None:
|
||||||
|
self.control_plane = control_plane
|
||||||
|
self.openclaw = openclaw
|
||||||
|
self.hermes = hermes
|
||||||
|
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
|
||||||
|
self.openclaw_key_id = openclaw_key_id
|
||||||
|
self.default_cell_id = default_cell_id
|
||||||
|
self.default_profile_id = default_profile_id
|
||||||
|
self._dispatches: dict[tuple[str, str], DispatchRecord] = {}
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
task = self.control_plane.get_task(context, task_id)
|
||||||
|
if task.state != TaskState.APPROVED:
|
||||||
|
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||||
|
agent = self.control_plane.get_agent(context, task.agent_id)
|
||||||
|
existing = self._dispatches.get((task.tenant_id, task.task_id))
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
running = self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.RUNNING,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime = agent.runtime.strip().lower()
|
||||||
|
try:
|
||||||
|
if runtime == "openclaw":
|
||||||
|
record = self._dispatch_openclaw(
|
||||||
|
context,
|
||||||
|
agent,
|
||||||
|
running,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
elif runtime == "hermes":
|
||||||
|
record = self._dispatch_hermes(context, agent, running)
|
||||||
|
else:
|
||||||
|
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
|
||||||
|
except Exception as exc:
|
||||||
|
current = self.control_plane.get_task(context, task.task_id)
|
||||||
|
if current.state == TaskState.RUNNING:
|
||||||
|
self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.FAILED,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
error={"code": "dispatch_failed", "message": str(exc)},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._dispatches[(task.tenant_id, task.task_id)] = record
|
||||||
|
return record
|
||||||
|
|
||||||
|
def _dispatch_openclaw(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
agent: AgentRecord,
|
||||||
|
task: TaskRecord,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float | None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
if self.openclaw is None or self.openclaw_signing_material_resolver is None:
|
||||||
|
raise OrchestrationError("OpenClaw runtime is not configured")
|
||||||
|
key_material = self.openclaw_signing_material_resolver(
|
||||||
|
task.tenant_id,
|
||||||
|
self.openclaw_key_id,
|
||||||
|
)
|
||||||
|
command = OpenClawCommand.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=task.action,
|
||||||
|
payload=task.payload,
|
||||||
|
idempotency_key=task.idempotency_key or task.task_id,
|
||||||
|
key_id=self.openclaw_key_id,
|
||||||
|
)
|
||||||
|
command = sign_command(command, key_material)
|
||||||
|
result = self.openclaw.execute(command, timeout_seconds=timeout_seconds)
|
||||||
|
self.reconcile_openclaw(context, result)
|
||||||
|
return DispatchRecord(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_execution_id=command.command_id,
|
||||||
|
state=result.state.value,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dispatch_hermes(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
agent: AgentRecord,
|
||||||
|
task: TaskRecord,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
if self.hermes is None:
|
||||||
|
raise OrchestrationError("Hermes runtime is not configured")
|
||||||
|
metadata = dict(agent.metadata)
|
||||||
|
job = HermesJob.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
|
||||||
|
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
|
||||||
|
task_id=task.task_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=task.action,
|
||||||
|
payload=task.payload,
|
||||||
|
idempotency_key=task.idempotency_key or task.task_id,
|
||||||
|
priority=int(metadata.get("priority", 100)),
|
||||||
|
)
|
||||||
|
queued = self.hermes.enqueue(job)
|
||||||
|
return DispatchRecord(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
runtime="hermes",
|
||||||
|
runtime_execution_id=queued.job_id,
|
||||||
|
state=queued.state.value,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reconcile_openclaw(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
result: OpenClawResult,
|
||||||
|
) -> TaskRecord:
|
||||||
|
task = self.control_plane.get_task(context, result.task_id)
|
||||||
|
if result.tenant_id != task.tenant_id:
|
||||||
|
raise OrchestrationError("OpenClaw result tenant mismatch")
|
||||||
|
if task.state != TaskState.RUNNING:
|
||||||
|
return task
|
||||||
|
if result.state == CommandState.COMPLETED:
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.COMPLETED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
result=result.output or {},
|
||||||
|
)
|
||||||
|
if result.state in {
|
||||||
|
CommandState.FAILED,
|
||||||
|
CommandState.TIMED_OUT,
|
||||||
|
CommandState.REJECTED,
|
||||||
|
}:
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.FAILED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
error=result.error or {"code": result.state.value.lower()},
|
||||||
|
)
|
||||||
|
if result.state == CommandState.CANCELLED:
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.CANCELLED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def reconcile_hermes(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
result: HermesResult,
|
||||||
|
) -> TaskRecord:
|
||||||
|
task = self.control_plane.get_task(context, result.task_id)
|
||||||
|
if result.tenant_id != task.tenant_id:
|
||||||
|
raise OrchestrationError("Hermes result tenant mismatch")
|
||||||
|
if task.state != TaskState.RUNNING:
|
||||||
|
return task
|
||||||
|
if result.state.value == "COMPLETED":
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.COMPLETED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
result=result.output or {},
|
||||||
|
)
|
||||||
|
if result.state.value == "FAILED":
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.FAILED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
error=result.error or {"code": "hermes_failed"},
|
||||||
|
)
|
||||||
|
if result.state.value == "CANCELLED":
|
||||||
|
return self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.CANCELLED,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
|
||||||
|
return {
|
||||||
|
task_id: record
|
||||||
|
for (record_tenant, task_id), record in self._dispatches.items()
|
||||||
|
if record_tenant == tenant_id
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Durable single-process persistence for the AIOS V2 control plane.
|
||||||
|
|
||||||
|
The candidate uses an atomic SQLite snapshot so a gateway restart does not lose
|
||||||
|
tenants, agents, task state, idempotency records, or audit events. The domain
|
||||||
|
service remains storage-agnostic; a later HA backend can replace this module
|
||||||
|
without changing API or runtime-adapter contracts.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
AuditEvent,
|
||||||
|
ControlPlaneService,
|
||||||
|
InMemoryControlPlaneStore,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
TenantRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteSnapshotRepository:
|
||||||
|
"""Store one versioned control-plane snapshot in SQLite."""
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS control_plane_snapshot (
|
||||||
|
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
|
||||||
|
schema_version INTEGER NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def load(self) -> dict[str, Any] | None:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT schema_version, payload_json FROM control_plane_snapshot WHERE singleton_id = 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
schema_version, payload_json = row
|
||||||
|
if int(schema_version) != self.SCHEMA_VERSION:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unsupported control-plane snapshot version: {schema_version}"
|
||||||
|
)
|
||||||
|
decoded = json.loads(str(payload_json))
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
raise RuntimeError("invalid control-plane snapshot")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
def save(self, payload: dict[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO control_plane_snapshot (
|
||||||
|
singleton_id, schema_version, payload_json, updated_at
|
||||||
|
) VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(singleton_id) DO UPDATE SET
|
||||||
|
schema_version = excluded.schema_version,
|
||||||
|
payload_json = excluded.payload_json,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(self.SCHEMA_VERSION, encoded),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class DurableControlPlaneService(ControlPlaneService):
|
||||||
|
"""Control-plane service that persists after every successful mutation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: SQLiteSnapshotRepository,
|
||||||
|
*,
|
||||||
|
policy_evaluator=None,
|
||||||
|
) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
store = InMemoryControlPlaneStore()
|
||||||
|
self._restore(store, repository.load())
|
||||||
|
super().__init__(store, policy_evaluator=policy_evaluator)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore(
|
||||||
|
store: InMemoryControlPlaneStore,
|
||||||
|
snapshot: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
if not snapshot:
|
||||||
|
return
|
||||||
|
for item in snapshot.get("tenants", []):
|
||||||
|
record = TenantRecord(**item)
|
||||||
|
store.tenants[record.tenant_id] = record
|
||||||
|
for item in snapshot.get("agents", []):
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["capabilities"] = tuple(normalized.get("capabilities", ()))
|
||||||
|
record = AgentRecord(**normalized)
|
||||||
|
store.agents[record.agent_id] = record
|
||||||
|
for item in snapshot.get("tasks", []):
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["state"] = TaskState(normalized["state"])
|
||||||
|
record = TaskRecord(**normalized)
|
||||||
|
store.tasks[record.task_id] = record
|
||||||
|
store.audit_events.extend(AuditEvent(**item) for item in snapshot.get("audit", []))
|
||||||
|
for item in snapshot.get("idempotency", []):
|
||||||
|
store.idempotency[(item["tenant_id"], item["key"])] = (
|
||||||
|
item["task_id"],
|
||||||
|
item["digest"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _snapshot(self) -> dict[str, Any]:
|
||||||
|
with self.store.lock:
|
||||||
|
return {
|
||||||
|
"schema_version": self.repository.SCHEMA_VERSION,
|
||||||
|
"tenants": [asdict(item) for item in self.store.tenants.values()],
|
||||||
|
"agents": [asdict(item) for item in self.store.agents.values()],
|
||||||
|
"tasks": [
|
||||||
|
{**asdict(item), "state": item.state.value}
|
||||||
|
for item in self.store.tasks.values()
|
||||||
|
],
|
||||||
|
"audit": [asdict(item) for item in self.store.audit_events],
|
||||||
|
"idempotency": [
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"key": key,
|
||||||
|
"task_id": task_id,
|
||||||
|
"digest": digest,
|
||||||
|
}
|
||||||
|
for (tenant_id, key), (task_id, digest)
|
||||||
|
in self.store.idempotency.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _mutate(self, operation: Callable[[], Any]) -> Any:
|
||||||
|
with self.store.lock:
|
||||||
|
result = operation()
|
||||||
|
self.repository.save(self._snapshot())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def register_tenant(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).register_tenant(*args, **kwargs))
|
||||||
|
|
||||||
|
def register_agent(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).register_agent(*args, **kwargs))
|
||||||
|
|
||||||
|
def create_task(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).create_task(*args, **kwargs))
|
||||||
|
|
||||||
|
def transition_task(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).transition_task(*args, **kwargs))
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
"""Fail-closed access to the authoritative bare Git repository."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import urllib.parse
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
_ALLOWED_COMMANDS = frozenset(
|
|
||||||
{
|
|
||||||
"cat-file",
|
|
||||||
"config",
|
|
||||||
"diff",
|
|
||||||
"for-each-ref",
|
|
||||||
"ls-remote",
|
|
||||||
"merge-base",
|
|
||||||
"push",
|
|
||||||
"remote",
|
|
||||||
"rev-parse",
|
|
||||||
"show-ref",
|
|
||||||
"tag",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
||||||
_MAX_OUTPUT = 1_048_576
|
|
||||||
_TIMEOUT = 8.0
|
|
||||||
|
|
||||||
|
|
||||||
class AuthoritativeGitError(RuntimeError):
|
|
||||||
"""A fail-closed authoritative repository or ref query error."""
|
|
||||||
|
|
||||||
|
|
||||||
class AuthoritativeGitNotFound(AuthoritativeGitError):
|
|
||||||
"""A validated revision does not exist in the authoritative repository."""
|
|
||||||
|
|
||||||
|
|
||||||
class AuthoritativeGitOutputTooLarge(AuthoritativeGitError):
|
|
||||||
"""A controlled Git query exceeded the response-size boundary."""
|
|
||||||
|
|
||||||
|
|
||||||
def _redact(text: str) -> str:
|
|
||||||
value = str(text or "")
|
|
||||||
value = re.sub(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+", r"\1REDACTED", value)
|
|
||||||
value = re.sub(r"(?i)((?:access_)?token\s*[:=]\s*)\S+", r"\1REDACTED", value)
|
|
||||||
value = re.sub(r"(?i)(https?://[^/\s:@]+:)[^@\s/]+@", r"\1REDACTED@", value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _configured_path(git_dir: str | Path | None = None) -> Path:
|
|
||||||
raw = str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
|
|
||||||
if not raw:
|
|
||||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
|
||||||
path = Path(raw)
|
|
||||||
if not path.is_absolute() or ".." in path.parts:
|
|
||||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
|
||||||
try:
|
|
||||||
resolved = path.resolve(strict=True)
|
|
||||||
except OSError as exc:
|
|
||||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE") from exc
|
|
||||||
if not resolved.is_dir():
|
|
||||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
git_dir: Path,
|
|
||||||
args: list[str],
|
|
||||||
*,
|
|
||||||
check: bool,
|
|
||||||
timeout: float = _TIMEOUT,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
if not args or args[0] not in _ALLOWED_COMMANDS:
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "--git-dir", str(git_dir), *args],
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=timeout,
|
|
||||||
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
|
|
||||||
if len(result.stdout) + len(result.stderr) > _MAX_OUTPUT:
|
|
||||||
raise AuthoritativeGitOutputTooLarge("GIT_OUTPUT_LIMIT_EXCEEDED")
|
|
||||||
if check and result.returncode != 0:
|
|
||||||
detail = _redact(result.stderr.strip() or result.stdout.strip())
|
|
||||||
raise AuthoritativeGitError(
|
|
||||||
"GIT_REF_QUERY_FAILED" + (f": {detail[:160]}" if detail else "")
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _repository_identity_from_remote(url: str) -> str:
|
|
||||||
value = str(url or "").strip().split("?", 1)[0].rstrip("/")
|
|
||||||
if not value:
|
|
||||||
return ""
|
|
||||||
if "://" in value:
|
|
||||||
value = urllib.parse.urlparse(value).path
|
|
||||||
elif ":" in value:
|
|
||||||
value = value.rsplit(":", 1)[1]
|
|
||||||
value = value.rsplit("@", 1)[-1].strip("/")
|
|
||||||
value = re.sub(r"\.git$", "", value, flags=re.IGNORECASE)
|
|
||||||
parts = [part for part in value.split("/") if part]
|
|
||||||
if len(parts) >= 2:
|
|
||||||
value = "/".join(parts[-2:])
|
|
||||||
return value.strip("/").lower()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_repository(
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
repository_identity: str | None = None,
|
|
||||||
) -> tuple[Path, str]:
|
|
||||||
path = _configured_path(git_dir)
|
|
||||||
expected = str(
|
|
||||||
repository_identity
|
|
||||||
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
|
||||||
).strip().strip("/")
|
|
||||||
if not expected or expected.count("/") != 1:
|
|
||||||
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
|
|
||||||
bare = _run(path, ["rev-parse", "--is-bare-repository"], check=True)
|
|
||||||
if bare.stdout.strip().lower() != "true":
|
|
||||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
|
||||||
remote = _run(path, ["config", "--get", "remote.gitea.url"], check=False)
|
|
||||||
actual = _repository_identity_from_remote(remote.stdout)
|
|
||||||
if actual != expected.lower():
|
|
||||||
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
|
|
||||||
return path, expected
|
|
||||||
|
|
||||||
|
|
||||||
def run_authoritative_git(
|
|
||||||
args: list[str],
|
|
||||||
*,
|
|
||||||
check: bool = True,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
timeout: float = _TIMEOUT,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
path, _ = verify_repository(git_dir=git_dir)
|
|
||||||
return _run(path, list(args), check=check, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def authoritative_head_oid(
|
|
||||||
*,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
) -> str:
|
|
||||||
result = run_authoritative_git(["rev-parse", "--verify", "refs/heads/main"], git_dir=git_dir)
|
|
||||||
oid = result.stdout.strip().lower()
|
|
||||||
if not _OID_RE.fullmatch(oid):
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
||||||
return oid
|
|
||||||
|
|
||||||
|
|
||||||
def authoritative_tags(
|
|
||||||
*,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
) -> list[dict[str, str | None]]:
|
|
||||||
result = run_authoritative_git(
|
|
||||||
[
|
|
||||||
"for-each-ref",
|
|
||||||
"refs/tags",
|
|
||||||
"--format=%(refname:strip=2)\t%(objectname)\t%(objecttype)",
|
|
||||||
],
|
|
||||||
git_dir=git_dir,
|
|
||||||
)
|
|
||||||
tags: list[dict[str, str | None]] = []
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
name, oid, object_type = (line.split("\t", 2) + ["", ""])[:3]
|
|
||||||
if not name or not _OID_RE.fullmatch(oid.lower()):
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
||||||
peeled_result = run_authoritative_git(
|
|
||||||
["rev-parse", "--verify", f"refs/tags/{name}^{{commit}}"],
|
|
||||||
git_dir=git_dir,
|
|
||||||
)
|
|
||||||
peeled = peeled_result.stdout.strip().lower()
|
|
||||||
if not _OID_RE.fullmatch(peeled):
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
||||||
tags.append(
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"tag_oid": oid.lower(),
|
|
||||||
"object_type": object_type,
|
|
||||||
"peeled_target_oid": peeled,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return tags
|
|
||||||
|
|
||||||
|
|
||||||
def authoritative_branches(
|
|
||||||
*,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
result = run_authoritative_git(
|
|
||||||
[
|
|
||||||
"for-each-ref",
|
|
||||||
"refs/heads",
|
|
||||||
"--format=%(refname:strip=2)\t%(objectname)",
|
|
||||||
],
|
|
||||||
git_dir=git_dir,
|
|
||||||
)
|
|
||||||
branches: list[dict[str, str]] = []
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
name, oid = (line.split("\t", 1) + [""])[:2]
|
|
||||||
if not name or not _OID_RE.fullmatch(oid.lower()):
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
||||||
branches.append({"name": name, "oid": oid.lower()})
|
|
||||||
return branches
|
|
||||||
|
|
||||||
|
|
||||||
def authoritative_commit(
|
|
||||||
oid: str,
|
|
||||||
*,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
) -> str:
|
|
||||||
value = str(oid or "").strip().lower()
|
|
||||||
if not _OID_RE.fullmatch(value):
|
|
||||||
raise ValueError("commit OID must be a full 40-character value")
|
|
||||||
result = run_authoritative_git(
|
|
||||||
["rev-parse", "--verify", f"{value}^{{commit}}"],
|
|
||||||
git_dir=git_dir,
|
|
||||||
)
|
|
||||||
resolved = result.stdout.strip().lower()
|
|
||||||
if resolved != value:
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
def authoritative_lineage(
|
|
||||||
ancestor: str,
|
|
||||||
descendant: str,
|
|
||||||
*,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
) -> None:
|
|
||||||
authoritative_commit(ancestor, git_dir=git_dir)
|
|
||||||
authoritative_commit(descendant, git_dir=git_dir)
|
|
||||||
result = run_authoritative_git(
|
|
||||||
["merge-base", "--is-ancestor", ancestor, descendant],
|
|
||||||
check=False,
|
|
||||||
git_dir=git_dir,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise AuthoritativeGitError("evidence lineage mismatch")
|
|
||||||
|
|
||||||
|
|
||||||
_SAFE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_ref_name(value: str) -> bool:
|
|
||||||
return bool(
|
|
||||||
_SAFE_REF_RE.fullmatch(value)
|
|
||||||
and ".." not in value
|
|
||||||
and "//" not in value
|
|
||||||
and "@{" not in value
|
|
||||||
and not value.endswith(("/", ".", ".lock"))
|
|
||||||
and not any(part in {"", ".", ".."} for part in value.split("/"))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_git_revision(
|
|
||||||
value: str,
|
|
||||||
field: str,
|
|
||||||
*,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
) -> str:
|
|
||||||
revision = str(value or "").strip()
|
|
||||||
if not revision or revision != str(value or "") or revision.startswith("-"):
|
|
||||||
raise ValueError(f"invalid {field} revision")
|
|
||||||
if _OID_RE.fullmatch(revision.lower()):
|
|
||||||
try:
|
|
||||||
return authoritative_commit(revision.lower(), git_dir=git_dir)
|
|
||||||
except AuthoritativeGitError as exc:
|
|
||||||
raise AuthoritativeGitNotFound(f"{field} revision not found") from exc
|
|
||||||
if not _safe_ref_name(revision) or any(token in revision for token in ("^", "~", ":", "\\")):
|
|
||||||
raise ValueError(f"invalid {field} revision")
|
|
||||||
if revision.startswith("refs/"):
|
|
||||||
if not revision.startswith(("refs/heads/", "refs/tags/")):
|
|
||||||
raise ValueError(f"invalid {field} revision")
|
|
||||||
candidates = [revision]
|
|
||||||
else:
|
|
||||||
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
|
|
||||||
matches: list[str] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
result = run_authoritative_git(
|
|
||||||
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
|
|
||||||
check=False,
|
|
||||||
git_dir=git_dir,
|
|
||||||
)
|
|
||||||
oid = result.stdout.strip().lower()
|
|
||||||
if result.returncode == 0 and _OID_RE.fullmatch(oid):
|
|
||||||
matches.append(oid)
|
|
||||||
if not matches:
|
|
||||||
raise AuthoritativeGitNotFound(f"{field} revision not found")
|
|
||||||
if len(set(matches)) != 1 or len(matches) != 1:
|
|
||||||
raise ValueError(f"ambiguous {field} revision")
|
|
||||||
return matches[0]
|
|
||||||
|
|
||||||
|
|
||||||
def authoritative_diff(
|
|
||||||
from_revision: str,
|
|
||||||
to_revision: str,
|
|
||||||
*,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
path, _ = verify_repository(git_dir=git_dir)
|
|
||||||
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
|
||||||
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
|
||||||
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
|
||||||
result = run_authoritative_git(args, git_dir=path)
|
|
||||||
return {
|
|
||||||
"code": 0,
|
|
||||||
"stdout": result.stdout,
|
|
||||||
"stderr": "",
|
|
||||||
"cmd": ["git", f"--git-dir={path}", *args],
|
|
||||||
"from_oid": from_oid,
|
|
||||||
"to_oid": to_oid,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def repository_snapshot(
|
|
||||||
*,
|
|
||||||
git_dir: str | Path | None = None,
|
|
||||||
repository_identity: str | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
configured = bool(
|
|
||||||
str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
path, identity = verify_repository(
|
|
||||||
git_dir=git_dir,
|
|
||||||
repository_identity=repository_identity,
|
|
||||||
)
|
|
||||||
head = authoritative_head_oid(git_dir=path)
|
|
||||||
return {
|
|
||||||
"repository_available": True,
|
|
||||||
"authoritative_git_dir_configured": configured,
|
|
||||||
"authoritative_git_dir_readable": True,
|
|
||||||
"authoritative_git_dir": str(path),
|
|
||||||
"authoritative_head_oid": head,
|
|
||||||
"repository_identity": identity,
|
|
||||||
"query_error": None,
|
|
||||||
}
|
|
||||||
except (AuthoritativeGitError, ValueError) as exc:
|
|
||||||
return {
|
|
||||||
"repository_available": False,
|
|
||||||
"authoritative_git_dir_configured": configured,
|
|
||||||
"authoritative_git_dir_readable": False,
|
|
||||||
"authoritative_git_dir": None,
|
|
||||||
"authoritative_head_oid": None,
|
|
||||||
"repository_identity": str(
|
|
||||||
repository_identity
|
|
||||||
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
|
||||||
).strip() or None,
|
|
||||||
"query_error": _redact(str(exc)),
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
|||||||
"""Fail-closed operation authorization for an isolated Git writer."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from isolated_writer_token_registry import TokenRegistry
|
|
||||||
|
|
||||||
|
|
||||||
ROUTE_OPERATIONS = {
|
|
||||||
("GET", "/api/runtime-status"): "runtime.status.read",
|
|
||||||
("GET", "/api/baseline"): "baseline.read",
|
|
||||||
("GET", "/api/patches"): "patches.read",
|
|
||||||
("POST", "/api/commit-file"): "commit_file.atomic",
|
|
||||||
}
|
|
||||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationError(PermissionError):
|
|
||||||
def __init__(self, code: str, status: int = 403):
|
|
||||||
super().__init__(code)
|
|
||||||
self.code = code
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AuthorizedGrant:
|
|
||||||
record: dict[str, Any]
|
|
||||||
operation: str
|
|
||||||
use_count: int
|
|
||||||
|
|
||||||
@property
|
|
||||||
def token_id(self) -> str:
|
|
||||||
return str(self.record["token_id"])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def task_id(self) -> str:
|
|
||||||
return str(self.record["task_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def operation_for(method: str, path: str) -> str:
|
|
||||||
operation = ROUTE_OPERATIONS.get((method.upper(), path))
|
|
||||||
if not operation:
|
|
||||||
raise AuthorizationError("isolated_writer_operation_forbidden")
|
|
||||||
return operation
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_time(value: Any) -> datetime:
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise AuthorizationError("writer_token_time_invalid") from None
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
raise AuthorizationError("writer_token_time_invalid")
|
|
||||||
return parsed.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _git_is_ancestor(workspace: Path, base: str, branch: str) -> bool:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "merge-base", "--is-ancestor", base, branch],
|
|
||||||
cwd=workspace,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_grant(
|
|
||||||
grant: dict[str, Any],
|
|
||||||
*,
|
|
||||||
audience: str,
|
|
||||||
source_ip: str,
|
|
||||||
operation: str,
|
|
||||||
branch: str | None,
|
|
||||||
path: str | None,
|
|
||||||
workspace: Path,
|
|
||||||
current_time: datetime,
|
|
||||||
) -> None:
|
|
||||||
if int(grant.get("schema_version", 0)) != 2:
|
|
||||||
raise AuthorizationError("writer_token_policy_v2_required", 401)
|
|
||||||
if str(grant.get("audience") or "") != audience:
|
|
||||||
raise AuthorizationError("writer_token_audience_rejected")
|
|
||||||
try:
|
|
||||||
peer = ipaddress.ip_address(source_ip)
|
|
||||||
networks = [ipaddress.ip_network(str(value), strict=True) for value in grant.get("source_cidrs", [])]
|
|
||||||
except ValueError:
|
|
||||||
raise AuthorizationError("writer_token_source_rejected") from None
|
|
||||||
if not networks or not any(peer in network for network in networks):
|
|
||||||
raise AuthorizationError("writer_token_source_rejected")
|
|
||||||
created = _parse_time(grant.get("created_at"))
|
|
||||||
not_before = _parse_time(grant.get("not_before"))
|
|
||||||
expires = _parse_time(grant.get("expires_at"))
|
|
||||||
if expires <= not_before or (expires - created).total_seconds() > 1800:
|
|
||||||
raise AuthorizationError("writer_token_lifetime_rejected")
|
|
||||||
if current_time < not_before:
|
|
||||||
raise AuthorizationError("writer_token_not_yet_valid", 401)
|
|
||||||
if current_time >= expires:
|
|
||||||
raise AuthorizationError("writer_token_expired", 401)
|
|
||||||
if bool(grant.get("revoked")):
|
|
||||||
raise AuthorizationError("writer_token_revoked", 401)
|
|
||||||
operations = grant.get("allowed_operations")
|
|
||||||
if not isinstance(operations, list) or operation not in operations:
|
|
||||||
raise AuthorizationError("writer_token_operation_rejected")
|
|
||||||
expected_branch = str(grant.get("allowed_branch") or "")
|
|
||||||
configured_branch = os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", "")
|
|
||||||
if expected_branch != configured_branch:
|
|
||||||
raise AuthorizationError("writer_token_branch_rejected")
|
|
||||||
if branch is not None and branch != expected_branch:
|
|
||||||
raise AuthorizationError("writer_token_branch_rejected")
|
|
||||||
if path is not None:
|
|
||||||
paths = grant.get("allowed_paths")
|
|
||||||
if not isinstance(paths, list) or path not in paths:
|
|
||||||
raise AuthorizationError("writer_token_path_rejected")
|
|
||||||
base = str(grant.get("approved_base_oid") or "").lower()
|
|
||||||
if not OID_RE.fullmatch(base):
|
|
||||||
raise AuthorizationError("writer_token_base_rejected")
|
|
||||||
if not _git_is_ancestor(workspace, base, expected_branch):
|
|
||||||
raise AuthorizationError("writer_token_base_not_ancestor")
|
|
||||||
|
|
||||||
|
|
||||||
def authorize(
|
|
||||||
registry: TokenRegistry,
|
|
||||||
token: str,
|
|
||||||
*,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
source_ip: str,
|
|
||||||
workspace: Path,
|
|
||||||
branch: str | None = None,
|
|
||||||
target_path: str | None = None,
|
|
||||||
current_time: datetime | None = None,
|
|
||||||
) -> AuthorizedGrant:
|
|
||||||
if os.getenv("AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED", "") != "2":
|
|
||||||
raise AuthorizationError("writer_token_policy_v2_not_required")
|
|
||||||
if not token:
|
|
||||||
raise AuthorizationError("unauthorized", 401)
|
|
||||||
grant = registry.find(token)
|
|
||||||
if grant is None:
|
|
||||||
raise AuthorizationError("unauthorized", 401)
|
|
||||||
operation = operation_for(method, path)
|
|
||||||
_validate_grant(
|
|
||||||
grant,
|
|
||||||
audience=os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", ""),
|
|
||||||
source_ip=source_ip,
|
|
||||||
operation=operation,
|
|
||||||
branch=branch,
|
|
||||||
path=target_path,
|
|
||||||
workspace=workspace,
|
|
||||||
current_time=current_time or datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
limits = grant.get("max_uses")
|
|
||||||
if not isinstance(limits, dict):
|
|
||||||
raise AuthorizationError("writer_token_use_limit_missing")
|
|
||||||
try:
|
|
||||||
used = registry.consume(str(grant["token_id"]), operation, int(limits[operation]))
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
raise AuthorizationError("writer_token_use_limit_missing") from None
|
|
||||||
except PermissionError as exc:
|
|
||||||
code = str(exc)
|
|
||||||
raise AuthorizationError(code, 401 if code == "writer_token_revoked" else 403) from None
|
|
||||||
return AuthorizedGrant(grant, operation, used)
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
"""Atomic, idempotent commit-file Saga for the isolated writer."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import aios_isolated_writer_policy as policy
|
|
||||||
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
|
||||||
IDEMPOTENCY_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{7,127}")
|
|
||||||
STATES = frozenset({
|
|
||||||
"PENDING", "FILE_WRITTEN", "COMMITTED", "PUSHED", "VERIFIED", "COMPLETED",
|
|
||||||
"FAILED_RECOVERABLE", "MANUAL_REVIEW_REQUIRED",
|
|
||||||
})
|
|
||||||
_LOCKS: dict[str, threading.Lock] = {}
|
|
||||||
_LOCKS_GUARD = threading.Lock()
|
|
||||||
_LEDGER_INIT_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
class CommitFileError(RuntimeError):
|
|
||||||
def __init__(self, code: str, status: int = 400):
|
|
||||||
super().__init__(code)
|
|
||||||
self.code = code
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
|
|
||||||
class SimulatedCrash(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_digest(data: dict[str, Any]) -> str:
|
|
||||||
payload = {
|
|
||||||
"branch": data["branch"],
|
|
||||||
"path": data["path"],
|
|
||||||
"content_sha256": hashlib.sha256(data["content"].encode("utf-8")).hexdigest(),
|
|
||||||
"message": data["message"],
|
|
||||||
"expected_head_oid": data["expected_head_oid"],
|
|
||||||
}
|
|
||||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
||||||
return hashlib.sha256(encoded).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class OperationLedger:
|
|
||||||
def __init__(self, path: Path | str):
|
|
||||||
self.path = Path(path)
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if self.path.parent.is_symlink() or self.path.is_symlink():
|
|
||||||
raise PermissionError("writer_operation_ledger_symlink_rejected")
|
|
||||||
with _LEDGER_INIT_LOCK:
|
|
||||||
with self.connect() as db:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS writer_operations (
|
|
||||||
operation_id TEXT PRIMARY KEY,
|
|
||||||
token_id TEXT NOT NULL,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL UNIQUE,
|
|
||||||
operation_digest TEXT NOT NULL,
|
|
||||||
branch TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
content_sha256 TEXT NOT NULL,
|
|
||||||
pre_head_oid TEXT NOT NULL,
|
|
||||||
commit_oid TEXT,
|
|
||||||
push_status TEXT,
|
|
||||||
verification_status TEXT,
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
error_code TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
os.chmod(self.path, 0o600)
|
|
||||||
|
|
||||||
def connect(self) -> sqlite3.Connection:
|
|
||||||
db = sqlite3.connect(self.path, timeout=30)
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
return db
|
|
||||||
|
|
||||||
def get_or_create(self, grant: Any, data: dict[str, Any], digest: str) -> dict[str, Any]:
|
|
||||||
key = data["idempotency_key"]
|
|
||||||
with self.connect() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT * FROM writer_operations WHERE idempotency_key=?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
db.commit()
|
|
||||||
record = dict(row)
|
|
||||||
if record["operation_digest"] != digest:
|
|
||||||
raise CommitFileError("writer_idempotency_conflict", 409)
|
|
||||||
if (
|
|
||||||
record["token_id"] != grant.token_id
|
|
||||||
or record["task_id"] != grant.task_id
|
|
||||||
):
|
|
||||||
raise CommitFileError("writer_idempotency_owner_conflict", 409)
|
|
||||||
return record
|
|
||||||
operation_id = hashlib.sha256(
|
|
||||||
f"{grant.task_id}\0{key}".encode("utf-8")
|
|
||||||
).hexdigest()
|
|
||||||
content_sha = hashlib.sha256(data["content"].encode("utf-8")).hexdigest()
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO writer_operations(
|
|
||||||
operation_id,token_id,task_id,idempotency_key,operation_digest,
|
|
||||||
branch,path,content_sha256,pre_head_oid,state
|
|
||||||
) VALUES(?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
operation_id, grant.token_id, grant.task_id, key, digest,
|
|
||||||
data["branch"], data["path"], content_sha,
|
|
||||||
data["expected_head_oid"], "PENDING",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return self.get(key)
|
|
||||||
|
|
||||||
def get(self, key: str) -> dict[str, Any]:
|
|
||||||
with self.connect() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT * FROM writer_operations WHERE idempotency_key=?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise KeyError(key)
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
def update(self, key: str, state: str, **fields: Any) -> dict[str, Any]:
|
|
||||||
if state not in STATES:
|
|
||||||
raise ValueError("invalid writer operation state")
|
|
||||||
allowed = {"commit_oid", "push_status", "verification_status", "error_code"}
|
|
||||||
assignments = ["state=?", "updated_at=CURRENT_TIMESTAMP"]
|
|
||||||
values: list[Any] = [state]
|
|
||||||
for name, value in fields.items():
|
|
||||||
if name not in allowed:
|
|
||||||
raise ValueError("invalid ledger field")
|
|
||||||
assignments.append(f"{name}=?")
|
|
||||||
values.append(value)
|
|
||||||
values.append(key)
|
|
||||||
with self.connect() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
db.execute(
|
|
||||||
f"UPDATE writer_operations SET {','.join(assignments)} WHERE idempotency_key=?",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return self.get(key)
|
|
||||||
|
|
||||||
|
|
||||||
class AtomicCommitFile:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
workspace: Path,
|
|
||||||
bare_repo: Path,
|
|
||||||
ledger: OperationLedger,
|
|
||||||
remote: str,
|
|
||||||
audit,
|
|
||||||
crash_after_state: str | None = None,
|
|
||||||
):
|
|
||||||
self.workspace = workspace
|
|
||||||
self.bare_repo = bare_repo
|
|
||||||
self.ledger = ledger
|
|
||||||
self.remote = remote
|
|
||||||
self.audit = audit
|
|
||||||
self.crash_after_state = crash_after_state
|
|
||||||
|
|
||||||
def _git(self, *args: str, check: bool = True) -> str:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", *args], cwd=self.workspace, text=True, capture_output=True
|
|
||||||
)
|
|
||||||
if check and result.returncode != 0:
|
|
||||||
raise CommitFileError("writer_git_operation_failed", 500)
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
def _transition(self, key: str, state: str, **fields: Any) -> dict[str, Any]:
|
|
||||||
record = self.ledger.update(key, state, **fields)
|
|
||||||
if self.crash_after_state == state:
|
|
||||||
raise SimulatedCrash(state)
|
|
||||||
return record
|
|
||||||
|
|
||||||
def _validate(self, data: Any) -> dict[str, Any]:
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise CommitFileError("writer_request_object_required")
|
|
||||||
required = ("branch", "path", "content", "message", "expected_head_oid", "idempotency_key")
|
|
||||||
if any(field not in data for field in required):
|
|
||||||
raise CommitFileError("writer_request_incomplete")
|
|
||||||
if data.get("force") or data.get("force_push"):
|
|
||||||
raise CommitFileError("isolated_writer_force_rejected", 403)
|
|
||||||
if set(data) != set(required):
|
|
||||||
raise CommitFileError("writer_request_fields_rejected")
|
|
||||||
item = {field: data[field] for field in required}
|
|
||||||
if item["branch"] != policy.allowed_branch():
|
|
||||||
raise CommitFileError("isolated_writer_branch_rejected", 403)
|
|
||||||
try:
|
|
||||||
item["path"] = policy.validate_path(item["path"], self.workspace)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise CommitFileError(str(exc), 403) from None
|
|
||||||
if not isinstance(item["content"], str) or "\x00" in item["content"]:
|
|
||||||
raise CommitFileError("writer_content_rejected")
|
|
||||||
try:
|
|
||||||
policy.validate_content_for_path(item["path"], item["content"])
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise CommitFileError(str(exc), 403) from None
|
|
||||||
if len(item["content"].encode("utf-8")) > 1_048_576:
|
|
||||||
raise CommitFileError("writer_content_too_large", 413)
|
|
||||||
message = item["message"]
|
|
||||||
if (
|
|
||||||
not isinstance(message, str)
|
|
||||||
or not 1 <= len(message) <= 200
|
|
||||||
or "\x00" in message
|
|
||||||
or "\n" in message
|
|
||||||
or "\r" in message
|
|
||||||
):
|
|
||||||
raise CommitFileError("writer_message_rejected")
|
|
||||||
if not isinstance(item["expected_head_oid"], str) or not OID_RE.fullmatch(item["expected_head_oid"]):
|
|
||||||
raise CommitFileError("writer_expected_head_rejected")
|
|
||||||
if not isinstance(item["idempotency_key"], str) or not IDEMPOTENCY_RE.fullmatch(item["idempotency_key"]):
|
|
||||||
raise CommitFileError("writer_idempotency_key_rejected")
|
|
||||||
return item
|
|
||||||
|
|
||||||
def _atomic_write(self, relative: str, content: str) -> None:
|
|
||||||
target = self.workspace / relative
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fd, temporary = tempfile.mkstemp(prefix=".writer-", dir=target.parent)
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
|
||||||
stream.write(content.encode("utf-8"))
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
os.replace(temporary, target)
|
|
||||||
parent_fd = os.open(target.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
||||||
try:
|
|
||||||
os.fsync(parent_fd)
|
|
||||||
finally:
|
|
||||||
os.close(parent_fd)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temporary):
|
|
||||||
os.unlink(temporary)
|
|
||||||
|
|
||||||
def _verify_remote(self, branch: str, oid: str) -> None:
|
|
||||||
bare = subprocess.run(
|
|
||||||
["git", "--git-dir", str(self.bare_repo), "rev-parse", f"refs/heads/{branch}"],
|
|
||||||
text=True, capture_output=True,
|
|
||||||
)
|
|
||||||
if bare.returncode != 0 or bare.stdout.strip() != oid:
|
|
||||||
raise CommitFileError("writer_authoritative_bare_oid_mismatch", 500)
|
|
||||||
remote = subprocess.run(
|
|
||||||
["git", "ls-remote", "--heads", self.remote, f"refs/heads/{branch}"],
|
|
||||||
cwd=self.workspace, text=True, capture_output=True,
|
|
||||||
)
|
|
||||||
if remote.returncode != 0:
|
|
||||||
raise CommitFileError("writer_gitea_verification_failed", 503)
|
|
||||||
fields = remote.stdout.strip().split()
|
|
||||||
if len(fields) != 2:
|
|
||||||
raise CommitFileError("writer_gitea_ref_missing", 503)
|
|
||||||
if fields[0] != oid:
|
|
||||||
raise CommitFileError("writer_gitea_oid_conflict", 409)
|
|
||||||
|
|
||||||
def execute(self, grant: Any, raw_data: Any) -> dict[str, Any]:
|
|
||||||
data = self._validate(raw_data)
|
|
||||||
digest = canonical_digest(data)
|
|
||||||
branch = data["branch"]
|
|
||||||
with _LOCKS_GUARD:
|
|
||||||
lock = _LOCKS.setdefault(branch, threading.Lock())
|
|
||||||
lock_path = self.ledger.path.parent / f"{hashlib.sha256(branch.encode()).hexdigest()}.lock"
|
|
||||||
with lock, open(lock_path, "a+b") as branch_lock:
|
|
||||||
fcntl.flock(branch_lock.fileno(), fcntl.LOCK_EX)
|
|
||||||
return self._execute_locked(grant, data, digest)
|
|
||||||
|
|
||||||
def _execute_locked(self, grant: Any, data: dict[str, Any], digest: str) -> dict[str, Any]:
|
|
||||||
key = data["idempotency_key"]
|
|
||||||
record = self.ledger.get_or_create(grant, data, digest)
|
|
||||||
if record["state"] == "COMPLETED":
|
|
||||||
return self._result(record, idempotent=True)
|
|
||||||
if self._git("rev-parse", "--abbrev-ref", "HEAD") != data["branch"]:
|
|
||||||
record = self._transition(
|
|
||||||
key, "MANUAL_REVIEW_REQUIRED", error_code="writer_current_branch_conflict"
|
|
||||||
)
|
|
||||||
self._audit(grant, data, digest, record, "manual_review_required")
|
|
||||||
raise CommitFileError("writer_current_branch_conflict", 409)
|
|
||||||
head = self._git("rev-parse", "HEAD")
|
|
||||||
commit_oid = record.get("commit_oid")
|
|
||||||
if head != record["pre_head_oid"] and head != commit_oid:
|
|
||||||
record = self._transition(
|
|
||||||
key, "MANUAL_REVIEW_REQUIRED", error_code="writer_branch_head_conflict"
|
|
||||||
)
|
|
||||||
self._audit(grant, data, digest, record, "manual_review_required")
|
|
||||||
raise CommitFileError("writer_branch_head_conflict", 409)
|
|
||||||
try:
|
|
||||||
state = record["state"]
|
|
||||||
if state == "MANUAL_REVIEW_REQUIRED":
|
|
||||||
raise CommitFileError("writer_manual_review_required", 409)
|
|
||||||
if state == "FAILED_RECOVERABLE":
|
|
||||||
if record.get("push_status") == "confirmed":
|
|
||||||
state = "PUSHED"
|
|
||||||
else:
|
|
||||||
state = "COMMITTED" if commit_oid else "FILE_WRITTEN"
|
|
||||||
if state == "PENDING":
|
|
||||||
self._atomic_write(data["path"], data["content"])
|
|
||||||
record = self._transition(key, "FILE_WRITTEN")
|
|
||||||
state = record["state"]
|
|
||||||
if state == "FILE_WRITTEN":
|
|
||||||
actual_sha = hashlib.sha256(
|
|
||||||
(self.workspace / data["path"]).read_bytes()
|
|
||||||
).hexdigest()
|
|
||||||
if actual_sha != record["content_sha256"]:
|
|
||||||
raise CommitFileError("writer_file_content_conflict", 409)
|
|
||||||
staged_before = self._git("diff", "--cached", "--name-only", check=False).splitlines()
|
|
||||||
if staged_before:
|
|
||||||
raise CommitFileError("writer_extra_staged_files_rejected", 409)
|
|
||||||
self._git("add", "--", data["path"])
|
|
||||||
staged = self._git("diff", "--cached", "--name-only").splitlines()
|
|
||||||
if staged != [data["path"]]:
|
|
||||||
raise CommitFileError("writer_extra_staged_files_rejected", 409)
|
|
||||||
self._git("commit", "-m", data["message"])
|
|
||||||
commit_oid = self._git("rev-parse", "HEAD")
|
|
||||||
record = self._transition(key, "COMMITTED", commit_oid=commit_oid)
|
|
||||||
state = record["state"]
|
|
||||||
if state == "COMMITTED":
|
|
||||||
commit_oid = str(record["commit_oid"])
|
|
||||||
pushed = subprocess.run(
|
|
||||||
["git", "push", "--porcelain", self.remote, f"HEAD:refs/heads/{data['branch']}"],
|
|
||||||
cwd=self.workspace, text=True, capture_output=True,
|
|
||||||
)
|
|
||||||
if pushed.returncode != 0:
|
|
||||||
self._transition(
|
|
||||||
key, "FAILED_RECOVERABLE",
|
|
||||||
push_status="failed", error_code="writer_gitea_push_failed",
|
|
||||||
)
|
|
||||||
raise CommitFileError("writer_gitea_push_failed", 503)
|
|
||||||
record = self._transition(key, "PUSHED", push_status="confirmed")
|
|
||||||
state = record["state"]
|
|
||||||
if state == "PUSHED":
|
|
||||||
commit_oid = str(record["commit_oid"])
|
|
||||||
self._verify_remote(data["branch"], commit_oid)
|
|
||||||
record = self._transition(
|
|
||||||
key, "VERIFIED", verification_status="bare+gitea"
|
|
||||||
)
|
|
||||||
state = record["state"]
|
|
||||||
if state == "VERIFIED":
|
|
||||||
record = self._transition(key, "COMPLETED", error_code=None)
|
|
||||||
self._audit(grant, data, digest, record, "completed")
|
|
||||||
return self._result(record, idempotent=False)
|
|
||||||
except SimulatedCrash:
|
|
||||||
raise
|
|
||||||
except CommitFileError as exc:
|
|
||||||
latest = self.ledger.get(key)
|
|
||||||
if latest["state"] not in {"FAILED_RECOVERABLE", "MANUAL_REVIEW_REQUIRED"}:
|
|
||||||
failure_state = (
|
|
||||||
"MANUAL_REVIEW_REQUIRED"
|
|
||||||
if exc.code in {
|
|
||||||
"writer_gitea_oid_conflict",
|
|
||||||
"writer_file_content_conflict",
|
|
||||||
"writer_current_branch_conflict",
|
|
||||||
}
|
|
||||||
else "FAILED_RECOVERABLE"
|
|
||||||
)
|
|
||||||
latest = self._transition(key, failure_state, error_code=exc.code)
|
|
||||||
self._audit(grant, data, digest, latest, "failed")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _audit(self, grant: Any, data: dict[str, Any], digest: str, record: dict[str, Any], result: str) -> None:
|
|
||||||
self.audit({
|
|
||||||
"token_id": grant.token_id,
|
|
||||||
"task_id": grant.task_id,
|
|
||||||
"audience": grant.record["audience"],
|
|
||||||
"source_ip": grant.record.get("_source_ip", "socket-peer"),
|
|
||||||
"operation": grant.operation,
|
|
||||||
"branch": data["branch"],
|
|
||||||
"path": data["path"],
|
|
||||||
"idempotency_key": data["idempotency_key"],
|
|
||||||
"operation_digest": digest,
|
|
||||||
"path_policy_sha256": policy.configured_path_policy_sha256(),
|
|
||||||
"pre_head_oid": record["pre_head_oid"],
|
|
||||||
"commit_oid": record.get("commit_oid"),
|
|
||||||
"result": result,
|
|
||||||
})
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _result(record: dict[str, Any], *, idempotent: bool) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"status": "completed",
|
|
||||||
"state": record["state"],
|
|
||||||
"operation_id": record["operation_id"],
|
|
||||||
"branch": record["branch"],
|
|
||||||
"path": record["path"],
|
|
||||||
"commit_oid": record.get("commit_oid"),
|
|
||||||
"push_status": record.get("push_status"),
|
|
||||||
"verification_status": record.get("verification_status"),
|
|
||||||
"idempotent": idempotent,
|
|
||||||
}
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
"""Strict service-level exact-path policy for isolated Git writers."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import grp
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import stat
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
|
|
||||||
MAX_POLICY_BYTES = 65_536
|
|
||||||
POLICY_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"schema_version",
|
|
||||||
"policy_id",
|
|
||||||
"audience",
|
|
||||||
"repository_identity",
|
|
||||||
"allowed_branch",
|
|
||||||
"allowed_operations",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
OPERATION_FIELDS = frozenset({"exact_paths", "max_content_bytes", "content_profile"})
|
|
||||||
SUPPORTED_OPERATIONS = frozenset({"commit_file.atomic"})
|
|
||||||
SUPPORTED_CONTENT_PROFILES = frozenset({"systemd-codex-review-worker-v1"})
|
|
||||||
_SECRET_RE = re.compile(
|
|
||||||
r"(?i)(authorization\s*:|bearer\s+|(?:token|password|secret)\s*[:=])"
|
|
||||||
)
|
|
||||||
_SYSTEMD_NAME_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*\Z")
|
|
||||||
_SYSTEMD_MAX_LINE_BYTES = 4096
|
|
||||||
_SYSTEMD_ALLOWED_DIRECTIVES = {
|
|
||||||
"Unit": frozenset({"Description", "After", "Wants"}),
|
|
||||||
"Service": frozenset(
|
|
||||||
{
|
|
||||||
"Type",
|
|
||||||
"User",
|
|
||||||
"Group",
|
|
||||||
"WorkingDirectory",
|
|
||||||
"Environment",
|
|
||||||
"ExecStart",
|
|
||||||
"Restart",
|
|
||||||
"RestartSec",
|
|
||||||
"NoNewPrivileges",
|
|
||||||
"PrivateTmp",
|
|
||||||
"PrivateDevices",
|
|
||||||
"ProtectSystem",
|
|
||||||
"ProtectHome",
|
|
||||||
"ProtectKernelTunables",
|
|
||||||
"ProtectKernelModules",
|
|
||||||
"ProtectControlGroups",
|
|
||||||
"LockPersonality",
|
|
||||||
"RestrictSUIDSGID",
|
|
||||||
"RestrictRealtime",
|
|
||||||
"MemoryDenyWriteExecute",
|
|
||||||
"RuntimeDirectory",
|
|
||||||
"StateDirectory",
|
|
||||||
"CacheDirectory",
|
|
||||||
"ReadOnlyPaths",
|
|
||||||
"ReadWritePaths",
|
|
||||||
"UMask",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"Install": frozenset({"WantedBy"}),
|
|
||||||
}
|
|
||||||
_SYSTEMD_MULTI_DIRECTIVES = frozenset(
|
|
||||||
{
|
|
||||||
("Service", "Environment"),
|
|
||||||
("Service", "ReadOnlyPaths"),
|
|
||||||
("Service", "ReadWritePaths"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_SYSTEMD_EXACT_SINGLE_VALUES = {
|
|
||||||
("Unit", "Description"): "AIOS Codex Review/Test Worker",
|
|
||||||
("Unit", "After"): "network-online.target",
|
|
||||||
("Unit", "Wants"): "network-online.target",
|
|
||||||
("Service", "Type"): "simple",
|
|
||||||
("Service", "User"): "aiman",
|
|
||||||
("Service", "Group"): "gitops",
|
|
||||||
(
|
|
||||||
"Service",
|
|
||||||
"WorkingDirectory",
|
|
||||||
): "/usr/libexec/aios-codex-review-test-worker/current",
|
|
||||||
(
|
|
||||||
"Service",
|
|
||||||
"ExecStart",
|
|
||||||
): "/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
|
||||||
("Service", "Restart"): "on-failure",
|
|
||||||
("Service", "RestartSec"): "2",
|
|
||||||
("Service", "NoNewPrivileges"): "true",
|
|
||||||
("Service", "PrivateTmp"): "true",
|
|
||||||
("Service", "PrivateDevices"): "true",
|
|
||||||
("Service", "ProtectSystem"): "strict",
|
|
||||||
("Service", "ProtectHome"): "true",
|
|
||||||
("Service", "ProtectKernelTunables"): "true",
|
|
||||||
("Service", "ProtectKernelModules"): "true",
|
|
||||||
("Service", "ProtectControlGroups"): "true",
|
|
||||||
("Service", "LockPersonality"): "true",
|
|
||||||
("Service", "RestrictSUIDSGID"): "true",
|
|
||||||
("Service", "RestrictRealtime"): "true",
|
|
||||||
("Service", "MemoryDenyWriteExecute"): "true",
|
|
||||||
("Service", "RuntimeDirectory"): "aios-codex-review-test-worker",
|
|
||||||
("Service", "StateDirectory"): "aios-codex-review-test-worker",
|
|
||||||
("Service", "CacheDirectory"): "aios-codex-review-test-worker",
|
|
||||||
("Service", "UMask"): "0077",
|
|
||||||
("Install", "WantedBy"): "multi-user.target",
|
|
||||||
}
|
|
||||||
_SYSTEMD_EXACT_MULTI_VALUES = {
|
|
||||||
("Service", "Environment"): (
|
|
||||||
"AIOS_CODEX_WORKER_MODE=shadow",
|
|
||||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED=false",
|
|
||||||
"PYTHONDONTWRITEBYTECODE=1",
|
|
||||||
),
|
|
||||||
("Service", "ReadOnlyPaths"): (
|
|
||||||
"/data/aios-gitops/repo.git",
|
|
||||||
"/usr/libexec/aios-codex-review-test-worker",
|
|
||||||
),
|
|
||||||
("Service", "ReadWritePaths"): (
|
|
||||||
"/run/aios-codex-review-test-worker",
|
|
||||||
"/var/lib/aios-codex-review-test-worker",
|
|
||||||
"/var/cache/aios-codex-review-test-worker",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PathPolicyError(PermissionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class OperationPolicy:
|
|
||||||
exact_paths: tuple[str, ...]
|
|
||||||
max_content_bytes: int
|
|
||||||
content_profile: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ServicePathPolicy:
|
|
||||||
schema_version: int
|
|
||||||
policy_id: str
|
|
||||||
audience: str
|
|
||||||
repository_identity: str
|
|
||||||
allowed_branch: str
|
|
||||||
allowed_operations: dict[str, OperationPolicy]
|
|
||||||
sha256: str
|
|
||||||
|
|
||||||
def operation(self, name: str) -> OperationPolicy:
|
|
||||||
try:
|
|
||||||
return self.allowed_operations[name]
|
|
||||||
except KeyError:
|
|
||||||
raise PathPolicyError("isolated_writer_service_operation_rejected") from None
|
|
||||||
|
|
||||||
def require_exact_path(self, operation: str, path: str) -> OperationPolicy:
|
|
||||||
selected = self.operation(operation)
|
|
||||||
if path not in selected.exact_paths:
|
|
||||||
raise PathPolicyError("isolated_writer_service_path_rejected")
|
|
||||||
return selected
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_json(value: Any) -> bytes:
|
|
||||||
return (
|
|
||||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
+ "\n"
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_relative_path_syntax(path: str) -> str:
|
|
||||||
if not isinstance(path, str):
|
|
||||||
raise PathPolicyError("isolated_writer_path_rejected")
|
|
||||||
if not path or path != path.strip() or "\x00" in path or "\\" in path:
|
|
||||||
raise PathPolicyError("isolated_writer_path_rejected")
|
|
||||||
try:
|
|
||||||
decoded = unquote(path, errors="strict")
|
|
||||||
except (UnicodeDecodeError, ValueError):
|
|
||||||
raise PathPolicyError("isolated_writer_path_rejected") from None
|
|
||||||
if decoded != path:
|
|
||||||
raise PathPolicyError("isolated_writer_path_rejected")
|
|
||||||
posix = PurePosixPath(path)
|
|
||||||
windows = PureWindowsPath(path)
|
|
||||||
parts = path.split("/")
|
|
||||||
if (
|
|
||||||
posix.is_absolute()
|
|
||||||
or windows.is_absolute()
|
|
||||||
or bool(windows.drive)
|
|
||||||
or any(part in {"", ".", ".."} for part in parts)
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_path_rejected")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def _require_fields(value: dict[str, Any], allowed: frozenset[str], code: str) -> None:
|
|
||||||
if set(value) != allowed:
|
|
||||||
raise PathPolicyError(code)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_policy(document: Any, canonical: bytes) -> ServicePathPolicy:
|
|
||||||
if not isinstance(document, dict):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_object_required")
|
|
||||||
_require_fields(document, POLICY_FIELDS, "isolated_writer_path_policy_fields_rejected")
|
|
||||||
if document["schema_version"] != 1:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_schema_rejected")
|
|
||||||
text_fields = (
|
|
||||||
"policy_id",
|
|
||||||
"audience",
|
|
||||||
"repository_identity",
|
|
||||||
"allowed_branch",
|
|
||||||
)
|
|
||||||
if any(
|
|
||||||
not isinstance(document[field], str) or not document[field].strip()
|
|
||||||
for field in text_fields
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_value_rejected")
|
|
||||||
raw_operations = document["allowed_operations"]
|
|
||||||
if not isinstance(raw_operations, dict) or not raw_operations:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_operations_rejected")
|
|
||||||
if not set(raw_operations).issubset(SUPPORTED_OPERATIONS):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_operations_rejected")
|
|
||||||
operations: dict[str, OperationPolicy] = {}
|
|
||||||
for name, raw in raw_operations.items():
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_operation_rejected")
|
|
||||||
_require_fields(
|
|
||||||
raw,
|
|
||||||
OPERATION_FIELDS,
|
|
||||||
"isolated_writer_path_policy_operation_fields_rejected",
|
|
||||||
)
|
|
||||||
paths = raw["exact_paths"]
|
|
||||||
if (
|
|
||||||
not isinstance(paths, list)
|
|
||||||
or not paths
|
|
||||||
or any(not isinstance(item, str) for item in paths)
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_paths_rejected")
|
|
||||||
validated = tuple(validate_relative_path_syntax(item) for item in paths)
|
|
||||||
if len(set(validated)) != len(validated):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_paths_rejected")
|
|
||||||
maximum = raw["max_content_bytes"]
|
|
||||||
if not isinstance(maximum, int) or isinstance(maximum, bool) or not 1 <= maximum <= 1_048_576:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_size_rejected")
|
|
||||||
profile = raw["content_profile"]
|
|
||||||
if profile not in SUPPORTED_CONTENT_PROFILES:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_profile_rejected")
|
|
||||||
operations[name] = OperationPolicy(validated, maximum, profile)
|
|
||||||
return ServicePathPolicy(
|
|
||||||
schema_version=1,
|
|
||||||
policy_id=document["policy_id"],
|
|
||||||
audience=document["audience"],
|
|
||||||
repository_identity=document["repository_identity"],
|
|
||||||
allowed_branch=document["allowed_branch"],
|
|
||||||
allowed_operations=operations,
|
|
||||||
sha256=hashlib.sha256(canonical).hexdigest(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_path_policy(
|
|
||||||
path: Path,
|
|
||||||
*,
|
|
||||||
expected_owner_uid: int = 0,
|
|
||||||
expected_group_gid: int | None = None,
|
|
||||||
expected_parent_mode: int = 0o750,
|
|
||||||
expected_file_mode: int = 0o640,
|
|
||||||
) -> ServicePathPolicy:
|
|
||||||
lexical = Path(os.path.abspath(str(path)))
|
|
||||||
if lexical.is_symlink():
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_symlink_rejected")
|
|
||||||
try:
|
|
||||||
parent_info = lexical.parent.stat(follow_symlinks=False)
|
|
||||||
file_info = lexical.stat(follow_symlinks=False)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_missing") from None
|
|
||||||
if stat.S_ISLNK(parent_info.st_mode) or not stat.S_ISDIR(parent_info.st_mode):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_parent_rejected")
|
|
||||||
if stat.S_ISLNK(file_info.st_mode) or not stat.S_ISREG(file_info.st_mode):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_file_rejected")
|
|
||||||
if lexical.resolve(strict=True) != lexical:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_symlink_rejected")
|
|
||||||
if (
|
|
||||||
parent_info.st_uid != expected_owner_uid
|
|
||||||
or stat.S_IMODE(parent_info.st_mode) != expected_parent_mode
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_parent_permissions_rejected")
|
|
||||||
if (
|
|
||||||
file_info.st_uid != expected_owner_uid
|
|
||||||
or stat.S_IMODE(file_info.st_mode) != expected_file_mode
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_permissions_rejected")
|
|
||||||
if expected_group_gid is not None and (
|
|
||||||
parent_info.st_gid != expected_group_gid or file_info.st_gid != expected_group_gid
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_group_rejected")
|
|
||||||
if file_info.st_size <= 0 or file_info.st_size > MAX_POLICY_BYTES:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_file_size_rejected")
|
|
||||||
raw = lexical.read_bytes()
|
|
||||||
try:
|
|
||||||
document = json.loads(raw.decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_json_rejected") from None
|
|
||||||
canonical = _canonical_json(document)
|
|
||||||
if raw != canonical:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_not_canonical")
|
|
||||||
return _parse_policy(document, canonical)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def configured_path_policy() -> ServicePathPolicy | None:
|
|
||||||
configured = os.getenv("AIOS_GITOPS_PATH_POLICY_FILE", "").strip()
|
|
||||||
if not configured:
|
|
||||||
return None
|
|
||||||
if not Path(configured).is_absolute():
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_path_rejected")
|
|
||||||
try:
|
|
||||||
gitops_gid = grp.getgrnam("gitops").gr_gid
|
|
||||||
except KeyError:
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_group_rejected") from None
|
|
||||||
loaded = load_path_policy(Path(configured), expected_group_gid=gitops_gid)
|
|
||||||
if loaded.audience != os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", ""):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_audience_rejected")
|
|
||||||
if loaded.repository_identity != os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", ""):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_repository_rejected")
|
|
||||||
if loaded.allowed_branch != os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", ""):
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_branch_rejected")
|
|
||||||
return loaded
|
|
||||||
|
|
||||||
|
|
||||||
def clear_path_policy_cache() -> None:
|
|
||||||
configured_path_policy.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
def require_service_path(operation: str, path: str) -> OperationPolicy:
|
|
||||||
loaded = configured_path_policy()
|
|
||||||
if loaded is None:
|
|
||||||
raise PathPolicyError("isolated_writer_service_path_policy_required")
|
|
||||||
return loaded.require_exact_path(operation, path)
|
|
||||||
|
|
||||||
|
|
||||||
def configured_path_policy_sha256() -> str | None:
|
|
||||||
loaded = configured_path_policy()
|
|
||||||
return None if loaded is None else loaded.sha256
|
|
||||||
|
|
||||||
|
|
||||||
def validate_content(operation: str, path: str, content: str) -> None:
|
|
||||||
selected = require_service_path(operation, path)
|
|
||||||
try:
|
|
||||||
encoded = content.encode("utf-8", errors="strict")
|
|
||||||
except (AttributeError, UnicodeEncodeError):
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected") from None
|
|
||||||
if len(encoded) > selected.max_content_bytes:
|
|
||||||
raise PathPolicyError("isolated_writer_service_content_too_large")
|
|
||||||
if selected.content_profile == "systemd-codex-review-worker-v1":
|
|
||||||
_validate_systemd_codex_review_worker(content)
|
|
||||||
return
|
|
||||||
raise PathPolicyError("isolated_writer_path_policy_profile_rejected")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_systemd_codex_review_worker(content: str) -> None:
|
|
||||||
if not isinstance(content, str):
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
try:
|
|
||||||
content.encode("utf-8", errors="strict")
|
|
||||||
except UnicodeEncodeError:
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected") from None
|
|
||||||
if (
|
|
||||||
"\x00" in content
|
|
||||||
or "\r" in content
|
|
||||||
or _SECRET_RE.search(content)
|
|
||||||
or not content.endswith("\n")
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
|
|
||||||
sections: set[str] = set()
|
|
||||||
values: dict[tuple[str, str], list[str]] = {}
|
|
||||||
section: str | None = None
|
|
||||||
for line in content.splitlines():
|
|
||||||
if len(line.encode("utf-8")) > _SYSTEMD_MAX_LINE_BYTES:
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
if line[0].isspace() or line.startswith(("#", ";")):
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
if line.startswith("[") and line.endswith("]"):
|
|
||||||
candidate = line[1:-1]
|
|
||||||
if (
|
|
||||||
not _SYSTEMD_NAME_RE.fullmatch(candidate)
|
|
||||||
or candidate not in _SYSTEMD_ALLOWED_DIRECTIVES
|
|
||||||
or candidate in sections
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
sections.add(candidate)
|
|
||||||
section = candidate
|
|
||||||
continue
|
|
||||||
if section is None or "=" not in line:
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
directive, value = line.split("=", 1)
|
|
||||||
if (
|
|
||||||
not _SYSTEMD_NAME_RE.fullmatch(directive)
|
|
||||||
or directive not in _SYSTEMD_ALLOWED_DIRECTIVES[section]
|
|
||||||
or not value
|
|
||||||
or value != value.strip()
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
key = (section, directive)
|
|
||||||
if key in values and key not in _SYSTEMD_MULTI_DIRECTIVES:
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
values.setdefault(key, []).append(value)
|
|
||||||
|
|
||||||
if sections != set(_SYSTEMD_ALLOWED_DIRECTIVES):
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
if set(values) != set(_SYSTEMD_EXACT_SINGLE_VALUES) | set(
|
|
||||||
_SYSTEMD_EXACT_MULTI_VALUES
|
|
||||||
):
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
for key, expected in _SYSTEMD_EXACT_SINGLE_VALUES.items():
|
|
||||||
if values.get(key) != [expected]:
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
for key, expected in _SYSTEMD_EXACT_MULTI_VALUES.items():
|
|
||||||
observed = values.get(key, [])
|
|
||||||
if len(observed) != len(set(observed)) or tuple(observed) != expected:
|
|
||||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
"""Digest-only, task-scoped token registry for isolated writers."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import grp
|
|
||||||
import pwd
|
|
||||||
import secrets
|
|
||||||
import sqlite3
|
|
||||||
import stat
|
|
||||||
import threading
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_REGISTRY_INIT_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def token_digest(token: str) -> str:
|
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class TokenRegistry:
|
|
||||||
def __init__(self, path: Path | str):
|
|
||||||
self.path = Path(path)
|
|
||||||
self._prepare()
|
|
||||||
|
|
||||||
def _prepare(self) -> None:
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if self.path.parent.is_symlink() or self.path.is_symlink():
|
|
||||||
raise PermissionError("writer_token_registry_symlink_rejected")
|
|
||||||
with _REGISTRY_INIT_LOCK:
|
|
||||||
with self._connect() as db:
|
|
||||||
db.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS token_grants (
|
|
||||||
token_id TEXT PRIMARY KEY,
|
|
||||||
token_digest TEXT NOT NULL UNIQUE,
|
|
||||||
grant_json TEXT NOT NULL,
|
|
||||||
revoked INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS token_uses (
|
|
||||||
token_id TEXT NOT NULL,
|
|
||||||
operation TEXT NOT NULL,
|
|
||||||
use_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY(token_id, operation),
|
|
||||||
FOREIGN KEY(token_id) REFERENCES token_grants(token_id)
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
os.chmod(self.path, 0o600)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
db = sqlite3.connect(self.path, timeout=30, isolation_level=None)
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
|
||||||
return db
|
|
||||||
|
|
||||||
def issue(self, grant: dict[str, Any], token: str | None = None) -> tuple[str, dict[str, Any]]:
|
|
||||||
value = token or secrets.token_urlsafe(32)
|
|
||||||
if len(value.encode("utf-8")) < 32:
|
|
||||||
raise ValueError("writer_token_entropy_insufficient")
|
|
||||||
record = dict(grant)
|
|
||||||
record["schema_version"] = 2
|
|
||||||
record["token_digest"] = token_digest(value)
|
|
||||||
record["revoked"] = False
|
|
||||||
record.setdefault("created_at", utc_now())
|
|
||||||
required = (
|
|
||||||
"token_id",
|
|
||||||
"audience",
|
|
||||||
"task_id",
|
|
||||||
"source_cidrs",
|
|
||||||
"allowed_branch",
|
|
||||||
"approved_base_oid",
|
|
||||||
"allowed_operations",
|
|
||||||
"allowed_paths",
|
|
||||||
"not_before",
|
|
||||||
"expires_at",
|
|
||||||
"max_uses",
|
|
||||||
"issued_by",
|
|
||||||
)
|
|
||||||
if any(not record.get(field) for field in required):
|
|
||||||
raise ValueError("writer_token_grant_incomplete")
|
|
||||||
encoded = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
|
||||||
with self._connect() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO token_grants(token_id, token_digest, grant_json, revoked, created_at)
|
|
||||||
VALUES(?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
str(record["token_id"]),
|
|
||||||
str(record["token_digest"]),
|
|
||||||
encoded,
|
|
||||||
0,
|
|
||||||
str(record["created_at"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
db.execute("COMMIT")
|
|
||||||
except Exception:
|
|
||||||
db.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
return value, record
|
|
||||||
|
|
||||||
def find(self, token: str) -> dict[str, Any] | None:
|
|
||||||
candidate = token_digest(token)
|
|
||||||
with self._connect() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"SELECT token_digest, grant_json, revoked FROM token_grants"
|
|
||||||
).fetchall()
|
|
||||||
match = None
|
|
||||||
for row in rows:
|
|
||||||
if hmac.compare_digest(str(row["token_digest"]), candidate):
|
|
||||||
match = dict(json.loads(str(row["grant_json"])))
|
|
||||||
match["revoked"] = bool(row["revoked"])
|
|
||||||
return match
|
|
||||||
|
|
||||||
def consume(self, token_id: str, operation: str, maximum: int) -> int:
|
|
||||||
if maximum < 1:
|
|
||||||
raise PermissionError("writer_token_operation_exhausted")
|
|
||||||
with self._connect() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT revoked FROM token_grants WHERE token_id=?",
|
|
||||||
(token_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None or bool(row["revoked"]):
|
|
||||||
raise PermissionError("writer_token_revoked")
|
|
||||||
used_row = db.execute(
|
|
||||||
"SELECT use_count FROM token_uses WHERE token_id=? AND operation=?",
|
|
||||||
(token_id, operation),
|
|
||||||
).fetchone()
|
|
||||||
used = int(used_row["use_count"]) if used_row else 0
|
|
||||||
if used >= maximum:
|
|
||||||
raise PermissionError("writer_token_operation_exhausted")
|
|
||||||
current = used + 1
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO token_uses(token_id, operation, use_count)
|
|
||||||
VALUES(?,?,?)
|
|
||||||
ON CONFLICT(token_id, operation)
|
|
||||||
DO UPDATE SET use_count=excluded.use_count
|
|
||||||
""",
|
|
||||||
(token_id, operation, current),
|
|
||||||
)
|
|
||||||
db.execute("COMMIT")
|
|
||||||
return current
|
|
||||||
except Exception:
|
|
||||||
db.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def revoke(self, token_id: str) -> bool:
|
|
||||||
with self._connect() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
changed = db.execute(
|
|
||||||
"UPDATE token_grants SET revoked=1 WHERE token_id=? AND revoked=0",
|
|
||||||
(token_id,),
|
|
||||||
).rowcount
|
|
||||||
db.execute("COMMIT")
|
|
||||||
return bool(changed)
|
|
||||||
|
|
||||||
def usage(self, token_id: str, operation: str) -> int:
|
|
||||||
with self._connect() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT use_count FROM token_uses WHERE token_id=? AND operation=?",
|
|
||||||
(token_id, operation),
|
|
||||||
).fetchone()
|
|
||||||
return int(row["use_count"]) if row else 0
|
|
||||||
|
|
||||||
|
|
||||||
def _write_secret_file(
|
|
||||||
path: Path,
|
|
||||||
token: str,
|
|
||||||
*,
|
|
||||||
owner: str | None = None,
|
|
||||||
group: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if path.parent.is_symlink() or path.is_symlink():
|
|
||||||
raise PermissionError("writer_token_file_symlink_rejected")
|
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
||||||
if hasattr(os, "O_NOFOLLOW"):
|
|
||||||
flags |= os.O_NOFOLLOW
|
|
||||||
fd = os.open(path, flags, 0o600)
|
|
||||||
try:
|
|
||||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
||||||
raise PermissionError("writer_token_file_not_regular")
|
|
||||||
if owner is not None or group is not None:
|
|
||||||
uid = pwd.getpwnam(owner).pw_uid if owner is not None else -1
|
|
||||||
gid = grp.getgrnam(group).gr_gid if group is not None else -1
|
|
||||||
os.fchown(fd, uid, gid)
|
|
||||||
os.write(fd, token.encode("utf-8"))
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--registry", required=True)
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
issue = sub.add_parser("issue")
|
|
||||||
issue.add_argument("--grant-file", required=True)
|
|
||||||
issue.add_argument("--token-file", required=True)
|
|
||||||
issue.add_argument("--owner")
|
|
||||||
issue.add_argument("--group")
|
|
||||||
revoke = sub.add_parser("revoke")
|
|
||||||
revoke.add_argument("--token-id", required=True)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
registry = TokenRegistry(args.registry)
|
|
||||||
if args.command == "issue":
|
|
||||||
grant = json.loads(Path(args.grant_file).read_text(encoding="utf-8"))
|
|
||||||
token, record = registry.issue(grant)
|
|
||||||
_write_secret_file(
|
|
||||||
Path(args.token_file),
|
|
||||||
token,
|
|
||||||
owner=args.owner,
|
|
||||||
group=args.group,
|
|
||||||
)
|
|
||||||
print(json.dumps({"token_id": record["token_id"], "created": True}, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
changed = registry.revoke(args.token_id)
|
|
||||||
print(json.dumps({"token_id": args.token_id, "revoked": changed}, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,564 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Execution-readiness adapters and the fail-closed Shadow controller service."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import dataclasses
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from http import HTTPStatus
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Mapping, Protocol
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
||||||
Ed25519PrivateKey,
|
|
||||||
)
|
|
||||||
|
|
||||||
from silent_release_controller import _sanitize, utc_now
|
|
||||||
from silent_release_hardening import (
|
|
||||||
BuilderAttestation,
|
|
||||||
CapabilityAuthorizationError,
|
|
||||||
ReleaseRecoveryWorker,
|
|
||||||
SignedApprovalVerifier,
|
|
||||||
SignedReleasePlanApproval,
|
|
||||||
SilentReleaseActionService,
|
|
||||||
TrustedCandidateBundle,
|
|
||||||
_canonical_json,
|
|
||||||
_durable_json,
|
|
||||||
_safe_child,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BuilderSigner(Protocol):
|
|
||||||
@property
|
|
||||||
def key_id(self) -> str:
|
|
||||||
...
|
|
||||||
|
|
||||||
def sign(self, payload: bytes) -> bytes:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class LocalTestBuilderSigner:
|
|
||||||
"""Ephemeral in-memory signer. It is forbidden for production execution."""
|
|
||||||
|
|
||||||
test_only = True
|
|
||||||
|
|
||||||
def __init__(self, key_id: str, private_key: Ed25519PrivateKey):
|
|
||||||
self._key_id = str(key_id)
|
|
||||||
self._private_key = private_key
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_id(self) -> str:
|
|
||||||
return self._key_id
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def generate(
|
|
||||||
cls, key_id: str = "shadow-builder-test-key"
|
|
||||||
) -> "LocalTestBuilderSigner":
|
|
||||||
return cls(key_id, Ed25519PrivateKey.generate())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def public_key(self):
|
|
||||||
return self._private_key.public_key()
|
|
||||||
|
|
||||||
def sign(self, payload: bytes) -> bytes:
|
|
||||||
return self._private_key.sign(payload)
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalKmsBuilderSigner:
|
|
||||||
"""Signer facade whose private operation remains in KMS/HSM."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
key_id: str,
|
|
||||||
credential_reference: str,
|
|
||||||
sign_callback: Callable[[str, bytes], bytes],
|
|
||||||
):
|
|
||||||
if not str(credential_reference).startswith(
|
|
||||||
("kms://", "hsm://", "secret-manager://")
|
|
||||||
):
|
|
||||||
raise ValueError("production builder requires a KMS/HSM reference")
|
|
||||||
self._key_id = str(key_id)
|
|
||||||
self.credential_reference = str(credential_reference)
|
|
||||||
self._sign_callback = sign_callback
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_id(self) -> str:
|
|
||||||
return self._key_id
|
|
||||||
|
|
||||||
def sign(self, payload: bytes) -> bytes:
|
|
||||||
return bytes(self._sign_callback(self.credential_reference, payload))
|
|
||||||
|
|
||||||
|
|
||||||
class SignedTrustedBuilderAdapter:
|
|
||||||
"""Signs evidence produced by a trusted collector, never caller evidence."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
collector: Callable[[Mapping[str, Any]], TrustedCandidateBundle],
|
|
||||||
signer: BuilderSigner,
|
|
||||||
):
|
|
||||||
self.collector = collector
|
|
||||||
self.signer = signer
|
|
||||||
|
|
||||||
def build(self, intent: Mapping[str, Any]) -> TrustedCandidateBundle:
|
|
||||||
forbidden = {
|
|
||||||
"artifact_sha256",
|
|
||||||
"canary",
|
|
||||||
"checks",
|
|
||||||
"manifest_sha256",
|
|
||||||
"tests",
|
|
||||||
"tree_oid",
|
|
||||||
}
|
|
||||||
if forbidden.intersection(intent):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"trusted builder rejects caller-supplied evidence"
|
|
||||||
)
|
|
||||||
unsigned = self.collector(dict(intent))
|
|
||||||
if unsigned.attestation is not None:
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"collector cannot self-attest its evidence"
|
|
||||||
)
|
|
||||||
candidate = unsigned.candidate
|
|
||||||
test_sha = hashlib.sha256(
|
|
||||||
_canonical_json(_sanitize(candidate.tests))
|
|
||||||
).hexdigest()
|
|
||||||
canary_sha = hashlib.sha256(
|
|
||||||
_canonical_json(_sanitize(candidate.canary))
|
|
||||||
).hexdigest()
|
|
||||||
attestation_id = "builder-attestation-" + hashlib.sha256(
|
|
||||||
(
|
|
||||||
candidate.candidate_id
|
|
||||||
+ "\0"
|
|
||||||
+ candidate.source_commit
|
|
||||||
+ "\0"
|
|
||||||
+ candidate.artifact_sha256
|
|
||||||
).encode("utf-8")
|
|
||||||
).hexdigest()[:24]
|
|
||||||
attestation = BuilderAttestation(
|
|
||||||
builder_attestation_id=attestation_id,
|
|
||||||
builder_key_id=self.signer.key_id,
|
|
||||||
source_commit=candidate.source_commit,
|
|
||||||
tree_oid=candidate.tree_oid,
|
|
||||||
artifact_sha256=candidate.artifact_sha256,
|
|
||||||
manifest_sha256=candidate.manifest_sha256,
|
|
||||||
package_closure_sha256=(
|
|
||||||
unsigned.release_plan.deployment.package_closure_sha256
|
|
||||||
),
|
|
||||||
test_evidence_sha256=test_sha,
|
|
||||||
canary_evidence_sha256=canary_sha,
|
|
||||||
issued_at=utc_now(),
|
|
||||||
signature="",
|
|
||||||
)
|
|
||||||
attestation = dataclasses.replace(
|
|
||||||
attestation,
|
|
||||||
signature=base64.b64encode(
|
|
||||||
self.signer.sign(attestation.canonical_bytes())
|
|
||||||
).decode("ascii"),
|
|
||||||
)
|
|
||||||
return dataclasses.replace(unsigned, attestation=attestation)
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalBackendAdapter(Protocol):
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
...
|
|
||||||
|
|
||||||
def verify(
|
|
||||||
self,
|
|
||||||
approval: SignedReleasePlanApproval,
|
|
||||||
*,
|
|
||||||
candidate,
|
|
||||||
release_plan,
|
|
||||||
policy_sha256: str,
|
|
||||||
) -> SignedReleasePlanApproval:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class LocalTestApprovalSigner:
|
|
||||||
"""Test-only approval signer used solely for Shadow acceptance."""
|
|
||||||
|
|
||||||
test_only = True
|
|
||||||
|
|
||||||
def __init__(self, key_id: str, private_key: Ed25519PrivateKey):
|
|
||||||
self.key_id = str(key_id)
|
|
||||||
self.private_key = private_key
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def generate(
|
|
||||||
cls, key_id: str = "shadow-owner-test-key"
|
|
||||||
) -> "LocalTestApprovalSigner":
|
|
||||||
return cls(key_id, Ed25519PrivateKey.generate())
|
|
||||||
|
|
||||||
def sign(
|
|
||||||
self, approval: SignedReleasePlanApproval
|
|
||||||
) -> SignedReleasePlanApproval:
|
|
||||||
unsigned = dataclasses.replace(
|
|
||||||
approval, key_id=self.key_id, signature=""
|
|
||||||
)
|
|
||||||
return dataclasses.replace(
|
|
||||||
unsigned,
|
|
||||||
signature=base64.b64encode(
|
|
||||||
self.private_key.sign(unsigned.canonical_bytes())
|
|
||||||
).decode("ascii"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProductionApprovalBackendAdapter:
|
|
||||||
"""Verification-only production adapter configured by a secret/KMS ref."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
verifier: SignedApprovalVerifier,
|
|
||||||
credential_reference: str,
|
|
||||||
):
|
|
||||||
if not str(credential_reference).startswith(
|
|
||||||
("kms://", "hsm://", "secret-manager://")
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"production approval backend requires a KMS/secret reference"
|
|
||||||
)
|
|
||||||
self.verifier = verifier
|
|
||||||
self.credential_reference = str(credential_reference)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def verify(
|
|
||||||
self,
|
|
||||||
approval: SignedReleasePlanApproval,
|
|
||||||
*,
|
|
||||||
candidate,
|
|
||||||
release_plan,
|
|
||||||
policy_sha256: str,
|
|
||||||
) -> SignedReleasePlanApproval:
|
|
||||||
return self.verifier.verify_release_plan(
|
|
||||||
approval, candidate, release_plan, policy_sha256
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UnavailableProductionApprovalBackend:
|
|
||||||
credential_reference = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def verify(self, *args, **kwargs):
|
|
||||||
raise CapabilityAuthorizationError(
|
|
||||||
"production approval backend is not configured"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LeaderLock:
|
|
||||||
def __init__(self, path: Path):
|
|
||||||
self.path = Path(path)
|
|
||||||
self.handle = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chmod(self.path.parent, 0o700)
|
|
||||||
self.handle = self.path.open("a+", encoding="utf-8")
|
|
||||||
os.chmod(self.path, 0o600)
|
|
||||||
try:
|
|
||||||
import fcntl
|
|
||||||
|
|
||||||
fcntl.flock(
|
|
||||||
self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
|
|
||||||
)
|
|
||||||
except BlockingIOError as exc:
|
|
||||||
self.handle.close()
|
|
||||||
self.handle = None
|
|
||||||
raise RuntimeError("silent release leader is already active") from exc
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb):
|
|
||||||
if self.handle is not None:
|
|
||||||
try:
|
|
||||||
import fcntl
|
|
||||||
|
|
||||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
|
||||||
finally:
|
|
||||||
self.handle.close()
|
|
||||||
self.handle = None
|
|
||||||
|
|
||||||
|
|
||||||
class PersistentSilentReleaseController:
|
|
||||||
"""Idempotent main-event controller with durable startup/recovery reports."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
root: Path,
|
|
||||||
service: SilentReleaseActionService,
|
|
||||||
*,
|
|
||||||
main_probe: Callable[[], str],
|
|
||||||
recovery_worker: ReleaseRecoveryWorker | None = None,
|
|
||||||
):
|
|
||||||
self.root = Path(root)
|
|
||||||
self.service = service
|
|
||||||
self.main_probe = main_probe
|
|
||||||
self.recovery_worker = recovery_worker
|
|
||||||
self.leader_path = self.root / "controller.leader.lock"
|
|
||||||
self.status_path = self.root / "controller-status.json"
|
|
||||||
|
|
||||||
def startup_verify(self) -> dict[str, Any]:
|
|
||||||
checked = 0
|
|
||||||
failed = 0
|
|
||||||
for journal in sorted(self.service.sagas.root.glob("*.events.jsonl")):
|
|
||||||
candidate_id = journal.name[: -len(".events.jsonl")]
|
|
||||||
saga = self.service.sagas.load(candidate_id)
|
|
||||||
checked += 1
|
|
||||||
if not saga.journal_integrity:
|
|
||||||
failed += 1
|
|
||||||
status = {
|
|
||||||
"checked_sagas": checked,
|
|
||||||
"execution_enabled": self.service.execution_enabled,
|
|
||||||
"failed_closed": failed,
|
|
||||||
"shadow_mode": not self.service.execution_enabled,
|
|
||||||
"side_effects_executed": False,
|
|
||||||
"status": "READY" if failed == 0 else "FAIL_CLOSED",
|
|
||||||
}
|
|
||||||
_durable_json(self.status_path, status)
|
|
||||||
return status
|
|
||||||
|
|
||||||
def run_once(self) -> dict[str, Any]:
|
|
||||||
with LeaderLock(self.leader_path):
|
|
||||||
startup = self.startup_verify()
|
|
||||||
if startup["status"] != "READY":
|
|
||||||
result = {
|
|
||||||
"execution_enabled": self.service.execution_enabled,
|
|
||||||
"intent": None,
|
|
||||||
"notification": None,
|
|
||||||
"recovery": None,
|
|
||||||
"shadow_mode": not self.service.execution_enabled,
|
|
||||||
"side_effects_executed": False,
|
|
||||||
"startup": startup,
|
|
||||||
"status": "FAIL_CLOSED",
|
|
||||||
}
|
|
||||||
_durable_json(self.status_path, result)
|
|
||||||
return _sanitize(result)
|
|
||||||
source_commit = str(self.main_probe())
|
|
||||||
event_id = f"main-{source_commit}"
|
|
||||||
intent = self.service.create_release_intent(
|
|
||||||
{"event_id": event_id, "source_commit": source_commit}
|
|
||||||
)
|
|
||||||
notification = None
|
|
||||||
if intent.get("candidate_id"):
|
|
||||||
notification = self.service.ensure_owner_notification(
|
|
||||||
str(intent["candidate_id"])
|
|
||||||
)
|
|
||||||
recovery = (
|
|
||||||
self.recovery_worker.run_once()
|
|
||||||
if self.recovery_worker is not None
|
|
||||||
else self.service.get_recovery_status()
|
|
||||||
)
|
|
||||||
result = {
|
|
||||||
"execution_enabled": self.service.execution_enabled,
|
|
||||||
"intent": intent,
|
|
||||||
"notification": notification,
|
|
||||||
"recovery": recovery,
|
|
||||||
"shadow_mode": not self.service.execution_enabled,
|
|
||||||
"side_effects_executed": False,
|
|
||||||
"startup": startup,
|
|
||||||
}
|
|
||||||
_durable_json(self.status_path, result)
|
|
||||||
return _sanitize(result)
|
|
||||||
|
|
||||||
|
|
||||||
class ShadowActionHandler(BaseHTTPRequestHandler):
|
|
||||||
service: SilentReleaseActionService
|
|
||||||
controller: PersistentSilentReleaseController
|
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
|
||||||
return
|
|
||||||
|
|
||||||
def _json(self, payload: Mapping[str, Any], status=HTTPStatus.OK):
|
|
||||||
body = json.dumps(_sanitize(dict(payload)), sort_keys=True).encode()
|
|
||||||
self.send_response(int(status))
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def _body(self) -> dict[str, Any]:
|
|
||||||
size = int(self.headers.get("Content-Length", "0"))
|
|
||||||
if size > 1024 * 1024:
|
|
||||||
raise ValueError("request body is too large")
|
|
||||||
return json.loads(self.rfile.read(size) or b"{}")
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
query = parse_qs(parsed.query)
|
|
||||||
candidate_id = str((query.get("candidate_id") or [""])[0])
|
|
||||||
try:
|
|
||||||
if parsed.path == "/healthz":
|
|
||||||
return self._json(
|
|
||||||
{
|
|
||||||
"execution_enabled": False,
|
|
||||||
"shadow_mode": True,
|
|
||||||
"side_effects_executed": False,
|
|
||||||
"status": "ok",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
routes = {
|
|
||||||
"/getReleaseCandidate": self.service.get_release_candidate,
|
|
||||||
"/getReleasePlan": self.service.get_release_plan,
|
|
||||||
"/getReleaseAcceptance": self.service.get_acceptance,
|
|
||||||
"/getReleaseSaga": self.service.get_release_saga,
|
|
||||||
"/getCapabilityStatus": self.service.get_capability_status,
|
|
||||||
}
|
|
||||||
if parsed.path in routes:
|
|
||||||
return self._json(routes[parsed.path](candidate_id))
|
|
||||||
if parsed.path == "/getRecoveryStatus":
|
|
||||||
return self._json(self.service.get_recovery_status())
|
|
||||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
||||||
except (ValueError, FileNotFoundError) as exc:
|
|
||||||
self._json(
|
|
||||||
{"error": type(exc).__name__}, HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
try:
|
|
||||||
body = self._body()
|
|
||||||
if parsed.path == "/createReleaseIntent":
|
|
||||||
return self._json(self.service.create_release_intent(body))
|
|
||||||
if parsed.path in {
|
|
||||||
"/submitSignedApproval",
|
|
||||||
"/submitOwnerApproval",
|
|
||||||
}:
|
|
||||||
return self._json(self.service.submit_owner_approval(body))
|
|
||||||
if parsed.path == "/startDeployment":
|
|
||||||
return self._json(
|
|
||||||
self.service.start_deployment(
|
|
||||||
str(body.get("candidate_id") or "")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
||||||
except (ValueError, FileNotFoundError, CapabilityAuthorizationError) as exc:
|
|
||||||
self._json(
|
|
||||||
{"error": type(exc).__name__}, HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def git_main_probe(repository: Path) -> Callable[[], str]:
|
|
||||||
repository = Path(repository)
|
|
||||||
|
|
||||||
def probe() -> str:
|
|
||||||
completed = subprocess.run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
f"--git-dir={repository}",
|
|
||||||
"rev-parse",
|
|
||||||
"refs/heads/main",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=15,
|
|
||||||
env={"PATH": os.environ.get("PATH", "")},
|
|
||||||
)
|
|
||||||
return completed.stdout.strip()
|
|
||||||
|
|
||||||
return probe
|
|
||||||
|
|
||||||
|
|
||||||
def serve_shadow(
|
|
||||||
*,
|
|
||||||
state_root: Path,
|
|
||||||
repository: Path,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
poll_seconds: float,
|
|
||||||
) -> None:
|
|
||||||
service = SilentReleaseActionService.shadow(state_root)
|
|
||||||
controller = PersistentSilentReleaseController(
|
|
||||||
state_root,
|
|
||||||
service,
|
|
||||||
main_probe=git_main_probe(repository),
|
|
||||||
)
|
|
||||||
controller.run_once()
|
|
||||||
stop = threading.Event()
|
|
||||||
|
|
||||||
def controller_loop() -> None:
|
|
||||||
while not stop.wait(poll_seconds):
|
|
||||||
try:
|
|
||||||
controller.run_once()
|
|
||||||
except Exception as exc: # fail closed and keep the API available
|
|
||||||
_durable_json(
|
|
||||||
controller.status_path,
|
|
||||||
{
|
|
||||||
"error": type(exc).__name__,
|
|
||||||
"execution_enabled": False,
|
|
||||||
"shadow_mode": True,
|
|
||||||
"side_effects_executed": False,
|
|
||||||
"status": "FAIL_CLOSED",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
handler = type(
|
|
||||||
"ConfiguredShadowActionHandler",
|
|
||||||
(ShadowActionHandler,),
|
|
||||||
{"service": service, "controller": controller},
|
|
||||||
)
|
|
||||||
server = ThreadingHTTPServer((host, port), handler)
|
|
||||||
api_thread = threading.Thread(
|
|
||||||
target=server.serve_forever,
|
|
||||||
name="silent-release-shadow-api",
|
|
||||||
daemon=False,
|
|
||||||
)
|
|
||||||
controller_thread = threading.Thread(
|
|
||||||
target=controller_loop,
|
|
||||||
name="silent-release-shadow-controller",
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
api_thread.start()
|
|
||||||
controller_thread.start()
|
|
||||||
try:
|
|
||||||
while api_thread.is_alive():
|
|
||||||
api_thread.join(timeout=1)
|
|
||||||
finally:
|
|
||||||
stop.set()
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
controller_thread.join(timeout=max(1.0, poll_seconds + 1.0))
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--state-root", required=True, type=Path)
|
|
||||||
parser.add_argument("--repository", required=True, type=Path)
|
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
|
||||||
parser.add_argument("--port", default=18891, type=int)
|
|
||||||
parser.add_argument("--poll-seconds", default=30.0, type=float)
|
|
||||||
arguments = parser.parse_args()
|
|
||||||
if arguments.poll_seconds <= 0:
|
|
||||||
raise SystemExit("poll seconds must be positive")
|
|
||||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
|
|
||||||
raise SystemExit("GitOps writes must remain disabled")
|
|
||||||
if (
|
|
||||||
os.environ.get(
|
|
||||||
"AIOS_SILENT_RELEASE_EXECUTION_ENABLED", "false"
|
|
||||||
).lower()
|
|
||||||
!= "false"
|
|
||||||
):
|
|
||||||
raise SystemExit("Silent Release execution must remain disabled")
|
|
||||||
serve_shadow(
|
|
||||||
state_root=arguments.state_root,
|
|
||||||
repository=arguments.repository,
|
|
||||||
host=arguments.host,
|
|
||||||
port=arguments.port,
|
|
||||||
poll_seconds=arguments.poll_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
"""Task Plane runtime package."""
|
|
||||||
|
|
||||||
from .models import (
|
|
||||||
EvidenceEnvelope,
|
|
||||||
ProbeEvidence,
|
|
||||||
ProbeOutcome,
|
|
||||||
TaskPlan,
|
|
||||||
TaskRecord,
|
|
||||||
TaskStatus,
|
|
||||||
TaskStateTransitionError,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"EvidenceEnvelope",
|
|
||||||
"ProbeEvidence",
|
|
||||||
"ProbeOutcome",
|
|
||||||
"TaskPlan",
|
|
||||||
"TaskRecord",
|
|
||||||
"TaskStatus",
|
|
||||||
"TaskStateTransitionError",
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Adapters for executing tasks via worker backends."""
|
|
||||||
|
|
||||||
from .base import WorkerAdapter
|
|
||||||
from .openclaw import OpenClawAdapter
|
|
||||||
from .codex import CodexAdapter
|
|
||||||
from .hermes import HermesAdapter
|
|
||||||
from .a2a import A2AAdapter
|
|
||||||
from .systemd_worker import SystemdWorkerAdapter
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"WorkerAdapter",
|
|
||||||
"OpenClawAdapter",
|
|
||||||
"CodexAdapter",
|
|
||||||
"HermesAdapter",
|
|
||||||
"A2AAdapter",
|
|
||||||
"SystemdWorkerAdapter",
|
|
||||||
]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .base import WorkerAdapter
|
|
||||||
|
|
||||||
|
|
||||||
class A2AAdapter(WorkerAdapter):
|
|
||||||
worker_name = "a2a"
|
|
||||||
|
|
||||||
def capabilities(self) -> list[str]:
|
|
||||||
return ["task.exchange", "agent.handoff"]
|
|
||||||
|
|
||||||
def claim(self, task):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
|
||||||
|
|
||||||
def execute(self, task, claim):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
|
||||||
|
|
||||||
def heartbeat(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
|
||||||
|
|
||||||
def report_progress(self, claim, progress):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
|
||||||
|
|
||||||
def probe(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
|
||||||
|
|
||||||
def cancel(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
|
||||||
|
|
||||||
def collect_result(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""Base worker adapter contract."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import abc
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerAdapter(abc.ABC):
|
|
||||||
worker_name = "base"
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def capabilities(self) -> list[str]:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def claim(self, task):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def execute(self, task, claim):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def heartbeat(self, claim):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def report_progress(self, claim, progress):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def probe(self, claim):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def cancel(self, claim):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def collect_result(self, claim):
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .base import WorkerAdapter
|
|
||||||
|
|
||||||
|
|
||||||
class CodexAdapter(WorkerAdapter):
|
|
||||||
worker_name = "codex"
|
|
||||||
|
|
||||||
def capabilities(self) -> list[str]:
|
|
||||||
return ["code.read", "code.write.patch", "test.python", "review.security"]
|
|
||||||
|
|
||||||
def claim(self, task):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
|
||||||
|
|
||||||
def execute(self, task, claim):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
|
||||||
|
|
||||||
def heartbeat(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
|
||||||
|
|
||||||
def report_progress(self, claim, progress):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
|
||||||
|
|
||||||
def probe(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
|
||||||
|
|
||||||
def cancel(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
|
||||||
|
|
||||||
def collect_result(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .base import WorkerAdapter
|
|
||||||
|
|
||||||
|
|
||||||
class HermesAdapter(WorkerAdapter):
|
|
||||||
worker_name = "hermes"
|
|
||||||
|
|
||||||
def capabilities(self) -> list[str]:
|
|
||||||
return ["architecture.review", "policy.review", "threat.model"]
|
|
||||||
|
|
||||||
def claim(self, task):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
|
||||||
|
|
||||||
def execute(self, task, claim):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
|
||||||
|
|
||||||
def heartbeat(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
|
||||||
|
|
||||||
def report_progress(self, claim, progress):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
|
||||||
|
|
||||||
def probe(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
|
||||||
|
|
||||||
def cancel(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
|
||||||
|
|
||||||
def collect_result(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .base import WorkerAdapter
|
|
||||||
|
|
||||||
|
|
||||||
class OpenClawAdapter(WorkerAdapter):
|
|
||||||
worker_name = "openclaw"
|
|
||||||
|
|
||||||
def capabilities(self) -> list[str]:
|
|
||||||
return ["code.read", "code.write.patch", "test.execute", "runtime.shadow"]
|
|
||||||
|
|
||||||
def claim(self, task):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
|
||||||
|
|
||||||
def execute(self, task, claim):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
|
||||||
|
|
||||||
def heartbeat(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
|
||||||
|
|
||||||
def report_progress(self, claim, progress):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
|
||||||
|
|
||||||
def probe(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
|
||||||
|
|
||||||
def cancel(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
|
||||||
|
|
||||||
def collect_result(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .base import WorkerAdapter
|
|
||||||
|
|
||||||
|
|
||||||
class SystemdWorkerAdapter(WorkerAdapter):
|
|
||||||
worker_name = "systemd_worker"
|
|
||||||
|
|
||||||
def capabilities(self) -> list[str]:
|
|
||||||
return ["systemd.exec", "runtime.shell"]
|
|
||||||
|
|
||||||
def claim(self, task):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
|
||||||
|
|
||||||
def execute(self, task, claim):
|
|
||||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
|
||||||
|
|
||||||
def heartbeat(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
|
||||||
|
|
||||||
def report_progress(self, claim, progress):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
|
||||||
|
|
||||||
def probe(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
|
||||||
|
|
||||||
def cancel(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
|
||||||
|
|
||||||
def collect_result(self, claim):
|
|
||||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Agent registry abstraction."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .errors import NotFoundError
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRegistry:
|
|
||||||
def __init__(self):
|
|
||||||
self._agents: dict[str, dict] = {}
|
|
||||||
|
|
||||||
def register(self, agent_id: str, metadata: dict) -> None:
|
|
||||||
self._agents[agent_id] = {"agent_id": agent_id, **metadata}
|
|
||||||
|
|
||||||
def unregister(self, agent_id: str) -> None:
|
|
||||||
if agent_id not in self._agents:
|
|
||||||
raise NotFoundError("agent")
|
|
||||||
del self._agents[agent_id]
|
|
||||||
|
|
||||||
def list(self) -> list[dict]:
|
|
||||||
return list(self._agents.values())
|
|
||||||
|
|
||||||
def get(self, agent_id: str) -> dict:
|
|
||||||
try:
|
|
||||||
return self._agents[agent_id]
|
|
||||||
except KeyError:
|
|
||||||
raise NotFoundError("agent")
|
|
||||||
@@ -1,557 +0,0 @@
|
|||||||
"""Task Plane HTTP API implementation (stdlib-only)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
import urllib.parse
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from .delegation import DelegationEngine
|
|
||||||
from .errors import (
|
|
||||||
ConflictError,
|
|
||||||
ForbiddenError,
|
|
||||||
NotFoundError,
|
|
||||||
PayloadTooLargeError,
|
|
||||||
TaskPlaneHTTPError,
|
|
||||||
UnauthorizedError,
|
|
||||||
ValidationError,
|
|
||||||
)
|
|
||||||
from .evidence import EvidenceService
|
|
||||||
from .journal import TaskJournal
|
|
||||||
from .lease import LeaseManager
|
|
||||||
from .models import (
|
|
||||||
EvidenceEnvelope,
|
|
||||||
ResultEnvelope,
|
|
||||||
TaskPlan,
|
|
||||||
TaskRecord,
|
|
||||||
iso8601_now,
|
|
||||||
contains_secret_text,
|
|
||||||
validate_identifier,
|
|
||||||
)
|
|
||||||
from .policy import PolicyEngine
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
from .worker_registry import WorkerRegistry
|
|
||||||
|
|
||||||
|
|
||||||
def _json_payload(data: dict[str, Any], status: int = 200) -> tuple[int, bytes]:
|
|
||||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
||||||
return status, body
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
|
|
||||||
try:
|
|
||||||
number = int(value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValidationError(f"invalid {name}") from exc
|
|
||||||
if not minimum <= number <= maximum:
|
|
||||||
raise ValidationError(f"invalid {name}")
|
|
||||||
return number
|
|
||||||
|
|
||||||
|
|
||||||
class TaskPlaneService:
|
|
||||||
def __init__(self, store: TaskPlaneStore, policy: PolicyEngine | None = None):
|
|
||||||
self.store = store
|
|
||||||
self.policy = policy or PolicyEngine(
|
|
||||||
execution_enabled=False,
|
|
||||||
identity_capabilities={
|
|
||||||
"anon": {"task.creator", "task.read", "task.audit"}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.journal = TaskJournal(store)
|
|
||||||
self.lease = LeaseManager(store)
|
|
||||||
self.workers = WorkerRegistry(store)
|
|
||||||
self.evidence = EvidenceService(store)
|
|
||||||
self.delegation = DelegationEngine(store)
|
|
||||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
|
||||||
|
|
||||||
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
|
||||||
if body.get("parent_task_id"):
|
|
||||||
raise ForbiddenError("child tasks must use the delegate endpoint")
|
|
||||||
issuer = body.get("issuer", {})
|
|
||||||
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
|
||||||
self.policy.assert_allowed("create", actor)
|
|
||||||
try:
|
|
||||||
plan = TaskPlan.from_dict(body)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValidationError("invalid task plan") from exc
|
|
||||||
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
|
||||||
return task.to_dict()
|
|
||||||
|
|
||||||
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
|
||||||
if actor is not None:
|
|
||||||
self.policy.assert_allowed("read", actor)
|
|
||||||
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
|
||||||
|
|
||||||
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
|
||||||
if actor is not None:
|
|
||||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
|
||||||
return self.store.get_task(task_id).to_dict()
|
|
||||||
|
|
||||||
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
|
||||||
if actor is not None:
|
|
||||||
self.policy.assert_allowed("read", actor)
|
|
||||||
return self.workers.get(worker_id).to_dict()
|
|
||||||
|
|
||||||
def list_workers(self, actor: str | None = None) -> list[dict[str, Any]]:
|
|
||||||
if actor is not None:
|
|
||||||
self.policy.assert_allowed("read", actor)
|
|
||||||
return [w.to_dict() for w in self.workers.list()]
|
|
||||||
|
|
||||||
def register_worker(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
actor = str(body.get("actor_identity") or body.get("identity") or "")
|
|
||||||
is_admin = actor in self.policy.admin_identities
|
|
||||||
try:
|
|
||||||
existing = self.store.get_worker(str(body.get("worker_id") or ""))
|
|
||||||
except NotFoundError:
|
|
||||||
existing = None
|
|
||||||
if existing is not None and str(body.get("identity") or "") != existing.identity:
|
|
||||||
raise ConflictError("worker identity takeover rejected")
|
|
||||||
self.policy.assert_readonly_worker_registration(actor, body)
|
|
||||||
self.policy.assert_allowed(
|
|
||||||
"worker.admin" if is_admin else "worker.register", actor
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
worker = self.workers.register(
|
|
||||||
body,
|
|
||||||
actor_identity=actor,
|
|
||||||
is_admin=is_admin,
|
|
||||||
expected_revision=body.get("expected_revision"),
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError, KeyError) as exc:
|
|
||||||
raise ValidationError("invalid worker registration") from exc
|
|
||||||
return worker.to_dict()
|
|
||||||
|
|
||||||
def worker_heartbeat(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
interval = payload.get("heartbeat_interval_seconds")
|
|
||||||
actor = str(payload.get("actor_identity") or "")
|
|
||||||
self.policy.assert_readonly_worker_binding(actor, worker_id)
|
|
||||||
self.policy.assert_allowed("worker.register", actor)
|
|
||||||
return self.workers.heartbeat(
|
|
||||||
worker_id,
|
|
||||||
heartbeat_interval_seconds=interval,
|
|
||||||
actor_identity=actor,
|
|
||||||
expected_revision=payload.get("expected_revision"),
|
|
||||||
).to_dict()
|
|
||||||
|
|
||||||
def disable_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
actor = str(payload.get("actor_identity") or "")
|
|
||||||
self.policy.assert_allowed("worker.admin", actor)
|
|
||||||
return self.store.disable_worker(
|
|
||||||
worker_id,
|
|
||||||
actor_identity=actor,
|
|
||||||
expected_revision=int(payload.get("expected_revision")),
|
|
||||||
reason=str(payload.get("reason") or "disabled by administrator"),
|
|
||||||
).to_dict()
|
|
||||||
|
|
||||||
def restore_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
actor = str(payload.get("actor_identity") or "")
|
|
||||||
self.policy.assert_allowed("worker.admin", actor)
|
|
||||||
return self.store.restore_worker(
|
|
||||||
worker_id,
|
|
||||||
actor_identity=actor,
|
|
||||||
expected_revision=int(payload.get("expected_revision")),
|
|
||||||
).to_dict()
|
|
||||||
|
|
||||||
def claim(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
||||||
self.policy.assert_allowed("claim", actor)
|
|
||||||
worker_id = body.get("worker_id")
|
|
||||||
if not worker_id:
|
|
||||||
raise ValidationError("worker_id required")
|
|
||||||
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
|
||||||
self.policy.assert_allowed("claim", actor, task=task)
|
|
||||||
lease_seconds = _bounded_int(
|
|
||||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
||||||
)
|
|
||||||
claim = self.lease.claim(
|
|
||||||
task_id=task_id,
|
|
||||||
worker_id=worker_id,
|
|
||||||
worker_identity=actor,
|
|
||||||
lease_seconds=lease_seconds,
|
|
||||||
expected_revision=task.revision,
|
|
||||||
operation_digest=task.task_plan.operation_digest(),
|
|
||||||
)
|
|
||||||
return {"claim_id": claim.claim_id, **claim.to_dict()}
|
|
||||||
|
|
||||||
def heartbeat(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
claim_id = body.get("claim_id")
|
|
||||||
if not claim_id:
|
|
||||||
raise ValidationError("claim_id required")
|
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
claim = self.store.get_claim(claim_id)
|
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
||||||
self.policy.assert_allowed("heartbeat", actor, task=task)
|
|
||||||
if claim.worker_identity != actor:
|
|
||||||
raise ForbiddenError("claim identity mismatch")
|
|
||||||
lease_seconds = _bounded_int(
|
|
||||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
||||||
)
|
|
||||||
claim = self.lease.heartbeat(task_id, claim_id, lease_seconds)
|
|
||||||
return {"claim_id": claim_id, "lease_until": claim.lease_until}
|
|
||||||
|
|
||||||
def progress(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
claim_id = body.get("claim_id")
|
|
||||||
if not claim_id:
|
|
||||||
raise ValidationError("claim_id required")
|
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
||||||
progress = body.get("progress")
|
|
||||||
stage = body.get("stage", "RUNNING")
|
|
||||||
message = body.get("message")
|
|
||||||
if progress is None:
|
|
||||||
raise ValidationError("progress required")
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
claim = self.store.get_claim(claim_id)
|
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
||||||
self.policy.assert_allowed("progress", actor, task=task)
|
|
||||||
if task.task_plan.data_classification == "credential-secret":
|
|
||||||
if message not in {None, ""} or contains_secret_text(stage):
|
|
||||||
raise ValidationError(
|
|
||||||
"credential-secret progress must contain metadata only"
|
|
||||||
)
|
|
||||||
lease_seconds = _bounded_int(
|
|
||||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
||||||
)
|
|
||||||
claim = self.store.record_progress(
|
|
||||||
task_id,
|
|
||||||
claim_id,
|
|
||||||
actor,
|
|
||||||
progress=progress,
|
|
||||||
stage=str(stage),
|
|
||||||
message=None if message is None else str(message),
|
|
||||||
lease_seconds=lease_seconds,
|
|
||||||
)
|
|
||||||
return {"claim_id": claim.claim_id, "status": claim.status, "progress": progress}
|
|
||||||
|
|
||||||
def complete(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
claim_id = body.get("claim_id")
|
|
||||||
if not claim_id:
|
|
||||||
raise ValidationError("claim_id required")
|
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
|
|
||||||
status = body.get("status", "COMPLETED")
|
|
||||||
outputs = body.get("outputs", [])
|
|
||||||
evidences = body.get("evidence", [])
|
|
||||||
if not isinstance(outputs, list):
|
|
||||||
raise ValidationError("outputs must be list")
|
|
||||||
if not isinstance(evidences, list):
|
|
||||||
raise ValidationError("evidence must be list")
|
|
||||||
if any(not isinstance(ev, dict) for ev in evidences):
|
|
||||||
raise ValidationError("evidence items must be objects")
|
|
||||||
claim = self.store.get_claim(claim_id)
|
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
||||||
self.policy.assert_allowed("result", actor, task=task)
|
|
||||||
if claim.worker_identity != actor:
|
|
||||||
raise ForbiddenError("claim identity mismatch")
|
|
||||||
if task.task_plan.data_classification == "credential-secret":
|
|
||||||
allowed_result_keys = {
|
|
||||||
"kind",
|
|
||||||
"hash",
|
|
||||||
"sha256",
|
|
||||||
"count",
|
|
||||||
"status",
|
|
||||||
"error_code",
|
|
||||||
"signature",
|
|
||||||
"attestation_ref",
|
|
||||||
}
|
|
||||||
for entry in [*outputs, *evidences]:
|
|
||||||
if (
|
|
||||||
not isinstance(entry, dict)
|
|
||||||
or set(entry) - allowed_result_keys
|
|
||||||
or contains_secret_text(entry)
|
|
||||||
):
|
|
||||||
raise ValidationError(
|
|
||||||
"credential-secret result must contain hash/count/status only"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = ResultEnvelope(
|
|
||||||
task_id=task_id,
|
|
||||||
claim_id=claim_id,
|
|
||||||
worker_identity=actor,
|
|
||||||
status=status,
|
|
||||||
outputs=outputs,
|
|
||||||
evidence=evidences,
|
|
||||||
started_at=str(body.get("started_at") or iso8601_now()),
|
|
||||||
completed_at=str(body.get("completed_at") or iso8601_now()),
|
|
||||||
result_digest=body.get("result_digest"),
|
|
||||||
signature=body.get("signature"),
|
|
||||||
attestation_ref=body.get("attestation_ref"),
|
|
||||||
operation_digest=claim.operation_digest,
|
|
||||||
)
|
|
||||||
evidence_decision = self.policy.evidence_verifier.verify(task, evidences)
|
|
||||||
if not evidence_decision.allowed:
|
|
||||||
raise ForbiddenError(evidence_decision.reason)
|
|
||||||
result_decision = self.policy.verify_result(result, task)
|
|
||||||
if not result_decision.allowed:
|
|
||||||
raise ForbiddenError(result_decision.reason)
|
|
||||||
result.result_digest = result.computed_digest()
|
|
||||||
previous = self.store._last_evidence_hash(task_id)
|
|
||||||
evidence_entries = []
|
|
||||||
for evidence in evidences:
|
|
||||||
envelope = EvidenceEnvelope.create(
|
|
||||||
task_id=task_id,
|
|
||||||
claim_id=claim_id,
|
|
||||||
kind=str(evidence.get("kind") or evidence.get("type") or "generic"),
|
|
||||||
worker_identity=actor,
|
|
||||||
payload=evidence,
|
|
||||||
previous_evidence_hash=previous,
|
|
||||||
signature=evidence.get("signature"),
|
|
||||||
attestation_ref=evidence.get("attestation_ref"),
|
|
||||||
)
|
|
||||||
evidence_entries.append(envelope)
|
|
||||||
previous = envelope.evidence_digest
|
|
||||||
self.store.complete_claim_with_evidence_and_event(
|
|
||||||
task_id,
|
|
||||||
claim_id,
|
|
||||||
status
|
|
||||||
if status
|
|
||||||
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
|
|
||||||
else "COMPLETED",
|
|
||||||
result.to_dict(),
|
|
||||||
evidence_entries,
|
|
||||||
)
|
|
||||||
return result.to_dict()
|
|
||||||
|
|
||||||
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
claim_id = body.get("claim_id")
|
|
||||||
if not claim_id:
|
|
||||||
raise ValidationError("claim_id required")
|
|
||||||
reason = body.get("reason", "failed")
|
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
||||||
self.policy.assert_allowed("fail", actor)
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
self.policy.assert_allowed("fail", actor, task=task)
|
|
||||||
claim = self.store.get_claim(claim_id)
|
|
||||||
if claim.worker_identity != actor:
|
|
||||||
raise ForbiddenError("claim identity mismatch")
|
|
||||||
status = "FAILED_RECOVERABLE" if body.get("recoverable", False) else "FAILED_FINAL"
|
|
||||||
self.store.fail_claim_with_event(task_id, claim_id, status, str(reason))
|
|
||||||
return {"task_id": task_id, "status": status, "reason": reason}
|
|
||||||
|
|
||||||
def cancel(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
|
||||||
self.policy.assert_allowed("cancel", actor)
|
|
||||||
reason = body.get("reason", "cancelled")
|
|
||||||
task = self.store.get_task(task_id)
|
|
||||||
self.policy.assert_allowed("cancel", actor, task=task)
|
|
||||||
updated = self.store.cancel_task_with_event(
|
|
||||||
task_id, task.revision, reason=reason
|
|
||||||
)
|
|
||||||
return updated.to_dict()
|
|
||||||
|
|
||||||
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
actor = body.get("actor_identity") or "owner"
|
|
||||||
self.policy.assert_allowed("delegate", actor)
|
|
||||||
parent = self.store.get_task(task_id)
|
|
||||||
self.policy.assert_allowed("delegate", actor, task=parent)
|
|
||||||
if "task" in body:
|
|
||||||
try:
|
|
||||||
child_plan = TaskPlan.from_dict(body["task"])
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValidationError("invalid child task plan") from exc
|
|
||||||
else:
|
|
||||||
plan_body = dict(body)
|
|
||||||
plan_body.pop("actor_identity", None)
|
|
||||||
try:
|
|
||||||
child_plan = TaskPlan.from_dict(plan_body)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValidationError("invalid child task plan") from exc
|
|
||||||
child_plan.parent_task_id = task_id
|
|
||||||
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
|
||||||
return child.to_dict()
|
|
||||||
|
|
||||||
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
|
||||||
if actor is not None:
|
|
||||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
|
||||||
self.store.get_task(task_id)
|
|
||||||
return self.store.list_events(task_id)
|
|
||||||
|
|
||||||
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
|
||||||
if actor is not None:
|
|
||||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
|
||||||
self.store.get_task(task_id)
|
|
||||||
return self.evidence.list(task_id)
|
|
||||||
|
|
||||||
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
|
||||||
if actor is not None:
|
|
||||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
|
||||||
self.store.get_task(task_id)
|
|
||||||
return self.store.list_children(task_id)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|
||||||
service: TaskPlaneService | None = None
|
|
||||||
authenticator: Callable[[str], str | None] | None = None
|
|
||||||
max_body_bytes = 1_000_000
|
|
||||||
|
|
||||||
def log_message(self, format: str, *args: Any) -> None:
|
|
||||||
# BaseHTTPRequestHandler logs the raw request target; suppress it so
|
|
||||||
# attacker-controlled paths or query strings cannot leak credentials.
|
|
||||||
return
|
|
||||||
|
|
||||||
def _json(self) -> tuple[str | None, dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError("invalid content length") from exc
|
|
||||||
if length < 0:
|
|
||||||
raise ValidationError("invalid content length")
|
|
||||||
if length > self.max_body_bytes:
|
|
||||||
raise PayloadTooLargeError("payload exceeds limit")
|
|
||||||
raw = self.rfile.read(length) if length > 0 else b""
|
|
||||||
if not raw:
|
|
||||||
return None, {}
|
|
||||||
try:
|
|
||||||
decoded = raw.decode("utf-8")
|
|
||||||
payload = json.loads(decoded)
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ValidationError("JSON payload must be an object")
|
|
||||||
return decoded, payload
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ValidationError("invalid JSON payload") from exc
|
|
||||||
|
|
||||||
def _response(self, status: int, payload: Any) -> None:
|
|
||||||
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
||||||
self.send_header("Content-Length", str(len(encoded)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(encoded)
|
|
||||||
|
|
||||||
def _authenticated_identity(self) -> str:
|
|
||||||
authenticator = type(self).authenticator
|
|
||||||
if authenticator is None:
|
|
||||||
raise UnauthorizedError("authentication unavailable")
|
|
||||||
header = self.headers.get("Authorization", "")
|
|
||||||
if not header.startswith("Bearer "):
|
|
||||||
raise UnauthorizedError("bearer authentication required")
|
|
||||||
token = header[7:]
|
|
||||||
if not token or any(ord(ch) < 33 for ch in token):
|
|
||||||
raise UnauthorizedError("invalid bearer token")
|
|
||||||
identity = authenticator(token)
|
|
||||||
if not identity:
|
|
||||||
raise UnauthorizedError("invalid credentials")
|
|
||||||
return str(identity)
|
|
||||||
|
|
||||||
def _handle(self, handler):
|
|
||||||
try:
|
|
||||||
if self.service is None:
|
|
||||||
raise TaskPlaneHTTPError("service not configured")
|
|
||||||
result = handler()
|
|
||||||
if isinstance(result, tuple):
|
|
||||||
status, payload = result
|
|
||||||
else:
|
|
||||||
status, payload = 200, result
|
|
||||||
self._response(status, payload)
|
|
||||||
except TaskPlaneHTTPError as exc:
|
|
||||||
self._response(exc.status_code, exc.to_payload())
|
|
||||||
except Exception as exc: # defensive
|
|
||||||
if isinstance(exc, TaskPlaneHTTPError):
|
|
||||||
raise
|
|
||||||
self._response(
|
|
||||||
500,
|
|
||||||
{"error": "internal_error", "message": "internal server error"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def do_GET(self): # noqa: N802
|
|
||||||
def route():
|
|
||||||
path = urllib.parse.urlparse(self.path).path
|
|
||||||
parts = [p for p in path.split("/") if p]
|
|
||||||
for part in parts[1:]:
|
|
||||||
if part not in {"events", "evidence", "children", "heartbeat"}:
|
|
||||||
try:
|
|
||||||
validate_identifier(part, "resource identifier")
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError("invalid resource identifier") from exc
|
|
||||||
identity = self._authenticated_identity()
|
|
||||||
|
|
||||||
if len(parts) == 1 and parts[0] == "tasks":
|
|
||||||
return 200, self.service.list_tasks(identity)
|
|
||||||
if len(parts) == 2 and parts[0] == "tasks":
|
|
||||||
task_id = parts[1]
|
|
||||||
return 200, self.service.get_task(task_id, identity)
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "events":
|
|
||||||
return 200, self.service.get_events(parts[1], identity)
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "evidence":
|
|
||||||
return 200, self.service.get_evidence(parts[1], identity)
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "children":
|
|
||||||
return 200, self.service.get_children(parts[1], identity)
|
|
||||||
if len(parts) == 1 and parts[0] == "workers":
|
|
||||||
return 200, self.service.list_workers(identity)
|
|
||||||
if len(parts) == 2 and parts[0] == "workers":
|
|
||||||
return 200, self.service.get_worker(parts[1], identity)
|
|
||||||
raise NotFoundError("unknown endpoint")
|
|
||||||
|
|
||||||
self._handle(route)
|
|
||||||
|
|
||||||
def do_POST(self): # noqa: N802
|
|
||||||
def route():
|
|
||||||
body_text, body = self._json()
|
|
||||||
identity = self._authenticated_identity()
|
|
||||||
path = urllib.parse.urlparse(self.path).path
|
|
||||||
parts = [p for p in path.split("/") if p]
|
|
||||||
headers = {k.lower(): v for k, v in self.headers.items()}
|
|
||||||
|
|
||||||
if len(parts) == 1 and parts[0] == "tasks":
|
|
||||||
issuer = body.get("issuer")
|
|
||||||
if issuer and (
|
|
||||||
not isinstance(issuer, dict) or issuer.get("id") != identity
|
|
||||||
):
|
|
||||||
raise ForbiddenError("issuer identity mismatch")
|
|
||||||
body["issuer"] = {"type": "authenticated", "id": identity}
|
|
||||||
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
|
||||||
if body.get("actor_identity") not in {None, identity}:
|
|
||||||
raise ForbiddenError("actor identity mismatch")
|
|
||||||
body["actor_identity"] = identity
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "claim":
|
|
||||||
return 200, self.service.claim(parts[1], body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "heartbeat":
|
|
||||||
return 200, self.service.heartbeat(parts[1], body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "progress":
|
|
||||||
return 200, self.service.progress(parts[1], body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "result":
|
|
||||||
return 200, self.service.complete(parts[1], body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "fail":
|
|
||||||
return 200, self.service.fail(parts[1], body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel":
|
|
||||||
return 200, self.service.cancel(parts[1], body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate":
|
|
||||||
return 200, self.service.delegate(parts[1], body or {})
|
|
||||||
|
|
||||||
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
|
|
||||||
if (
|
|
||||||
body.get("identity") != identity
|
|
||||||
and identity not in self.service.policy.admin_identities
|
|
||||||
):
|
|
||||||
raise ForbiddenError("worker identity mismatch")
|
|
||||||
return 201, self.service.register_worker(body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "heartbeat":
|
|
||||||
return 200, self.service.worker_heartbeat(parts[1], body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "disable":
|
|
||||||
return 200, self.service.disable_worker(parts[1], body or {})
|
|
||||||
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "restore":
|
|
||||||
return 200, self.service.restore_worker(parts[1], body or {})
|
|
||||||
raise NotFoundError("unknown endpoint")
|
|
||||||
|
|
||||||
self._handle(route)
|
|
||||||
|
|
||||||
|
|
||||||
def run_server(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
service: TaskPlaneService,
|
|
||||||
*,
|
|
||||||
authenticator: Callable[[str], str | None] | None = None,
|
|
||||||
):
|
|
||||||
handler = TaskPlaneRequestHandler
|
|
||||||
handler.service = service
|
|
||||||
handler.authenticator = authenticator
|
|
||||||
server = ThreadingHTTPServer((host, port), handler)
|
|
||||||
server.serve_forever()
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Capability matcher helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fnmatch import fnmatch
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_capability(capability: str) -> str:
|
|
||||||
return capability.strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def capability_matches(granted: str, required: str) -> bool:
|
|
||||||
granted = _normalize_capability(granted)
|
|
||||||
required = _normalize_capability(required)
|
|
||||||
if granted == required:
|
|
||||||
return True
|
|
||||||
# A granted wildcard such as "test.*" should match required "test.unit"
|
|
||||||
if "*" in granted:
|
|
||||||
return fnmatch(required, granted)
|
|
||||||
# A required wildcard is treated as a suffix/prefix requirement for callers.
|
|
||||||
if "*" in required:
|
|
||||||
return fnmatch(granted, required)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def has_capabilities(granted_caps: list[str], required_caps: list[str]) -> bool:
|
|
||||||
granted = [_normalize_capability(c) for c in granted_caps]
|
|
||||||
for required in required_caps:
|
|
||||||
required = _normalize_capability(required)
|
|
||||||
if not any(capability_matches(g, required) for g in granted):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_capabilities(caps: list[str]) -> list[str]:
|
|
||||||
return sorted({_normalize_capability(cap) for cap in caps})
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""Delegation guardrails."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .errors import ConflictError, ForbiddenError, NotFoundError
|
|
||||||
from .models import Budget, TaskPlan, TaskRecord
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
class DelegationEngine:
|
|
||||||
def __init__(self, store: TaskPlaneStore):
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def can_delegate(self, parent_id: str, child_plan: TaskPlan) -> bool:
|
|
||||||
parent = self.store.get_task(parent_id)
|
|
||||||
if parent.status == "COMPLETED":
|
|
||||||
raise ConflictError("cannot delegate completed task")
|
|
||||||
if parent.task_plan.max_delegation_depth <= 0:
|
|
||||||
raise ConflictError("max delegation depth not configured")
|
|
||||||
|
|
||||||
depth = self._depth(parent_id)
|
|
||||||
if depth >= parent.task_plan.max_delegation_depth:
|
|
||||||
raise ConflictError("max delegation depth reached")
|
|
||||||
|
|
||||||
if child_plan.parent_task_id not in {None, parent_id}:
|
|
||||||
raise ConflictError("delegation parent mismatch")
|
|
||||||
current = parent_id
|
|
||||||
while current:
|
|
||||||
if child_plan.task_id and current == child_plan.task_id:
|
|
||||||
raise ConflictError("delegation cycle")
|
|
||||||
current = self.store.parent_of(current)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def delegate(self, parent_id: str, child_plan: TaskPlan, *, caller: str) -> TaskRecord:
|
|
||||||
self.can_delegate(parent_id, child_plan)
|
|
||||||
parent = self.store.get_task(parent_id)
|
|
||||||
if child_plan.required_capabilities and not set(child_plan.required_capabilities).issubset(
|
|
||||||
set(parent.task_plan.required_capabilities or [])
|
|
||||||
):
|
|
||||||
raise ForbiddenError("child capabilities exceed parent constraints")
|
|
||||||
risk_rank = {"low": 1, "medium": 2, "high": 3}
|
|
||||||
if risk_rank.get(child_plan.risk, 99) > risk_rank.get(parent.task_plan.risk, -1):
|
|
||||||
raise ForbiddenError("child risk exceeds parent constraints")
|
|
||||||
child_plan.parent_task_id = parent_id
|
|
||||||
child_plan.correlation_id = parent.task_plan.correlation_id or parent.task_id
|
|
||||||
child_plan.preferred_workers = parent.task_plan.preferred_workers
|
|
||||||
child_plan.max_delegation_depth = min(
|
|
||||||
child_plan.max_delegation_depth,
|
|
||||||
parent.task_plan.max_delegation_depth,
|
|
||||||
)
|
|
||||||
child_plan.approval_policy = parent.task_plan.approval_policy
|
|
||||||
|
|
||||||
parent_budget = self.store.get_task_budget(parent_id)
|
|
||||||
required = Budget()
|
|
||||||
for name in ("time_seconds", "attempts", "tokens", "cost"):
|
|
||||||
available = getattr(parent_budget, name)
|
|
||||||
requested = getattr(child_plan.budgets, name)
|
|
||||||
if name == "attempts" and requested is None:
|
|
||||||
requested = 1
|
|
||||||
if available is not None:
|
|
||||||
requested = 0 if requested is None else requested
|
|
||||||
if requested > available:
|
|
||||||
raise ConflictError(f"budget exceeded for {name}")
|
|
||||||
setattr(child_plan.budgets, name, requested)
|
|
||||||
setattr(required, name, requested)
|
|
||||||
|
|
||||||
# A retried identical delegation is a read, not a second budget charge.
|
|
||||||
if child_plan.task_id:
|
|
||||||
try:
|
|
||||||
existing = self.store.get_task(child_plan.task_id)
|
|
||||||
except NotFoundError:
|
|
||||||
existing = None
|
|
||||||
if existing is not None:
|
|
||||||
if (
|
|
||||||
existing.task_plan.parent_task_id == parent_id
|
|
||||||
and existing.task_plan.operation_digest()
|
|
||||||
== child_plan.operation_digest()
|
|
||||||
):
|
|
||||||
return existing
|
|
||||||
raise ConflictError("delegation task_id conflict")
|
|
||||||
|
|
||||||
return self.store.delegate_task_with_budget_and_events(
|
|
||||||
parent_id, child_plan, caller=caller
|
|
||||||
)
|
|
||||||
|
|
||||||
def _depth(self, task_id: str) -> int:
|
|
||||||
return self.store._compute_depth(task_id)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Error types for task plane operations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class TaskPlaneError(Exception):
|
|
||||||
"""Base exception for API-compatible failures."""
|
|
||||||
|
|
||||||
status_code = 500
|
|
||||||
error_code = "task_plane_error"
|
|
||||||
|
|
||||||
def __init__(self, message: str, *, code: str | None = None, details: dict | None = None):
|
|
||||||
super().__init__(message)
|
|
||||||
self.message = message
|
|
||||||
self.details = details or {}
|
|
||||||
if code:
|
|
||||||
self.error_code = code
|
|
||||||
|
|
||||||
def to_payload(self) -> dict:
|
|
||||||
return {"error": self.error_code, "message": self.message, "details": self.details}
|
|
||||||
|
|
||||||
|
|
||||||
class TaskPlaneHTTPError(TaskPlaneError):
|
|
||||||
"""Base class with explicit HTTP status."""
|
|
||||||
|
|
||||||
status_code = 500
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(TaskPlaneHTTPError):
|
|
||||||
status_code = 400
|
|
||||||
error_code = "validation_error"
|
|
||||||
|
|
||||||
|
|
||||||
class UnauthorizedError(TaskPlaneHTTPError):
|
|
||||||
status_code = 401
|
|
||||||
error_code = "unauthorized"
|
|
||||||
|
|
||||||
|
|
||||||
class ForbiddenError(TaskPlaneHTTPError):
|
|
||||||
status_code = 403
|
|
||||||
error_code = "forbidden"
|
|
||||||
|
|
||||||
|
|
||||||
class NotFoundError(TaskPlaneHTTPError):
|
|
||||||
status_code = 404
|
|
||||||
error_code = "not_found"
|
|
||||||
|
|
||||||
|
|
||||||
class ConflictError(TaskPlaneHTTPError):
|
|
||||||
status_code = 409
|
|
||||||
error_code = "conflict"
|
|
||||||
|
|
||||||
|
|
||||||
class PayloadTooLargeError(TaskPlaneHTTPError):
|
|
||||||
status_code = 413
|
|
||||||
error_code = "payload_too_large"
|
|
||||||
|
|
||||||
|
|
||||||
class FailedDependencyError(TaskPlaneHTTPError):
|
|
||||||
status_code = 424
|
|
||||||
error_code = "failed_dependency"
|
|
||||||
|
|
||||||
|
|
||||||
class LockedError(TaskPlaneHTTPError):
|
|
||||||
status_code = 423
|
|
||||||
error_code = "locked"
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceUnavailableError(TaskPlaneHTTPError):
|
|
||||||
status_code = 503
|
|
||||||
error_code = "service_unavailable"
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""CloudEvents-compatible event envelope and durable outbox helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Callable, Protocol
|
|
||||||
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CloudEvent:
|
|
||||||
specversion: str = "1.0"
|
|
||||||
id: str = ""
|
|
||||||
type: str = ""
|
|
||||||
source: str = "spiffe://aios/task-plane"
|
|
||||||
subject: str = ""
|
|
||||||
datacontenttype: str = "application/json"
|
|
||||||
data: dict | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def time(self) -> str:
|
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
def asdict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"specversion": self.specversion,
|
|
||||||
"id": self.id,
|
|
||||||
"type": self.type,
|
|
||||||
"source": self.source,
|
|
||||||
"subject": self.subject,
|
|
||||||
"time": self.time,
|
|
||||||
"datacontenttype": self.datacontenttype,
|
|
||||||
"data": self.data or {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
NOT_DELIVERED = "NOT_DELIVERED"
|
|
||||||
DELIVERED = "DELIVERED"
|
|
||||||
FAILED_DELIVERY = "FAILED"
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationAdapter(Protocol):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def deliver(self, envelope: dict) -> str | None:
|
|
||||||
"""Return a provider receipt, or None when delivery is unconfirmed."""
|
|
||||||
|
|
||||||
|
|
||||||
class CallbackNotificationAdapter:
|
|
||||||
"""Adapter seam for webhook, NATS, GPT, dashboard, and WeChat transports."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, callback: Callable[[dict], str | None]):
|
|
||||||
self.name = name
|
|
||||||
self._callback = callback
|
|
||||||
|
|
||||||
def deliver(self, envelope: dict) -> str | None:
|
|
||||||
return self._callback(envelope)
|
|
||||||
|
|
||||||
|
|
||||||
class WebhookAdapter(CallbackNotificationAdapter):
|
|
||||||
def __init__(self, callback: Callable[[dict], str | None]):
|
|
||||||
super().__init__("webhook", callback)
|
|
||||||
|
|
||||||
|
|
||||||
class NatsJetStreamAdapter(CallbackNotificationAdapter):
|
|
||||||
def __init__(self, callback: Callable[[dict], str | None]):
|
|
||||||
super().__init__("nats-jetstream", callback)
|
|
||||||
|
|
||||||
|
|
||||||
class GPTResultCallbackAdapter(CallbackNotificationAdapter):
|
|
||||||
def __init__(self, callback: Callable[[dict], str | None]):
|
|
||||||
super().__init__("gpt-result-callback", callback)
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardAdapter(CallbackNotificationAdapter):
|
|
||||||
def __init__(self, callback: Callable[[dict], str | None]):
|
|
||||||
super().__init__("dashboard", callback)
|
|
||||||
|
|
||||||
|
|
||||||
class WeChatGatewayAdapter(CallbackNotificationAdapter):
|
|
||||||
def __init__(self, callback: Callable[[dict], str | None]):
|
|
||||||
super().__init__("wechat-gateway", callback)
|
|
||||||
|
|
||||||
|
|
||||||
class DurableOutboxDispatcher:
|
|
||||||
def __init__(self, store: TaskPlaneStore):
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def dispatch(self, message_id: str, adapter: NotificationAdapter) -> dict:
|
|
||||||
row = self.store.get_outbox_message(message_id)
|
|
||||||
import json
|
|
||||||
|
|
||||||
envelope = json.loads(row["payload"])
|
|
||||||
try:
|
|
||||||
receipt = adapter.deliver(envelope)
|
|
||||||
except Exception:
|
|
||||||
return self.store.record_outbox_delivery(
|
|
||||||
message_id,
|
|
||||||
receipt=None,
|
|
||||||
error=f"{adapter.name} delivery failed",
|
|
||||||
)
|
|
||||||
return self.store.record_outbox_delivery(
|
|
||||||
message_id,
|
|
||||||
receipt=str(receipt) if receipt else None,
|
|
||||||
error=None if receipt else f"{adapter.name} receipt unavailable",
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""Evidence helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
def evidence_digest(payload: dict) -> str:
|
|
||||||
body = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
||||||
return hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceService:
|
|
||||||
def __init__(self, store: TaskPlaneStore):
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def add(self, task_id: str, kind: str, payload: dict, worker_identity: str) -> str:
|
|
||||||
return self.store.add_evidence(task_id, kind, payload, worker_identity)
|
|
||||||
|
|
||||||
def list(self, task_id: str) -> list[dict]:
|
|
||||||
return self.store.list_evidence(task_id)
|
|
||||||
|
|
||||||
def verify(self, task_id: str, required_kinds: list[str]) -> bool:
|
|
||||||
evidences = self.list(task_id)
|
|
||||||
observed = {e["kind"] for e in evidences}
|
|
||||||
return set(required_kinds).issubset(
|
|
||||||
observed
|
|
||||||
) and self.store.verify_evidence_integrity(task_id)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Event journaling helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .errors import ValidationError
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
class TaskJournal:
|
|
||||||
def __init__(self, store: TaskPlaneStore):
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def append(self, task_id: str, event_type: str, data: dict, *, subject: str | None = None) -> None:
|
|
||||||
if not event_type:
|
|
||||||
raise ValidationError("event_type required")
|
|
||||||
self.store.append_event(task_id, event_type, data, subject=subject)
|
|
||||||
|
|
||||||
def list(self, task_id: str) -> list[dict]:
|
|
||||||
return self.store.list_events(task_id)
|
|
||||||
|
|
||||||
def check_chain(self, task_id: str) -> bool:
|
|
||||||
return self.store.verify_event_chain(task_id)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Claim and lease management helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .errors import LockedError
|
|
||||||
from .models import ProbeEvidence
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
class LeaseManager:
|
|
||||||
def __init__(self, store: TaskPlaneStore):
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def claim(self, task_id: str, worker_id: str, worker_identity: str, lease_seconds: int, expected_revision: int, operation_digest: str):
|
|
||||||
return self.store.claim_task(task_id, worker_id, worker_identity, lease_seconds, expected_revision, operation_digest)
|
|
||||||
|
|
||||||
def heartbeat(self, task_id: str, claim_id: str, lease_seconds: int):
|
|
||||||
claim = self.store.heartbeat_claim(task_id, claim_id, lease_seconds)
|
|
||||||
return claim
|
|
||||||
|
|
||||||
def probe(self, claim_id: str):
|
|
||||||
claim = self.store.get_claim(claim_id)
|
|
||||||
if claim.status != "ACTIVE":
|
|
||||||
raise LockedError("claim not active")
|
|
||||||
return claim
|
|
||||||
|
|
||||||
def recover(self, task_id: str, claim_id: str, probe: ProbeEvidence):
|
|
||||||
return self.store.recover_expired_claim(
|
|
||||||
task_id,
|
|
||||||
claim_id,
|
|
||||||
probe,
|
|
||||||
)
|
|
||||||
@@ -1,829 +0,0 @@
|
|||||||
"""Core data models for the task plane."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
import urllib.parse
|
|
||||||
from enum import StrEnum
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
|
||||||
"secret",
|
|
||||||
"password",
|
|
||||||
"token",
|
|
||||||
"api_key",
|
|
||||||
"access_token",
|
|
||||||
"refresh_token",
|
|
||||||
"authorization",
|
|
||||||
"private_key",
|
|
||||||
"client_secret",
|
|
||||||
"credential",
|
|
||||||
}
|
|
||||||
IDENTIFIER_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
|
||||||
TYPE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
|
||||||
RISK_LEVELS = {"low", "medium", "high"}
|
|
||||||
DATA_CLASSIFICATIONS = {
|
|
||||||
"public",
|
|
||||||
"internal",
|
|
||||||
"confidential",
|
|
||||||
"credential-secret",
|
|
||||||
}
|
|
||||||
SECRET_TEXT_PATTERNS = (
|
|
||||||
re.compile(r"(?i)\bAuthorization\s*:\s*Bearer\s+\S+"),
|
|
||||||
re.compile(r"(?i)\b(?:password|passwd|api[_-]?key|access[_-]?token|secret)\s*[:=]\s*\S+"),
|
|
||||||
re.compile(r"\bsk-[A-Za-z0-9_-]{12,}\b"),
|
|
||||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
||||||
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
|
||||||
)
|
|
||||||
TASK_PLAN_KEYS = {
|
|
||||||
"schema_version",
|
|
||||||
"task_id",
|
|
||||||
"task_type",
|
|
||||||
"issuer",
|
|
||||||
"intent",
|
|
||||||
"required_capabilities",
|
|
||||||
"preferred_workers",
|
|
||||||
"risk",
|
|
||||||
"approval_policy",
|
|
||||||
"dependencies",
|
|
||||||
"max_delegation_depth",
|
|
||||||
"budgets",
|
|
||||||
"evidence_requirements",
|
|
||||||
"parent_task_id",
|
|
||||||
"correlation_id",
|
|
||||||
"metadata",
|
|
||||||
"data_classification",
|
|
||||||
"execution_zone",
|
|
||||||
"network_policy",
|
|
||||||
"logging_policy",
|
|
||||||
"allowed_input_paths",
|
|
||||||
"allowed_output_paths",
|
|
||||||
"output_policy",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _now_ts() -> float:
|
|
||||||
return time.time()
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: Any) -> str:
|
|
||||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
|
|
||||||
|
|
||||||
def compute_sha256_hex(value: Any) -> str:
|
|
||||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def contains_secret_text(value: Any) -> bool:
|
|
||||||
if isinstance(value, str):
|
|
||||||
return any(pattern.search(value) for pattern in SECRET_TEXT_PATTERNS)
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return any(contains_secret_text(item) for item in value.values())
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
return any(contains_secret_text(item) for item in value)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def validate_relative_paths(values: Any, name: str) -> list[str]:
|
|
||||||
paths = _normalize_list(values)
|
|
||||||
for value in paths:
|
|
||||||
decoded = urllib.parse.unquote(value)
|
|
||||||
parts = value.replace("\\", "/").split("/")
|
|
||||||
if (
|
|
||||||
decoded != value
|
|
||||||
or "\\" in value
|
|
||||||
or value.startswith("/")
|
|
||||||
or re.match(r"^[A-Za-z]:", value)
|
|
||||||
or any(part in {"", ".", ".."} for part in parts)
|
|
||||||
):
|
|
||||||
raise ValueError(f"invalid {name}")
|
|
||||||
return paths
|
|
||||||
|
|
||||||
|
|
||||||
def validate_identifier(value: Any, name: str) -> str:
|
|
||||||
text = str(value or "")
|
|
||||||
if (
|
|
||||||
not IDENTIFIER_RE.fullmatch(text)
|
|
||||||
or urllib.parse.unquote(text) != text
|
|
||||||
or any(ord(ch) < 32 or ord(ch) == 127 for ch in text)
|
|
||||||
):
|
|
||||||
raise ValueError(f"invalid {name}")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def validate_type(value: Any, name: str) -> str:
|
|
||||||
text = str(value or "")
|
|
||||||
if not TYPE_RE.fullmatch(text):
|
|
||||||
raise ValueError(f"invalid {name}")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def redact_value(value: Any) -> Any:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
redacted = {}
|
|
||||||
for key, inner in value.items():
|
|
||||||
lowered = str(key).lower()
|
|
||||||
if lowered in SENSITIVE_KEYS or lowered.endswith(
|
|
||||||
("_password", "_secret", "_access_token", "_refresh_token", "_private_key")
|
|
||||||
):
|
|
||||||
redacted[key] = "[REDACTED]"
|
|
||||||
else:
|
|
||||||
redacted[key] = redact_value(inner)
|
|
||||||
return redacted
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [redact_value(v) for v in value]
|
|
||||||
if isinstance(value, tuple):
|
|
||||||
return tuple(redact_value(v) for v in value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class TaskStateTransitionError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TaskStatus(StrEnum):
|
|
||||||
CREATED = "CREATED"
|
|
||||||
QUEUED = "QUEUED"
|
|
||||||
CLAIMED = "CLAIMED"
|
|
||||||
RUNNING = "RUNNING"
|
|
||||||
WAITING_CHILD = "WAITING_CHILD"
|
|
||||||
VERIFYING = "VERIFYING"
|
|
||||||
COMPLETED = "COMPLETED"
|
|
||||||
FAILED_RECOVERABLE = "FAILED_RECOVERABLE"
|
|
||||||
FAILED_FINAL = "FAILED_FINAL"
|
|
||||||
CANCELLED = "CANCELLED"
|
|
||||||
MANUAL_REVIEW_REQUIRED = "MANUAL_REVIEW_REQUIRED"
|
|
||||||
|
|
||||||
|
|
||||||
TASK_STATES = {
|
|
||||||
status.value for status in TaskStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
TASK_NEXT_STATES = {
|
|
||||||
"CREATED": {"QUEUED", "CLAIMED", "CANCELLED", "MANUAL_REVIEW_REQUIRED"},
|
|
||||||
"QUEUED": {"CLAIMED", "CANCELLED", "FAILED_RECOVERABLE", "FAILED_FINAL"},
|
|
||||||
"CLAIMED": {"RUNNING", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
|
|
||||||
"RUNNING": {"VERIFYING", "WAITING_CHILD", "COMPLETED", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
|
|
||||||
"WAITING_CHILD": {"RUNNING", "VERIFYING", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
|
|
||||||
"VERIFYING": {"COMPLETED", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
|
|
||||||
"COMPLETED": set(),
|
|
||||||
"FAILED_RECOVERABLE": {"CLAIMED", "RUNNING", "CANCELLED"},
|
|
||||||
"FAILED_FINAL": set(),
|
|
||||||
"CANCELLED": set(),
|
|
||||||
"MANUAL_REVIEW_REQUIRED": {"RUNNING", "CANCELLED"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Budget:
|
|
||||||
time_seconds: int | None = None
|
|
||||||
attempts: int | None = None
|
|
||||||
tokens: int | None = None
|
|
||||||
cost: float | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> "Budget":
|
|
||||||
if not data:
|
|
||||||
return cls()
|
|
||||||
if not isinstance(data, dict) or set(data) - {
|
|
||||||
"time_seconds",
|
|
||||||
"attempts",
|
|
||||||
"tokens",
|
|
||||||
"cost",
|
|
||||||
}:
|
|
||||||
raise ValueError("invalid budget")
|
|
||||||
for name in ("time_seconds", "attempts", "tokens", "cost"):
|
|
||||||
value = data.get(name)
|
|
||||||
if value is not None:
|
|
||||||
try:
|
|
||||||
if float(value) < 0:
|
|
||||||
raise ValueError("budget values must be non-negative")
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("invalid budget value") from exc
|
|
||||||
return cls(
|
|
||||||
time_seconds=_to_positive_int(data.get("time_seconds")),
|
|
||||||
attempts=_to_positive_int(data.get("attempts")),
|
|
||||||
tokens=_to_positive_int(data.get("tokens")),
|
|
||||||
cost=_to_float(data.get("cost")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"time_seconds": self.time_seconds,
|
|
||||||
"attempts": self.attempts,
|
|
||||||
"tokens": self.tokens,
|
|
||||||
"cost": self.cost,
|
|
||||||
}
|
|
||||||
|
|
||||||
def merge(self, other: "Budget") -> "Budget":
|
|
||||||
return Budget(
|
|
||||||
time_seconds=_first_non_none(self.time_seconds, other.time_seconds),
|
|
||||||
attempts=_first_non_none(self.attempts, other.attempts),
|
|
||||||
tokens=_first_non_none(self.tokens, other.tokens),
|
|
||||||
cost=_first_non_none(self.cost, other.cost),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TaskPlan:
|
|
||||||
schema_version: int = 1
|
|
||||||
task_id: str | None = None
|
|
||||||
task_type: str = "code.change"
|
|
||||||
issuer: dict[str, Any] = field(default_factory=dict)
|
|
||||||
intent: str | None = None
|
|
||||||
required_capabilities: list[str] = field(default_factory=list)
|
|
||||||
preferred_workers: list[str] = field(default_factory=list)
|
|
||||||
risk: str = "medium"
|
|
||||||
approval_policy: str = "patch-only"
|
|
||||||
dependencies: list[str] = field(default_factory=list)
|
|
||||||
max_delegation_depth: int = 5
|
|
||||||
budgets: Budget = field(default_factory=Budget)
|
|
||||||
evidence_requirements: list[str] = field(default_factory=list)
|
|
||||||
parent_task_id: str | None = None
|
|
||||||
correlation_id: str | None = None
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
data_classification: str = "internal"
|
|
||||||
execution_zone: str = "isolated-development"
|
|
||||||
network_policy: str = "controlled"
|
|
||||||
logging_policy: str = "redacted"
|
|
||||||
allowed_input_paths: list[str] = field(default_factory=list)
|
|
||||||
allowed_output_paths: list[str] = field(default_factory=list)
|
|
||||||
output_policy: str = "standard"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any] | None) -> "TaskPlan":
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError("task plan payload must be an object")
|
|
||||||
unknown = set(data) - TASK_PLAN_KEYS
|
|
||||||
if unknown:
|
|
||||||
raise ValueError("unknown task plan fields")
|
|
||||||
if "task_id" not in data or data["task_id"] is None:
|
|
||||||
data = dict(data)
|
|
||||||
data["task_id"] = f"task-{uuid.uuid4()}"
|
|
||||||
risk = str(data.get("risk", "medium"))
|
|
||||||
if risk not in RISK_LEVELS:
|
|
||||||
raise ValueError("invalid risk")
|
|
||||||
issuer = data.get("issuer", {})
|
|
||||||
if not isinstance(issuer, dict):
|
|
||||||
raise ValueError("issuer must be an object")
|
|
||||||
schema_version = int(data.get("schema_version", 1))
|
|
||||||
if schema_version != 1:
|
|
||||||
raise ValueError("unsupported schema_version")
|
|
||||||
max_depth = _to_positive_int(data.get("max_delegation_depth"), default=5)
|
|
||||||
if max_depth is None or max_depth > 32:
|
|
||||||
raise ValueError("invalid max_delegation_depth")
|
|
||||||
classification = str(data.get("data_classification", "internal"))
|
|
||||||
if classification not in DATA_CLASSIFICATIONS:
|
|
||||||
raise ValueError("invalid data_classification")
|
|
||||||
execution_zone = validate_type(
|
|
||||||
data.get("execution_zone", "isolated-development"), "execution_zone"
|
|
||||||
)
|
|
||||||
network_policy = validate_type(
|
|
||||||
data.get("network_policy", "controlled"), "network_policy"
|
|
||||||
)
|
|
||||||
logging_policy = validate_type(
|
|
||||||
data.get("logging_policy", "redacted"), "logging_policy"
|
|
||||||
)
|
|
||||||
output_policy = validate_type(
|
|
||||||
data.get("output_policy", "standard"), "output_policy"
|
|
||||||
)
|
|
||||||
if contains_secret_text(
|
|
||||||
{
|
|
||||||
"intent": data.get("intent"),
|
|
||||||
"metadata": data.get("metadata", {}),
|
|
||||||
}
|
|
||||||
):
|
|
||||||
raise ValueError("task content contains secret material")
|
|
||||||
if classification == "credential-secret":
|
|
||||||
if execution_zone != "secure-file-transformer":
|
|
||||||
raise ValueError("credential-secret requires secure-file-transformer")
|
|
||||||
if network_policy != "denied":
|
|
||||||
raise ValueError("credential-secret requires network denied")
|
|
||||||
if logging_policy != "metadata-only":
|
|
||||||
raise ValueError("credential-secret requires metadata-only logging")
|
|
||||||
if output_policy != "hash-count-status-only":
|
|
||||||
raise ValueError("credential-secret requires restricted output")
|
|
||||||
return cls(
|
|
||||||
schema_version=schema_version,
|
|
||||||
task_id=validate_identifier(data["task_id"], "task_id"),
|
|
||||||
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
|
||||||
issuer=issuer,
|
|
||||||
intent=data.get("intent"),
|
|
||||||
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
|
||||||
preferred_workers=_normalize_list(data.get("preferred_workers", [])),
|
|
||||||
risk=risk,
|
|
||||||
approval_policy=validate_type(
|
|
||||||
data.get("approval_policy", "patch-only"), "approval_policy"
|
|
||||||
),
|
|
||||||
dependencies=_normalize_list(data.get("dependencies", [])),
|
|
||||||
max_delegation_depth=max_depth,
|
|
||||||
budgets=Budget.from_dict(data.get("budgets")),
|
|
||||||
evidence_requirements=_normalize_list(data.get("evidence_requirements", [])),
|
|
||||||
parent_task_id=data.get("parent_task_id"),
|
|
||||||
correlation_id=data.get("correlation_id"),
|
|
||||||
metadata=data.get("metadata", {}) if isinstance(data.get("metadata", {}), dict) else {},
|
|
||||||
data_classification=classification,
|
|
||||||
execution_zone=execution_zone,
|
|
||||||
network_policy=network_policy,
|
|
||||||
logging_policy=logging_policy,
|
|
||||||
allowed_input_paths=validate_relative_paths(
|
|
||||||
data.get("allowed_input_paths", []), "allowed_input_paths"
|
|
||||||
),
|
|
||||||
allowed_output_paths=validate_relative_paths(
|
|
||||||
data.get("allowed_output_paths", []), "allowed_output_paths"
|
|
||||||
),
|
|
||||||
output_policy=output_policy,
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self, *, redact: bool = False) -> dict[str, Any]:
|
|
||||||
payload = {
|
|
||||||
"schema_version": self.schema_version,
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"task_type": self.task_type,
|
|
||||||
"issuer": self.issuer,
|
|
||||||
"intent": self.intent,
|
|
||||||
"required_capabilities": list(self.required_capabilities),
|
|
||||||
"preferred_workers": list(self.preferred_workers),
|
|
||||||
"risk": self.risk,
|
|
||||||
"approval_policy": self.approval_policy,
|
|
||||||
"dependencies": list(self.dependencies),
|
|
||||||
"max_delegation_depth": self.max_delegation_depth,
|
|
||||||
"budgets": self.budgets.to_dict(),
|
|
||||||
"evidence_requirements": list(self.evidence_requirements),
|
|
||||||
"parent_task_id": self.parent_task_id,
|
|
||||||
"correlation_id": self.correlation_id,
|
|
||||||
"metadata": dict(self.metadata),
|
|
||||||
"data_classification": self.data_classification,
|
|
||||||
"execution_zone": self.execution_zone,
|
|
||||||
"network_policy": self.network_policy,
|
|
||||||
"logging_policy": self.logging_policy,
|
|
||||||
"allowed_input_paths": list(self.allowed_input_paths),
|
|
||||||
"allowed_output_paths": list(self.allowed_output_paths),
|
|
||||||
"output_policy": self.output_policy,
|
|
||||||
}
|
|
||||||
if redact:
|
|
||||||
payload = redact_value(payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def operation_digest(self) -> str:
|
|
||||||
return compute_sha256_hex(self.to_dict())
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TaskRecord:
|
|
||||||
task_id: str
|
|
||||||
status: str
|
|
||||||
revision: int
|
|
||||||
task_plan: TaskPlan
|
|
||||||
created_at: float
|
|
||||||
updated_at: float
|
|
||||||
root_task_id: str
|
|
||||||
idempotency_key: str | None = None
|
|
||||||
failure_reason: str | None = None
|
|
||||||
|
|
||||||
def with_status(self, status: str) -> "TaskRecord":
|
|
||||||
allowed = TASK_NEXT_STATES.get(self.status, set())
|
|
||||||
if status != self.status and (not allowed and self.status not in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}):
|
|
||||||
raise TaskStateTransitionError(f"Cannot transition {self.status} -> {status}")
|
|
||||||
if status not in TASK_STATES:
|
|
||||||
raise TaskStateTransitionError(f"Invalid status {status}")
|
|
||||||
if status != self.status and self.status in TASK_NEXT_STATES and status not in TASK_NEXT_STATES[self.status]:
|
|
||||||
raise TaskStateTransitionError(f"Cannot transition {self.status} -> {status}")
|
|
||||||
return TaskRecord(
|
|
||||||
task_id=self.task_id,
|
|
||||||
status=status,
|
|
||||||
revision=self.revision + 1,
|
|
||||||
task_plan=self.task_plan,
|
|
||||||
created_at=self.created_at,
|
|
||||||
updated_at=_now_ts(),
|
|
||||||
root_task_id=self.root_task_id,
|
|
||||||
idempotency_key=self.idempotency_key,
|
|
||||||
failure_reason=self.failure_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self, *, include_task=True, redact_payload: bool = True) -> dict[str, Any]:
|
|
||||||
payload = {
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"status": self.status,
|
|
||||||
"revision": self.revision,
|
|
||||||
"root_task_id": self.root_task_id,
|
|
||||||
"parent_task_id": self.task_plan.parent_task_id,
|
|
||||||
"created_at": _fmt_ts(self.created_at),
|
|
||||||
"updated_at": _fmt_ts(self.updated_at),
|
|
||||||
"failure_reason": self.failure_reason,
|
|
||||||
}
|
|
||||||
if include_task:
|
|
||||||
payload["plan"] = self.task_plan.to_dict(redact=redact_payload)
|
|
||||||
if self.idempotency_key:
|
|
||||||
payload["idempotency_key"] = self.idempotency_key
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WorkerRegistration:
|
|
||||||
worker_id: str
|
|
||||||
worker_type: str
|
|
||||||
identity: str
|
|
||||||
capabilities: list[str]
|
|
||||||
trust_zone: str
|
|
||||||
max_concurrency: int
|
|
||||||
heartbeat_interval_seconds: int
|
|
||||||
supported_protocols: list[str]
|
|
||||||
registered_at: float
|
|
||||||
last_heartbeat: float
|
|
||||||
status: str = "active"
|
|
||||||
disabled: bool = False
|
|
||||||
owner_identity: str = ""
|
|
||||||
registration_revision: int = 1
|
|
||||||
registration_policy: str = "self"
|
|
||||||
created_at: float = 0.0
|
|
||||||
updated_at: float = 0.0
|
|
||||||
disabled_reason: str | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any]) -> "WorkerRegistration":
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError("worker registration payload must be an object")
|
|
||||||
now = _now_ts()
|
|
||||||
worker_id = validate_identifier(data.get("worker_id"), "worker_id")
|
|
||||||
worker_type = validate_type(data.get("worker_type"), "worker_type")
|
|
||||||
identity = str(data.get("identity") or "").strip()
|
|
||||||
if not identity or any(ord(ch) < 32 for ch in identity):
|
|
||||||
raise ValueError("invalid identity")
|
|
||||||
max_concurrency = _to_positive_int(data.get("max_concurrency"), default=1)
|
|
||||||
heartbeat_interval = _to_positive_int(
|
|
||||||
data.get("heartbeat_interval_seconds"), default=15
|
|
||||||
)
|
|
||||||
if not max_concurrency:
|
|
||||||
raise ValueError("max_concurrency must be positive")
|
|
||||||
if not heartbeat_interval:
|
|
||||||
raise ValueError("heartbeat_interval_seconds must be positive")
|
|
||||||
return cls(
|
|
||||||
worker_id=worker_id,
|
|
||||||
worker_type=worker_type,
|
|
||||||
identity=identity,
|
|
||||||
capabilities=_normalize_list(data.get("capabilities", [])),
|
|
||||||
trust_zone=str(data.get("trust_zone") or "default"),
|
|
||||||
max_concurrency=max_concurrency,
|
|
||||||
heartbeat_interval_seconds=heartbeat_interval,
|
|
||||||
supported_protocols=_normalize_list(
|
|
||||||
data.get("supported_protocols", ["aios-task-v1"])
|
|
||||||
),
|
|
||||||
registered_at=float(data.get("registered_at") or now),
|
|
||||||
last_heartbeat=float(data.get("last_heartbeat") or now),
|
|
||||||
status=str(data.get("status") or "active"),
|
|
||||||
disabled=bool(data.get("disabled", False)),
|
|
||||||
owner_identity=str(data.get("owner_identity") or identity),
|
|
||||||
registration_revision=int(data.get("registration_revision") or 1),
|
|
||||||
registration_policy=str(data.get("registration_policy") or "self"),
|
|
||||||
created_at=float(data.get("created_at") or data.get("registered_at") or now),
|
|
||||||
updated_at=float(data.get("updated_at") or now),
|
|
||||||
disabled_reason=data.get("disabled_reason"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
|
|
||||||
payload = {
|
|
||||||
"worker_id": self.worker_id,
|
|
||||||
"worker_type": self.worker_type,
|
|
||||||
"identity": self.identity,
|
|
||||||
"capabilities": list(self.capabilities),
|
|
||||||
"trust_zone": self.trust_zone,
|
|
||||||
"max_concurrency": self.max_concurrency,
|
|
||||||
"heartbeat_interval_seconds": self.heartbeat_interval_seconds,
|
|
||||||
"supported_protocols": list(self.supported_protocols),
|
|
||||||
"registered_at": _fmt_ts(self.registered_at),
|
|
||||||
"last_heartbeat": _fmt_ts(self.last_heartbeat),
|
|
||||||
"status": self.status,
|
|
||||||
"disabled": self.disabled,
|
|
||||||
"owner_identity": self.owner_identity,
|
|
||||||
"registration_revision": self.registration_revision,
|
|
||||||
"registration_policy": self.registration_policy,
|
|
||||||
"created_at": _fmt_ts(self.created_at or self.registered_at),
|
|
||||||
"updated_at": _fmt_ts(self.updated_at or self.last_heartbeat),
|
|
||||||
"disabled_reason": self.disabled_reason,
|
|
||||||
}
|
|
||||||
if redact_payload:
|
|
||||||
payload = redact_value(payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EvidenceEnvelope:
|
|
||||||
schema_version: int
|
|
||||||
evidence_id: str
|
|
||||||
task_id: str
|
|
||||||
claim_id: str
|
|
||||||
kind: str
|
|
||||||
worker_identity: str
|
|
||||||
payload: dict[str, Any]
|
|
||||||
created_at: float
|
|
||||||
previous_evidence_hash: str
|
|
||||||
evidence_digest: str
|
|
||||||
signature: str | None = None
|
|
||||||
attestation_ref: str | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
claim_id: str,
|
|
||||||
kind: str,
|
|
||||||
worker_identity: str,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
previous_evidence_hash: str,
|
|
||||||
signature: str | None = None,
|
|
||||||
attestation_ref: str | None = None,
|
|
||||||
evidence_id: str | None = None,
|
|
||||||
created_at: float | None = None,
|
|
||||||
) -> "EvidenceEnvelope":
|
|
||||||
envelope = cls(
|
|
||||||
schema_version=1,
|
|
||||||
evidence_id=evidence_id or f"evd-{uuid.uuid4()}",
|
|
||||||
task_id=validate_identifier(task_id, "task_id"),
|
|
||||||
claim_id=validate_identifier(claim_id, "claim_id"),
|
|
||||||
kind=validate_type(kind, "evidence kind"),
|
|
||||||
worker_identity=str(worker_identity),
|
|
||||||
payload=redact_value(payload),
|
|
||||||
created_at=float(created_at or _now_ts()),
|
|
||||||
previous_evidence_hash=str(previous_evidence_hash or ""),
|
|
||||||
evidence_digest="",
|
|
||||||
signature=signature,
|
|
||||||
attestation_ref=attestation_ref,
|
|
||||||
)
|
|
||||||
return dataclass_replace(
|
|
||||||
envelope, evidence_digest=compute_sha256_hex(envelope.digest_payload())
|
|
||||||
)
|
|
||||||
|
|
||||||
def digest_payload(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": self.schema_version,
|
|
||||||
"evidence_id": self.evidence_id,
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"claim_id": self.claim_id,
|
|
||||||
"kind": self.kind,
|
|
||||||
"worker_identity": self.worker_identity,
|
|
||||||
"payload": self.payload,
|
|
||||||
"created_at": self.created_at,
|
|
||||||
"previous_evidence_hash": self.previous_evidence_hash,
|
|
||||||
"attestation_ref": self.attestation_ref,
|
|
||||||
}
|
|
||||||
|
|
||||||
def verify(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
expected_task_id: str,
|
|
||||||
expected_claim_id: str,
|
|
||||||
expected_previous_hash: str,
|
|
||||||
attestation_verifier: Any = None,
|
|
||||||
) -> bool:
|
|
||||||
if (
|
|
||||||
self.schema_version != 1
|
|
||||||
or self.task_id != expected_task_id
|
|
||||||
or self.claim_id != expected_claim_id
|
|
||||||
or self.previous_evidence_hash != expected_previous_hash
|
|
||||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
if self.signature or self.attestation_ref:
|
|
||||||
return True if attestation_verifier is None else bool(attestation_verifier(self))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
**self.digest_payload(),
|
|
||||||
"evidence_digest": self.evidence_digest,
|
|
||||||
"signature": self.signature,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ProbeOutcome(StrEnum):
|
|
||||||
NOT_APPLIED = "NOT_APPLIED"
|
|
||||||
APPLIED_VERIFIED = "APPLIED_VERIFIED"
|
|
||||||
INDETERMINATE = "INDETERMINATE"
|
|
||||||
CONFLICT = "CONFLICT"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProbeEvidence:
|
|
||||||
schema_version: int
|
|
||||||
probe_id: str
|
|
||||||
task_id: str
|
|
||||||
claim_id: str
|
|
||||||
worker_id: str
|
|
||||||
worker_identity: str
|
|
||||||
operation_digest: str
|
|
||||||
outcome: ProbeOutcome
|
|
||||||
observed_at: float
|
|
||||||
expires_at: float
|
|
||||||
probe_executor_identity: str
|
|
||||||
evidence_digest: str
|
|
||||||
signature: str | None = None
|
|
||||||
attestation_ref: str | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, **data: Any) -> "ProbeEvidence":
|
|
||||||
outcome = data["outcome"]
|
|
||||||
if not isinstance(outcome, ProbeOutcome):
|
|
||||||
outcome = ProbeOutcome(str(outcome))
|
|
||||||
probe = cls(
|
|
||||||
schema_version=1,
|
|
||||||
probe_id=data.get("probe_id") or f"probe-{uuid.uuid4()}",
|
|
||||||
task_id=validate_identifier(data["task_id"], "task_id"),
|
|
||||||
claim_id=validate_identifier(data["claim_id"], "claim_id"),
|
|
||||||
worker_id=validate_identifier(data["worker_id"], "worker_id"),
|
|
||||||
worker_identity=str(data["worker_identity"]),
|
|
||||||
operation_digest=str(data["operation_digest"]),
|
|
||||||
outcome=outcome,
|
|
||||||
observed_at=float(data["observed_at"]),
|
|
||||||
expires_at=float(data["expires_at"]),
|
|
||||||
probe_executor_identity=str(data["probe_executor_identity"]),
|
|
||||||
evidence_digest="",
|
|
||||||
signature=data.get("signature"),
|
|
||||||
attestation_ref=data.get("attestation_ref"),
|
|
||||||
)
|
|
||||||
return dataclass_replace(
|
|
||||||
probe, evidence_digest=compute_sha256_hex(probe.digest_payload())
|
|
||||||
)
|
|
||||||
|
|
||||||
def digest_payload(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schema_version": self.schema_version,
|
|
||||||
"probe_id": self.probe_id,
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"claim_id": self.claim_id,
|
|
||||||
"worker_id": self.worker_id,
|
|
||||||
"worker_identity": self.worker_identity,
|
|
||||||
"operation_digest": self.operation_digest,
|
|
||||||
"outcome": self.outcome.value,
|
|
||||||
"observed_at": self.observed_at,
|
|
||||||
"expires_at": self.expires_at,
|
|
||||||
"probe_executor_identity": self.probe_executor_identity,
|
|
||||||
"attestation_ref": self.attestation_ref,
|
|
||||||
}
|
|
||||||
|
|
||||||
def verify(self, *, now: float, attestation_verifier: Any) -> bool:
|
|
||||||
if (
|
|
||||||
self.schema_version != 1
|
|
||||||
or self.expires_at <= now
|
|
||||||
or self.observed_at > now
|
|
||||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
|
||||||
or not (self.signature or self.attestation_ref)
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
return bool(attestation_verifier and attestation_verifier(self))
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
**self.digest_payload(),
|
|
||||||
"evidence_digest": self.evidence_digest,
|
|
||||||
"signature": self.signature,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def dataclass_replace(value: Any, **changes: Any) -> Any:
|
|
||||||
from dataclasses import replace
|
|
||||||
|
|
||||||
return replace(value, **changes)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WorkerClaim:
|
|
||||||
claim_id: str
|
|
||||||
task_id: str
|
|
||||||
worker_id: str
|
|
||||||
worker_identity: str
|
|
||||||
claimed_at: float
|
|
||||||
lease_until: float
|
|
||||||
attempt: int
|
|
||||||
task_revision: int
|
|
||||||
operation_digest: str
|
|
||||||
status: str = "ACTIVE"
|
|
||||||
|
|
||||||
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
|
|
||||||
payload = {
|
|
||||||
"claim_id": self.claim_id,
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"worker_id": self.worker_id,
|
|
||||||
"worker_identity": self.worker_identity,
|
|
||||||
"claimed_at": _fmt_ts(self.claimed_at),
|
|
||||||
"lease_until": _fmt_ts(self.lease_until),
|
|
||||||
"attempt": self.attempt,
|
|
||||||
"task_revision": self.task_revision,
|
|
||||||
"operation_digest": self.operation_digest,
|
|
||||||
"status": self.status,
|
|
||||||
}
|
|
||||||
if redact_payload:
|
|
||||||
payload = redact_value(payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResultEnvelope:
|
|
||||||
task_id: str
|
|
||||||
claim_id: str
|
|
||||||
worker_identity: str
|
|
||||||
status: str
|
|
||||||
outputs: list[dict[str, Any]]
|
|
||||||
evidence: list[dict[str, Any]]
|
|
||||||
started_at: str
|
|
||||||
completed_at: str
|
|
||||||
result_digest: str | None = None
|
|
||||||
signature: str | None = None
|
|
||||||
attestation_ref: str | None = None
|
|
||||||
operation_digest: str | None = None
|
|
||||||
trust_status: str = "untrusted_shadow_result"
|
|
||||||
|
|
||||||
def digest_payload(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"claim_id": self.claim_id,
|
|
||||||
"worker_identity": self.worker_identity,
|
|
||||||
"status": self.status,
|
|
||||||
"outputs": self.outputs,
|
|
||||||
"evidence": self.evidence,
|
|
||||||
"started_at": self.started_at,
|
|
||||||
"completed_at": self.completed_at,
|
|
||||||
"operation_digest": self.operation_digest,
|
|
||||||
"attestation_ref": self.attestation_ref,
|
|
||||||
}
|
|
||||||
|
|
||||||
def computed_digest(self) -> str:
|
|
||||||
return compute_sha256_hex(self.digest_payload())
|
|
||||||
|
|
||||||
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
|
|
||||||
payload = {
|
|
||||||
"task_id": self.task_id,
|
|
||||||
"claim_id": self.claim_id,
|
|
||||||
"worker_identity": self.worker_identity,
|
|
||||||
"status": self.status,
|
|
||||||
"outputs": self.outputs,
|
|
||||||
"evidence": self.evidence,
|
|
||||||
"started_at": self.started_at,
|
|
||||||
"completed_at": self.completed_at,
|
|
||||||
"result_digest": self.result_digest,
|
|
||||||
"signature": self.signature,
|
|
||||||
"attestation_ref": self.attestation_ref,
|
|
||||||
"operation_digest": self.operation_digest,
|
|
||||||
"trust_status": self.trust_status,
|
|
||||||
}
|
|
||||||
if redact_payload:
|
|
||||||
payload = redact_value(payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_list(value: Any) -> list[str]:
|
|
||||||
if not isinstance(value, list):
|
|
||||||
raise ValueError("expected list")
|
|
||||||
if len(value) > 128 or any(
|
|
||||||
not isinstance(v, str)
|
|
||||||
or not v
|
|
||||||
or len(v) > 256
|
|
||||||
or any(ord(ch) < 32 for ch in v)
|
|
||||||
for v in value
|
|
||||||
):
|
|
||||||
raise ValueError("invalid list item")
|
|
||||||
return list(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_positive_int(value: Any, default: int | None = None) -> int | None:
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
number = int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
return number if number >= 0 else default
|
|
||||||
|
|
||||||
|
|
||||||
def _to_float(value: Any) -> float | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _first_non_none(primary: Any, fallback: Any) -> Any:
|
|
||||||
if primary is not None:
|
|
||||||
return primary
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def iso8601_now() -> str:
|
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_ts(value: float | None) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return datetime.fromtimestamp(value, tz=timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
"""Policy and verification logic (fail-closed)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from .errors import ForbiddenError, LockedError
|
|
||||||
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
|
||||||
|
|
||||||
|
|
||||||
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
|
||||||
READONLY_WORKER_ACTIONS = frozenset(
|
|
||||||
{"claim", "result", "fail", "progress", "heartbeat"}
|
|
||||||
)
|
|
||||||
ALL_ACTION_CAPABILITIES = frozenset(
|
|
||||||
{
|
|
||||||
"task.creator",
|
|
||||||
"worker.self_register",
|
|
||||||
"worker.admin",
|
|
||||||
"task.claim",
|
|
||||||
"task.cancel",
|
|
||||||
"task.delegate",
|
|
||||||
"task.read",
|
|
||||||
"task.audit",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PolicyDecision:
|
|
||||||
allowed: bool
|
|
||||||
reason: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ReadonlyWorkerProfile:
|
|
||||||
"""A narrowly-scoped identity allowed to process safe Shadow tasks only."""
|
|
||||||
|
|
||||||
worker_id: str
|
|
||||||
identity: str
|
|
||||||
capabilities: frozenset[str]
|
|
||||||
task_types: frozenset[str] = frozenset({"code.review", "test.execute"})
|
|
||||||
|
|
||||||
def permits_task(self, task: TaskRecord) -> bool:
|
|
||||||
plan = task.task_plan
|
|
||||||
if plan.task_type not in self.task_types or plan.risk != "low":
|
|
||||||
return False
|
|
||||||
if plan.data_classification not in {"public", "internal"}:
|
|
||||||
return False
|
|
||||||
if plan.metadata.get("production_side_effects") is not False:
|
|
||||||
return False
|
|
||||||
return set(plan.required_capabilities).issubset(self.capabilities)
|
|
||||||
|
|
||||||
|
|
||||||
class IdentityVerifier:
|
|
||||||
def __init__(self, trusted_identities: set[str] | None = None):
|
|
||||||
self.trusted_identities = trusted_identities
|
|
||||||
|
|
||||||
def verify(self, identity: str) -> bool:
|
|
||||||
if self.trusted_identities is None:
|
|
||||||
return True
|
|
||||||
return identity in self.trusted_identities
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceVerifier:
|
|
||||||
def __init__(self, mandatory: list[str] | None = None):
|
|
||||||
self.mandatory = set(mandatory or [])
|
|
||||||
|
|
||||||
def verify(self, task: TaskRecord, evidences: list[dict]) -> PolicyDecision:
|
|
||||||
required = set(task.task_plan.evidence_requirements or [])
|
|
||||||
if not required and not self.mandatory:
|
|
||||||
return PolicyDecision(True)
|
|
||||||
observed = {e.get("kind") for e in evidences}
|
|
||||||
missing = required.union(self.mandatory) - observed
|
|
||||||
if missing:
|
|
||||||
return PolicyDecision(False, f"missing evidence: {', '.join(sorted(missing))}")
|
|
||||||
return PolicyDecision(True)
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyEngine:
|
|
||||||
"""
|
|
||||||
Fail-closed policy engine with minimal but explicit decisions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
identity_verifier: IdentityVerifier | None = None,
|
|
||||||
evidence_verifier: EvidenceVerifier | None = None,
|
|
||||||
execution_enabled: bool = False,
|
|
||||||
max_risk: str = "medium",
|
|
||||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
|
||||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
|
||||||
identity_capabilities: dict[str, set[str]] | None = None,
|
|
||||||
admin_identities: set[str] | None = None,
|
|
||||||
readonly_worker_execution_enabled: bool = False,
|
|
||||||
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
|
||||||
) -> None:
|
|
||||||
if max_risk not in RISK_RANK:
|
|
||||||
raise ValueError("invalid max_risk")
|
|
||||||
self.identity_verifier = identity_verifier or IdentityVerifier()
|
|
||||||
self.evidence_verifier = evidence_verifier or EvidenceVerifier()
|
|
||||||
self.execution_enabled = execution_enabled
|
|
||||||
self.max_risk = max_risk
|
|
||||||
self.result_signature_verifier = result_signature_verifier
|
|
||||||
self.probe_attestation_verifier = probe_attestation_verifier
|
|
||||||
self.identity_capabilities = {
|
|
||||||
identity: set(capabilities)
|
|
||||||
for identity, capabilities in (identity_capabilities or {}).items()
|
|
||||||
}
|
|
||||||
self.admin_identities = set(admin_identities or ())
|
|
||||||
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
|
||||||
self.readonly_worker_profiles = {
|
|
||||||
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
|
||||||
}
|
|
||||||
|
|
||||||
def _readonly_worker_allows(
|
|
||||||
self, action: str, actor: str, task: TaskRecord | None
|
|
||||||
) -> bool:
|
|
||||||
profile = self.readonly_worker_profiles.get(actor)
|
|
||||||
return bool(
|
|
||||||
self.readonly_worker_execution_enabled
|
|
||||||
and action in READONLY_WORKER_ACTIONS
|
|
||||||
and profile is not None
|
|
||||||
and (task is None or profile.permits_task(task))
|
|
||||||
)
|
|
||||||
|
|
||||||
def assert_readonly_worker_binding(self, actor: str, worker_id: str) -> None:
|
|
||||||
profile = self.readonly_worker_profiles.get(actor)
|
|
||||||
if profile is not None and profile.worker_id != worker_id:
|
|
||||||
raise ForbiddenError("worker identity binding mismatch")
|
|
||||||
|
|
||||||
def assert_readonly_worker_registration(self, actor: str, payload: dict) -> None:
|
|
||||||
profile = self.readonly_worker_profiles.get(actor)
|
|
||||||
if profile is None:
|
|
||||||
return
|
|
||||||
if (
|
|
||||||
str(payload.get("worker_id") or "") != profile.worker_id
|
|
||||||
or str(payload.get("identity") or "") != profile.identity
|
|
||||||
or set(payload.get("capabilities") or []) != set(profile.capabilities)
|
|
||||||
or str(payload.get("trust_zone") or "") != "isolated-review"
|
|
||||||
or int(payload.get("max_concurrency") or 0) != 1
|
|
||||||
or int(payload.get("heartbeat_interval_seconds") or 0) != 15
|
|
||||||
):
|
|
||||||
raise ForbiddenError("readonly worker registration mismatch")
|
|
||||||
|
|
||||||
def verify_result(
|
|
||||||
self, result: ResultEnvelope, task: TaskRecord | None = None
|
|
||||||
) -> PolicyDecision:
|
|
||||||
computed = result.computed_digest()
|
|
||||||
if result.result_digest and result.result_digest != computed:
|
|
||||||
return PolicyDecision(False, "result digest mismatch")
|
|
||||||
sensitive = bool(
|
|
||||||
task
|
|
||||||
and (
|
|
||||||
task.task_plan.data_classification
|
|
||||||
in {"confidential", "credential-secret"}
|
|
||||||
or any(
|
|
||||||
capability.startswith(
|
|
||||||
("code.write", "deploy", "secret", "production")
|
|
||||||
)
|
|
||||||
for capability in task.task_plan.required_capabilities
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if sensitive and not (result.signature or result.attestation_ref):
|
|
||||||
return PolicyDecision(False, "trusted result proof required")
|
|
||||||
if result.signature is not None or result.attestation_ref is not None:
|
|
||||||
if self.result_signature_verifier is None:
|
|
||||||
return PolicyDecision(False, "result signature verifier unavailable")
|
|
||||||
if not self.result_signature_verifier(result):
|
|
||||||
return PolicyDecision(False, "result signature invalid")
|
|
||||||
result.trust_status = "trusted_result"
|
|
||||||
else:
|
|
||||||
result.trust_status = "untrusted_shadow_result"
|
|
||||||
return PolicyDecision(True)
|
|
||||||
|
|
||||||
def authorize(self, action: str, actor: str, task: TaskRecord | None = None, context: dict | None = None) -> PolicyDecision:
|
|
||||||
context = context or {}
|
|
||||||
if not self.identity_verifier.verify(actor):
|
|
||||||
return PolicyDecision(False, "identity untrusted")
|
|
||||||
|
|
||||||
action_capabilities = {
|
|
||||||
"create": "task.creator",
|
|
||||||
"read": "task.read",
|
|
||||||
"audit": "task.audit",
|
|
||||||
"claim": "task.claim",
|
|
||||||
"result": "task.claim",
|
|
||||||
"fail": "task.claim",
|
|
||||||
"progress": "task.claim",
|
|
||||||
"heartbeat": "task.claim",
|
|
||||||
"cancel": "task.cancel",
|
|
||||||
"delegate": "task.delegate",
|
|
||||||
"worker.register": "worker.self_register",
|
|
||||||
"worker.admin": "worker.admin",
|
|
||||||
}
|
|
||||||
required_capability = action_capabilities.get(action)
|
|
||||||
if required_capability:
|
|
||||||
granted = set(
|
|
||||||
self.identity_capabilities.get(
|
|
||||||
actor, self.identity_capabilities.get("*", set())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if required_capability not in granted:
|
|
||||||
return PolicyDecision(False, f"action {action} not permitted")
|
|
||||||
|
|
||||||
if action in {"claim", "result", "fail", "cancel", "progress", "delegate", "heartbeat"} and not self.execution_enabled:
|
|
||||||
if not self._readonly_worker_allows(action, actor, task):
|
|
||||||
return PolicyDecision(False, "execution disabled")
|
|
||||||
|
|
||||||
if action == "delegate" and task and task.status not in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}:
|
|
||||||
risk = str(context.get("risk", task.task_plan.risk))
|
|
||||||
if RISK_RANK.get(risk, 2) > RISK_RANK.get(self.max_risk, 1):
|
|
||||||
return PolicyDecision(False, f"risk {risk} exceeds policy")
|
|
||||||
|
|
||||||
return PolicyDecision(True)
|
|
||||||
|
|
||||||
def assert_allowed(self, action: str, actor: str, task: TaskRecord | None = None, context: dict | None = None):
|
|
||||||
decision = self.authorize(action, actor, task, context=context)
|
|
||||||
if not decision.allowed:
|
|
||||||
if decision.reason == "identity untrusted":
|
|
||||||
raise ForbiddenError(decision.reason)
|
|
||||||
if decision.reason == "execution disabled":
|
|
||||||
raise LockedError("execution disabled")
|
|
||||||
raise ForbiddenError(decision.reason)
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""Task scheduler primitives."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from .errors import NotFoundError
|
|
||||||
from .capabilities import has_capabilities
|
|
||||||
from .models import WorkerClaim
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
class TaskScheduler:
|
|
||||||
def __init__(self, store: TaskPlaneStore):
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def next_queued_task(self, worker_id: str):
|
|
||||||
worker = self.store.get_worker(worker_id)
|
|
||||||
tasks = sorted(
|
|
||||||
self.store.list_tasks(),
|
|
||||||
key=lambda task: (
|
|
||||||
-int(task.task_plan.metadata.get("priority", 0)),
|
|
||||||
task.created_at,
|
|
||||||
task.task_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for task in tasks:
|
|
||||||
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
|
||||||
continue
|
|
||||||
if task.task_plan.task_type == "worker.registry.audit":
|
|
||||||
continue
|
|
||||||
if task.status == "FAILED_RECOVERABLE" and not self.store.task_has_retryable_probe(
|
|
||||||
task.task_id
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if not has_capabilities(
|
|
||||||
worker.capabilities, task.task_plan.required_capabilities
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if any(
|
|
||||||
self.store.get_task(dependency).status != "COMPLETED"
|
|
||||||
for dependency in task.task_plan.dependencies
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
task.task_plan.data_classification == "credential-secret"
|
|
||||||
and worker.worker_type != "secure_file_transformer"
|
|
||||||
and "secure_file_transformer" not in worker.capabilities
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
return task
|
|
||||||
return None
|
|
||||||
|
|
||||||
def schedule_for_worker(self, worker_id: str, lease_seconds: int = 15) -> WorkerClaim:
|
|
||||||
worker = self.store.get_worker(worker_id)
|
|
||||||
if worker.disabled:
|
|
||||||
from .errors import LockedError
|
|
||||||
|
|
||||||
raise LockedError("worker disabled")
|
|
||||||
if time.time() > worker.last_heartbeat + (
|
|
||||||
worker.heartbeat_interval_seconds * 3
|
|
||||||
):
|
|
||||||
from .errors import LockedError
|
|
||||||
|
|
||||||
raise LockedError("worker offline")
|
|
||||||
task = self.next_queued_task(worker_id)
|
|
||||||
if task is None:
|
|
||||||
raise NotFoundError("no queued tasks available")
|
|
||||||
claim = self.store.claim_task(
|
|
||||||
task.task_id,
|
|
||||||
worker_id,
|
|
||||||
worker.identity,
|
|
||||||
lease_seconds=lease_seconds,
|
|
||||||
expected_revision=task.revision,
|
|
||||||
operation_digest=task.task_plan.operation_digest(),
|
|
||||||
)
|
|
||||||
return claim
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
|||||||
"""Worker registry abstraction and read helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from .capabilities import has_capabilities
|
|
||||||
from .errors import NotFoundError
|
|
||||||
from .models import WorkerRegistration
|
|
||||||
from .store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerRegistry:
|
|
||||||
def __init__(self, store: TaskPlaneStore):
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def register(
|
|
||||||
self,
|
|
||||||
payload: dict,
|
|
||||||
*,
|
|
||||||
actor_identity: str,
|
|
||||||
is_admin: bool,
|
|
||||||
expected_revision: int | None,
|
|
||||||
) -> WorkerRegistration:
|
|
||||||
reg = WorkerRegistration.from_dict(payload)
|
|
||||||
return self.store.register_worker(
|
|
||||||
reg,
|
|
||||||
actor_identity=actor_identity,
|
|
||||||
is_admin=is_admin,
|
|
||||||
expected_revision=expected_revision,
|
|
||||||
)
|
|
||||||
|
|
||||||
def heartbeat(
|
|
||||||
self,
|
|
||||||
worker_id: str,
|
|
||||||
heartbeat_interval_seconds: int | None = None,
|
|
||||||
*,
|
|
||||||
actor_identity: str,
|
|
||||||
expected_revision: int | None,
|
|
||||||
) -> WorkerRegistration:
|
|
||||||
return self.store.heartbeat_worker(
|
|
||||||
worker_id,
|
|
||||||
heartbeat_interval_seconds=heartbeat_interval_seconds,
|
|
||||||
actor_identity=actor_identity,
|
|
||||||
expected_revision=expected_revision,
|
|
||||||
)
|
|
||||||
|
|
||||||
def list(self):
|
|
||||||
return self.store.list_workers()
|
|
||||||
|
|
||||||
def get(self, worker_id: str) -> WorkerRegistration:
|
|
||||||
try:
|
|
||||||
return self.store.get_worker(worker_id)
|
|
||||||
except NotFoundError:
|
|
||||||
raise
|
|
||||||
|
|
||||||
def can_execute(self, worker_id: str, required_capabilities: list[str]) -> bool:
|
|
||||||
worker = self.store.get_worker(worker_id)
|
|
||||||
return has_capabilities(worker.capabilities, required_capabilities)
|
|
||||||
|
|
||||||
def is_online(self, worker_id: str, *, now: float | None = None) -> bool:
|
|
||||||
worker = self.store.get_worker(worker_id)
|
|
||||||
deadline = worker.last_heartbeat + (worker.heartbeat_interval_seconds * 3)
|
|
||||||
return not worker.disabled and (time.time() if now is None else now) <= deadline
|
|
||||||
|
|
||||||
def stale_workers(self, *, now: float | None = None) -> list[WorkerRegistration]:
|
|
||||||
current = time.time() if now is None else now
|
|
||||||
return [worker for worker in self.list() if not self.is_online(worker.worker_id, now=current)]
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "aios.gitea-native-approval/v1",
|
|
||||||
"approval_type": "IMPLEMENTATION",
|
|
||||||
"task_id": "AIOS-INTEGRATION-REFRESH-PROVISIONER-IMPLEMENTATION-20260726",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"branch": "patch/integration-refresh-grant-provisioner-20260726",
|
|
||||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
|
||||||
"actor_identity": "chatgpt-gitops-bot",
|
|
||||||
"writer_audience": "aios-integration-refresh-provisioner-development-writer",
|
|
||||||
"risk": "high",
|
|
||||||
"allowed_paths": [
|
|
||||||
"active-runtime/app/**",
|
|
||||||
"tests/test_integration_refresh_provisioner.py",
|
|
||||||
"tests/test_isolated_writer_integration_merge.py",
|
|
||||||
"tests/test_gitea_branch_read_projection.py",
|
|
||||||
"tests/test_isolated_writer_token_policy_v2.py",
|
|
||||||
"deploy/systemd/aios-integration-refresh-*.service",
|
|
||||||
"docs/autonomous-release/**"
|
|
||||||
],
|
|
||||||
"allowed_operations": [
|
|
||||||
"repository.source_map.read",
|
|
||||||
"runtime.unit_metadata.read",
|
|
||||||
"git.ref.read",
|
|
||||||
"git.diff.read",
|
|
||||||
"git.branch.patch.create",
|
|
||||||
"commit_file.atomic",
|
|
||||||
"git.push.patch_branch",
|
|
||||||
"test.execute.allowlisted",
|
|
||||||
"artifact.build.control_plane_patch",
|
|
||||||
"evidence.write.task_runtime"
|
|
||||||
],
|
|
||||||
"denied_operations": [
|
|
||||||
"main.write",
|
|
||||||
"main.merge",
|
|
||||||
"tag.write",
|
|
||||||
"tag.delete",
|
|
||||||
"tag.rewrite",
|
|
||||||
"force_push",
|
|
||||||
"history.rewrite",
|
|
||||||
"baseline.write",
|
|
||||||
"registry.write",
|
|
||||||
"production.deploy",
|
|
||||||
"production.rollback",
|
|
||||||
"secret.read",
|
|
||||||
"secret.export",
|
|
||||||
"bare_ref_direct_write"
|
|
||||||
],
|
|
||||||
"expires_at": "2026-07-27T03:35:55Z"
|
|
||||||
}
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "aios.gitea-native-approval/v1",
|
|
||||||
"approval_type": "IMPLEMENTATION",
|
|
||||||
"task_id": "AIOS-INTEGRATION-REFRESH-PROVISIONER-IMPLEMENTATION-20260726",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"branch": "patch/integration-refresh-grant-provisioner-20260726",
|
|
||||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
|
||||||
"actor_identity": "chatgpt-gitops-bot",
|
|
||||||
"writer_audience": "aios-integration-refresh-provisioner-development-writer",
|
|
||||||
"risk": "high",
|
|
||||||
"allowed_paths": [
|
|
||||||
"active-runtime/app/**",
|
|
||||||
"tests/test_integration_refresh_provisioner.py",
|
|
||||||
"tests/test_isolated_writer_integration_merge.py",
|
|
||||||
"tests/test_gitea_branch_read_projection.py",
|
|
||||||
"tests/test_isolated_writer_token_policy_v2.py",
|
|
||||||
"deploy/systemd/aios-integration-refresh-*.service",
|
|
||||||
"docs/autonomous-release/**"
|
|
||||||
],
|
|
||||||
"allowed_operations": [
|
|
||||||
"repository.source_map.read",
|
|
||||||
"runtime.unit_metadata.read",
|
|
||||||
"git.ref.read",
|
|
||||||
"git.diff.read",
|
|
||||||
"git.branch.patch.create",
|
|
||||||
"commit_file.atomic",
|
|
||||||
"git.push.patch_branch",
|
|
||||||
"test.execute.allowlisted",
|
|
||||||
"artifact.build.control_plane_patch",
|
|
||||||
"evidence.write.task_runtime"
|
|
||||||
],
|
|
||||||
"denied_operations": [
|
|
||||||
"main.write",
|
|
||||||
"main.merge",
|
|
||||||
"tag.write",
|
|
||||||
"tag.delete",
|
|
||||||
"tag.rewrite",
|
|
||||||
"force_push",
|
|
||||||
"history.rewrite",
|
|
||||||
"baseline.write",
|
|
||||||
"registry.write",
|
|
||||||
"production.deploy",
|
|
||||||
"production.rollback",
|
|
||||||
"secret.read",
|
|
||||||
"secret.export",
|
|
||||||
"bare_ref_direct_write"
|
|
||||||
],
|
|
||||||
"expires_at": "2026-07-27T13:00:11Z"
|
|
||||||
}
|
|
||||||
-151
@@ -1,151 +0,0 @@
|
|||||||
{
|
|
||||||
"actor_identity": "chatgpt-gitops-bot",
|
|
||||||
"allowed_operations": [
|
|
||||||
"runtime.source.read.exact",
|
|
||||||
"runtime.source.hash",
|
|
||||||
"runtime.unit_metadata.read",
|
|
||||||
"package.ownership.read",
|
|
||||||
"git.object.lookup.by_blob_oid",
|
|
||||||
"secret.scan.local_source",
|
|
||||||
"repository.source_import.byte_exact",
|
|
||||||
"repository.module.create",
|
|
||||||
"source_map.evidence.write",
|
|
||||||
"git.branch.patch.create",
|
|
||||||
"commit_file.atomic",
|
|
||||||
"git.push.patch_branch",
|
|
||||||
"test.execute.allowlisted",
|
|
||||||
"artifact.build.control_plane_patch",
|
|
||||||
"evidence.write.task_runtime"
|
|
||||||
],
|
|
||||||
"allowed_paths": [
|
|
||||||
"active-runtime/app/aios_autonomous_release_authorization/**",
|
|
||||||
"active-runtime/app/aios_chatgpt_action_task_router/**",
|
|
||||||
"active-runtime/app/aios_isolated_writer_api/**",
|
|
||||||
"tests/test_integration_refresh_provisioner.py",
|
|
||||||
"tests/test_isolated_writer_integration_merge.py",
|
|
||||||
"tests/test_gitea_branch_read_projection.py",
|
|
||||||
"tests/test_isolated_writer_token_policy_v2.py",
|
|
||||||
"deploy/systemd/aios-integration-refresh-*.service",
|
|
||||||
"docs/autonomous-release/**"
|
|
||||||
],
|
|
||||||
"allowed_runtime_reads": [
|
|
||||||
"/usr/libexec/aios-autonomous-release-authorization/controller.py",
|
|
||||||
"/usr/libexec/aios-chatgpt-action-task-router-continuous-20260726/action_task_router.py"
|
|
||||||
],
|
|
||||||
"approval_type": "SOURCE_MAPPING_AND_IMPORT",
|
|
||||||
"canonical_paths": {
|
|
||||||
"controller": "active-runtime/app/aios_autonomous_release_authorization/controller.py",
|
|
||||||
"isolated_writer_api": "active-runtime/app/aios_isolated_writer_api/**",
|
|
||||||
"router": "active-runtime/app/aios_chatgpt_action_task_router/action_task_router.py"
|
|
||||||
},
|
|
||||||
"denied_operations": [
|
|
||||||
"secret.value.return",
|
|
||||||
"secret.matching_line.return",
|
|
||||||
"secret.context.return",
|
|
||||||
"runtime.source.modify",
|
|
||||||
"runtime.unit.modify",
|
|
||||||
"arbitrary_path.read",
|
|
||||||
"secret.read.by_agent",
|
|
||||||
"secret.export",
|
|
||||||
"main.write",
|
|
||||||
"main.merge",
|
|
||||||
"governance.approvals.direct_write",
|
|
||||||
"integration.write",
|
|
||||||
"tag.write",
|
|
||||||
"tag.delete",
|
|
||||||
"tag.rewrite",
|
|
||||||
"force_push",
|
|
||||||
"history.rewrite",
|
|
||||||
"bare_ref_direct_write",
|
|
||||||
"baseline.write",
|
|
||||||
"registry.write",
|
|
||||||
"production.deploy",
|
|
||||||
"production.rollback"
|
|
||||||
],
|
|
||||||
"expires_at": "2026-07-27T22:36:27Z",
|
|
||||||
"extends_approval_merge_oid": "45f3226662965c8d41ec1775e8c8857f2b986397",
|
|
||||||
"finding_policy": {
|
|
||||||
"controller_byte_exact_import_allowed": true,
|
|
||||||
"controller_finding_status": "PASS",
|
|
||||||
"controller_sanitized_source_creation_allowed": false,
|
|
||||||
"finding_classification_by_automation": "PROHIBITED",
|
|
||||||
"isolated_writer_api_module_create_allowed": true,
|
|
||||||
"matching_source_lines_disclosed": false,
|
|
||||||
"router_byte_exact_import_allowed": true,
|
|
||||||
"router_finding_status": "PASS",
|
|
||||||
"router_sanitized_source_creation_allowed": false,
|
|
||||||
"scan_decision": "MAPPING_IMPORT_ONLY",
|
|
||||||
"secret_content_disclosed": false
|
|
||||||
},
|
|
||||||
"implementation_base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
|
||||||
"implementation_branch": "patch/integration-refresh-grant-provisioner-20260726",
|
|
||||||
"issued_at": "2026-07-27T10:36:27Z",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"required_controls": {
|
|
||||||
"controller_finding_must_be_resolved": false,
|
|
||||||
"controller_import_blob_must_equal_runtime_blob": true,
|
|
||||||
"controller_import_must_be_byte_exact": true,
|
|
||||||
"controller_import_sha256_must_equal_runtime_sha256": true,
|
|
||||||
"dual_human_approval_required": true,
|
|
||||||
"global_gitops_writes_must_remain_disabled": true,
|
|
||||||
"new_task_bound_grant_required": true,
|
|
||||||
"old_implementation_token_auth_status": 401,
|
|
||||||
"old_implementation_writer_revoked": true,
|
|
||||||
"old_secret_revocation_must_be_verified": false,
|
|
||||||
"router_finding_must_be_resolved": false,
|
|
||||||
"router_import_blob_must_equal_runtime_blob": true,
|
|
||||||
"router_import_must_be_byte_exact": true,
|
|
||||||
"router_import_sha256_must_equal_runtime_sha256": true,
|
|
||||||
"sanitized_source_behavioral_equivalence_required": false,
|
|
||||||
"sanitized_source_secret_scan_must_pass": false,
|
|
||||||
"true_positive_requires_rotation": false
|
|
||||||
},
|
|
||||||
"risk": "high",
|
|
||||||
"runtime_source_evidence": {
|
|
||||||
"controller": {
|
|
||||||
"finding_count": 0,
|
|
||||||
"findings": [],
|
|
||||||
"git_blob_oid": "9c68be36e0b6a18260e3099e3d7b0dcab6d814aa",
|
|
||||||
"group_gid": 0,
|
|
||||||
"mode": "0o700",
|
|
||||||
"mtime_ns": 1785043975531298047,
|
|
||||||
"owner_uid": 0,
|
|
||||||
"package_owner": "UNPACKAGED",
|
|
||||||
"referencing_systemd_units": [
|
|
||||||
"aios-autonomous-release-authorization-controller.service"
|
|
||||||
],
|
|
||||||
"resolved_path": "/usr/libexec/aios-autonomous-release-authorization/controller.py",
|
|
||||||
"secret_content_returned": false,
|
|
||||||
"secret_scan": "PASS",
|
|
||||||
"sha256": "39b532c3058c4ae49ed691d7c66c3b7745a25111fa61039f388c8335fd262c50",
|
|
||||||
"size": 24204,
|
|
||||||
"utf8_python": true
|
|
||||||
},
|
|
||||||
"isolated_writer_api": {
|
|
||||||
"canonical_source_path": "NOT_ESTABLISHED",
|
|
||||||
"runtime_module_status": "ABSENT"
|
|
||||||
},
|
|
||||||
"router": {
|
|
||||||
"finding_count": 0,
|
|
||||||
"findings": [],
|
|
||||||
"git_blob_oid": "ee0595ca61a0598f8cb3dc10865a255e21679b2a",
|
|
||||||
"group_gid": 0,
|
|
||||||
"mode": "0o644",
|
|
||||||
"mtime_ns": 1785042222580725999,
|
|
||||||
"owner_uid": 0,
|
|
||||||
"package_owner": "UNPACKAGED",
|
|
||||||
"referencing_systemd_units": [
|
|
||||||
"aios-chatgpt-autonomous-release-route.service"
|
|
||||||
],
|
|
||||||
"resolved_path": "/usr/libexec/aios-chatgpt-action-task-router-continuous-20260726/action_task_router.py",
|
|
||||||
"secret_content_returned": false,
|
|
||||||
"secret_scan": "PASS",
|
|
||||||
"sha256": "738fb7035a6b792115d6467b4d1e4ffd47a8c5d5646f7032ebc68ea587cbd554",
|
|
||||||
"size": 25952,
|
|
||||||
"utf8_python": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"schema": "aios.gitea-native-approval/v1",
|
|
||||||
"task_id": "AIOS-INTEGRATION-REFRESH-PROVISIONER-IMPLEMENTATION-20260726",
|
|
||||||
"writer_audience": "aios-integration-refresh-provisioner-development-writer"
|
|
||||||
}
|
|
||||||
-157
@@ -1,157 +0,0 @@
|
|||||||
{
|
|
||||||
"actor_identity": "chatgpt-gitops-bot",
|
|
||||||
"allowed_operations": [
|
|
||||||
"runtime.source.read.exact",
|
|
||||||
"runtime.source.hash",
|
|
||||||
"runtime.unit_metadata.read",
|
|
||||||
"package.ownership.read",
|
|
||||||
"git.object.lookup.by_blob_oid",
|
|
||||||
"secret.scan.local_source",
|
|
||||||
"repository.source_import.byte_exact",
|
|
||||||
"repository.module.create",
|
|
||||||
"source_map.evidence.write",
|
|
||||||
"git.branch.patch.create",
|
|
||||||
"commit_file.atomic",
|
|
||||||
"git.push.patch_branch",
|
|
||||||
"test.execute.allowlisted",
|
|
||||||
"artifact.build.control_plane_patch",
|
|
||||||
"evidence.write.task_runtime"
|
|
||||||
],
|
|
||||||
"allowed_paths": [
|
|
||||||
"active-runtime/app/aios_autonomous_release_authorization/**",
|
|
||||||
"active-runtime/app/aios_chatgpt_action_task_router/**",
|
|
||||||
"active-runtime/app/aios_isolated_writer_api/**",
|
|
||||||
"tests/test_integration_refresh_provisioner.py",
|
|
||||||
"tests/test_isolated_writer_integration_merge.py",
|
|
||||||
"tests/test_gitea_branch_read_projection.py",
|
|
||||||
"tests/test_isolated_writer_token_policy_v2.py",
|
|
||||||
"deploy/systemd/aios-integration-refresh-*.service",
|
|
||||||
"docs/autonomous-release/**"
|
|
||||||
],
|
|
||||||
"allowed_runtime_reads": [
|
|
||||||
"/usr/libexec/aios-autonomous-release-authorization/controller.py",
|
|
||||||
"/usr/libexec/aios-chatgpt-action-task-router-continuous-20260726/action_task_router.py"
|
|
||||||
],
|
|
||||||
"approval_type": "SOURCE_MAPPING_AND_IMPORT_EXECUTION_RENEWAL",
|
|
||||||
"canonical_paths": {
|
|
||||||
"controller": "active-runtime/app/aios_autonomous_release_authorization/controller.py",
|
|
||||||
"isolated_writer_api": "active-runtime/app/aios_isolated_writer_api/**",
|
|
||||||
"router": "active-runtime/app/aios_chatgpt_action_task_router/action_task_router.py"
|
|
||||||
},
|
|
||||||
"denied_operations": [
|
|
||||||
"secret.value.return",
|
|
||||||
"secret.matching_line.return",
|
|
||||||
"secret.context.return",
|
|
||||||
"runtime.source.modify",
|
|
||||||
"runtime.unit.modify",
|
|
||||||
"arbitrary_path.read",
|
|
||||||
"secret.read.by_agent",
|
|
||||||
"secret.export",
|
|
||||||
"main.write",
|
|
||||||
"main.merge",
|
|
||||||
"governance.approvals.direct_write",
|
|
||||||
"integration.write",
|
|
||||||
"tag.write",
|
|
||||||
"tag.delete",
|
|
||||||
"tag.rewrite",
|
|
||||||
"force_push",
|
|
||||||
"history.rewrite",
|
|
||||||
"bare_ref_direct_write",
|
|
||||||
"baseline.write",
|
|
||||||
"registry.write",
|
|
||||||
"production.deploy",
|
|
||||||
"production.rollback"
|
|
||||||
],
|
|
||||||
"expected_governance_base_oid": "9514062cf33e45941abe4a8c98377e0713b0b3f6",
|
|
||||||
"expires_at": "2026-07-28T18:10:44Z",
|
|
||||||
"extends_approval_merge_oid": "45f3226662965c8d41ec1775e8c8857f2b986397",
|
|
||||||
"finding_policy": {
|
|
||||||
"controller_byte_exact_import_allowed": true,
|
|
||||||
"controller_finding_status": "PASS",
|
|
||||||
"controller_sanitized_source_creation_allowed": false,
|
|
||||||
"finding_classification_by_automation": "PROHIBITED",
|
|
||||||
"isolated_writer_api_module_create_allowed": true,
|
|
||||||
"matching_source_lines_disclosed": false,
|
|
||||||
"router_byte_exact_import_allowed": true,
|
|
||||||
"router_finding_status": "PASS",
|
|
||||||
"router_sanitized_source_creation_allowed": false,
|
|
||||||
"scan_decision": "MAPPING_IMPORT_ONLY",
|
|
||||||
"secret_content_disclosed": false
|
|
||||||
},
|
|
||||||
"implementation_base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
|
||||||
"implementation_branch": "patch/integration-refresh-grant-provisioner-20260726",
|
|
||||||
"issued_at": "2026-07-28T06:10:44Z",
|
|
||||||
"renews_source_mapping_approval_merge_oid": "9514062cf33e45941abe4a8c98377e0713b0b3f6",
|
|
||||||
"renews_source_mapping_manifest_blob_oid": "00c5e90e529cb98c7750a5761b0a0d6ccf507174",
|
|
||||||
"renews_source_mapping_manifest_sha256": "17af8ebc43673c0e46bb9ab1bf44497e8c147f1b4a9d7a8a404edd5c0abd6f21",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"required_controls": {
|
|
||||||
"controller_finding_must_be_resolved": false,
|
|
||||||
"controller_import_blob_must_equal_runtime_blob": true,
|
|
||||||
"controller_import_must_be_byte_exact": true,
|
|
||||||
"controller_import_sha256_must_equal_runtime_sha256": true,
|
|
||||||
"dual_human_approval_required": true,
|
|
||||||
"global_gitops_writes_must_remain_disabled": true,
|
|
||||||
"new_task_bound_grant_required": true,
|
|
||||||
"old_implementation_token_auth_status": 401,
|
|
||||||
"old_implementation_writer_revoked": true,
|
|
||||||
"old_secret_revocation_must_be_verified": false,
|
|
||||||
"router_finding_must_be_resolved": false,
|
|
||||||
"router_import_blob_must_equal_runtime_blob": true,
|
|
||||||
"router_import_must_be_byte_exact": true,
|
|
||||||
"router_import_sha256_must_equal_runtime_sha256": true,
|
|
||||||
"sanitized_source_behavioral_equivalence_required": false,
|
|
||||||
"sanitized_source_secret_scan_must_pass": false,
|
|
||||||
"true_positive_requires_rotation": false
|
|
||||||
},
|
|
||||||
"risk": "high",
|
|
||||||
"runtime_source_evidence": {
|
|
||||||
"controller": {
|
|
||||||
"finding_count": 0,
|
|
||||||
"findings": [],
|
|
||||||
"git_blob_oid": "9c68be36e0b6a18260e3099e3d7b0dcab6d814aa",
|
|
||||||
"group_gid": 0,
|
|
||||||
"mode": "0o700",
|
|
||||||
"mtime_ns": 1785043975531298047,
|
|
||||||
"owner_uid": 0,
|
|
||||||
"package_owner": "UNPACKAGED",
|
|
||||||
"referencing_systemd_units": [
|
|
||||||
"aios-autonomous-release-authorization-controller.service"
|
|
||||||
],
|
|
||||||
"resolved_path": "/usr/libexec/aios-autonomous-release-authorization/controller.py",
|
|
||||||
"secret_content_returned": false,
|
|
||||||
"secret_scan": "PASS",
|
|
||||||
"sha256": "39b532c3058c4ae49ed691d7c66c3b7745a25111fa61039f388c8335fd262c50",
|
|
||||||
"size": 24204,
|
|
||||||
"utf8_python": true
|
|
||||||
},
|
|
||||||
"isolated_writer_api": {
|
|
||||||
"canonical_source_path": "NOT_ESTABLISHED",
|
|
||||||
"runtime_module_status": "ABSENT"
|
|
||||||
},
|
|
||||||
"observed_at": "2026-07-28T05:53:01.253909Z",
|
|
||||||
"router": {
|
|
||||||
"finding_count": 0,
|
|
||||||
"findings": [],
|
|
||||||
"git_blob_oid": "ee0595ca61a0598f8cb3dc10865a255e21679b2a",
|
|
||||||
"group_gid": 0,
|
|
||||||
"mode": "0o644",
|
|
||||||
"mtime_ns": 1785042222580725999,
|
|
||||||
"owner_uid": 0,
|
|
||||||
"package_owner": "UNPACKAGED",
|
|
||||||
"referencing_systemd_units": [
|
|
||||||
"aios-chatgpt-autonomous-release-route.service"
|
|
||||||
],
|
|
||||||
"resolved_path": "/usr/libexec/aios-chatgpt-action-task-router-continuous-20260726/action_task_router.py",
|
|
||||||
"secret_content_returned": false,
|
|
||||||
"secret_scan": "PASS",
|
|
||||||
"sha256": "738fb7035a6b792115d6467b4d1e4ffd47a8c5d5646f7032ebc68ea587cbd554",
|
|
||||||
"size": 25952,
|
|
||||||
"utf8_python": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"runtime_source_read_projection_evidence_sha256": "23f03d9c6abe6f11e1acda5210b8708696e3b77caa0083b6bfaef30a44b2a35f",
|
|
||||||
"schema": "aios.gitea-native-approval/v1",
|
|
||||||
"task_id": "AIOS-INTEGRATION-REFRESH-PROVISIONER-IMPLEMENTATION-20260726",
|
|
||||||
"writer_audience": "aios-integration-refresh-provisioner-development-writer"
|
|
||||||
}
|
|
||||||
+12
-24
@@ -1,25 +1,13 @@
|
|||||||
{
|
{
|
||||||
"current_baseline": "V2R1C2",
|
"current_baseline": "V1R4C6",
|
||||||
"previous_baseline": "V2R1C1",
|
"previous_baseline": "V1R4C5",
|
||||||
"promoted_at": "2026-07-22T01:40:51Z",
|
"promoted_at": "2026-07-11T21:00:00+08:00",
|
||||||
"git": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
|
"git": "e204a90398bf646d46245b3088226749fa056edd",
|
||||||
"promoted_by": "codex/owner-approved-gitops",
|
"promoted_by": "openclaw/admin",
|
||||||
"promotion_reason": "Owner-authorized V2R1C2 Unified Portal production release after exact tag binding, complete evidence verification, and production acceptance PASS.",
|
"promotion_reason": "V1R4C6 admin search agent integration, search gateway, agent platform, VERSION bump, merge tests deploy pipeline — not yet deployed to production",
|
||||||
"version": "V2R1C2",
|
"constraints": {
|
||||||
"release_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
|
"memory_search_enabled": false,
|
||||||
"tag_oid": "a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36",
|
"force_push_allowed": false,
|
||||||
"target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
|
"gitea_db_mutation_requires_owner_approval": true
|
||||||
"release_run_id": "AIOS-V2R1C2-PRODUCTION-20260722T011836Z",
|
}
|
||||||
"status": "PRODUCTION_DEPLOYED",
|
}
|
||||||
"production_service": "aios-v2r1c2-production.service",
|
|
||||||
"production_port": 18920,
|
|
||||||
"rollback_version": "V2R1C1",
|
|
||||||
"constraints": {
|
|
||||||
"product_code_changed": true,
|
|
||||||
"production_redeployed": true,
|
|
||||||
"production_traffic_changed": true,
|
|
||||||
"tag_rewritten": false,
|
|
||||||
"force_push_allowed": false,
|
|
||||||
"rollback_assets_preserved": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 4,
|
|
||||||
"automatic": [
|
|
||||||
"build",
|
|
||||||
"test",
|
|
||||||
"artifact",
|
|
||||||
"canary",
|
|
||||||
"evidence"
|
|
||||||
],
|
|
||||||
"owner": [
|
|
||||||
"signed_release_plan"
|
|
||||||
],
|
|
||||||
"forbidden": [
|
|
||||||
"force_push",
|
|
||||||
"tag_rewrite",
|
|
||||||
"direct_main_push",
|
|
||||||
"baseline_mutation",
|
|
||||||
"registry_mutation"
|
|
||||||
],
|
|
||||||
"runtime_deployment": {
|
|
||||||
"approval_action": "signed_release_plan",
|
|
||||||
"required_capability": "runtime_switch",
|
|
||||||
"tag_operations_allowed": false,
|
|
||||||
"baseline_mutation_allowed": false,
|
|
||||||
"registry_mutation_allowed": false,
|
|
||||||
"writer_default": false,
|
|
||||||
"rollback_required": true
|
|
||||||
},
|
|
||||||
"tag_release": {
|
|
||||||
"approval_action": "signed_release_plan",
|
|
||||||
"required_capability": "tag_publish",
|
|
||||||
"separate_controller": true,
|
|
||||||
"duplicate_conflict": "fail_closed",
|
|
||||||
"rewrite_allowed": false,
|
|
||||||
"force_push_allowed": false
|
|
||||||
},
|
|
||||||
"baseline_registry_finalize": {
|
|
||||||
"approval_action": "signed_release_plan",
|
|
||||||
"required_capability": "baseline_registry_finalize",
|
|
||||||
"separate_controller": true,
|
|
||||||
"remote_tag_verification_required": true,
|
|
||||||
"gitea_api_verification_required": true,
|
|
||||||
"registry_baseline_consistency_required": true
|
|
||||||
},
|
|
||||||
"approval": {
|
|
||||||
"algorithm": "Ed25519",
|
|
||||||
"trust_domain": "aios.production",
|
|
||||||
"maximum_lifetime_seconds": 3600,
|
|
||||||
"release_plan_sha256_required": true,
|
|
||||||
"not_before_required": true,
|
|
||||||
"replay_protection": true,
|
|
||||||
"revocation_fail_closed": true,
|
|
||||||
"key_rotation_supported": true,
|
|
||||||
"capabilities": [
|
|
||||||
"runtime_switch",
|
|
||||||
"tag_publish",
|
|
||||||
"baseline_registry_finalize"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution": {
|
|
||||||
"environment_variable": "AIOS_SILENT_RELEASE_EXECUTION_ENABLED",
|
|
||||||
"default_enabled": false,
|
|
||||||
"shadow_side_effects_allowed": false,
|
|
||||||
"durable_saga_required": true,
|
|
||||||
"pre_switch_revalidation_required": true,
|
|
||||||
"trusted_candidate_builder_required": true,
|
|
||||||
"actual_plan_regeneration_required": true,
|
|
||||||
"startup_fail_closed_on_missing_component": true,
|
|
||||||
"recovery_worker_shadow_default": true,
|
|
||||||
"production_approval_backend_required": true,
|
|
||||||
"production_builder_signer": "kms-hsm-secret-manager-only",
|
|
||||||
"production_execution_without_backend": "unavailable"
|
|
||||||
},
|
|
||||||
"durability": {
|
|
||||||
"journal": "sha256-hash-chain",
|
|
||||||
"probe_journal": "sha256-hash-chain",
|
|
||||||
"exclusive_capability_claims": true,
|
|
||||||
"side_effect_probe_before_lease_takeover": true,
|
|
||||||
"side_effect_probe_before_failed_recoverable_retry": true,
|
|
||||||
"probe_results": [
|
|
||||||
"NOT_APPLIED",
|
|
||||||
"APPLIED_VERIFIED",
|
|
||||||
"INDETERMINATE",
|
|
||||||
"CONFLICT"
|
|
||||||
],
|
|
||||||
"probe_operation_digest_required": true,
|
|
||||||
"probe_freshness_required": true,
|
|
||||||
"tamper_result": "MANUAL_OWNER_REVIEW_REQUIRED"
|
|
||||||
},
|
|
||||||
"tag_pending": {
|
|
||||||
"required_observations": [
|
|
||||||
"remote_git_ref",
|
|
||||||
"annotated_tag_object",
|
|
||||||
"peeled_target",
|
|
||||||
"gitea_api",
|
|
||||||
"gitea_listing",
|
|
||||||
"repository_identity"
|
|
||||||
],
|
|
||||||
"indeterminate_result": "RECONCILIATION_REQUIRED",
|
|
||||||
"conflict_result": "MANUAL_OWNER_REVIEW_REQUIRED",
|
|
||||||
"automatic_reconciliation": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
version: 1
|
|
||||||
policy_id: gitops-isolated-worktree-governance-20260714T232948UTC8
|
|
||||||
|
|
||||||
runtime_identity:
|
|
||||||
user: aiman
|
|
||||||
group: gitops
|
|
||||||
umask: "0002"
|
|
||||||
prohibit_root_workspace_writes: true
|
|
||||||
|
|
||||||
repository:
|
|
||||||
source_worktree: /data/aios-gitops/workspace
|
|
||||||
bare_repo: /data/aios-gitops/repo.git
|
|
||||||
worktree_root: /data/aios-gitops/worktrees
|
|
||||||
remote: origin
|
|
||||||
|
|
||||||
worktrees:
|
|
||||||
controller: /data/aios-gitops/worktrees/controller
|
|
||||||
task_pattern: /data/aios-gitops/worktrees/{agent}/{task_id}
|
|
||||||
allowed_agents:
|
|
||||||
- openclaw
|
|
||||||
- chatgpt
|
|
||||||
- controller
|
|
||||||
require_clean_start: true
|
|
||||||
remove_after_successful_push: true
|
|
||||||
retain_on_failure: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
owner: aiman
|
|
||||||
group: gitops
|
|
||||||
directory_mode: "2775"
|
|
||||||
file_mode: "0664"
|
|
||||||
default_acl:
|
|
||||||
user:aiman: rwx
|
|
||||||
group:gitops: rwx
|
|
||||||
apply_to:
|
|
||||||
- /data/aios-gitops
|
|
||||||
- /data/aios-gitops/worktrees
|
|
||||||
|
|
||||||
preflight:
|
|
||||||
required_user: aiman
|
|
||||||
required_group: gitops
|
|
||||||
required_umask: "0002"
|
|
||||||
require_writable_git_dir: true
|
|
||||||
require_writable_task_dir: true
|
|
||||||
require_writable_result_dir: true
|
|
||||||
reject_shared_source_worktree: true
|
|
||||||
reject_existing_git_lock: true
|
|
||||||
|
|
||||||
notifications:
|
|
||||||
owner_on_received: required
|
|
||||||
owner_on_terminal: required
|
|
||||||
preferred_channel: weixin
|
|
||||||
redact_secrets: true
|
|
||||||
|
|
||||||
rollback:
|
|
||||||
preserve_source_worktree: true
|
|
||||||
remove_created_worktrees: true
|
|
||||||
restore_service_dropins: true
|
|
||||||
restore_service_state: true
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Install as a systemd drop-in for GitOps/OpenClaw writer services.
|
|
||||||
# Example: /etc/systemd/system/<service>.service.d/10-runtime-identity.conf
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=aiman
|
|
||||||
Group=gitops
|
|
||||||
SupplementaryGroups=gitops
|
|
||||||
UMask=0002
|
|
||||||
Environment=AIOS_GITOPS_ROOT=/data/aios-gitops
|
|
||||||
Environment=AIOS_BARE_REPO=/data/aios-gitops/repo.git
|
|
||||||
Environment=AIOS_WORKTREE_ROOT=/data/aios-gitops/worktrees
|
|
||||||
Environment=AIOS_RUNTIME_USER=aiman
|
|
||||||
Environment=AIOS_RUNTIME_GROUP=gitops
|
|
||||||
Environment=AIOS_RUNTIME_UMASK=0002
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/opt/aios/app \
|
||||||
|
AIOS_V2_HOST=0.0.0.0 \
|
||||||
|
AIOS_V2_PORT=18920 \
|
||||||
|
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 12001 aios \
|
||||||
|
&& useradd --system --uid 12001 --gid aios --home-dir /nonexistent --shell /usr/sbin/nologin aios \
|
||||||
|
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2
|
||||||
|
|
||||||
|
WORKDIR /opt/aios/app
|
||||||
|
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
|
||||||
|
|
||||||
|
USER aios:aios
|
||||||
|
EXPOSE 18920
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
|
||||||
|
|
||||||
|
CMD ["python3", "-m", "aios_v2.app"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
aios-v2:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: deploy/aios-v2/docker/Dockerfile
|
||||||
|
image: aios-v2:V2R1C0-candidate
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${AIOS_V2_PORT:-18920}:18920"
|
||||||
|
environment:
|
||||||
|
AIOS_V2_HOST: 0.0.0.0
|
||||||
|
AIOS_V2_PORT: 18920
|
||||||
|
AIOS_V2_STATE_DIR: /var/lib/aios-v2
|
||||||
|
AIOS_OPENCLAW_URL: ${AIOS_OPENCLAW_URL:-}
|
||||||
|
AIOS_OPENCLAW_CREDENTIAL_REF: ${AIOS_OPENCLAW_CREDENTIAL_REF:-}
|
||||||
|
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE: ${AIOS_OPENCLAW_SIGNING_MATERIAL_FILE:-}
|
||||||
|
AIOS_RELEASE_CHANNEL: candidate
|
||||||
|
AIOS_RELEASE_VERSION: V2R1C0-candidate
|
||||||
|
volumes:
|
||||||
|
- aios-v2-state:/var/lib/aios-v2
|
||||||
|
# Mount runtime signing material explicitly when OpenClaw is enabled.
|
||||||
|
# The host file must be mode 0600; never store it in this repository.
|
||||||
|
# - /etc/aios-v2/openclaw-signing-material:/run/secrets/openclaw-signing-material:ro
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python3
|
||||||
|
- -c
|
||||||
|
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18920/api/v2/health', timeout=3).read()"
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 4
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
aios-v2-state:
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# AIOS V2 candidate runtime configuration.
|
||||||
|
# Copy to /etc/aios-v2/aios-v2.env and keep ownership root:aios, mode 0640.
|
||||||
|
|
||||||
|
AIOS_V2_HOST=127.0.0.1
|
||||||
|
AIOS_V2_PORT=18920
|
||||||
|
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||||
|
|
||||||
|
# Optional OpenClaw runtime. Leave blank to run with OpenClaw disabled.
|
||||||
|
AIOS_OPENCLAW_URL=
|
||||||
|
AIOS_OPENCLAW_CREDENTIAL_REF=
|
||||||
|
|
||||||
|
# File path only. The referenced file must be mode 0600 or stricter and contain
|
||||||
|
# at least 32 bytes. Never place signing material in this env file or Git.
|
||||||
|
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=
|
||||||
|
|
||||||
|
# Reverse proxy must authenticate users and inject X-AIOS-* identity headers.
|
||||||
|
AIOS_TRUST_PROXY_HEADERS=true
|
||||||
|
|
||||||
|
# Candidate deployment metadata exposed through health tooling.
|
||||||
|
AIOS_RELEASE_CHANNEL=candidate
|
||||||
|
AIOS_RELEASE_VERSION=V2R1C0-candidate
|
||||||
@@ -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,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SERVICE="${AIOS_V2_SERVICE:-aios-v2}"
|
||||||
|
RELEASE_ROOT="${AIOS_V2_RELEASE_ROOT:-/opt/aios/releases}"
|
||||||
|
CURRENT_LINK="${AIOS_V2_CURRENT_LINK:-/opt/aios/current}"
|
||||||
|
STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
|
||||||
|
BACKUP_ROOT="${AIOS_V2_BACKUP_ROOT:-/var/backups/aios-v2}"
|
||||||
|
TARGET_RELEASE="${1:-}"
|
||||||
|
RESTORE_STATE="${AIOS_V2_RESTORE_STATE:-0}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
|
||||||
|
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
|
||||||
|
|
||||||
|
if [[ "$TARGET_RELEASE" = /* ]]; then
|
||||||
|
TARGET_PATH="$(readlink -f "$TARGET_RELEASE")"
|
||||||
|
else
|
||||||
|
[[ "$TARGET_RELEASE" != *"/"* && "$TARGET_RELEASE" != "." && "$TARGET_RELEASE" != ".." ]] \
|
||||||
|
|| fail "invalid release name"
|
||||||
|
TARGET_PATH="$(readlink -f "$RELEASE_ROOT/$TARGET_RELEASE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -d "$TARGET_PATH/active-runtime/app" ]] || fail "target release is invalid: $TARGET_PATH"
|
||||||
|
[[ -f "$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh" ]] \
|
||||||
|
|| fail "target release has no healthcheck"
|
||||||
|
|
||||||
|
PREVIOUS_PATH=""
|
||||||
|
if [[ -L "$CURRENT_LINK" ]]; then
|
||||||
|
PREVIOUS_PATH="$(readlink -f "$CURRENT_LINK")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
BACKUP_DIR="$BACKUP_ROOT/rollback-$STAMP"
|
||||||
|
install -d -m 0750 "$BACKUP_DIR"
|
||||||
|
|
||||||
|
if [[ -d "$STATE_DIR" ]]; then
|
||||||
|
tar --xattrs --acls -C "$(dirname "$STATE_DIR")" \
|
||||||
|
-czf "$BACKUP_DIR/state-before-rollback.tar.gz" \
|
||||||
|
"$(basename "$STATE_DIR")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$PREVIOUS_PATH" > "$BACKUP_DIR/previous-release.txt"
|
||||||
|
printf '%s\n' "$TARGET_PATH" > "$BACKUP_DIR/target-release.txt"
|
||||||
|
|
||||||
|
systemctl stop "$SERVICE"
|
||||||
|
ln -sfn "$TARGET_PATH" "${CURRENT_LINK}.next"
|
||||||
|
mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start "$SERVICE"
|
||||||
|
|
||||||
|
if AIOS_V2_SERVICE="$SERVICE" \
|
||||||
|
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||||
|
"$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh"; then
|
||||||
|
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Target release failed healthcheck; restoring previous release." >&2
|
||||||
|
systemctl stop "$SERVICE" || true
|
||||||
|
|
||||||
|
if [[ -n "$PREVIOUS_PATH" && -d "$PREVIOUS_PATH" ]]; then
|
||||||
|
ln -sfn "$PREVIOUS_PATH" "${CURRENT_LINK}.previous"
|
||||||
|
mv -Tf "${CURRENT_LINK}.previous" "$CURRENT_LINK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RESTORE_STATE" == "1" && -f "$BACKUP_DIR/state-before-rollback.tar.gz" ]]; then
|
||||||
|
rm -rf "$STATE_DIR"
|
||||||
|
tar --xattrs --acls -C "$(dirname "$STATE_DIR")" \
|
||||||
|
-xzf "$BACKUP_DIR/state-before-rollback.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl start "$SERVICE" || true
|
||||||
|
|
||||||
|
if [[ -n "$PREVIOUS_PATH" && -x "$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" ]]; then
|
||||||
|
AIOS_V2_SERVICE="$SERVICE" \
|
||||||
|
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||||
|
"$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS V2 Control Plane Candidate
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios
|
||||||
|
Group=aios
|
||||||
|
WorkingDirectory=/opt/aios/current/active-runtime/app
|
||||||
|
EnvironmentFile=-/etc/aios-v2/aios-v2.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/bin/python3 -m aios_v2.app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStartSec=30
|
||||||
|
TimeoutStopSec=30
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
ReadWritePaths=/var/lib/aios-v2
|
||||||
|
StateDirectory=aios-v2
|
||||||
|
StateDirectoryMode=0750
|
||||||
|
UMask=0027
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# AIOS V2R1C2 Unified Portal production metadata.
|
|
||||||
# No secret values belong in this file; runtime integrations use references.
|
|
||||||
|
|
||||||
AIOS_V2_HOST=127.0.0.1
|
|
||||||
AIOS_V2_PORT=18920
|
|
||||||
AIOS_V2_STATE_DIR=/var/lib/aios-v2r1c2-production
|
|
||||||
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2r1c2-production/run-provenance.sqlite3
|
|
||||||
AIOS_EXTERNAL_TASK_STORE_FILE=/var/lib/aios-v2r1c2-production/external-task-truth.sqlite3
|
|
||||||
AIOS_EXECUTOR_CATALOG_FILE=/var/lib/aios-v2r1c2-production/executor-catalog.json
|
|
||||||
AIOS_EXECUTION_PLAN_FILES=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json:/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
|
||||||
AIOS_EXECUTION_PLAN_FILE=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
|
||||||
|
|
||||||
AIOS_RELEASE_VERSION=V2R1C2
|
|
||||||
AIOS_TARGET_VERSION=V2R1C2
|
|
||||||
AIOS_BASELINE_VERSION=V2R1C1
|
|
||||||
AIOS_PREVIOUS_VERSION=V2R1C1
|
|
||||||
AIOS_RELEASE_STATUS=PRODUCTION
|
|
||||||
AIOS_RELEASE_SOURCE_OID=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
|
|
||||||
AIOS_RELEASE_OID=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
|
|
||||||
AIOS_RELEASE_TAG=V2R1C2
|
|
||||||
AIOS_RELEASE_EVIDENCE_SHA256=2e27bb73b8e6297bd3077238f73a96d697fc3c3611c601c4dc9357e5fdcd8eee
|
|
||||||
|
|
||||||
AIOS_OPENCLAW_CONTRACT_VERSION=aios.openclaw.command.v2
|
|
||||||
AIOS_OPENCLAW_SIGNATURE_ALGORITHM=Ed25519
|
|
||||||
AIOS_OPENCLAW_PLUGIN_VERSION=2.1.0-rc.4
|
|
||||||
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
|
|
||||||
AIOS_OWNER_WECHAT_TARGET_REF=AIOS_OWNER_WECHAT_TARGET_REF
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS V2R1C2 Unified Portal Production
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
Conflicts=aios-orchestrator-gateway.service
|
|
||||||
ConditionPathExists=/opt/aios-v2r1c2/active-runtime/app/aios_web/release_application.py
|
|
||||||
ConditionPathExists=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=aios
|
|
||||||
Group=aios
|
|
||||||
WorkingDirectory=/opt/aios-v2r1c2/active-runtime/app
|
|
||||||
EnvironmentFile=/etc/aios-v2/aios-v2r1c2-production.env
|
|
||||||
Environment=PYTHONUNBUFFERED=1
|
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ExecStartPre=/usr/bin/test "$AIOS_V2_PORT" = "18920"
|
|
||||||
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_SOURCE_OID" = "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24"
|
|
||||||
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_TAG" = "V2R1C2"
|
|
||||||
ExecStart=/usr/bin/python3 -m aios_web.release_application
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=3
|
|
||||||
TimeoutStartSec=60
|
|
||||||
TimeoutStopSec=30
|
|
||||||
KillSignal=SIGTERM
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ProtectKernelTunables=true
|
|
||||||
ProtectKernelModules=true
|
|
||||||
ProtectControlGroups=true
|
|
||||||
RestrictSUIDSGID=true
|
|
||||||
RestrictRealtime=true
|
|
||||||
LockPersonality=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
ReadWritePaths=/var/lib/aios-v2r1c2-production
|
|
||||||
StateDirectory=aios-v2r1c2-production
|
|
||||||
StateDirectoryMode=0750
|
|
||||||
UMask=0027
|
|
||||||
LimitNOFILE=65536
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -11,8 +11,6 @@ Environment=AIOS_GITOPS_WORKSPACE=/data/aios-gitops/workspace
|
|||||||
Environment=AIOS_GITOPS_DB=/data/aios-gitops/runtime/gitops_model.sqlite3
|
Environment=AIOS_GITOPS_DB=/data/aios-gitops/runtime/gitops_model.sqlite3
|
||||||
Environment=AIOS_GITOPS_TOKEN_FILE=/data/aios-gitops/runtime/secrets/gitops.tokens
|
Environment=AIOS_GITOPS_TOKEN_FILE=/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||||
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git
|
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git
|
||||||
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
|
||||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
|
||||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||||
Environment=AIOS_GITOPS_PORT=18890
|
Environment=AIOS_GITOPS_PORT=18890
|
||||||
ExecStart=/usr/bin/python3 /data/aios-gitops/workspace/active-runtime/app/aios_chatgpt_actions_api.py
|
ExecStart=/usr/bin/python3 /data/aios-gitops/workspace/active-runtime/app/aios_chatgpt_actions_api.py
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS Silent Release Shadow Controller
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=aiman
|
|
||||||
Group=gitops
|
|
||||||
UMask=0077
|
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ExecStart=/usr/bin/python3 /usr/libexec/aios-silent-release-shadow/current/active-runtime/app/silent_release_execution.py --state-root /var/lib/aios-silent-release-shadow --repository /data/aios-gitops/repo.git --host 127.0.0.1 --port 18891 --poll-seconds 30
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ReadWritePaths=/var/lib/aios-silent-release-shadow
|
|
||||||
ReadOnlyPaths=/data/aios-gitops/repo.git /usr/libexec/aios-silent-release-shadow
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# AIOS V2 用户需求规格
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 状态:候选版本需求基线
|
||||||
|
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、审计与部署
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
现有系统已经具备 OpenClaw、Hermes、Agent Router、GitOps、局部 WebUI、审计和多租户候选能力。AIOS V2 的目标不是推倒重写,而是将这些分散能力收敛到统一、可审计、可 Web 化、可多租户隔离的控制面。
|
||||||
|
|
||||||
|
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录和执行证据。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
|
||||||
|
|
||||||
|
## 2. 用户角色
|
||||||
|
|
||||||
|
| 角色 | 主要诉求 |
|
||||||
|
|---|---|
|
||||||
|
| 平台管理员 | 管理租户、运行时、发布、GitOps 状态和全局策略 |
|
||||||
|
| 租户管理员 | 管理本租户 Agent、任务、审批、审计和成员权限 |
|
||||||
|
| 操作员 | 创建任务、发起执行、取消任务、查看运行状态 |
|
||||||
|
| 审批人 | 审批高风险任务,查看策略判定依据 |
|
||||||
|
| 审计员 | 查询本租户完整审计链和执行证据 |
|
||||||
|
| Runtime Worker | 领取已批准任务、心跳、返回执行结果 |
|
||||||
|
| 开发与运维人员 | 通过 GitOps 提交、测试、部署、回滚和修复 Bug |
|
||||||
|
|
||||||
|
## 3. 核心用户需求
|
||||||
|
|
||||||
|
### UR-001 唯一控制面
|
||||||
|
|
||||||
|
所有任务创建、审批、取消、状态变化、策略判定和审计必须由 AIOS Control Plane 统一处理。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Web Console、API、Codex、OpenClaw 和 Hermes 均不得绕过 AIOS 创建或修改任务生命周期。
|
||||||
|
- OpenClaw/Hermes 的公开接口仅限执行、状态、取消、心跳和结果回传。
|
||||||
|
|
||||||
|
### UR-002 复用现有能力
|
||||||
|
|
||||||
|
系统不得重写已经稳定的 OpenClaw 执行能力、Hermes Runtime、Agent Router 或 GitOps 基础设施。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 通过 Adapter/Boundary 接入现有能力。
|
||||||
|
- 替换控制逻辑时保留原执行接口和可迁移数据。
|
||||||
|
|
||||||
|
### UR-003 OpenClaw 与 Hermes 解耦
|
||||||
|
|
||||||
|
OpenClaw 与 Hermes 必须从策略、租户、审批和任务生命周期中逐步解耦。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- OpenClaw 仅接收已签名、已批准、带租户上下文的命令。
|
||||||
|
- Hermes 仅接收已批准的租户分区 Job。
|
||||||
|
- 二者均不得直接写 Control Plane 权威状态。
|
||||||
|
|
||||||
|
### UR-004 全面 Web 化
|
||||||
|
|
||||||
|
所有面向用户和运维的能力必须通过 Web Console 或稳定 API 访问。
|
||||||
|
|
||||||
|
Web Console 至少展示:
|
||||||
|
|
||||||
|
- Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence、GitOps Status。
|
||||||
|
- 所有写操作由 AIOS API 完成,浏览器不得直接调用 OpenClaw/Hermes。
|
||||||
|
|
||||||
|
### UR-005 多租户隔离
|
||||||
|
|
||||||
|
所有模块必须支持租户隔离,且默认拒绝跨租户访问。
|
||||||
|
|
||||||
|
隔离范围:
|
||||||
|
|
||||||
|
- API 与会话
|
||||||
|
- Tenant/Agent/Task 数据
|
||||||
|
- OpenClaw 命令
|
||||||
|
- Hermes Queue、Cell、Profile、Worker Lease
|
||||||
|
- Memory、Cache、Artifact、SecretRef
|
||||||
|
- Audit、Execution Evidence、日志和指标
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 资源记录的 `tenant_id` 不可为空。
|
||||||
|
- 跨租户访问返回拒绝或“资源不存在”,不得泄露存在性。
|
||||||
|
- 必须有跨租户负向测试。
|
||||||
|
|
||||||
|
### UR-006 权威任务生命周期
|
||||||
|
|
||||||
|
任务必须遵循统一状态机:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||||
|
| | |
|
||||||
|
+----------+----------+-> CANCELLED
|
||||||
|
+-> FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 禁止越级变化,例如 `CREATED -> COMPLETED`。
|
||||||
|
- 每次变化包含 revision、principal、trace、时间和审计事件。
|
||||||
|
|
||||||
|
### UR-007 策略与审批
|
||||||
|
|
||||||
|
所有有副作用任务必须经过策略判定;高风险任务必须支持人工审批。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Policy Decision 包含 allow/deny、reason 和 policy version。
|
||||||
|
- 拒绝事件进入审计。
|
||||||
|
- Runtime 不得自行放宽策略。
|
||||||
|
|
||||||
|
### UR-008 幂等与并发安全
|
||||||
|
|
||||||
|
任务和 Runtime 执行必须支持幂等与乐观并发控制。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 相同租户与幂等键、相同内容返回同一任务/执行。
|
||||||
|
- 相同幂等键不同内容返回冲突。
|
||||||
|
- revision 或 branch lease 冲突不得静默覆盖。
|
||||||
|
|
||||||
|
### UR-009 可观测与执行证据
|
||||||
|
|
||||||
|
平台必须能够确认 Codex、OpenClaw、Hermes 等执行器是否领取任务、当前进度、最近提交、测试结果和完成状态。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 存在 claim、heartbeat、progress、result 和 evidence 记录。
|
||||||
|
- Web/API 可按租户查询。
|
||||||
|
- 执行证据不得包含明文密钥。
|
||||||
|
|
||||||
|
### UR-010 GitOps 唯一变更流程
|
||||||
|
|
||||||
|
所有代码、配置、文档和部署资产修改必须进入非 `main` 分支,通过 commit、测试、审查和受控提升。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Controller worktree 必须 detached 且 clean。
|
||||||
|
- 本地 bare repo 与 Gitea 目标分支 OID 一致。
|
||||||
|
- 不允许直接 push `main`、直接创建生产 Tag 或绕过审批部署。
|
||||||
|
|
||||||
|
### UR-011 持久化与恢复
|
||||||
|
|
||||||
|
租户、Agent、Task、Audit、Idempotency 和 Execution Evidence 必须在服务重启后恢复。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 候选版支持 SQLite WAL 原子快照。
|
||||||
|
- 有 restart recovery 测试。
|
||||||
|
- 生产版需进一步提供迁移、备份、恢复、HA 和容量证据。
|
||||||
|
|
||||||
|
### UR-012 审计与合规
|
||||||
|
|
||||||
|
所有控制与执行动作必须生成不可混租户的审计事件。
|
||||||
|
|
||||||
|
至少记录:
|
||||||
|
|
||||||
|
- tenant、principal、trace、action、resource、outcome、policy version、timestamp。
|
||||||
|
- OpenClaw/Hermes Runtime 生命周期。
|
||||||
|
- GitOps、部署和回滚动作。
|
||||||
|
|
||||||
|
### UR-013 密钥安全
|
||||||
|
|
||||||
|
Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key、密码、Token 或私钥。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Git 只存 `secret_ref`、`credential_ref`、`key_id` 或受保护文件路径。
|
||||||
|
- Secret 文件权限为 `0600` 或更严格。
|
||||||
|
- Secret Scanner 默认拒绝可疑提交。
|
||||||
|
|
||||||
|
### UR-014 部署与回滚
|
||||||
|
|
||||||
|
系统必须提供 systemd、Docker/Compose、健康检查和受保护回滚入口。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 服务以非 root 权限运行。
|
||||||
|
- 状态目录独立持久化。
|
||||||
|
- 回滚前自动备份状态。
|
||||||
|
- 默认只回滚代码,状态恢复需要显式授权。
|
||||||
|
|
||||||
|
### UR-015 文档与代码同步
|
||||||
|
|
||||||
|
任何影响用户行为、架构边界、API、数据模型、状态机、安全策略、部署或运维方式的变更,必须在同一分支同步更新 `docs/aios-v2/`。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 架构/功能代码变化但无文档变化时,文档同步门禁失败。
|
||||||
|
- Traceability Matrix 必须指向当前代码和测试。
|
||||||
|
- 新架构决策必须增加或更新 ADR。
|
||||||
|
|
||||||
|
## 4. 非功能需求
|
||||||
|
|
||||||
|
### NFR-001 安全
|
||||||
|
|
||||||
|
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计。
|
||||||
|
|
||||||
|
### NFR-002 可靠性
|
||||||
|
|
||||||
|
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、健康检查和回滚。
|
||||||
|
|
||||||
|
### NFR-003 可维护性
|
||||||
|
|
||||||
|
- 模块边界稳定,Control Plane 与 Runtime 解耦;代码、文档、测试和部署可追踪。
|
||||||
|
|
||||||
|
### NFR-004 可扩展性
|
||||||
|
|
||||||
|
- Store、Transport、Policy、EventPublisher、Runtime Adapter 均通过接口扩展。
|
||||||
|
|
||||||
|
### NFR-005 可运维性
|
||||||
|
|
||||||
|
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告。
|
||||||
|
|
||||||
|
### NFR-006 性能候选指标
|
||||||
|
|
||||||
|
候选版上线前至少验证:
|
||||||
|
|
||||||
|
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间。
|
||||||
|
- 具体 SLO 在预发布基准测试后固化,禁止无证据承诺。
|
||||||
|
|
||||||
|
## 5. 本候选版本范围
|
||||||
|
|
||||||
|
### 已纳入代码
|
||||||
|
|
||||||
|
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、部署和回滚。
|
||||||
|
|
||||||
|
### 尚需环境验证
|
||||||
|
|
||||||
|
- 精确测试执行证据。
|
||||||
|
- OpenClaw 真实 Runtime 联调。
|
||||||
|
- 生产数据库/队列替换或容量验证。
|
||||||
|
- 预发布性能、安全、重启和回滚演练。
|
||||||
|
- Owner 对 merge、Tag 和生产部署的批准。
|
||||||
|
|
||||||
|
## 6. 用户验收场景
|
||||||
|
|
||||||
|
1. 平台管理员创建租户并注册 OpenClaw/Hermes Agent。
|
||||||
|
2. 操作员创建任务,审批人批准。
|
||||||
|
3. AIOS Orchestrator 选择 Runtime,并创建签名命令或租户分区 Job。
|
||||||
|
4. Runtime 执行并返回结果。
|
||||||
|
5. AIOS 更新 Task、Audit 和 Execution Evidence。
|
||||||
|
6. 租户用户在 Web Console 查看完整链路。
|
||||||
|
7. 跨租户访问被拒绝。
|
||||||
|
8. 服务重启后状态恢复。
|
||||||
|
9. 候选部署健康检查通过,回滚演练成功。
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
# AIOS V2 目标架构
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 架构状态:目标架构已实现为候选代码,待预发布验证
|
||||||
|
- 适用范围:Control Plane、Agent Router、OpenClaw、Hermes、Web Console、GitOps、审计、部署与运维
|
||||||
|
|
||||||
|
## 1. 架构目标
|
||||||
|
|
||||||
|
AIOS V2 的核心目标是把分散在 OpenClaw、Hermes、Agent Router、局部 WebUI 和 GitOps 中的控制能力收敛为一个权威控制面,同时保留现有执行能力,避免推倒重写。
|
||||||
|
|
||||||
|
目标状态:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户 / Web Console / Public API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Control Plane
|
||||||
|
Tenant | Identity | Agent | Task | Policy | Approval | Audit
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
OpenClaw Adapter Hermes Boundary
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
OpenClaw Runtime Hermes Workers
|
||||||
|
\ /
|
||||||
|
v v
|
||||||
|
Result Reconciliation + Execution Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
设计结论:
|
||||||
|
|
||||||
|
- AIOS 是唯一控制面。
|
||||||
|
- Agent Router 是调度平面,不是第二控制面。
|
||||||
|
- OpenClaw 是执行适配与 Runtime。
|
||||||
|
- Hermes 是队列、Worker、Profile 与执行 Runtime。
|
||||||
|
- Web Console 只调用 AIOS API。
|
||||||
|
- GitOps 是代码、配置、文档和发布变更的唯一入口。
|
||||||
|
|
||||||
|
## 2. 从现有架构到目标架构
|
||||||
|
|
||||||
|
### 2.1 现有能力
|
||||||
|
|
||||||
|
现有系统已经具备:
|
||||||
|
|
||||||
|
- OpenClaw 执行与 Fleet 能力。
|
||||||
|
- Hermes Worker、Profile、队列和工作区能力。
|
||||||
|
- Agent Router 路由经验和部分调度接口。
|
||||||
|
- GitOps 分支、提交、审批、回滚和审计能力。
|
||||||
|
- Tenant Context、Tenant Router、Memory Scope、SecretRef、Message Ledger 和审计候选实现。
|
||||||
|
- 局部 WebUI、Gateway 和自动任务领取能力。
|
||||||
|
|
||||||
|
### 2.2 现有问题
|
||||||
|
|
||||||
|
- 控制逻辑分散,可能存在多个任务事实源。
|
||||||
|
- OpenClaw/Hermes 内部仍可能保存租户、策略或任务状态。
|
||||||
|
- 候选能力分散在多个分支,主线缺少权威实现。
|
||||||
|
- WebUI 与 API 入口不统一。
|
||||||
|
- 分支名称、代码、测试和部署证据之间缺少映射。
|
||||||
|
- 多租户隔离多为逻辑约束,尚缺全链路生产证据。
|
||||||
|
|
||||||
|
### 2.3 演进原则
|
||||||
|
|
||||||
|
- 先加边界和适配器,再迁移控制权。
|
||||||
|
- 先复用接口,再替换内部实现。
|
||||||
|
- 先在候选分支验证,再受控提升。
|
||||||
|
- 不允许同时存在两套权威任务生命周期。
|
||||||
|
- 不以“大分支存在”代替验收证据。
|
||||||
|
|
||||||
|
## 3. 逻辑分层
|
||||||
|
|
||||||
|
### 3.1 Web 与 API 层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 提供 Tenant、Agent、Task、Approval、Runtime、Audit、Execution Evidence 和 GitOps 页面/API。
|
||||||
|
- 依赖可信身份代理注入 `X-AIOS-*` 身份头。
|
||||||
|
- 不允许浏览器直接访问 OpenClaw 或 Hermes。
|
||||||
|
- 不允许客户端自行选择或覆盖 `tenant_id`。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/app.py`
|
||||||
|
- `active-runtime/app/aios_v2/gateway.py`
|
||||||
|
- `active-runtime/app/aios_v2/console.py`
|
||||||
|
|
||||||
|
### 3.2 AIOS Control Plane
|
||||||
|
|
||||||
|
权威职责:
|
||||||
|
|
||||||
|
- Tenant Registry
|
||||||
|
- Agent Registry
|
||||||
|
- Task Lifecycle
|
||||||
|
- Policy Decision
|
||||||
|
- Approval
|
||||||
|
- Audit Event
|
||||||
|
- Runtime Inventory
|
||||||
|
- Execution Evidence
|
||||||
|
- Deployment/GitOps Status
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- OpenClaw 内部执行细节。
|
||||||
|
- Hermes Worker 内部执行细节。
|
||||||
|
- 直接持有 Runtime 凭据明文。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
- `active-runtime/app/aios_v2/persistence.py`
|
||||||
|
- `active-runtime/app/aios_v2/execution_tracking.py`
|
||||||
|
- `active-runtime/app/aios_v2/gitops_status.py`
|
||||||
|
|
||||||
|
### 3.3 Execution Orchestrator / Agent Router
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只接受 `APPROVED` 任务。
|
||||||
|
- 根据 Agent Registry 和 Policy Result 选择 Runtime。
|
||||||
|
- 生成 OpenClawCommand 或 HermesJob。
|
||||||
|
- 传播 tenant、task、trace、idempotency 和 policy version。
|
||||||
|
- 将 Runtime Result 回写 AIOS Task。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- 创建租户。
|
||||||
|
- 修改策略。
|
||||||
|
- 绕过审批。
|
||||||
|
- 保存第二套任务状态机。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_v2/orchestrator.py`
|
||||||
|
|
||||||
|
### 3.4 OpenClaw Adapter
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 验证签名命令。
|
||||||
|
- 执行、查询、取消、健康检查和回调。
|
||||||
|
- 支持幂等、超时和租户隔离。
|
||||||
|
- 把 Runtime 生命周期转换为 AIOS 审计事件。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- Tenant、Policy、Approval、Task Lifecycle。
|
||||||
|
- 向浏览器暴露公网控制接口。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_openclaw/contract.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/adapter.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/transport.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/health.py`
|
||||||
|
- `active-runtime/app/aios_openclaw/callback.py`
|
||||||
|
|
||||||
|
### 3.5 Hermes Runtime Boundary
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- Tenant/Cell/Profile 分区队列。
|
||||||
|
- Worker claim、start、heartbeat、complete、fail、cancel。
|
||||||
|
- Lease 超时和自动回队列。
|
||||||
|
- Result 回传和 Runtime Health。
|
||||||
|
|
||||||
|
不负责:
|
||||||
|
|
||||||
|
- Policy、Approval、Tenant Registry、权威 Task Lifecycle。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `active-runtime/app/aios_hermes/contract.py`
|
||||||
|
- `active-runtime/app/aios_hermes/adapter.py`
|
||||||
|
- `active-runtime/app/aios_hermes/worker.py`
|
||||||
|
|
||||||
|
### 3.6 Persistence
|
||||||
|
|
||||||
|
候选实现使用 SQLite WAL 原子快照,持久化:
|
||||||
|
|
||||||
|
- Tenant
|
||||||
|
- Agent
|
||||||
|
- Task
|
||||||
|
- Audit
|
||||||
|
- Idempotency
|
||||||
|
- Execution Evidence 文件
|
||||||
|
|
||||||
|
生产演进:
|
||||||
|
|
||||||
|
- Store 接口保持稳定。
|
||||||
|
- 可替换为 PostgreSQL、对象存储或分布式事件存储。
|
||||||
|
- 生产替换必须提供迁移、备份、恢复、HA、容量和租户隔离证据。
|
||||||
|
|
||||||
|
## 4. 多租户架构
|
||||||
|
|
||||||
|
### 4.1 权威租户上下文
|
||||||
|
|
||||||
|
每次请求和执行至少传播:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
session_id
|
||||||
|
trace_id
|
||||||
|
task_id
|
||||||
|
policy_version
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime 还传播:
|
||||||
|
|
||||||
|
```text
|
||||||
|
command_id / job_id
|
||||||
|
cell_id
|
||||||
|
profile_id
|
||||||
|
idempotency_key
|
||||||
|
worker_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 隔离层级
|
||||||
|
|
||||||
|
| 层级 | 隔离要求 |
|
||||||
|
|---|---|
|
||||||
|
| HTTP/API | 可信身份头、租户不可由浏览器覆盖 |
|
||||||
|
| Control Plane | 所有资源带 `tenant_id`,跨租户默认拒绝 |
|
||||||
|
| OpenClaw | 签名命令绑定 tenant/task/trace |
|
||||||
|
| Hermes | tenant/cell/profile 三层分区 |
|
||||||
|
| Storage | 候选为显式租户字段;生产需 RLS 或物理隔离策略 |
|
||||||
|
| Evidence/Audit | 仅按当前租户查询 |
|
||||||
|
| Cache/Memory | key 必须包含 tenant namespace |
|
||||||
|
| Secret | 只保存引用,不保存明文 |
|
||||||
|
| Logs/Metrics | 必须包含 tenant/task/trace 标签,并限制查询范围 |
|
||||||
|
|
||||||
|
### 4.3 失败行为
|
||||||
|
|
||||||
|
- 跨租户资源访问返回拒绝或“未找到”。
|
||||||
|
- 不泄露其他租户资源存在性。
|
||||||
|
- 缺少 tenant context 时 fail closed。
|
||||||
|
- Runtime 返回的 tenant/task/trace 不匹配时拒绝回写。
|
||||||
|
|
||||||
|
## 5. 任务与执行数据流
|
||||||
|
|
||||||
|
### 5.1 任务创建与审批
|
||||||
|
|
||||||
|
```text
|
||||||
|
User/API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Trusted Identity
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Gateway
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Policy Gate
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task CREATED
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Approval
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 OpenClaw 执行
|
||||||
|
|
||||||
|
```text
|
||||||
|
APPROVED Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Signed OpenClawCommand
|
||||||
|
|
|
||||||
|
v
|
||||||
|
OpenClaw Adapter
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Runtime Result
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task Reconciliation + Audit + Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Hermes 执行
|
||||||
|
|
||||||
|
```text
|
||||||
|
APPROVED Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Tenant/Cell/Profile HermesJob
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Worker Claim + Lease + Heartbeat
|
||||||
|
|
|
||||||
|
v
|
||||||
|
HermesResult
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Task Reconciliation + Audit + Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 安全架构
|
||||||
|
|
||||||
|
### 6.1 身份边界
|
||||||
|
|
||||||
|
- Public API 位于可信反向代理之后。
|
||||||
|
- 反向代理完成登录、会话、MFA 和身份头注入。
|
||||||
|
- 候选应用拒绝未认证身份断言。
|
||||||
|
- 生产不得允许客户端直接伪造 `X-AIOS-*`。
|
||||||
|
|
||||||
|
### 6.2 Runtime 命令完整性
|
||||||
|
|
||||||
|
OpenClawCommand 使用规范化序列化和 HMAC-SHA256:
|
||||||
|
|
||||||
|
- 签名覆盖 tenant、task、trace、action、payload、idempotency 和时间。
|
||||||
|
- 支持 key ID、到期时间和时钟偏差。
|
||||||
|
- Runtime 密钥通过受保护文件或外部 Secret Provider 解析。
|
||||||
|
|
||||||
|
### 6.3 Secret 边界
|
||||||
|
|
||||||
|
Git 允许:
|
||||||
|
|
||||||
|
```text
|
||||||
|
secret_ref
|
||||||
|
credential_ref
|
||||||
|
key_id
|
||||||
|
受保护文件路径
|
||||||
|
```
|
||||||
|
|
||||||
|
Git 禁止:
|
||||||
|
|
||||||
|
```text
|
||||||
|
API Key
|
||||||
|
密码
|
||||||
|
Token
|
||||||
|
私钥
|
||||||
|
签名材料
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Web 安全
|
||||||
|
|
||||||
|
候选应用设置:
|
||||||
|
|
||||||
|
- no-store
|
||||||
|
- nosniff
|
||||||
|
- DENY frame
|
||||||
|
- no-referrer
|
||||||
|
- CSP
|
||||||
|
- 请求体大小限制
|
||||||
|
|
||||||
|
生产还需验证:
|
||||||
|
|
||||||
|
- CSRF/CORS
|
||||||
|
- Rate Limit
|
||||||
|
- Session Timeout
|
||||||
|
- CSP Nonce/Hash
|
||||||
|
- WAF 和反向代理访问策略
|
||||||
|
|
||||||
|
## 7. 可靠性架构
|
||||||
|
|
||||||
|
### 7.1 幂等
|
||||||
|
|
||||||
|
- Control Plane:`tenant_id + idempotency_key + request_digest`。
|
||||||
|
- OpenClaw:`tenant_id + idempotency_key + signature`。
|
||||||
|
- Hermes:`tenant_id + idempotency_key + job_digest`。
|
||||||
|
|
||||||
|
### 7.2 并发
|
||||||
|
|
||||||
|
- Task 使用 revision。
|
||||||
|
- Hermes 使用 revision + worker lease。
|
||||||
|
- GitOps 使用 detached controller + branch lease。
|
||||||
|
- 持久化使用 SQLite `BEGIN IMMEDIATE` 和原子快照。
|
||||||
|
|
||||||
|
### 7.3 恢复
|
||||||
|
|
||||||
|
- Controller restart:detached guard。
|
||||||
|
- AIOS restart:SQLite restore。
|
||||||
|
- Worker crash:lease 到期后 Job 回到 QUEUED。
|
||||||
|
- Release failure:健康检查失败自动恢复 previous release。
|
||||||
|
|
||||||
|
## 8. 可观测架构
|
||||||
|
|
||||||
|
统一关联键:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
trace_id
|
||||||
|
task_id
|
||||||
|
agent_id
|
||||||
|
command_id / job_id
|
||||||
|
worker_id
|
||||||
|
commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Web Console 展示:
|
||||||
|
|
||||||
|
- Tasks
|
||||||
|
- Agents
|
||||||
|
- Runtime Health
|
||||||
|
- Audit
|
||||||
|
- Execution Evidence
|
||||||
|
- GitOps Status
|
||||||
|
|
||||||
|
生产还需接入:
|
||||||
|
|
||||||
|
- 结构化日志
|
||||||
|
- Metrics
|
||||||
|
- Distributed Tracing
|
||||||
|
- Alerting
|
||||||
|
- SLO Dashboard
|
||||||
|
|
||||||
|
## 9. 部署架构
|
||||||
|
|
||||||
|
支持两种候选部署:
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
- 非 root `aios` 用户。
|
||||||
|
- `ProtectSystem=strict`、`NoNewPrivileges` 等安全选项。
|
||||||
|
- 状态目录 `/var/lib/aios-v2`。
|
||||||
|
|
||||||
|
### Docker/Compose
|
||||||
|
|
||||||
|
- 非 root UID/GID。
|
||||||
|
- Read-only root filesystem。
|
||||||
|
- Drop all capabilities。
|
||||||
|
- State volume。
|
||||||
|
- HTTP healthcheck。
|
||||||
|
|
||||||
|
## 10. 架构约束
|
||||||
|
|
||||||
|
以下行为视为架构违规:
|
||||||
|
|
||||||
|
1. OpenClaw/Hermes 直接创建或批准 AIOS Task。
|
||||||
|
2. Web Console 直接调用 Runtime。
|
||||||
|
3. 新模块不携带 `tenant_id`。
|
||||||
|
4. 新增第二套任务状态机或租户事实源。
|
||||||
|
5. 在 Git 中提交明文凭据。
|
||||||
|
6. 绕过 GitOps 修改生产代码/配置。
|
||||||
|
7. 架构或功能变化未同步更新 `docs/aios-v2/`。
|
||||||
|
8. 未通过测试和预发布门禁直接提升候选版本。
|
||||||
|
|
||||||
|
## 11. 目标架构完成判定
|
||||||
|
|
||||||
|
目标架构只有在以下证据齐全时才视为完成:
|
||||||
|
|
||||||
|
- 文档与代码映射完整。
|
||||||
|
- Candidate tests 对精确 commit 执行通过。
|
||||||
|
- OpenClaw/Hermes 实际 Runtime 联调通过。
|
||||||
|
- 多租户负向测试通过。
|
||||||
|
- 重启恢复和回滚演练通过。
|
||||||
|
- GitOps 本地/Gitea OID 一致。
|
||||||
|
- Owner 批准 merge、Tag 和生产部署。
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# AIOS V2 迁移与交付策略
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 交付原则:增量迁移、能力复用、控制权收敛、GitOps 唯一变更入口
|
||||||
|
|
||||||
|
## 1. 迁移目标
|
||||||
|
|
||||||
|
AIOS V2 不通过一次性重写替换现有系统,而是在保留 OpenClaw、Hermes、Agent Router、GitOps 和既有运行能力的前提下,将租户、策略、任务、审批、审计和发布治理收敛到 AIOS Control Plane。
|
||||||
|
|
||||||
|
迁移完成的标志不是“新代码存在”,而是:
|
||||||
|
|
||||||
|
- AIOS 成为唯一权威控制面;
|
||||||
|
- OpenClaw/Hermes 不再保存第二套控制状态;
|
||||||
|
- 所有控制功能 Web/API 化;
|
||||||
|
- 所有模块通过租户隔离、测试和预发布门禁;
|
||||||
|
- 本地 Git、Gitea、测试、部署和回滚证据可追踪。
|
||||||
|
|
||||||
|
## 2. 为什么采用增量迁移
|
||||||
|
|
||||||
|
### 2.1 避免能力回退
|
||||||
|
|
||||||
|
OpenClaw、Hermes 和 Agent Router 已经包含大量运行经验、边界处理和执行能力。整体重写会扩大风险面,并导致业务能力、操作流程和历史数据回退。
|
||||||
|
|
||||||
|
### 2.2 保持可回滚
|
||||||
|
|
||||||
|
每个迁移步骤必须:
|
||||||
|
|
||||||
|
- 独立分支;
|
||||||
|
- 小范围提交;
|
||||||
|
- 独立测试;
|
||||||
|
- 可单独回滚;
|
||||||
|
- 不要求立即关闭旧执行路径。
|
||||||
|
|
||||||
|
### 2.3 先收敛控制权,再优化内部实现
|
||||||
|
|
||||||
|
迁移优先级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
唯一入口
|
||||||
|
-> 权威租户/任务/策略
|
||||||
|
-> Runtime Adapter
|
||||||
|
-> 数据层生产化
|
||||||
|
-> Web 与可观测
|
||||||
|
-> 性能与 HA
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 分阶段迁移路线
|
||||||
|
|
||||||
|
### Phase 0:GitOps 与架构事实源
|
||||||
|
|
||||||
|
目标:建立可靠变更链和权威设计基线。
|
||||||
|
|
||||||
|
已完成候选能力:
|
||||||
|
|
||||||
|
- Controller detached guard;
|
||||||
|
- 避免 fetch 到已检出本地分支;
|
||||||
|
- Gitea SSH 镜像;
|
||||||
|
- `docs/aios-v2/` 架构事实源;
|
||||||
|
- 候选分支、部署和回滚资产。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- Controller detached 且 clean;
|
||||||
|
- 本地/Gitea OID 一致;
|
||||||
|
- 架构与需求文档纳入版本。
|
||||||
|
|
||||||
|
### Phase 1:Control Plane 权威化
|
||||||
|
|
||||||
|
目标:将 Tenant、Agent、Task、Policy、Approval 和 Audit 收敛到 AIOS。
|
||||||
|
|
||||||
|
候选实现:
|
||||||
|
|
||||||
|
- `aios_v2/control_plane.py`
|
||||||
|
- `aios_v2/persistence.py`
|
||||||
|
- `aios_v2/gateway.py`
|
||||||
|
- `aios_v2/app.py`
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 任务状态机统一;
|
||||||
|
- 幂等、revision、审计和跨租户拒绝通过测试;
|
||||||
|
- 服务重启后权威状态恢复。
|
||||||
|
|
||||||
|
### Phase 2:OpenClaw 解耦
|
||||||
|
|
||||||
|
目标:OpenClaw 从控制逻辑转为执行适配器。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
|
||||||
|
1. 定义签名命令合同;
|
||||||
|
2. 引入 Adapter;
|
||||||
|
3. 将直接调用迁移到 AIOS Orchestrator;
|
||||||
|
4. Runtime 只返回执行结果和健康状态;
|
||||||
|
5. 关闭或网络隔离旧公共控制入口。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 未批准任务无法生成有效命令;
|
||||||
|
- 命令绑定 tenant/task/trace/idempotency;
|
||||||
|
- 旧控制接口冻结并记录替代路径。
|
||||||
|
|
||||||
|
### Phase 3:Hermes 解耦
|
||||||
|
|
||||||
|
目标:Hermes 只承担租户分区队列和 Worker Runtime。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
|
||||||
|
1. 定义 HermesJob/HermesResult;
|
||||||
|
2. 强制 tenant/cell/profile 分区;
|
||||||
|
3. 引入 claim/lease/heartbeat;
|
||||||
|
4. 将结果统一回写 AIOS;
|
||||||
|
5. 移除 Hermes 内部策略和任务事实源。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- Worker 无权创建 AIOS Task;
|
||||||
|
- 租约过期后 Job 可恢复;
|
||||||
|
- 跨租户 claim/read/result 被拒绝。
|
||||||
|
|
||||||
|
### Phase 4:Web 与运维统一
|
||||||
|
|
||||||
|
目标:所有用户与运维功能通过 AIOS Web/API。
|
||||||
|
|
||||||
|
范围:
|
||||||
|
|
||||||
|
- Tenant、Agent、Task、Runtime、Audit、Execution Evidence、GitOps;
|
||||||
|
- systemd/Docker/Compose;
|
||||||
|
- healthcheck、rollback、release report。
|
||||||
|
|
||||||
|
退出条件:
|
||||||
|
|
||||||
|
- 浏览器不直接调用 Runtime;
|
||||||
|
- 生产身份代理和权限模型通过验证;
|
||||||
|
- 部署、重启和回滚演练通过。
|
||||||
|
|
||||||
|
### Phase 5:生产数据层与 HA
|
||||||
|
|
||||||
|
目标:将候选 SQLite/内存运行状态替换或强化为生产级后端。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 数据迁移;
|
||||||
|
- RLS 或物理隔离;
|
||||||
|
- 备份恢复;
|
||||||
|
- HA 与容量;
|
||||||
|
- 多实例并发;
|
||||||
|
- 灾难恢复目标。
|
||||||
|
|
||||||
|
该阶段不得改变上层合同,避免 Runtime 和 Web 再次耦合存储实现。
|
||||||
|
|
||||||
|
## 4. 分支与提交策略
|
||||||
|
|
||||||
|
### 4.1 分支类型
|
||||||
|
|
||||||
|
| 类型 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `task/*` | 可执行任务合同与主体实现 |
|
||||||
|
| `patch/*` | 独立修复或窄范围增强 |
|
||||||
|
| `integration/*` | 多模块候选收敛 |
|
||||||
|
| `result/*` | 自动执行结果与证据 |
|
||||||
|
| `release/*` | 受控发布候选 |
|
||||||
|
|
||||||
|
### 4.2 规则
|
||||||
|
|
||||||
|
- 禁止直接修改 `main`;
|
||||||
|
- 一个提交应可独立审阅和回滚;
|
||||||
|
- 大文件和大范围变更应拆分;
|
||||||
|
- 所有行为变化必须同步文档和测试;
|
||||||
|
- 生产 Tag、部署和回滚需要 Owner 批准。
|
||||||
|
|
||||||
|
## 5. GitOps 交付流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
需求/架构更新
|
||||||
|
|
|
||||||
|
v
|
||||||
|
任务分支 + 文档 + 测试
|
||||||
|
|
|
||||||
|
v
|
||||||
|
代码提交 + Secret Gate
|
||||||
|
|
|
||||||
|
v
|
||||||
|
本地 bare repo 同步
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Gitea 镜像与 OID 校验
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Candidate 集成
|
||||||
|
|
|
||||||
|
v
|
||||||
|
自动测试与预发布
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Owner 审批
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Merge / Tag / Production Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 同步状态
|
||||||
|
|
||||||
|
GitOps 返回必须区分:
|
||||||
|
|
||||||
|
- local sync;
|
||||||
|
- Gitea sync;
|
||||||
|
- branch OID;
|
||||||
|
- workspace mode;
|
||||||
|
- dirty state。
|
||||||
|
|
||||||
|
仅返回 `common_git_dir synced` 不足以证明 Web 远端可见。
|
||||||
|
|
||||||
|
## 6. 发布门禁
|
||||||
|
|
||||||
|
### Gate A:设计完整性
|
||||||
|
|
||||||
|
- 用户需求、目标架构、模块契约、ADR 和追踪矩阵同步;
|
||||||
|
- 无冲突标记、断链或过期路径。
|
||||||
|
|
||||||
|
### Gate B:代码质量
|
||||||
|
|
||||||
|
- 包可导入;
|
||||||
|
- 静态编译通过;
|
||||||
|
- Secret Scanner 通过;
|
||||||
|
- 无已知越权旁路。
|
||||||
|
|
||||||
|
### Gate C:测试
|
||||||
|
|
||||||
|
- 单元、集成、多租户负向、重启恢复、Runtime 边界测试通过;
|
||||||
|
- 记录 commit、命令、exit code、pass/fail 和 artifact digest。
|
||||||
|
|
||||||
|
### Gate D:预发布
|
||||||
|
|
||||||
|
- systemd 或 Compose 启动成功;
|
||||||
|
- 健康检查通过;
|
||||||
|
- OpenClaw/Hermes 真实联调通过;
|
||||||
|
- 备份、重启和回滚演练通过;
|
||||||
|
- 日志中无敏感信息。
|
||||||
|
|
||||||
|
### Gate E:生产批准
|
||||||
|
|
||||||
|
- Owner 批准 merge、Tag 和生产部署;
|
||||||
|
- 发布窗口、回滚负责人和观察指标明确;
|
||||||
|
- 生产变更通过 GitOps 记录。
|
||||||
|
|
||||||
|
## 7. 回滚策略
|
||||||
|
|
||||||
|
### 7.1 代码回滚
|
||||||
|
|
||||||
|
- 使用 `/opt/aios/current` 原子 symlink 切换;
|
||||||
|
- 回滚前备份状态;
|
||||||
|
- 健康检查失败自动恢复 previous release。
|
||||||
|
|
||||||
|
### 7.2 状态回滚
|
||||||
|
|
||||||
|
默认不自动回滚状态,避免覆盖候选运行期间新增的 Task、Audit 和 Evidence。
|
||||||
|
|
||||||
|
状态恢复只有在:
|
||||||
|
|
||||||
|
- 明确确认 schema 不兼容;
|
||||||
|
- 数据备份可验证;
|
||||||
|
- Owner 显式授权;
|
||||||
|
|
||||||
|
时启用。
|
||||||
|
|
||||||
|
### 7.3 Runtime 回滚
|
||||||
|
|
||||||
|
OpenClaw/Hermes Adapter 采用稳定合同。若候选执行层失败,可恢复旧 Runtime 版本,但不得恢复旧控制权旁路。
|
||||||
|
|
||||||
|
## 8. Bug 修复流程
|
||||||
|
|
||||||
|
生产或预发布 Bug:
|
||||||
|
|
||||||
|
1. 创建 `patch/bugfix/*`;
|
||||||
|
2. 添加可复现测试;
|
||||||
|
3. 修复代码;
|
||||||
|
4. 更新相关需求/架构/契约/ADR;
|
||||||
|
5. 运行候选验收;
|
||||||
|
6. 进入新的 release candidate;
|
||||||
|
7. Owner 批准部署。
|
||||||
|
|
||||||
|
禁止在生产目录直接编辑文件后不回写 Git。
|
||||||
|
|
||||||
|
## 9. 文档同步规则
|
||||||
|
|
||||||
|
以下变化必须更新文档:
|
||||||
|
|
||||||
|
- 用户行为或页面;
|
||||||
|
- API 路径、Header、请求/响应;
|
||||||
|
- 数据模型、状态机、策略;
|
||||||
|
- OpenClaw/Hermes/Router 边界;
|
||||||
|
- 多租户与安全模型;
|
||||||
|
- 部署、健康检查、回滚;
|
||||||
|
- 测试或发布门禁。
|
||||||
|
|
||||||
|
对应关系:
|
||||||
|
|
||||||
|
- 需求变化:更新 `01-USER-REQUIREMENTS.md`;
|
||||||
|
- 架构变化:更新 `02-TARGET-ARCHITECTURE.md` 和 ADR;
|
||||||
|
- 迁移/发布变化:更新本文件;
|
||||||
|
- 合同变化:更新 `04-MODULE-CONTRACTS.md`;
|
||||||
|
- 代码/测试路径变化:更新 `05-TRACEABILITY-MATRIX.md`。
|
||||||
|
|
||||||
|
## 10. 当前候选后续步骤
|
||||||
|
|
||||||
|
1. 完成文档同步门禁和 Acceptance Runner;
|
||||||
|
2. 在精确 Candidate commit 执行测试;
|
||||||
|
3. 推送 Gitea 并校验 OID;
|
||||||
|
4. 部署隔离预发布环境;
|
||||||
|
5. 联调真实 OpenClaw/Hermes;
|
||||||
|
6. 执行重启、性能、安全和回滚演练;
|
||||||
|
7. 生成最终 evidence;
|
||||||
|
8. 请求 Owner 的 merge/Tag/production approval。
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
# AIOS V2 模块契约
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 状态:候选实现契约基线
|
||||||
|
- 原则:Control Plane 权威、Runtime 执行、全链路租户隔离、所有状态可审计
|
||||||
|
|
||||||
|
## 1. 通用上下文契约
|
||||||
|
|
||||||
|
所有控制与执行请求必须携带以下上下文:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `tenant_id` | 是 | 权威租户标识 |
|
||||||
|
| `principal_id` | 控制请求是 | 已认证主体 |
|
||||||
|
| `trace_id` | 是 | 全链路追踪 |
|
||||||
|
| `task_id` | 执行阶段是 | AIOS 权威任务 |
|
||||||
|
| `session_id` | Web/交互请求建议 | 用户会话 |
|
||||||
|
| `policy_version` | 控制请求是 | 策略版本 |
|
||||||
|
| `idempotency_key` | 创建/执行是 | 幂等键 |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 缺少必填字段时 fail closed。
|
||||||
|
- 客户端不得覆盖由可信代理或 AIOS 生成的 `tenant_id`。
|
||||||
|
- Runtime 返回的 tenant/task/trace 必须与请求完全匹配。
|
||||||
|
- 所有持久化键、缓存键和队列分区必须包含租户命名空间。
|
||||||
|
|
||||||
|
## 2. Tenant Contract
|
||||||
|
|
||||||
|
### 2.1 TenantRecord
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
display_name
|
||||||
|
status
|
||||||
|
metadata
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `tenant_id` 不可为空且不可变。
|
||||||
|
- `status` 至少支持 `active` 和受控停用状态。
|
||||||
|
- Tenant 只由 AIOS Control Plane 创建和维护。
|
||||||
|
- Runtime 不得创建租户。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_v2/control_plane.py`
|
||||||
|
|
||||||
|
## 3. Agent Contract
|
||||||
|
|
||||||
|
### 3.1 AgentRecord
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_id
|
||||||
|
tenant_id
|
||||||
|
display_name
|
||||||
|
runtime
|
||||||
|
capabilities
|
||||||
|
enabled
|
||||||
|
metadata
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Agent 属于唯一租户。
|
||||||
|
- `runtime` 由 Registry 决定,可取 `openclaw`、`hermes` 或后续注册 Runtime。
|
||||||
|
- `capabilities` 是允许调度的动作集合。
|
||||||
|
- `metadata` 可存 `cell_id`、`profile_id`、priority 等非敏感引用。
|
||||||
|
- 不得在 Agent metadata 中保存明文凭据。
|
||||||
|
|
||||||
|
## 4. Task Contract
|
||||||
|
|
||||||
|
### 4.1 TaskRecord
|
||||||
|
|
||||||
|
```text
|
||||||
|
task_id
|
||||||
|
tenant_id
|
||||||
|
agent_id
|
||||||
|
action
|
||||||
|
payload
|
||||||
|
state
|
||||||
|
revision
|
||||||
|
idempotency_key
|
||||||
|
request_digest
|
||||||
|
result
|
||||||
|
error
|
||||||
|
created_by
|
||||||
|
approved_by
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 状态机
|
||||||
|
|
||||||
|
```text
|
||||||
|
CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||||
|
| | |
|
||||||
|
+----------+----------+-> CANCELLED
|
||||||
|
+-> FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
允许转换:
|
||||||
|
|
||||||
|
| 当前 | 允许目标 |
|
||||||
|
|---|---|
|
||||||
|
| CREATED | APPROVED, CANCELLED |
|
||||||
|
| APPROVED | RUNNING, CANCELLED |
|
||||||
|
| RUNNING | COMPLETED, FAILED, CANCELLED |
|
||||||
|
| COMPLETED | 无 |
|
||||||
|
| FAILED | 无 |
|
||||||
|
| CANCELLED | 无 |
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 每次转换必须校验 `revision`。
|
||||||
|
- COMPLETED 必须带 `result`。
|
||||||
|
- FAILED 必须带 `error`。
|
||||||
|
- 每次转换生成 AuditEvent。
|
||||||
|
- Runtime 不得直接写 Task Store,只能通过 Orchestrator reconcile。
|
||||||
|
|
||||||
|
## 5. Policy Contract
|
||||||
|
|
||||||
|
### 5.1 PolicyDecision
|
||||||
|
|
||||||
|
```text
|
||||||
|
allowed
|
||||||
|
reason
|
||||||
|
policy_version
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Policy 默认拒绝。
|
||||||
|
- Runtime 不得覆盖 PolicyDecision。
|
||||||
|
- 拒绝必须审计。
|
||||||
|
- 高风险动作必须经过 Approval。
|
||||||
|
|
||||||
|
## 6. Audit Contract
|
||||||
|
|
||||||
|
### 6.1 AuditEvent
|
||||||
|
|
||||||
|
```text
|
||||||
|
event_id
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
trace_id
|
||||||
|
action
|
||||||
|
resource_type
|
||||||
|
resource_id
|
||||||
|
outcome
|
||||||
|
policy_version
|
||||||
|
details
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 按租户查询。
|
||||||
|
- `details` 不得包含明文凭据。
|
||||||
|
- OpenClaw/Hermes 生命周期事件必须映射到统一 AuditEvent。
|
||||||
|
- 生产版需增加不可变外部固化和完整性验证。
|
||||||
|
|
||||||
|
## 7. OpenClaw Contract
|
||||||
|
|
||||||
|
### 7.1 OpenClawCommand
|
||||||
|
|
||||||
|
```text
|
||||||
|
contract_version
|
||||||
|
command_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
action
|
||||||
|
payload
|
||||||
|
idempotency_key
|
||||||
|
created_at
|
||||||
|
expires_at
|
||||||
|
key_id
|
||||||
|
signature
|
||||||
|
```
|
||||||
|
|
||||||
|
签名覆盖:
|
||||||
|
|
||||||
|
- command_id
|
||||||
|
- tenant_id
|
||||||
|
- task_id
|
||||||
|
- trace_id
|
||||||
|
- action
|
||||||
|
- payload
|
||||||
|
- idempotency_key
|
||||||
|
- created_at / expires_at
|
||||||
|
- contract_version / key_id
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 使用规范化 JSON 和 HMAC-SHA256。
|
||||||
|
- 只接受已批准任务生成的命令。
|
||||||
|
- 过期、未来时间超差、签名错误命令必须拒绝。
|
||||||
|
- `key_id` 只引用运行时材料,不包含材料本身。
|
||||||
|
|
||||||
|
### 7.2 OpenClawResult
|
||||||
|
|
||||||
|
```text
|
||||||
|
command_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
state
|
||||||
|
output
|
||||||
|
error
|
||||||
|
started_at
|
||||||
|
finished_at
|
||||||
|
runtime_id
|
||||||
|
attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ACCEPTED
|
||||||
|
RUNNING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
CANCELLED
|
||||||
|
TIMED_OUT
|
||||||
|
REJECTED
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Adapter 对外只接受终态结果用于 reconcile。
|
||||||
|
- 身份字段不匹配时拒绝回写。
|
||||||
|
- 结果通过 Orchestrator 更新 AIOS Task。
|
||||||
|
|
||||||
|
### 7.3 RuntimeHealth
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime
|
||||||
|
runtime_id
|
||||||
|
status
|
||||||
|
version
|
||||||
|
heartbeat_at
|
||||||
|
active_tasks
|
||||||
|
workspace_isolated
|
||||||
|
credential_ref_configured
|
||||||
|
details
|
||||||
|
```
|
||||||
|
|
||||||
|
健康策略:
|
||||||
|
|
||||||
|
- heartbeat 不得超时。
|
||||||
|
- workspace isolation 与 credential reference 必须符合策略。
|
||||||
|
- 不安全 Runtime 应 quarantine,不得继续接收新任务。
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_openclaw/`
|
||||||
|
|
||||||
|
## 8. Hermes Contract
|
||||||
|
|
||||||
|
### 8.1 HermesJob
|
||||||
|
|
||||||
|
```text
|
||||||
|
job_id
|
||||||
|
tenant_id
|
||||||
|
cell_id
|
||||||
|
profile_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
action
|
||||||
|
payload
|
||||||
|
idempotency_key
|
||||||
|
priority
|
||||||
|
state
|
||||||
|
revision
|
||||||
|
assigned_worker_id
|
||||||
|
lease_expires_at
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
状态机:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QUEUED -> CLAIMED -> RUNNING -> COMPLETED
|
||||||
|
| | |
|
||||||
|
+---------+----------+-> CANCELLED
|
||||||
|
+-> FAILED
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `tenant_id + cell_id + profile_id` 形成执行分区。
|
||||||
|
- claim 只能领取同分区 Job。
|
||||||
|
- Worker ID 不得跨分区复用。
|
||||||
|
- Lease 到期 Job 回到 QUEUED。
|
||||||
|
- revision 冲突不得覆盖。
|
||||||
|
|
||||||
|
### 8.2 HermesResult
|
||||||
|
|
||||||
|
```text
|
||||||
|
job_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
trace_id
|
||||||
|
state
|
||||||
|
worker_id
|
||||||
|
output
|
||||||
|
error
|
||||||
|
started_at
|
||||||
|
finished_at
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- COMPLETED 必须有 output。
|
||||||
|
- FAILED 必须有 error。
|
||||||
|
- 只允许 AIOS Orchestrator reconcile。
|
||||||
|
|
||||||
|
### 8.3 HermesHealth
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime_id
|
||||||
|
status
|
||||||
|
version
|
||||||
|
heartbeat_at
|
||||||
|
active_workers
|
||||||
|
active_jobs
|
||||||
|
queued_jobs
|
||||||
|
tenant_partitions
|
||||||
|
profile_isolation_enforced
|
||||||
|
details
|
||||||
|
```
|
||||||
|
|
||||||
|
实现:`active-runtime/app/aios_hermes/`
|
||||||
|
|
||||||
|
## 9. Execution Evidence Contract
|
||||||
|
|
||||||
|
### 9.1 ExecutionEvidence
|
||||||
|
|
||||||
|
```text
|
||||||
|
execution_id
|
||||||
|
tenant_id
|
||||||
|
task_id
|
||||||
|
agent_id
|
||||||
|
executor
|
||||||
|
branch
|
||||||
|
status
|
||||||
|
progress_completed
|
||||||
|
progress_total
|
||||||
|
current_step
|
||||||
|
last_commit
|
||||||
|
test_status
|
||||||
|
test_summary
|
||||||
|
result_summary
|
||||||
|
claimed_at
|
||||||
|
heartbeat_at
|
||||||
|
completed_at
|
||||||
|
```
|
||||||
|
|
||||||
|
状态建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
claimed
|
||||||
|
running
|
||||||
|
blocked
|
||||||
|
complete
|
||||||
|
failed
|
||||||
|
cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 按租户存储和查询。
|
||||||
|
- 每个阻塞必须记录原因、责任方和解除条件。
|
||||||
|
- 每次结果必须记录 branch、commit、测试命令和状态。
|
||||||
|
- 不得包含 Secret 或完整敏感 payload。
|
||||||
|
|
||||||
|
## 10. GitOps Status Contract
|
||||||
|
|
||||||
|
```text
|
||||||
|
available
|
||||||
|
service_status
|
||||||
|
workspace_mode
|
||||||
|
workspace_branch
|
||||||
|
workspace_ref
|
||||||
|
workspace_dirty
|
||||||
|
candidate_branch
|
||||||
|
release_channel
|
||||||
|
release_version
|
||||||
|
sync_status
|
||||||
|
details
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- Web 响应不得包含 remote URL、凭据或 stderr。
|
||||||
|
- Controller 必须 `detached + clean`。
|
||||||
|
- `sync_status` 必须区分 local 和 Gitea 镜像状态。
|
||||||
|
|
||||||
|
## 11. Web/API Contract
|
||||||
|
|
||||||
|
### 11.1 身份头
|
||||||
|
|
||||||
|
可信代理注入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-AIOS-Authenticated
|
||||||
|
X-AIOS-Tenant-ID
|
||||||
|
X-AIOS-Principal-ID
|
||||||
|
X-AIOS-Roles
|
||||||
|
X-AIOS-Trace-ID
|
||||||
|
X-AIOS-Session-ID
|
||||||
|
X-AIOS-Policy-Version
|
||||||
|
```
|
||||||
|
|
||||||
|
生产约束:
|
||||||
|
|
||||||
|
- 代理必须删除客户端同名头后重新注入。
|
||||||
|
- 应用不得直接暴露公网端口。
|
||||||
|
|
||||||
|
### 11.2 核心 API
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v2/health
|
||||||
|
POST /api/v2/tenants
|
||||||
|
GET /api/v2/tenants/current
|
||||||
|
POST /api/v2/agents
|
||||||
|
GET /api/v2/agents
|
||||||
|
POST /api/v2/tasks
|
||||||
|
GET /api/v2/tasks
|
||||||
|
GET /api/v2/tasks/{id}
|
||||||
|
POST /api/v2/tasks/{id}/approve
|
||||||
|
POST /api/v2/tasks/{id}/start
|
||||||
|
POST /api/v2/tasks/{id}/complete
|
||||||
|
POST /api/v2/tasks/{id}/fail
|
||||||
|
POST /api/v2/tasks/{id}/cancel
|
||||||
|
POST /api/v2/tasks/{id}/dispatch
|
||||||
|
GET /api/v2/audit
|
||||||
|
GET /api/v2/runtime
|
||||||
|
GET /api/v2/executions
|
||||||
|
GET /api/v2/gitops
|
||||||
|
POST /api/v2/hermes/results/reconcile
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Persistence Contract
|
||||||
|
|
||||||
|
候选版:
|
||||||
|
|
||||||
|
- SQLite WAL
|
||||||
|
- `BEGIN IMMEDIATE`
|
||||||
|
- 原子 snapshot
|
||||||
|
- schema version
|
||||||
|
- restart restore
|
||||||
|
|
||||||
|
持久化范围:
|
||||||
|
|
||||||
|
- tenants
|
||||||
|
- agents
|
||||||
|
- tasks
|
||||||
|
- audit
|
||||||
|
- idempotency
|
||||||
|
|
||||||
|
生产替换要求:
|
||||||
|
|
||||||
|
- 不改变 Domain/API Contract。
|
||||||
|
- 提供 migration、backup、restore、HA、RLS/隔离和容量测试。
|
||||||
|
|
||||||
|
## 13. Deployment Contract
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
- 非 root `aios` 用户。
|
||||||
|
- 状态目录 `/var/lib/aios-v2`。
|
||||||
|
- 最小权限和文件系统保护。
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
- 非 root UID/GID。
|
||||||
|
- read-only root filesystem。
|
||||||
|
- drop all capabilities。
|
||||||
|
- 独立状态 volume。
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
- 回滚前备份状态。
|
||||||
|
- 默认只回滚代码。
|
||||||
|
- 状态恢复需显式授权。
|
||||||
|
- 回滚后必须执行 healthcheck。
|
||||||
|
|
||||||
|
## 14. 契约变更规则
|
||||||
|
|
||||||
|
任何以下变化必须在同一提交或同一分支更新本文档:
|
||||||
|
|
||||||
|
- 字段、状态、Header、API、错误语义;
|
||||||
|
- 租户或安全约束;
|
||||||
|
- OpenClaw/Hermes 边界;
|
||||||
|
- Persistence 或 Deployment Contract;
|
||||||
|
- Evidence/Audit 格式。
|
||||||
|
|
||||||
|
不兼容变化必须新增 ADR 和版本号,不得静默修改。
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# AIOS V2 需求—架构—代码—测试—部署追踪矩阵
|
||||||
|
|
||||||
|
- 文档版本:`V2R1C0-candidate`
|
||||||
|
- 目的:确保每项用户需求都能追踪到架构设计、代码实现、自动测试、部署资产和验收命令
|
||||||
|
|
||||||
|
## 1. 追踪规则
|
||||||
|
|
||||||
|
每条需求至少具备:
|
||||||
|
|
||||||
|
1. 需求编号;
|
||||||
|
2. 架构章节;
|
||||||
|
3. 实现路径;
|
||||||
|
4. 测试路径;
|
||||||
|
5. 部署/运维资产;
|
||||||
|
6. 验收状态。
|
||||||
|
|
||||||
|
状态定义:
|
||||||
|
|
||||||
|
- `Implemented`:代码已进入 Candidate 分支;
|
||||||
|
- `Test-defined`:测试代码已进入 Candidate 分支;
|
||||||
|
- `Test-verified`:精确 commit 的测试已运行并保存证据;
|
||||||
|
- `Pre-production verified`:预发布部署、健康检查和回滚演练已通过;
|
||||||
|
- `Production released`:Owner 批准并完成 merge/tag/deploy。
|
||||||
|
|
||||||
|
## 2. 核心追踪矩阵
|
||||||
|
|
||||||
|
| 需求 | 设计依据 | 代码实现 | 测试 | 部署/运维 | 当前状态 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| UR-001 AIOS 唯一控制面 | `02-TARGET-ARCHITECTURE.md` §3.2/§3.3;ADR-001 | `aios_v2/control_plane.py`、`aios_v2/orchestrator.py`、`aios_v2/app.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/systemd/aios-v2.service` | Implemented / Test-defined |
|
||||||
|
| UR-002 复用现有能力 | `02-TARGET-ARCHITECTURE.md` §2;`03-MIGRATION-AND-DELIVERY.md` §2 | `aios_openclaw/`、`aios_hermes/` | OpenClaw/Hermes flow tests | Candidate branch + adapter boundaries | Implemented / Test-defined |
|
||||||
|
| UR-003 OpenClaw/Hermes 解耦 | `02-TARGET-ARCHITECTURE.md` §3.4/§3.5;ADR-002 | `aios_openclaw/adapter.py`、`aios_hermes/adapter.py` | runtime boundary tests in candidate flow suite | systemd/Docker runtime configuration | Implemented / Test-defined |
|
||||||
|
| UR-004 全面 Web 化 | `02-TARGET-ARCHITECTURE.md` §3.1/§8 | `aios_v2/console.py`、`aios_v2/gateway.py`、`aios_v2/app.py` | API/console smoke test pending | Docker/systemd + reverse proxy requirement | Implemented / Test pending |
|
||||||
|
| UR-005 多租户隔离 | `02-TARGET-ARCHITECTURE.md` §4;ADR-004 | `control_plane.py`、`orchestrator.py`、`aios_openclaw/*`、`aios_hermes/*`、`execution_tracking.py` | `test_cross_tenant_task_read_is_denied` | tenant-scoped state/evidence directories | Implemented / Test-defined |
|
||||||
|
| UR-006 权威任务生命周期 | `04-MODULE-CONTRACTS.md` §4 | `control_plane.py` | OpenClaw/Hermes full-flow tests | persisted state + audit | Implemented / Test-defined |
|
||||||
|
| UR-007 策略与审批 | `04-MODULE-CONTRACTS.md` §5 | `control_plane.py`、`gateway.py` | role/policy negative tests需补充 | reverse proxy identity + audit | Implemented / Partial test |
|
||||||
|
| UR-008 幂等与并发安全 | `02-TARGET-ARCHITECTURE.md` §7 | `control_plane.py`、`aios_openclaw/adapter.py`、`aios_hermes/adapter.py`、`worker.py` | flow + restart tests;并发专项测试需补充 | GitOps detached guard + SQLite WAL | Implemented / Partial test |
|
||||||
|
| UR-009 执行证据 | `04-MODULE-CONTRACTS.md` §9 | `execution_tracking.py` | `test_execution_tracking_persists_claim_heartbeat_and_result` | `/var/lib/aios-v2/executions` | Implemented / Test-defined |
|
||||||
|
| UR-010 GitOps 唯一变更流程 | `03-MIGRATION-AND-DELIVERY.md` §4/§5;ADR-003 | GitOps Runtime、`gitops_status.py` | healthcheck 的 detached/clean 检查 | `deploy/aios-v2/healthcheck/check.sh` | Implemented / Runtime verified partially |
|
||||||
|
| UR-011 持久化与恢复 | `02-TARGET-ARCHITECTURE.md` §3.6 | `persistence.py` | `test_durable_service_recovers_state_after_restart` | state dir + backup/rollback | Implemented / Test-defined |
|
||||||
|
| UR-012 审计与合规 | `04-MODULE-CONTRACTS.md` §6 | `control_plane.py`、`aios_openclaw/callback.py`、`app.py` | flow test + audit assertions需补充 | Console audit view | Implemented / Partial test |
|
||||||
|
| UR-013 密钥安全 | `02-TARGET-ARCHITECTURE.md` §6;ADR-006 | `FileSigningMaterialResolver`、credential refs、Secret Gate | permission/secret-scan test需补充 | env template + external protected file | Implemented / Partial test |
|
||||||
|
| UR-014 部署与回滚 | `03-MIGRATION-AND-DELIVERY.md` §7 | deployment scripts/assets | acceptance runner + healthcheck | `deploy/aios-v2/` | Implemented / Pre-prod pending |
|
||||||
|
| UR-015 文档与代码同步 | `03-MIGRATION-AND-DELIVERY.md` §9;ADR-005 | `tools/aios_v2_docs_code_check.py` | checker self-test / acceptance runner | CI/Gitea Actions 可调用 | Implemented after checker commit |
|
||||||
|
|
||||||
|
## 3. API 追踪
|
||||||
|
|
||||||
|
| API | 需求 | 实现 | 测试状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET /api/v2/health` | UR-004, UR-014 | `gateway.py` | healthcheck 定义完成 |
|
||||||
|
| `POST /api/v2/tenants` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | 需增加 HTTP 集成测试 |
|
||||||
|
| `POST /api/v2/agents` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||||
|
| `POST /api/v2/tasks` | UR-006, UR-008 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||||
|
| `POST /api/v2/tasks/{id}/approve` | UR-006, UR-007 | `gateway.py` | Domain flow 已覆盖 |
|
||||||
|
| `POST /api/v2/tasks/{id}/dispatch` | UR-001, UR-003 | `app.py` / `orchestrator.py` | OpenClaw/Hermes flow 已覆盖 |
|
||||||
|
| `GET /api/v2/runtime` | UR-004, UR-009 | `app.py` | HTTP smoke test待执行 |
|
||||||
|
| `GET /api/v2/executions` | UR-009 | `app.py` / `execution_tracking.py` | Evidence persistence test已定义 |
|
||||||
|
| `GET /api/v2/gitops` | UR-010 | `app.py` / `gitops_status.py` | Runtime/healthcheck验证待执行 |
|
||||||
|
| `POST /api/v2/hermes/results/reconcile` | UR-003, UR-006 | `app.py` / `orchestrator.py` | Hermes flow已覆盖 Domain 路径 |
|
||||||
|
|
||||||
|
## 4. 运行时契约追踪
|
||||||
|
|
||||||
|
| 契约 | 设计 | 代码 | 测试 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| OpenClawCommand | `04-MODULE-CONTRACTS.md` §7.1 | `aios_openclaw/contract.py` | OpenClaw full-flow;签名负向测试需补充 |
|
||||||
|
| OpenClawResult | §7.2 | `aios_openclaw/contract.py` | OpenClaw full-flow |
|
||||||
|
| RuntimeHealth | §7.3 | `aios_openclaw/contract.py`、`health.py` | health policy测试需补充 |
|
||||||
|
| HermesJob | §8.1 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||||
|
| HermesResult | §8.2 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||||
|
| HermesHealth | §8.3 | `aios_hermes/contract.py`、`adapter.py` | healthcheck/worker test待执行 |
|
||||||
|
| ExecutionEvidence | §9 | `execution_tracking.py` | evidence persistence test |
|
||||||
|
| GitOpsStatus | §10 | `gitops_status.py` | healthcheck detached/clean检查 |
|
||||||
|
|
||||||
|
## 5. 部署追踪
|
||||||
|
|
||||||
|
| 能力 | 文件 | 验收命令 |
|
||||||
|
|---|---|---|
|
||||||
|
| systemd | `deploy/aios-v2/systemd/aios-v2.service` | `systemctl status aios-v2` |
|
||||||
|
| 环境模板 | `deploy/aios-v2/env/aios-v2.env.example` | `systemd-analyze verify` + 文件权限检查 |
|
||||||
|
| Docker | `deploy/aios-v2/docker/Dockerfile` | `docker build` |
|
||||||
|
| Compose | `deploy/aios-v2/docker/compose.yaml` | `docker compose config` / `up` |
|
||||||
|
| 健康检查 | `deploy/aios-v2/healthcheck/check.sh` | 直接执行 |
|
||||||
|
| 回滚 | `deploy/aios-v2/rollback/rollback.sh` | 预发布回滚演练 |
|
||||||
|
| 全量验收 | `deploy/aios-v2/acceptance/run-all.sh` | 直接执行 |
|
||||||
|
|
||||||
|
## 6. 文档覆盖追踪
|
||||||
|
|
||||||
|
| 文档 | 覆盖内容 | 代码/变更触发条件 |
|
||||||
|
|---|---|---|
|
||||||
|
| `README.md` | 事实源索引、状态语言、实现地图 | 新模块/目录/版本 |
|
||||||
|
| `01-USER-REQUIREMENTS.md` | 用户需求、NFR、验收场景 | 用户行为、功能范围、验收变化 |
|
||||||
|
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户 | 架构边界、数据流、运行时变化 |
|
||||||
|
| `03-MIGRATION-AND-DELIVERY.md` | 迁移、GitOps、Gate、回滚 | 发布流程、迁移阶段、门禁变化 |
|
||||||
|
| `04-MODULE-CONTRACTS.md` | 字段、API、状态机、错误语义 | Contract/API/模型变化 |
|
||||||
|
| `05-TRACEABILITY-MATRIX.md` | 需求到代码测试部署映射 | 代码、测试、部署路径变化 |
|
||||||
|
| `adr/*` | 关键不可逆或跨模块决策 | 新架构决策或旧决策替换 |
|
||||||
|
|
||||||
|
## 7. 当前未完成验证项
|
||||||
|
|
||||||
|
以下项目代码或测试定义已存在,但尚不能标记为 `Test-verified` 或 `Pre-production verified`:
|
||||||
|
|
||||||
|
1. 精确 Candidate commit 的测试执行结果;
|
||||||
|
2. HTTP API 与 Web Console 冒烟测试;
|
||||||
|
3. 真实 OpenClaw Runtime 签名、取消、超时和健康联调;
|
||||||
|
4. Hermes 长任务心跳、Worker 崩溃和 Lease 恢复演练;
|
||||||
|
5. 多实例 SQLite/并发容量边界;
|
||||||
|
6. systemd、Docker、Compose、restart 和 rollback 演练;
|
||||||
|
7. local/Gitea Candidate OID 一致证明;
|
||||||
|
8. 生产身份代理、防伪 Header、CSRF/CORS/Rate Limit 验证;
|
||||||
|
9. 文档—代码同步检查器在 CI/Acceptance 中执行通过。
|
||||||
|
|
||||||
|
## 8. 更新要求
|
||||||
|
|
||||||
|
任何代码、API、部署或测试路径变化,必须同步更新本矩阵。验收脚本会检查本文档引用的关键路径存在,并检查架构/功能代码变化是否同时包含 `docs/aios-v2/` 变化。
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# AIOS V2 Architecture and Delivery Index
|
||||||
|
|
||||||
|
- Version line: `V2R1C0-candidate`
|
||||||
|
- Candidate branch: `integration/aios-v2r1-cto-production-candidate-20260720`
|
||||||
|
- Architecture owner: AIOS V2 CTO
|
||||||
|
- Status: implementation-complete candidate; runtime and production verification pending
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This directory is the single source of truth for AIOS V2 requirements, target architecture, module boundaries, migration strategy, release gates and architectural decisions.
|
||||||
|
|
||||||
|
The design is based on the following non-negotiable principles:
|
||||||
|
|
||||||
|
1. Do not rewrite the existing platform.
|
||||||
|
2. Reuse OpenClaw, Hermes and Agent Router capabilities.
|
||||||
|
3. Decouple OpenClaw and Hermes from control-plane ownership.
|
||||||
|
4. AIOS is the only control plane.
|
||||||
|
5. All user-facing and operational capabilities are Web/API accessible.
|
||||||
|
6. Every module enforces multi-tenant isolation.
|
||||||
|
7. Every change flows through Git branches, commits, tests, review and controlled promotion.
|
||||||
|
|
||||||
|
## 2. Document map
|
||||||
|
|
||||||
|
| Document | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [01-USER-REQUIREMENTS.md](01-USER-REQUIREMENTS.md) | Product goals, user requirements, non-functional requirements and acceptance criteria |
|
||||||
|
| [02-TARGET-ARCHITECTURE.md](02-TARGET-ARCHITECTURE.md) | Target logical architecture, control boundaries, data flow, security and tenancy model |
|
||||||
|
| [03-MIGRATION-AND-DELIVERY.md](03-MIGRATION-AND-DELIVERY.md) | Incremental migration plan, reuse strategy, GitOps workflow and release gates |
|
||||||
|
| [04-MODULE-CONTRACTS.md](04-MODULE-CONTRACTS.md) | Tenant context, task lifecycle, OpenClaw, Hermes, audit and execution-evidence contracts |
|
||||||
|
| [05-TRACEABILITY-MATRIX.md](05-TRACEABILITY-MATRIX.md) | Requirement-to-design-to-code-to-test mapping |
|
||||||
|
| [adr/README.md](adr/README.md) | Architecture decision records |
|
||||||
|
|
||||||
|
## 3. Current implementation map
|
||||||
|
|
||||||
|
| Architecture capability | Candidate implementation |
|
||||||
|
|---|---|
|
||||||
|
| Tenant, agent, task, policy and audit control plane | `active-runtime/app/aios_v2/control_plane.py` |
|
||||||
|
| Durable control-plane state | `active-runtime/app/aios_v2/persistence.py` |
|
||||||
|
| Web/API gateway | `active-runtime/app/aios_v2/gateway.py` |
|
||||||
|
| Unified application entrypoint | `active-runtime/app/aios_v2/app.py` |
|
||||||
|
| Runtime dispatch and reconciliation | `active-runtime/app/aios_v2/orchestrator.py` |
|
||||||
|
| Agent execution evidence | `active-runtime/app/aios_v2/execution_tracking.py` |
|
||||||
|
| GitOps runtime visibility | `active-runtime/app/aios_v2/gitops_status.py` |
|
||||||
|
| Web Console | `active-runtime/app/aios_v2/console.py` |
|
||||||
|
| OpenClaw execution boundary | `active-runtime/app/aios_openclaw/` |
|
||||||
|
| Hermes runtime boundary | `active-runtime/app/aios_hermes/` |
|
||||||
|
| Candidate tests | `tests/aios_v2_candidate/` |
|
||||||
|
| Deployment, health and rollback | `deploy/aios-v2/` |
|
||||||
|
|
||||||
|
## 4. Status vocabulary
|
||||||
|
|
||||||
|
The project separates code availability from release readiness:
|
||||||
|
|
||||||
|
- **Designed**: documented boundary and acceptance criteria exist.
|
||||||
|
- **Implemented**: code is committed on the candidate branch.
|
||||||
|
- **Test-defined**: automated tests exist in Git.
|
||||||
|
- **Test-verified**: tests were executed for the exact commit and evidence was recorded.
|
||||||
|
- **Pre-production verified**: deployed in an isolated environment with health, restart and rollback evidence.
|
||||||
|
- **Production released**: owner-approved merge/tag/deployment completed.
|
||||||
|
|
||||||
|
A branch or file existing does not, by itself, mean a capability is production-ready.
|
||||||
|
|
||||||
|
## 5. Design summary
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser / Web Console / Public API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS Control Plane
|
||||||
|
Tenant | Agent | Task | Policy | Audit
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Router
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
OpenClaw Adapter Hermes Boundary
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
OpenClaw Runtime Hermes Workers
|
||||||
|
\
|
||||||
|
v
|
||||||
|
Result Reconciliation + Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
The control plane owns authoritative identity, policy, approval, task lifecycle and audit state. OpenClaw and Hermes receive only tenant-scoped, approved execution work and return runtime results.
|
||||||
|
|
||||||
|
## 6. Release rule
|
||||||
|
|
||||||
|
No candidate may be promoted until:
|
||||||
|
|
||||||
|
- requirement-to-code traceability is complete;
|
||||||
|
- multi-tenant negative tests pass;
|
||||||
|
- controller workspace is detached and clean;
|
||||||
|
- local Git and Gitea branch OIDs match;
|
||||||
|
- persistence restart recovery passes;
|
||||||
|
- healthcheck and rollback pass in pre-production;
|
||||||
|
- no plaintext secret enters Git;
|
||||||
|
- owner approval is recorded for merge, tag and production deployment.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# ADR-001: AIOS 作为唯一控制面
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-20
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
现有系统包含多个执行和调度能力,若每个 Runtime 保存自己的任务、租户和策略状态,会形成多个事实源。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
AIOS Control Plane 是唯一权威控制面,负责:
|
||||||
|
|
||||||
|
- Tenant
|
||||||
|
- Agent
|
||||||
|
- Task
|
||||||
|
- Policy
|
||||||
|
- Approval
|
||||||
|
- Audit
|
||||||
|
- Execution Evidence
|
||||||
|
|
||||||
|
OpenClaw、Hermes、Agent Router 只能执行或路由,不拥有第二套控制状态。
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Positive:
|
||||||
|
|
||||||
|
- 状态唯一;
|
||||||
|
- 审计完整;
|
||||||
|
- Web/API 统一。
|
||||||
|
|
||||||
|
Negative:
|
||||||
|
|
||||||
|
- Runtime 接入需要 Adapter。
|
||||||
|
- 迁移需要阶段性边界收敛。
|
||||||
|
|
||||||
|
## Compliance
|
||||||
|
|
||||||
|
任何新 Runtime 必须通过 AIOS Orchestrator 接入。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-002: OpenClaw/Hermes 与控制面解耦
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-20
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
OpenClaw 和 Hermes 已具备执行能力,但历史上可能承担部分控制职责。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
引入边界层:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AIOS Control Plane
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Adapter / Boundary
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenClaw:
|
||||||
|
|
||||||
|
- 执行签名命令;
|
||||||
|
- 返回结果;
|
||||||
|
- 提供健康状态。
|
||||||
|
|
||||||
|
Hermes:
|
||||||
|
|
||||||
|
- 队列;
|
||||||
|
- Worker;
|
||||||
|
- Lease;
|
||||||
|
- Heartbeat;
|
||||||
|
- 执行结果。
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Runtime 可独立演进。
|
||||||
|
- 控制逻辑不会分裂。
|
||||||
|
- 需要维护稳定 Contract。
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
先接 Adapter,再迁移旧调用入口,最后关闭控制旁路。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# ADR-003: 所有变更必须通过 GitOps
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-20
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
直接在生产目录、长期 worktree 或主分支上修改代码,会造成不可追踪、不可回滚和分支冲突。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
所有代码、配置、文档、测试、部署和回滚资产必须通过以下流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
non-main branch -> commit -> local sync -> Gitea sync -> test -> review -> controlled promotion
|
||||||
|
```
|
||||||
|
|
||||||
|
Controller worktree 必须长期保持 detached 且 clean。每个写任务使用独立工作区或受控写入流程;相同分支写入必须串行。
|
||||||
|
|
||||||
|
## Required evidence
|
||||||
|
|
||||||
|
每个任务必须记录:
|
||||||
|
|
||||||
|
- branch
|
||||||
|
- commit
|
||||||
|
- changed files
|
||||||
|
- test command and result
|
||||||
|
- blocker and resolution
|
||||||
|
- local/Gitea sync status
|
||||||
|
- rollback note
|
||||||
|
|
||||||
|
## Prohibited
|
||||||
|
|
||||||
|
- 直接 push `main`
|
||||||
|
- 未审批创建生产 Tag
|
||||||
|
- 在生产目录手工修改后不回写 Git
|
||||||
|
- fetch 到已被 worktree 检出的本地 branch ref
|
||||||
|
- 在 Git 中提交明文密钥
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
优点:可审计、可回滚、可并行治理。
|
||||||
|
|
||||||
|
成本:紧急修复也必须形成 patch branch 和证据。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# ADR-005: 文档与代码同步门禁
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-20
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
架构、功能和部署变化如果不同步文档,会导致版本无法审计,后续 Agent 无法判断真实设计。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
所有影响以下内容的变更必须同步 `docs/aios-v2/`:
|
||||||
|
|
||||||
|
- 用户行为;
|
||||||
|
- API;
|
||||||
|
- 数据模型;
|
||||||
|
- 状态机;
|
||||||
|
- Runtime Boundary;
|
||||||
|
- 安全策略;
|
||||||
|
- 部署流程;
|
||||||
|
- 测试门禁。
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
|
||||||
|
验收必须检查:
|
||||||
|
|
||||||
|
1. 文档引用代码路径存在;
|
||||||
|
2. API 文档有实现映射;
|
||||||
|
3. Contract 字段有模型映射;
|
||||||
|
4. Traceability Matrix 已更新;
|
||||||
|
5. ADR 与架构变化一致。
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 架构长期可维护;
|
||||||
|
- Agent 执行有明确上下文;
|
||||||
|
- Bug 修复有历史依据。
|
||||||
|
|
||||||
|
成本:
|
||||||
|
|
||||||
|
- 小功能也需要更新对应文档。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# AIOS V2 Architecture Decision Records
|
||||||
|
|
||||||
|
本目录记录影响多个模块、长期演进、兼容性、安全或交付流程的关键架构决策。
|
||||||
|
|
||||||
|
## ADR 列表
|
||||||
|
|
||||||
|
| ADR | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| [ADR-001](ADR-001-single-control-plane.md) | AIOS 作为唯一控制面 |
|
||||||
|
| [ADR-002](ADR-002-openclaw-hermes-decoupling.md) | OpenClaw/Hermes 与控制面解耦 |
|
||||||
|
| [ADR-003](ADR-003-gitops-only-change-flow.md) | 所有变更必须通过 GitOps |
|
||||||
|
| [ADR-004](ADR-004-multi-tenant-isolation.md) | 多租户隔离默认拒绝 |
|
||||||
|
| [ADR-005](ADR-005-document-code-sync-gate.md) | 文档与代码同步门禁 |
|
||||||
|
| [ADR-006](ADR-006-candidate-persistence-strategy.md) | 候选持久化采用可替换 SQLite Snapshot |
|
||||||
|
|
||||||
|
## ADR 状态
|
||||||
|
|
||||||
|
- `Proposed`:提出但未采纳。
|
||||||
|
- `Accepted`:当前有效决策。
|
||||||
|
- `Superseded`:被新 ADR 替代。
|
||||||
|
- `Deprecated`:保留历史但禁止新实现采用。
|
||||||
|
|
||||||
|
## 何时必须新增或更新 ADR
|
||||||
|
|
||||||
|
以下变化必须新增或更新 ADR:
|
||||||
|
|
||||||
|
1. 控制面与 Runtime 权限边界变化;
|
||||||
|
2. 多租户隔离模型变化;
|
||||||
|
3. API、数据模型或状态机出现不兼容变化;
|
||||||
|
4. 持久化、消息、密钥、审计或部署方案发生跨模块变化;
|
||||||
|
5. GitOps、发布、回滚或文档门禁变化;
|
||||||
|
6. 替换 OpenClaw、Hermes、Agent Router 或核心基础设施。
|
||||||
|
|
||||||
|
ADR 变化必须同步更新:
|
||||||
|
|
||||||
|
- `02-TARGET-ARCHITECTURE.md`
|
||||||
|
- `04-MODULE-CONTRACTS.md`
|
||||||
|
- `05-TRACEABILITY-MATRIX.md`
|
||||||
|
- 对应代码、测试和部署资产
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
# AIOS Silent Release Controller Design Report
|
|
||||||
|
|
||||||
状态:隔离分支实现;生产 mutation 禁止
|
|
||||||
目标:自动准备 → 自动验证 → 单次 Owner 签名授权 → 分控制器自动执行与验收
|
|
||||||
|
|
||||||
## 1. 当前痛点
|
|
||||||
|
|
||||||
现有流程需要多轮人工传递 commit、tree、artifact、manifest、测试、canary、drop-in
|
|
||||||
和回滚证据。证据在不同消息里重复抄写,容易出现:
|
|
||||||
|
|
||||||
- 构建后 artifact、manifest 或 policy 已变化,但旧批准仍被使用;
|
|
||||||
- Git、Gitea、systemd 与回滚结果由人工拼接,缺少同一一致性快照;
|
|
||||||
- runtime switch、tag、Baseline、Registry 的授权边界不清;
|
|
||||||
- 重启后只剩工作树内临时 JSON,无法可靠恢复;
|
|
||||||
- approval replay、并发 writer、key revoke 和 secret inheritance 缺少统一防线。
|
|
||||||
|
|
||||||
## 2. 新架构
|
|
||||||
|
|
||||||
```text
|
|
||||||
Build / Test / Canary
|
|
||||||
|
|
|
||||||
v
|
|
||||||
ReleaseCandidate + DeploymentPlan
|
|
||||||
|
|
|
||||||
v
|
|
||||||
ReleaseAcceptanceEngine
|
|
||||||
|
|
|
||||||
v
|
|
||||||
READY_FOR_APPROVAL
|
|
||||||
|
|
|
||||||
v
|
|
||||||
SignedReleasePlanApproval (Ed25519)
|
|
||||||
|
|
|
||||||
+--> runtime_switch capability
|
|
||||||
| DeploymentController
|
|
||||||
|
|
|
||||||
+--> tag_publish capability
|
|
||||||
| TagController
|
|
||||||
|
|
|
||||||
+--> baseline_registry_finalize capability
|
|
||||||
BaselineRegistryFinalizeController
|
|
||||||
|
|
||||||
Durable ReleaseSagaStore + CapabilityLedger + Event Journal
|
|
||||||
贯穿三个控制器;控制器互相不拥有对方的 mutation API。
|
|
||||||
```
|
|
||||||
|
|
||||||
一次 Owner 批准是同一个不可变 release plan 的签名,不是一个万能执行入口。三个 capability
|
|
||||||
分别消费、分别留痕、分别幂等恢复。`force_push`、`tag_rewrite`、`direct_main_push`
|
|
||||||
永远不是可签发 capability。
|
|
||||||
|
|
||||||
## 3. 状态机
|
|
||||||
|
|
||||||
```text
|
|
||||||
PREPARING
|
|
||||||
-> VERIFYING
|
|
||||||
-> READY_FOR_APPROVAL
|
|
||||||
-> APPROVED
|
|
||||||
-> PRE_SWITCH_REVALIDATING
|
|
||||||
-> SWITCH_PENDING
|
|
||||||
-> SWITCHED
|
|
||||||
-> RUNTIME_VERIFYING
|
|
||||||
-> TAG_PENDING
|
|
||||||
-> TAG_VERIFIED
|
|
||||||
-> BASELINE_REGISTRY_PENDING
|
|
||||||
-> COMPLETED
|
|
||||||
|
|
||||||
任一可恢复故障 -> RECONCILIATION_REQUIRED
|
|
||||||
对象冲突 -> MANUAL_OWNER_REVIEW_REQUIRED
|
|
||||||
切换后失败 -> ROLLBACK_PENDING -> ROLLED_BACK
|
|
||||||
```
|
|
||||||
|
|
||||||
状态快照使用 revision compare-and-set;事件同时写入 append-only journal。writer 崩溃后根据
|
|
||||||
状态确定唯一恢复动作。并发控制器中只有一个 CAS winner。
|
|
||||||
|
|
||||||
## 4. 数据模型
|
|
||||||
|
|
||||||
### ReleaseCandidate
|
|
||||||
|
|
||||||
```text
|
|
||||||
candidate_id
|
|
||||||
source_commit
|
|
||||||
tree_oid
|
|
||||||
artifact_sha256
|
|
||||||
manifest_sha256
|
|
||||||
tests
|
|
||||||
canary
|
|
||||||
rollback_ready
|
|
||||||
git_consistency
|
|
||||||
gitea_consistency
|
|
||||||
approval_state
|
|
||||||
```
|
|
||||||
|
|
||||||
### DeploymentPlan
|
|
||||||
|
|
||||||
```text
|
|
||||||
service
|
|
||||||
release_path / release_root
|
|
||||||
dropin_path / dropin_sha256
|
|
||||||
rollback_path / rollback_sha256
|
|
||||||
source_commit
|
|
||||||
artifact_sha256
|
|
||||||
manifest_sha256
|
|
||||||
policy_sha256
|
|
||||||
health_contract
|
|
||||||
sha256
|
|
||||||
```
|
|
||||||
|
|
||||||
### SignedReleasePlanApproval
|
|
||||||
|
|
||||||
```text
|
|
||||||
release_plan_id
|
|
||||||
candidate_id
|
|
||||||
source_commit
|
|
||||||
tree_oid
|
|
||||||
artifact_sha256
|
|
||||||
manifest_sha256
|
|
||||||
deployment_plan_sha256
|
|
||||||
policy_sha256
|
|
||||||
approval_plan_id
|
|
||||||
evidence_closure_oid
|
|
||||||
owner_approval_record_oid
|
|
||||||
capabilities
|
|
||||||
expires_at
|
|
||||||
nonce
|
|
||||||
key_id
|
|
||||||
signature
|
|
||||||
fingerprint
|
|
||||||
```
|
|
||||||
|
|
||||||
签名内容使用 canonical JSON,`signature` 不参与被签名字段。验证包括 Ed25519 签名、过期时间、
|
|
||||||
公钥状态、approval revoke、candidate/plan/policy 逐字段绑定和 capability allowlist。
|
|
||||||
|
|
||||||
## 5. API
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /createReleaseCandidate
|
|
||||||
GET /getReleaseCandidate?candidate_id=...
|
|
||||||
GET /getApprovalEnvelope?candidate_id=...
|
|
||||||
POST /submitOwnerApproval
|
|
||||||
POST /startDeployment
|
|
||||||
GET /getReleaseSaga?candidate_id=...
|
|
||||||
```
|
|
||||||
|
|
||||||
写接口要求 Bearer scope。`AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false` 是默认值;此时可以
|
|
||||||
持久化 candidate、生成 approval envelope、保存经验证的 Owner approval 和读取 Saga,
|
|
||||||
但 `/startDeployment` 只返回 `SHADOW_MODE` 与空 `side_effects`。
|
|
||||||
|
|
||||||
## 6. PreSwitchValidator
|
|
||||||
|
|
||||||
切换前重新收集并比较:
|
|
||||||
|
|
||||||
- source commit 与 tree;
|
|
||||||
- artifact、manifest、policy 和 deployment plan SHA-256;
|
|
||||||
- rollback readiness;
|
|
||||||
- local、authoritative bare 与 Gitea refs;
|
|
||||||
- repository identity;
|
|
||||||
- task writer/production writer 边界;
|
|
||||||
- health contract。
|
|
||||||
|
|
||||||
任一变化都抛出:
|
|
||||||
|
|
||||||
```text
|
|
||||||
APPROVAL_STALE_REAPPROVAL_REQUIRED
|
|
||||||
```
|
|
||||||
|
|
||||||
旧批准不会被“最佳努力”继续使用。
|
|
||||||
|
|
||||||
## 7. systemd 防线
|
|
||||||
|
|
||||||
- service 与 drop-in 均为显式 allowlist;
|
|
||||||
- release 与 drop-in resolved path 必须位于 release root 内;
|
|
||||||
- 拒绝普通文件被替换为 symlink,拒绝 symlink escape;
|
|
||||||
- deployment lock 覆盖 switch、restart 和首次 health check;
|
|
||||||
- restart 前后记录 MainPID 与 ActiveEnterTimestampMonotonic;
|
|
||||||
- artifact、drop-in、rollback 和 manifest 均按 plan hash 复验;
|
|
||||||
- 子进程只接收固定 allowlist 环境,不继承 token、password、credential、askpass、
|
|
||||||
auth socket、`LD_PRELOAD`、`PYTHONPATH` 或数据库连接串。
|
|
||||||
|
|
||||||
## 8. TagController 分离
|
|
||||||
|
|
||||||
TagController 必须独立完成 approval、execute、verify,并留下:
|
|
||||||
|
|
||||||
```text
|
|
||||||
evidence_closure_oid
|
|
||||||
approval_plan_id
|
|
||||||
repository_identity
|
|
||||||
remote_tag_oid
|
|
||||||
remote_peeled_target_oid
|
|
||||||
gitea_api_tag_oid
|
|
||||||
gitea_listing_visible
|
|
||||||
```
|
|
||||||
|
|
||||||
同名 tag 的 tag OID 或 peeled target 不同,进入
|
|
||||||
`MANUAL_OWNER_REVIEW_REQUIRED`;自动流程不 push、不删除、不重写、不 force push。
|
|
||||||
|
|
||||||
## 9. Baseline / Registry 门禁
|
|
||||||
|
|
||||||
只有以下全部一致时,`baseline_registry_finalize` capability 才能完成:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Registry = Baseline = remote tag = Gitea API = expected target
|
|
||||||
```
|
|
||||||
|
|
||||||
UNKNOWN、API 不可达、repository identity 不符或 listing 不可见均 fail closed。
|
|
||||||
|
|
||||||
## 10. Policy as Code
|
|
||||||
|
|
||||||
```text
|
|
||||||
automatic:
|
|
||||||
build, test, artifact, canary, evidence
|
|
||||||
|
|
||||||
owner:
|
|
||||||
signed_release_plan
|
|
||||||
|
|
||||||
capabilities:
|
|
||||||
runtime_switch
|
|
||||||
tag_publish
|
|
||||||
baseline_registry_finalize
|
|
||||||
|
|
||||||
forbidden:
|
|
||||||
force_push
|
|
||||||
tag_rewrite
|
|
||||||
direct_main_push
|
|
||||||
baseline_mutation
|
|
||||||
registry_mutation
|
|
||||||
```
|
|
||||||
|
|
||||||
最后两个 forbidden 项指“脱离受治理 finalize controller 的直接 mutation”。
|
|
||||||
|
|
||||||
## 11. Security model
|
|
||||||
|
|
||||||
- Ed25519 公钥 ring 支持 rotation 和 revoke,私钥不进入 runtime;
|
|
||||||
- approval plan 可撤销,过期、未知 key、错误签名均 fail closed;
|
|
||||||
- nonce 与 capability operation 持久化,重复相同请求幂等,不同 fingerprint 重放拒绝;
|
|
||||||
- Saga snapshot 原子写、journal append-only、CAS 防并发 writer;
|
|
||||||
- credential 只通过受控 reference 使用,不进入 Git、API 响应或异常;
|
|
||||||
- 子进程环境从空集合构建,不复制 `os.environ`;
|
|
||||||
- Shadow 模式默认开启,execution adapter 缺失时不能降级执行;
|
|
||||||
- runtime、tag、Baseline/Registry 分属三个能力最小化控制器。
|
|
||||||
|
|
||||||
## 12. Migration plan
|
|
||||||
|
|
||||||
1. Shadow:生成 candidate、plan、acceptance、approval package,仅比较现有人工结果。
|
|
||||||
2. Signed approval:接入 Owner 公钥 ring、撤销源和审计 record;仍不接生产 executor。
|
|
||||||
3. Canary execution:使用非生产 service/release root 验证锁、PID、health、崩溃恢复和回滚。
|
|
||||||
4. Controlled production switch:另行 Owner 授权启用 execution flag,只开放 runtime capability。
|
|
||||||
5. Tag/finalize:分别完成 Gitea 与 Registry/Baseline 生产验收后再启用对应 capability。
|
|
||||||
|
|
||||||
每个阶段均可退回上一个阶段;不开启下一能力不会影响已经启用的控制器。
|
|
||||||
|
|
||||||
## 13. Rollback plan
|
|
||||||
|
|
||||||
- switch 前保存旧 symlink target、PID、启动时间、artifact 与 plan fingerprint;
|
|
||||||
- restart 或 health 任一失败进入 `ROLLBACK_PENDING`;
|
|
||||||
- 原子恢复旧 target,只 restart 指定 allowlist service;
|
|
||||||
- 重新验证旧 runtime commit、writer=false 与 repository identity;
|
|
||||||
- 成功进入 `ROLLED_BACK`,失败进入 `MANUAL_OWNER_REVIEW_REQUIRED`;
|
|
||||||
- rollback 不修改 Git main、production tag、Baseline 或 Registry;
|
|
||||||
- 保留脱敏 journal、approval fingerprint 和失败证据。
|
|
||||||
|
|
||||||
## 14. 测试与外部依赖边界
|
|
||||||
|
|
||||||
自动测试覆盖签名伪造、字段篡改、过期/revoke/key rotation、nonce replay、CAS race、
|
|
||||||
崩溃恢复、artifact/policy/plan mutation、tag conflict、secret leakage、runtime without
|
|
||||||
`.git`、rollback 和 shadow no-side-effects。
|
|
||||||
|
|
||||||
本隔离实现不创建生产 KMS/key、不安装 systemd unit、不部署 Action runtime、不创建 tag,
|
|
||||||
也不修改 Baseline/Registry。上述动作必须在单独生产批准中执行。
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# AIOS Silent Release Execution Readiness
|
|
||||||
|
|
||||||
## Scope and stop point
|
|
||||||
|
|
||||||
This change makes the controller execution-ready while deploying only a
|
|
||||||
localhost-bound Shadow control plane. Both production gates remain false:
|
|
||||||
|
|
||||||
```text
|
|
||||||
AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Shadow completion is evidence preparation, not a production release. It cannot
|
|
||||||
write a production tag, switch a business runtime, or mutate Baseline/Registry.
|
|
||||||
|
|
||||||
## Recovery state machine
|
|
||||||
|
|
||||||
Every expired or `FAILED_RECOVERABLE` capability requires fresh, operation-bound
|
|
||||||
probe evidence before another adapter may run:
|
|
||||||
|
|
||||||
```text
|
|
||||||
NOT_APPLIED -> one new exclusive claim may retry
|
|
||||||
APPLIED_VERIFIED -> mark COMPLETED without adapter replay
|
|
||||||
INDETERMINATE -> RECONCILIATION_REQUIRED; no automatic writes
|
|
||||||
CONFLICT -> MANUAL_OWNER_REVIEW_REQUIRED; no automatic writes
|
|
||||||
```
|
|
||||||
|
|
||||||
Probe evidence binds the immutable operation digest, is freshness checked, and
|
|
||||||
is appended to a separate SHA-256 hash-chained journal. Completion still
|
|
||||||
requires the active claim ID. File locks and leases ensure that concurrent
|
|
||||||
recovery workers cannot execute the same adapter.
|
|
||||||
|
|
||||||
`TAG_PENDING` uses independent remote ref, annotated object, peeled target,
|
|
||||||
Gitea API, Gitea listing, and repository identity evidence. Exact absence may
|
|
||||||
roll back a switched runtime. Exact publication continues to finalization.
|
|
||||||
Unavailable/divergent evidence stops at reconciliation; a different OID or
|
|
||||||
target stops for Owner review. No branch can force, delete, or rewrite a tag.
|
|
||||||
|
|
||||||
## Trusted build and approval
|
|
||||||
|
|
||||||
Callers submit only an event ID and exact source commit. Test, canary, artifact,
|
|
||||||
manifest, rollback, secret scan, Git and Gitea evidence are generated by the
|
|
||||||
trusted builder. The builder emits an Ed25519 attestation binding:
|
|
||||||
|
|
||||||
- builder attestation/key IDs;
|
|
||||||
- source commit and tree OID;
|
|
||||||
- artifact, manifest and package-closure SHA-256;
|
|
||||||
- test and canary evidence SHA-256;
|
|
||||||
- issuance time and signature.
|
|
||||||
|
|
||||||
The controller contains only trusted public keys. Production signing is exposed
|
|
||||||
through a KMS/HSM/secret-manager adapter; a local private-key adapter is marked
|
|
||||||
test-only and is used only for ephemeral Shadow acceptance.
|
|
||||||
|
|
||||||
Owner approvals are immutable, time-bounded, revocable, trust-domain bound, and
|
|
||||||
single-use per capability ledger. If the production approval backend reference
|
|
||||||
is absent, production execution is unavailable while Shadow remains available.
|
|
||||||
Caller-supplied `owner_id` is never a fallback.
|
|
||||||
|
|
||||||
## Persistent controller and API
|
|
||||||
|
|
||||||
`aios-silent-release-controller.service` runs independently from the business
|
|
||||||
GitOps API. It uses a single leader lock, durable intents/candidates/Sagas,
|
|
||||||
startup journal verification, idempotent main events, one notification record,
|
|
||||||
and recovery reporting. The final deployed service is report-only Shadow mode.
|
|
||||||
|
|
||||||
The Action contract exposes:
|
|
||||||
|
|
||||||
- `createReleaseIntent`
|
|
||||||
- `getReleaseCandidate`
|
|
||||||
- `getReleasePlan`
|
|
||||||
- `getReleaseAcceptance`
|
|
||||||
- `submitSignedApproval`
|
|
||||||
- `getReleaseSaga`
|
|
||||||
- `getCapabilityStatus`
|
|
||||||
- `startDeployment`
|
|
||||||
- `getRecoveryStatus`
|
|
||||||
|
|
||||||
Every Shadow response declares `execution_enabled=false`, `shadow_mode=true`
|
|
||||||
and `side_effects_executed=false`. `startDeployment` returns a simulated
|
|
||||||
`SHADOW_COMPLETED` result and never claims production capabilities.
|
|
||||||
|
|
||||||
## Migration and rollback
|
|
||||||
|
|
||||||
1. Merge through a governed pull request.
|
|
||||||
2. Build from a clean detached worktree at the exact merge commit.
|
|
||||||
3. Verify artifact, manifest, package closure, tests, live fixture, no-.git
|
|
||||||
canary, rollback archive and secret scan.
|
|
||||||
4. Install a versioned Shadow release without changing the business runtime.
|
|
||||||
5. Start the separate localhost-only controller with both gates false.
|
|
||||||
6. Run Shadow candidates and freeze production PID/tag/Baseline/Registry hashes.
|
|
||||||
7. Remove ephemeral test keys and temporary repositories.
|
|
||||||
|
|
||||||
Rollback stops and disables only the Shadow controller and restores/removes its
|
|
||||||
versioned `current` link. The existing production API, tag set, Baseline and
|
|
||||||
Registry are not involved.
|
|
||||||
|
|
||||||
## Future activation envelope
|
|
||||||
|
|
||||||
Activation requires a new one-time Owner approval binding the exact controller
|
|
||||||
commit, artifact/manifest/policy/unit hashes, trusted key IDs, KMS/secret
|
|
||||||
references, release and rollback roots, authoritative Git/repository identity,
|
|
||||||
Shadow candidate/evidence IDs, test results, activation plan and rollback plan.
|
|
||||||
That approval may only flip
|
|
||||||
`AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false` to `true`; each real release still
|
|
||||||
requires its own signed ReleasePlan. This change does not request or consume
|
|
||||||
that activation approval.
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Silent Release Controller Production Contract
|
|
||||||
|
|
||||||
Status: review-only implementation. Production execution remains disabled.
|
|
||||||
|
|
||||||
## Trust boundary
|
|
||||||
|
|
||||||
The caller submits only release intent. A configured trusted builder owns ref
|
|
||||||
validation, build, tests, artifact and manifest hashing, package closure,
|
|
||||||
canary, rollback readiness, Git/Gitea consistency, and secret scanning. Caller
|
|
||||||
supplied success evidence is rejected.
|
|
||||||
|
|
||||||
The resulting immutable `ReleasePlan` contains deployment, tag, and
|
|
||||||
finalization plans. One Ed25519 owner signature binds its SHA-256 digest,
|
|
||||||
candidate evidence, policy digest, owner identity, trust domain, validity
|
|
||||||
window, evidence lineage, and three narrow capabilities.
|
|
||||||
|
|
||||||
## Durable execution
|
|
||||||
|
|
||||||
The Saga journal is the source of truth. Every record is sequence-numbered and
|
|
||||||
SHA-256 hash-chained from a fixed genesis hash. Snapshots are rebuildable
|
|
||||||
caches. Journal corruption, truncation, reordering, missing records, or hash
|
|
||||||
mismatch moves the release to `MANUAL_OWNER_REVIEW_REQUIRED`.
|
|
||||||
|
|
||||||
Capability execution uses an exclusive, leased claim. A second worker receives
|
|
||||||
`BUSY`; an expired claim is not reassigned until a capability-specific
|
|
||||||
side-effect probe reports that the operation was not applied. Runtime, tag,
|
|
||||||
and finalization remain separately claimed and recoverable.
|
|
||||||
|
|
||||||
## Execution order
|
|
||||||
|
|
||||||
```text
|
|
||||||
APPROVED
|
|
||||||
-> PRE_SWITCH_REVALIDATING
|
|
||||||
-> SWITCH_PENDING
|
|
||||||
-> SWITCHED
|
|
||||||
-> RUNTIME_VERIFYING
|
|
||||||
-> RUNTIME_VERIFIED
|
|
||||||
-> TAG_PENDING
|
|
||||||
-> TAG_VERIFIED
|
|
||||||
-> BASELINE_REGISTRY_PENDING
|
|
||||||
-> COMPLETED
|
|
||||||
```
|
|
||||||
|
|
||||||
The actual adapter configuration is regenerated before execution and must
|
|
||||||
match the signed plan. Trusted allowlists come from controlled policy, not the
|
|
||||||
request.
|
|
||||||
|
|
||||||
A failure before verified tag publication rolls the runtime back. A failure
|
|
||||||
after the exact tag is verified enters `RECONCILIATION_REQUIRED`; the tag is
|
|
||||||
never deleted or rewritten. A same-name/different-OID conflict enters
|
|
||||||
`MANUAL_OWNER_REVIEW_REQUIRED`. Registry/Baseline finalization requires exact
|
|
||||||
remote and Gitea tag proof, and Baseline is recorded last.
|
|
||||||
|
|
||||||
## Action and recovery contract
|
|
||||||
|
|
||||||
`submitOwnerApproval` verifies and durably stores the signed plan, then moves
|
|
||||||
the Saga to `APPROVED`. `startDeployment` loads that durable approval and
|
|
||||||
ignores caller-supplied approval content. Enabling execution without the
|
|
||||||
trusted builder, verifier, validator, policy, and orchestrator fails during
|
|
||||||
service construction.
|
|
||||||
|
|
||||||
The recovery worker scans incomplete journals and resumes through the same
|
|
||||||
claims and verification gates. It never automatically resumes a manual-review
|
|
||||||
Saga. In shadow mode it only reports state and performs no adapter side
|
|
||||||
effects.
|
|
||||||
|
|
||||||
The deployed Action factory does not enable the legacy caller-evidence path.
|
|
||||||
If the trusted builder is absent, candidate creation is rejected even while
|
|
||||||
execution remains in shadow mode. The compatibility path exists only through
|
|
||||||
the explicit test-only `SilentReleaseActionService.shadow()` constructor.
|
|
||||||
|
|
||||||
Finalization is composed from separate `RegistryController` and
|
|
||||||
`BaselineController` claims. Registry is verified first and Baseline is the
|
|
||||||
last mutation; a retry reuses completed Registry evidence and never moves
|
|
||||||
Baseline ahead of tag verification.
|
|
||||||
|
|
||||||
## Governance
|
|
||||||
|
|
||||||
- `force_push`: forbidden
|
|
||||||
- `tag_rewrite`: forbidden
|
|
||||||
- `direct_main_push`: forbidden
|
|
||||||
- production execution default: disabled
|
|
||||||
- production tag operations in this change: not executed
|
|
||||||
- Baseline/Registry mutations in this change: not executed
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user