Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a90023e8d1 | ||
|
|
db72e3184f | ||
|
|
4487a774fd | ||
|
|
a8e7a97f2d | ||
|
|
2f1d63c147 | ||
|
|
662ccf36f8 | ||
|
|
102d2306f0 | ||
|
|
28eca15d75 | ||
|
|
fb739f6e8f | ||
|
|
e6da39a3cd | ||
|
|
95d2e52cb5 | ||
|
|
283dec58ab | ||
|
|
7de1fe80ac | ||
|
|
89a502b39b | ||
|
|
61bec280e2 | ||
|
|
1791c91c5b | ||
|
|
e8c0f0250b | ||
|
|
94718308fc | ||
|
|
a4cbed0f5f | ||
|
|
25c7740739 | ||
|
|
8bbb049c39 | ||
|
|
1707cc4953 | ||
|
|
e397594200 | ||
|
|
68b7f2c1d2 | ||
|
|
fda98e61f5 | ||
|
|
ea9f599522 | ||
|
|
1aabcd8b62 | ||
|
|
4d68af9524 | ||
|
|
467b127804 | ||
|
|
50a73b211b | ||
|
|
f14d4b6e14 | ||
|
|
fc8bd8c8d4 | ||
|
|
382fe662a3 | ||
|
|
4754e5762a | ||
|
|
ce7d35a811 | ||
|
|
60ac7d9d0b | ||
|
|
2a10d1cdd9 | ||
|
|
677ea738e7 | ||
|
|
7154d62cca | ||
|
|
4e154c8baf | ||
|
|
b848361079 | ||
|
|
74224cfbd5 | ||
|
|
a7c09ba646 | ||
|
|
f055cb74bb | ||
|
|
071a378de6 | ||
|
|
a492b15df6 | ||
|
|
a9f0a4359b | ||
|
|
6a9fab894a | ||
|
|
e9111dea47 | ||
|
|
d25fbfa61e | ||
|
|
6b03c47625 | ||
|
|
770fe1ef66 | ||
|
|
7a025430f9 | ||
|
|
d079da4029 | ||
|
|
b8f7dc4112 | ||
|
|
947cdaff42 | ||
|
|
83b0c8bd74 | ||
|
|
fbd880cf8c | ||
|
|
819ac94c9f | ||
|
|
b02401e60d | ||
|
|
cbb2e232f5 | ||
|
|
9e46ac0504 | ||
|
|
ba7669c3b4 | ||
|
|
634f6b164f | ||
|
|
c9ce12d102 | ||
|
|
db50d2fa5a | ||
|
|
63fd23a464 | ||
|
|
2e03ed74f6 | ||
|
|
b86f51c9ab | ||
|
|
09448a5d3d | ||
|
|
18c719b2c6 | ||
|
|
b0a78fa9a8 | ||
|
|
0aca7dbcfc | ||
|
|
3f84d8991e | ||
|
|
5ac8f3ea6f | ||
|
|
bfb1c2fd52 | ||
|
|
cbfe1211c1 | ||
|
|
efd99e620c | ||
|
|
f23b694ad3 | ||
|
|
f97dea55ac | ||
|
|
efd08d7771 | ||
|
|
ff6cb2e217 | ||
|
|
d774620fea | ||
|
|
b26ff010fc | ||
|
|
4b49f2ba4b | ||
|
|
b4b8fe8828 | ||
|
|
4e8ab051a0 | ||
|
|
f61e35b218 | ||
|
|
b2f90f24a3 | ||
|
|
6721eb4752 | ||
|
|
a9e4fd1e40 | ||
|
|
621750976a | ||
|
|
5359e88806 | ||
|
|
76636931ba | ||
|
|
180c1fc66e | ||
|
|
c0ff052dab | ||
|
|
94cffb6ba3 | ||
|
|
9ad5628e69 | ||
|
|
6d397a7894 | ||
|
|
165fea6ba9 | ||
|
|
d079e2087c | ||
|
|
e3b36ebdcc | ||
|
|
5e791b11c0 | ||
|
|
50be65b76e | ||
|
|
7bb86e30bf | ||
|
|
ea56b45b7e | ||
|
|
f53682da7a | ||
|
|
71884ac088 | ||
|
|
969c278cb0 | ||
|
|
6fe2a1e036 | ||
|
|
9a60e0c68e | ||
|
|
46f553ca11 | ||
|
|
4f0a0c2184 | ||
|
|
c107b193d9 | ||
|
|
2db7ced7a4 | ||
|
|
6361d9a334 | ||
|
|
2cf54f04ef | ||
|
|
f840ce5c94 | ||
|
|
35e7b55490 | ||
|
|
1f8d1f23b3 | ||
|
|
e93011791e | ||
|
|
4460564a48 | ||
|
|
4d6658136e | ||
|
|
5767e665c2 |
@@ -0,0 +1,3 @@
|
|||||||
|
d23bb1ec24a7f49359314e04cd2c04c8f38876e45f25a701506499d1b34f270f MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json
|
||||||
|
5ce2894fdf8a22622954a1a10fb09fbab74af04f0260b249615c70236ff31731 RESULT-AIOS-V2R1-HERMES-LIFECYCLE-20260721.md
|
||||||
|
ca2ab3c22c2b2b33bbc03e59f1206b999ad1271de3e1938fd3a0b4234c613eef ../../../docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "aios.evidence.manifest.v1",
|
||||||
|
"generated_at": "2026-07-21T05:50:00Z",
|
||||||
|
"branch": "patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721",
|
||||||
|
"validated_oid": "95d2e52cb55c7b913e89bc6bed2bcdacf67512d4",
|
||||||
|
"evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z",
|
||||||
|
"evidence_hashes": {
|
||||||
|
"steps.tsv": "b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf",
|
||||||
|
"final-live-gate.log": "82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa",
|
||||||
|
"16R.log": "641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf",
|
||||||
|
"15.log": "b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e",
|
||||||
|
"13.log": "b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710",
|
||||||
|
"12R2.log": "0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"candidate_tests": {"passed": 49, "failed": 0},
|
||||||
|
"hermes_lifecycle": {"passed": 17, "failed": 0},
|
||||||
|
"gate_b": {"passed": 10, "failed": 0},
|
||||||
|
"offline_acceptance": {"passed": 8, "failed": 0, "expected_warnings": 1},
|
||||||
|
"openclaw_real_http": "PASS",
|
||||||
|
"restart_recovery": "PASS",
|
||||||
|
"rollback_forward": "PASS",
|
||||||
|
"state_preservation": "PASS",
|
||||||
|
"final_health_corrected_16R": "PASS"
|
||||||
|
},
|
||||||
|
"git_gate": {
|
||||||
|
"oid_match": true,
|
||||||
|
"candidate_clean": true,
|
||||||
|
"active_controller_clean": true,
|
||||||
|
"locked": 0,
|
||||||
|
"prunable": 0,
|
||||||
|
"worktree_total": 88,
|
||||||
|
"worktree_dirty": 10,
|
||||||
|
"global_worktree_clean": false
|
||||||
|
},
|
||||||
|
"resolved_blockers": [],
|
||||||
|
"active_blockers": [
|
||||||
|
"BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING",
|
||||||
|
"BLK-GLOBAL-WORKTREE-CLEANLINESS"
|
||||||
|
],
|
||||||
|
"overall": "PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS",
|
||||||
|
"release_decision": "STOP_PREPRODUCTION",
|
||||||
|
"secrets_recorded": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# AIOS V2R1 Hermes Lifecycle Result
|
||||||
|
|
||||||
|
- Branch: `patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
|
||||||
|
- Validated OID: `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
|
||||||
|
- Evidence root: `/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
|
||||||
|
- Hermes blocker: `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`
|
||||||
|
- Overall: `PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
|
||||||
|
- Release decision: `STOP_PREPRODUCTION`
|
||||||
|
|
||||||
|
Hermes lifecycle `17/17`, candidate `49/49`, Gate B `10/10`, offline acceptance `8/0/1`, OpenClaw real HTTP, restart recovery, rollback/forward, state preservation and corrected final health all passed at the validated OID. The Owner acceptance contract nevertheless forbids formally closing the Hermes blocker until every global gate also passes.
|
||||||
|
|
||||||
|
The candidate worktree and active Controller worktree are clean; local, worktree and Gitea OIDs match; locked/prunable are zero. The global authority currently has 10 dirty worktrees out of 88, so the Owner hard gate requiring every worktree to be clean fails.
|
||||||
|
|
||||||
|
No RC, Tag Approval Request, Tag, main mutation, force push, production traffic switch or secret disclosure was performed.
|
||||||
|
|
||||||
|
See `MANIFEST-AIOS-V2R1-HERMES-LIFECYCLE-20260721.json` and `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Independent Validator Result
|
||||||
|
|
||||||
|
- Model: `gpt-5.3-codex-spark`
|
||||||
|
- Reviewer identity: `gpt-5.3-codex-spark-validator`
|
||||||
|
- Broad live-review attempt: `TIMEOUT` after 600 seconds; no decision was accepted from that attempt.
|
||||||
|
- Narrow immutable evidence-bundle review: `SUCCESS`
|
||||||
|
- Cleanup review: `PASS`
|
||||||
|
- Release decision: `STOP_PREPRODUCTION`
|
||||||
|
- Verified: 10 Manifest records, 7 removed records, 3 retained roots, 7 local/remote Archive OID pairs, product OID equality, release/tag absence, and global dirty/locked/prunable all zero.
|
||||||
|
- Blocking finding: archived Portal Adapter `portal-surface` and `seed-ucc` commands returned non-zero.
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "aios.incubator.worktree-archive.v1",
|
||||||
|
"status": "CLEANUP_REVIEWED_RELEASE_STOPPED",
|
||||||
|
"release_gate": "STOP_PREPRODUCTION",
|
||||||
|
"release_gate_reasons": [
|
||||||
|
"archived Portal Adapter portal-surface contract tests failed",
|
||||||
|
"archived Portal Adapter Django test could not execute because Django is absent on the validation host"
|
||||||
|
],
|
||||||
|
"inventory_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-archive-20260721T064301Z",
|
||||||
|
"archive_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-task-archive-20260721T070301Z",
|
||||||
|
"removal_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-task-removal-20260721T070843Z",
|
||||||
|
"controller_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-controller-migration-20260721T070749Z",
|
||||||
|
"longterm_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-longterm-clean-20260721T071346Z",
|
||||||
|
"global_gate_evidence_root": "/var/lib/aios-gitops/acceptance/aios-v2r1c1-global-gate-20260721T071446Z",
|
||||||
|
"release_source": false,
|
||||||
|
"reviewed_count": 10,
|
||||||
|
"removed_count": 7,
|
||||||
|
"retained_active_count": 3,
|
||||||
|
"global_dirty_count": 0,
|
||||||
|
"locked_count": 0,
|
||||||
|
"prunable_count": 0,
|
||||||
|
"archive_validation": {
|
||||||
|
"passed_commands": 7,
|
||||||
|
"failed_commands": 1,
|
||||||
|
"blocked_commands": 1
|
||||||
|
},
|
||||||
|
"worktrees": [
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/chatgpt/TASK-V2R1-PERSONAL-WEIXIN-GOLDEN-CONTRACT-R2-20260715",
|
||||||
|
"owner": "chatgpt",
|
||||||
|
"task_id": "TASK-V2R1-PERSONAL-WEIXIN-GOLDEN-CONTRACT-R2-20260715",
|
||||||
|
"source_branch": "design/v2r1-personal-weixin-golden-contract-r2-20260715",
|
||||||
|
"source_oid": "68d27879103990aac692207a8512b3725c6d0d41",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 36,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"channel-neutral versioned contract SDK and deterministic fixture validation pattern"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"Personal-Weixin schemas, fixtures, security document and release-specific plan"
|
||||||
|
],
|
||||||
|
"archive_branch": "archive/chatgpt/TASK-V2R1-PERSONAL-WEIXIN-GOLDEN-CONTRACT-R2-20260715-20260721",
|
||||||
|
"archive_oid": "fe86e35257113853ba08b1438bc820bb4554e6e8",
|
||||||
|
"archive_remote_oid": "fe86e35257113853ba08b1438bc820bb4554e6e8",
|
||||||
|
"cleanup_method": "archive_commit_push_verify_then_worktree_remove_and_prune",
|
||||||
|
"removed_at": "2026-07-21T07:08:45Z",
|
||||||
|
"validation": "PASS: contract harness",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/chatgpt/TASK-V2R1-PERSONAL-WEIXIN-PORTAL-ADAPTER-20260715",
|
||||||
|
"owner": "chatgpt",
|
||||||
|
"task_id": "TASK-V2R1-PERSONAL-WEIXIN-PORTAL-ADAPTER-20260715",
|
||||||
|
"source_branch": "fix/v2r1-personal-weixin-portal-adapter-20260715",
|
||||||
|
"source_oid": "64270e74ba0835971ee20bf4d2b12b1d20b05533",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 2,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 1,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"generic auth-session state, expiry, challenge, replay and fail-closed policy"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"UCC/OpenClaw/Weixin adapter implementation; incomplete portal surface; runtime-specific binding code"
|
||||||
|
],
|
||||||
|
"archive_branch": "archive/chatgpt/TASK-V2R1-PERSONAL-WEIXIN-PORTAL-ADAPTER-20260715-20260721",
|
||||||
|
"archive_oid": "79f529a91f8bf7cf3237feb01e98807ab5ad10b3",
|
||||||
|
"archive_remote_oid": "79f529a91f8bf7cf3237feb01e98807ab5ad10b3",
|
||||||
|
"cleanup_method": "archive_commit_push_verify_then_worktree_remove_and_prune",
|
||||||
|
"removed_at": "2026-07-21T07:08:46Z",
|
||||||
|
"validation": "REJECTED_FOR_ADOPTION: official-login tests pass; portal-surface tests fail; Django test blocked by missing dependency",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/chatgpt/TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8",
|
||||||
|
"owner": "chatgpt",
|
||||||
|
"task_id": "TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8",
|
||||||
|
"source_branch": "task/chatgpt/TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8",
|
||||||
|
"source_oid": "b05eb1babe9c657e8776c09eec71302166902cc3",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 2,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"registered-worktree classification and dirty-count governance model"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"agent task/result documents and ACL probe evidence"
|
||||||
|
],
|
||||||
|
"archive_branch": "archive/chatgpt/TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8-20260721",
|
||||||
|
"archive_oid": "f0feed397dc67edab7daa16f0cdb5b553eca5668",
|
||||||
|
"archive_remote_oid": "f0feed397dc67edab7daa16f0cdb5b553eca5668",
|
||||||
|
"cleanup_method": "archive_commit_push_verify_then_worktree_remove_and_prune",
|
||||||
|
"removed_at": "2026-07-21T07:08:47Z",
|
||||||
|
"validation": "PASS: evidence integrity and zero-secret scan",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/codex/TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8",
|
||||||
|
"owner": "codex",
|
||||||
|
"task_id": "TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8",
|
||||||
|
"source_branch": "patch/chatgpt/v2r1-personal-weixin-b3-repair3-owner-20260717T102830UTC8",
|
||||||
|
"source_oid": "ef20c71247c6de6659e18fc57315da19d8a3834e",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 6,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"generic runner-provider preflight, immutable digest and reference-only credential contracts"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"Spark-specific systemd, provisioning, rollback and production wiring"
|
||||||
|
],
|
||||||
|
"archive_branch": "archive/codex/TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8-20260721",
|
||||||
|
"archive_oid": "d97cd3642528bd3373fe4cf5161a5d99c0b9285f",
|
||||||
|
"archive_remote_oid": "d97cd3642528bd3373fe4cf5161a5d99c0b9285f",
|
||||||
|
"cleanup_method": "archive_commit_push_verify_then_worktree_remove_and_prune",
|
||||||
|
"removed_at": "2026-07-21T07:08:48Z",
|
||||||
|
"validation": "PASS: 27 pytest tests",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8",
|
||||||
|
"owner": "openclaw",
|
||||||
|
"task_id": "TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8",
|
||||||
|
"source_branch": "patch/openclaw/v2r1-personal-weixin-b3-evidence-hardening-20260716T133000UTC8",
|
||||||
|
"source_oid": "8d9c6da90fc8caa69b691d243c6fefb40b6b0575",
|
||||||
|
"remote_oid": "8d9c6da90fc8caa69b691d243c6fefb40b6b0575",
|
||||||
|
"dirty_files_before": 3,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"canonical evidence envelope, streaming hash and independent-review integrity policy"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"Personal-Weixin B3 evidence tool, empty owner facts and release-specific output path"
|
||||||
|
],
|
||||||
|
"archive_branch": "archive/openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8-20260721",
|
||||||
|
"archive_oid": "2ddbc456124c02707dc23e8d6c241826d7026575",
|
||||||
|
"archive_remote_oid": "2ddbc456124c02707dc23e8d6c241826d7026575",
|
||||||
|
"cleanup_method": "archive_commit_push_verify_then_worktree_remove_and_prune",
|
||||||
|
"removed_at": "2026-07-21T07:08:49Z",
|
||||||
|
"validation": "PASS: compile, canonical template JSON and CLI help",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/openclaw/TASK-V2R1-OPENCLAW-WEIXIN-WEB-LOGIN-CONTRACT-20260715",
|
||||||
|
"owner": "openclaw",
|
||||||
|
"task_id": "TASK-V2R1-OPENCLAW-WEIXIN-WEB-LOGIN-CONTRACT-20260715",
|
||||||
|
"source_branch": "fix/v2r1-openclaw-weixin-web-login-contract-20260715",
|
||||||
|
"source_oid": "68d27879103990aac692207a8512b3725c6d0d41",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 20,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"generic auth-session state transitions and readiness/revocation contracts"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"OpenClaw and Tencent Weixin patches, candidate evidence and channel-specific code"
|
||||||
|
],
|
||||||
|
"archive_branch": "archive/openclaw/TASK-V2R1-OPENCLAW-WEIXIN-WEB-LOGIN-CONTRACT-20260715-20260721",
|
||||||
|
"archive_oid": "d03b4ee34debe6e30ce2af233e2226b02e6dd029",
|
||||||
|
"archive_remote_oid": "d03b4ee34debe6e30ce2af233e2226b02e6dd029",
|
||||||
|
"cleanup_method": "archive_commit_push_verify_then_worktree_remove_and_prune",
|
||||||
|
"removed_at": "2026-07-21T07:08:50Z",
|
||||||
|
"validation": "PASS: Node contract tests",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8",
|
||||||
|
"owner": "openclaw",
|
||||||
|
"task_id": "TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8",
|
||||||
|
"source_branch": "task/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8",
|
||||||
|
"source_oid": "b05eb1babe9c657e8776c09eec71302166902cc3",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 3,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"registered-worktree classification and cleanup authorization boundary"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"agent task/result documents and ACL probe evidence"
|
||||||
|
],
|
||||||
|
"archive_branch": "archive/openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8-20260721",
|
||||||
|
"archive_oid": "d9a85bb86cc09c117d63de803a404ee5a864e7c1",
|
||||||
|
"archive_remote_oid": "d9a85bb86cc09c117d63de803a404ee5a864e7c1",
|
||||||
|
"cleanup_method": "archive_commit_push_verify_then_worktree_remove_and_prune",
|
||||||
|
"removed_at": "2026-07-21T07:08:51Z",
|
||||||
|
"validation": "PASS: evidence integrity and zero-secret scan",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/codex",
|
||||||
|
"owner": "codex",
|
||||||
|
"task_id": "codex-long-term-root",
|
||||||
|
"source_branch": "acceptance/codex/TASK-CODEX-GITOPS-SECRET-ACL-AND-IDENTITY-HARDENING-20260715T004818UTC8-FINAL",
|
||||||
|
"source_oid": "b87266d63f4d16ba25b9c0041d6769822dfa99d4",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 56,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"registered child-worktree governance classification"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"all 55 retained registered child worktrees and their contents"
|
||||||
|
],
|
||||||
|
"archive_branch": "acceptance/codex/TASK-CODEX-GITOPS-SECRET-ACL-AND-IDENTITY-HARDENING-20260715T004818UTC8-FINAL",
|
||||||
|
"archive_oid": "0193a12b57c4974888b25753fd2bfb1f2028909b",
|
||||||
|
"archive_remote_oid": "0193a12b57c4974888b25753fd2bfb1f2028909b",
|
||||||
|
"cleanup_method": "retain active long-term root; commit exact registered-child ignore list",
|
||||||
|
"removed_at": null,
|
||||||
|
"validation": "PASS: 55 untracked entries exactly matched 55 registered child worktrees; final clean",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/controller",
|
||||||
|
"owner": "controller",
|
||||||
|
"task_id": "controller-long-term-root",
|
||||||
|
"source_branch": "DETACHED",
|
||||||
|
"source_oid": "102d2306f05397374fc91c711767021c5d9f41f9",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 11,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"runtime-state separation policy"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"generated health/watcher state and duplicate controller-run audit records"
|
||||||
|
],
|
||||||
|
"archive_branch": "archive/controller/runtime-baseline-20260721",
|
||||||
|
"archive_oid": "102d2306f05397374fc91c711767021c5d9f41f9",
|
||||||
|
"archive_remote_oid": "102d2306f05397374fc91c711767021c5d9f41f9",
|
||||||
|
"cleanup_method": "retain active source root; migrate watcher state to runtime directory; restore tracked generated state; remove only hash-matched duplicates",
|
||||||
|
"removed_at": null,
|
||||||
|
"validation": "PASS: watcher, model API and autonomous controller active; final clean",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"worktree_path": "/data/aios-gitops/worktrees/openclaw",
|
||||||
|
"owner": "openclaw",
|
||||||
|
"task_id": "openclaw-long-term-root",
|
||||||
|
"source_branch": "acceptance/openclaw/TASK-CODEX-GITOPS-SECRET-ACL-AND-IDENTITY-HARDENING-20260715T004818UTC8-FINAL",
|
||||||
|
"source_oid": "6cd0f5aa0ad28f767ffd5396c29d1a9182f88c43",
|
||||||
|
"remote_oid": "ABSENT_AT_INVENTORY",
|
||||||
|
"dirty_files_before": 5,
|
||||||
|
"secret_scan": {
|
||||||
|
"initial_violations": 0,
|
||||||
|
"final_violations": 0,
|
||||||
|
"secrets_recorded": false
|
||||||
|
},
|
||||||
|
"adopted_items": [
|
||||||
|
"registered child-worktree governance classification"
|
||||||
|
],
|
||||||
|
"excluded_items": [
|
||||||
|
"both retained registered child worktrees and their contents"
|
||||||
|
],
|
||||||
|
"archive_branch": "acceptance/openclaw/TASK-CODEX-GITOPS-SECRET-ACL-AND-IDENTITY-HARDENING-20260715T004818UTC8-FINAL",
|
||||||
|
"archive_oid": "633c8308f53b0ad04ce054ceafb624cb0904cbf4",
|
||||||
|
"archive_remote_oid": "633c8308f53b0ad04ce054ceafb624cb0904cbf4",
|
||||||
|
"cleanup_method": "retain active long-term root; commit exact registered-child ignore list",
|
||||||
|
"removed_at": null,
|
||||||
|
"validation": "PASS: 2 untracked entries exactly matched 2 registered child worktrees; final clean",
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unknown_value_policy": "UNVERIFIED_OWNER_INPUT_REQUIRED",
|
||||||
|
"secrets_recorded": false,
|
||||||
|
"reviewer": "gpt-5.3-codex-spark-validator",
|
||||||
|
"reviewer_decision": {
|
||||||
|
"cleanup_review": "PASS",
|
||||||
|
"release_decision": "STOP_PREPRODUCTION",
|
||||||
|
"verified_counts": {
|
||||||
|
"manifest_records": 10,
|
||||||
|
"removed_records": 7,
|
||||||
|
"retained_roots": 3,
|
||||||
|
"archive_oid_local_remote_pairs": 7,
|
||||||
|
"global_dirty": 0,
|
||||||
|
"global_locked": 0,
|
||||||
|
"global_prunable": 0
|
||||||
|
},
|
||||||
|
"finding": "portal-surface and seed-ucc archive validation commands returned non-zero",
|
||||||
|
"review_scope": "compact immutable evidence bundle after one broader live-review timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Worktree Cleanup Result
|
||||||
|
|
||||||
|
Status: `CLEANUP_REVIEWED_RELEASE_STOPPED`
|
||||||
|
|
||||||
|
- Reviewed: `10/10`
|
||||||
|
- Archived and removed: `7`
|
||||||
|
- Retained active and cleaned: `3`
|
||||||
|
- Global dirty worktrees: `0`
|
||||||
|
- Locked: `0`
|
||||||
|
- Prunable: `0`
|
||||||
|
- Product OID: `fb739f6e8ff9c031d319221ace68dce157694086`
|
||||||
|
- Incubator OID before this manifest commit: `4487a774fdeec51bc49253c2ad494c19d1b1c3e2`
|
||||||
|
- Release branch created: `false`
|
||||||
|
- V2R1C1 tag created: `false`
|
||||||
|
- Release decision: `STOP_PREPRODUCTION`
|
||||||
|
|
||||||
|
The stop decision is evidence-driven: the archived Portal Adapter portal-surface contract suite failed, and its Django test could not run because Django is absent on the host. No failed archive candidate was adopted into the incubator or product branch.
|
||||||
|
|
||||||
|
Independent reviewer: `gpt-5.3-codex-spark-validator`; cleanup `PASS`; release `STOP_PREPRODUCTION`.
|
||||||
-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"
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
# 执行 GitOps 独立 Worktree 与统一运行身份迁移
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
|
|
||||||
created_at: 2026-07-14T23:37:42+08:00
|
|
||||||
timezone: Asia/Shanghai
|
|
||||||
owner_approved: true
|
|
||||||
user_action_required: false
|
|
||||||
risk: high
|
|
||||||
production_business_change: false
|
|
||||||
```
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
根治共享 worktree 与多身份写入导致的 ACL、checkout 和未跟踪文件覆盖问题。
|
|
||||||
|
|
||||||
实施:
|
|
||||||
|
|
||||||
1. 统一 GitOps/OpenClaw 写入身份为 `aiman:gitops`;
|
|
||||||
2. 统一 `UMask=0002`;
|
|
||||||
3. 为 GitOps 根目录设置 setgid 与默认 ACL;
|
|
||||||
4. 建立 bare repo 和每任务独立 worktree;
|
|
||||||
5. 启用任务启动前自检;
|
|
||||||
6. 验证 OpenClaw 与 ChatGPT 并发任务互不干扰;
|
|
||||||
7. 保留原共享 workspace 作为只读回滚源,禁止智能体继续在其中切换分支。
|
|
||||||
|
|
||||||
## 强制通知
|
|
||||||
|
|
||||||
收到任务后立即微信通知 Owner:
|
|
||||||
|
|
||||||
```text
|
|
||||||
【任务已接收】GitOps独立worktree与统一运行身份迁移开始。将暂停写入服务、建立独立任务工作树并验证并发执行;生产业务版本不变。
|
|
||||||
```
|
|
||||||
|
|
||||||
任务结束后无论 PASS、BLOCKED 或 ROLLED_BACK,立即微信汇报结果并记录 message_id。
|
|
||||||
|
|
||||||
## 1. 变更前检查与封存
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -euo pipefail
|
|
||||||
cd /data/aios-gitops/workspace
|
|
||||||
pwd
|
|
||||||
id
|
|
||||||
umask
|
|
||||||
git status --short --branch
|
|
||||||
git rev-parse HEAD
|
|
||||||
systemctl list-units --type=service --all | grep -Ei 'openclaw|gitops|aios'
|
|
||||||
```
|
|
||||||
|
|
||||||
将工作区未提交内容完整提交到非 main 证据分支或 stash,禁止 `git clean -fd`。
|
|
||||||
|
|
||||||
记录当前运行服务、用户、组、umask、PID、工作区 HEAD 和未跟踪文件清单。
|
|
||||||
|
|
||||||
## 2. 暂停所有写入服务
|
|
||||||
|
|
||||||
识别并停止实际写入服务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl stop <gitops-writer-service>
|
|
||||||
systemctl stop <openclaw-controller-service>
|
|
||||||
systemctl stop <openclaw-watcher-service>
|
|
||||||
```
|
|
||||||
|
|
||||||
只停止写入者,不停止生产业务服务。记录每条命令和退出码。
|
|
||||||
|
|
||||||
## 3. 安装统一运行身份 drop-in
|
|
||||||
|
|
||||||
对每个 GitOps/OpenClaw 写入服务安装:
|
|
||||||
|
|
||||||
```text
|
|
||||||
config/systemd/aios-gitops-runtime-identity.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
User: aiman
|
|
||||||
Group: gitops
|
|
||||||
SupplementaryGroups: gitops
|
|
||||||
UMask: 0002
|
|
||||||
```
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl cat <service>
|
|
||||||
```
|
|
||||||
|
|
||||||
不得让服务继续以 root 写 workspace。
|
|
||||||
|
|
||||||
## 4. 初始化 bare repo 与独立 worktree 根目录
|
|
||||||
|
|
||||||
执行治理补丁中的脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/gitops/bootstrap_isolated_worktrees.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
验收:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
bare_repo: /data/aios-gitops/repo.git
|
|
||||||
worktree_root: /data/aios-gitops/worktrees
|
|
||||||
owner: aiman
|
|
||||||
group: gitops
|
|
||||||
directory_mode: 2775
|
|
||||||
default_acl: inherited
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 创建 controller 独立 worktree
|
|
||||||
|
|
||||||
以 `aiman`、`umask 0002` 执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u aiman -g gitops bash -lc '
|
|
||||||
umask 0002
|
|
||||||
cd /data/aios-gitops
|
|
||||||
scripts/gitops/task_worktree.sh create controller controller-runtime origin/main
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
将 controller/watcher 的工作目录改为:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/aios-gitops/worktrees/controller/controller-runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 启用任务启动前自检
|
|
||||||
|
|
||||||
每个任务在执行前必须运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/gitops/preflight_task_environment.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
任何一项失败时不得开始任务:
|
|
||||||
|
|
||||||
- 用户不是 aiman;
|
|
||||||
- 不在 gitops 组;
|
|
||||||
- umask 不是 0002;
|
|
||||||
- 使用共享 `/data/aios-gitops/workspace`;
|
|
||||||
- worktree 不干净;
|
|
||||||
- task/result/git dir 不可写;
|
|
||||||
- 存在 Git lock。
|
|
||||||
|
|
||||||
## 7. 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl start <gitops-writer-service>
|
|
||||||
systemctl start <openclaw-controller-service>
|
|
||||||
systemctl start <openclaw-watcher-service>
|
|
||||||
```
|
|
||||||
|
|
||||||
验证:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl show <service> -p User -p Group -p UMask -p WorkingDirectory
|
|
||||||
ps -eo user,group,pid,cmd | grep -Ei 'openclaw|gitops|aios'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 并发验收
|
|
||||||
|
|
||||||
创建两个独立任务:
|
|
||||||
|
|
||||||
```text
|
|
||||||
OpenClaw: TASK-WORKTREE-CONCURRENCY-OPENCLAW-<timestamp>
|
|
||||||
ChatGPT: TASK-WORKTREE-CONCURRENCY-CHATGPT-<timestamp>
|
|
||||||
```
|
|
||||||
|
|
||||||
分别使用:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/aios-gitops/worktrees/openclaw/<task-id>
|
|
||||||
/data/aios-gitops/worktrees/chatgpt/<task-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
两个任务同时执行:
|
|
||||||
|
|
||||||
1. 写各自 `.agent/tasks/<agent>/`;
|
|
||||||
2. 写各自 `.agent/results/<agent>/`;
|
|
||||||
3. 创建深层子目录,验证默认 ACL;
|
|
||||||
4. commit;
|
|
||||||
5. push;
|
|
||||||
6. 不切换共享 workspace 分支。
|
|
||||||
|
|
||||||
验收必须全部通过:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
permission_denied: 0
|
|
||||||
untracked_overwrite_errors: 0
|
|
||||||
git_lock_collisions: 0
|
|
||||||
openclaw_commit_push: PASS
|
|
||||||
chatgpt_commit_push: PASS
|
|
||||||
deep_directory_acl_inheritance: PASS
|
|
||||||
shared_workspace_branch_switches: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. 回滚
|
|
||||||
|
|
||||||
若迁移失败:
|
|
||||||
|
|
||||||
1. 停止新服务;
|
|
||||||
2. 保留失败 worktree 供审计;
|
|
||||||
3. 恢复 systemd drop-in 备份;
|
|
||||||
4. `daemon-reload`;
|
|
||||||
5. 恢复原服务状态;
|
|
||||||
6. 不删除 `/data/aios-gitops/workspace`;
|
|
||||||
7. 生产业务保持不变。
|
|
||||||
|
|
||||||
## 10. 结果文件
|
|
||||||
|
|
||||||
提交:
|
|
||||||
|
|
||||||
```text
|
|
||||||
.agent/results/openclaw/RESULT-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8.md
|
|
||||||
```
|
|
||||||
|
|
||||||
首段必须包含:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
status: PASS | BLOCKED | ROLLED_BACK
|
|
||||||
task_id: TASK-GITOPS-ISOLATED-WORKTREE-MIGRATION-20260714T233742UTC8
|
|
||||||
created_at: 2026-07-14T23:37:42+08:00
|
|
||||||
timezone: Asia/Shanghai
|
|
||||||
user_action_required: false
|
|
||||||
runtime_identity:
|
|
||||||
user:
|
|
||||||
group:
|
|
||||||
umask:
|
|
||||||
bare_repo:
|
|
||||||
worktree_root:
|
|
||||||
controller_worktree:
|
|
||||||
services:
|
|
||||||
default_acl:
|
|
||||||
preflight:
|
|
||||||
concurrency_test:
|
|
||||||
openclaw_branch:
|
|
||||||
openclaw_commit:
|
|
||||||
chatgpt_branch:
|
|
||||||
chatgpt_commit:
|
|
||||||
permission_denied_count:
|
|
||||||
untracked_overwrite_count:
|
|
||||||
git_lock_collision_count:
|
|
||||||
owner_notifications:
|
|
||||||
received_message_id:
|
|
||||||
terminal_message_id:
|
|
||||||
rollback:
|
|
||||||
remaining_risks:
|
|
||||||
next_step:
|
|
||||||
```
|
|
||||||
|
|
||||||
提交并推送到独立执行分支。禁止直接推 main。
|
|
||||||
|
|
||||||
## 完成微信模板
|
|
||||||
|
|
||||||
PASS:
|
|
||||||
|
|
||||||
```text
|
|
||||||
【任务完成】GitOps独立worktree迁移已通过。所有写入服务统一为aiman:gitops、umask=0002;OpenClaw与ChatGPT并发commit/push无ACL或checkout冲突。生产业务版本未变化。
|
|
||||||
```
|
|
||||||
|
|
||||||
阻塞:
|
|
||||||
|
|
||||||
```text
|
|
||||||
【任务阻塞】GitOps独立worktree迁移未通过。原因=<具体服务/权限/并发验收>;已保留原workspace和回滚能力,生产业务未变化。
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
# AIOS V2R1 生产候选报告
|
||||||
|
|
||||||
|
- 候选版本:`V2R1C0-candidate`
|
||||||
|
- 候选分支:`integration/aios-v2r1-cto-production-candidate-20260720`
|
||||||
|
- 报告语言:中文
|
||||||
|
- 架构负责人:AIOS V2 CTO
|
||||||
|
- 当前状态:代码与文档闭环已完成;精确 Commit 测试、真实 Runtime 联调和预发布验证待执行
|
||||||
|
|
||||||
|
## 1. 执行摘要
|
||||||
|
|
||||||
|
AIOS V2R1 Candidate 已完成以下核心建设:
|
||||||
|
|
||||||
|
- AIOS 唯一控制面;
|
||||||
|
- Tenant、Agent、Task、Policy、Approval 和 Audit 权威模型;
|
||||||
|
- SQLite WAL 候选持久化与重启恢复;
|
||||||
|
- Execution Orchestrator;
|
||||||
|
- OpenClaw 签名命令、Adapter、Transport、Health 和 Audit Callback;
|
||||||
|
- Hermes tenant/cell/profile 分区、Queue、Worker Lease、Heartbeat 和 Result;
|
||||||
|
- Agent Execution Evidence;
|
||||||
|
- Web Console;
|
||||||
|
- GitOps Status;
|
||||||
|
- systemd、Docker、Compose、Healthcheck 和 Rollback;
|
||||||
|
- 用户需求、目标架构、迁移策略、模块契约、追踪矩阵、文档治理和 ADR;
|
||||||
|
- 文档—代码匹配检查器;
|
||||||
|
- 全量验收入口;
|
||||||
|
- OpenClaw 预发布部署任务;
|
||||||
|
- Codex 架构监督和阻塞处理任务。
|
||||||
|
|
||||||
|
本报告不把“代码已提交”误报为“生产已验证”。以下状态仍需由无人值守任务执行并形成证据:
|
||||||
|
|
||||||
|
- 精确 Candidate Commit 的自动测试;
|
||||||
|
- 真实 OpenClaw Runtime 联调;
|
||||||
|
- Hermes 长任务、Worker 崩溃和 Lease Recovery 演练;
|
||||||
|
- systemd/Docker 预发布部署;
|
||||||
|
- 重启恢复与回滚演练;
|
||||||
|
- 本地仓库与 Gitea Candidate OID 一致验证;
|
||||||
|
- 身份代理、安全、性能和容量验证;
|
||||||
|
- Owner 对合并、Tag 和生产部署的批准。
|
||||||
|
|
||||||
|
## 2. 用户需求完成情况
|
||||||
|
|
||||||
|
| 需求 | 代码状态 | 测试状态 | 预发布状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AIOS 唯一控制面 | 已实现 | 已定义 | 待验证 |
|
||||||
|
| 复用 OpenClaw/Hermes/Agent Router | 已实现 | 已定义 | 待真实联调 |
|
||||||
|
| OpenClaw/Hermes 解耦 | 已实现 | 已定义 | 待旁路冻结验证 |
|
||||||
|
| 全面 Web 化 | 候选实现完成 | 冒烟测试待执行 | 待反向代理接入 |
|
||||||
|
| 多租户隔离 | 已实现 | Domain 负向测试已定义 | 待全链路安全验证 |
|
||||||
|
| 统一任务生命周期 | 已实现 | 已定义 | 待精确 Commit 执行 |
|
||||||
|
| 策略与审批 | 已实现 | 部分测试待补强 | 待身份代理验证 |
|
||||||
|
| 幂等与并发安全 | 已实现 | 基础测试已定义 | 待压力和并发验证 |
|
||||||
|
| 执行证据 | 已实现 | 已定义 | 待外部执行器持续回写 |
|
||||||
|
| GitOps 唯一流程 | 已实现并修复 Controller 冲突 | Runtime 部分验证 | 待双目标持续镜像证据 |
|
||||||
|
| 持久化与恢复 | 已实现 | 重启恢复测试已定义 | 待预发布演练 |
|
||||||
|
| 审计与合规 | 已实现候选 | 部分断言待补强 | 待不可变外部固化 |
|
||||||
|
| 密钥安全 | 引用模式已实现 | 基础扫描纳入验收 | 待 Secret Provider 联调 |
|
||||||
|
| 部署与回滚 | 资产完成 | 验收脚本已定义 | 待实际演练 |
|
||||||
|
| 文档与代码同步 | 已实现门禁 | 待精确 Commit 执行 | 待接入持续验收 |
|
||||||
|
|
||||||
|
## 3. 目标架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器 / Web Console / Public API
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AIOS 唯一控制面
|
||||||
|
Tenant | Agent | Task | Policy | Approval | Audit
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Execution Orchestrator / Agent Router
|
||||||
|
/ \
|
||||||
|
v v
|
||||||
|
OpenClaw Adapter Hermes Boundary
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
OpenClaw Runtime Hermes Workers
|
||||||
|
\ /
|
||||||
|
v v
|
||||||
|
结果回写 + 审计 + 执行证据
|
||||||
|
```
|
||||||
|
|
||||||
|
权威边界:
|
||||||
|
|
||||||
|
- AIOS:租户、身份、Agent、任务、策略、审批、审计和执行证据;
|
||||||
|
- Agent Router:调度与路由;
|
||||||
|
- OpenClaw:签名命令执行、取消、状态、健康和回调;
|
||||||
|
- Hermes:队列、Worker、Lease、Heartbeat 和结果;
|
||||||
|
- Web:只调用 AIOS API;
|
||||||
|
- GitOps:所有代码、文档、部署和修复变更入口。
|
||||||
|
|
||||||
|
## 4. 实现清单
|
||||||
|
|
||||||
|
### 4.1 Control Plane
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_v2/control_plane.py
|
||||||
|
active-runtime/app/aios_v2/persistence.py
|
||||||
|
active-runtime/app/aios_v2/gateway.py
|
||||||
|
active-runtime/app/aios_v2/app.py
|
||||||
|
active-runtime/app/aios_v2/orchestrator.py
|
||||||
|
active-runtime/app/aios_v2/execution_tracking.py
|
||||||
|
active-runtime/app/aios_v2/gitops_status.py
|
||||||
|
active-runtime/app/aios_v2/console.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 OpenClaw
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_openclaw/contract.py
|
||||||
|
active-runtime/app/aios_openclaw/adapter.py
|
||||||
|
active-runtime/app/aios_openclaw/transport.py
|
||||||
|
active-runtime/app/aios_openclaw/health.py
|
||||||
|
active-runtime/app/aios_openclaw/callback.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Hermes
|
||||||
|
|
||||||
|
```text
|
||||||
|
active-runtime/app/aios_hermes/contract.py
|
||||||
|
active-runtime/app/aios_hermes/adapter.py
|
||||||
|
active-runtime/app/aios_hermes/worker.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 测试与治理
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/aios_v2_candidate/test_candidate_flow.py
|
||||||
|
tools/aios_v2_docs_code_check.py
|
||||||
|
deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 部署
|
||||||
|
|
||||||
|
```text
|
||||||
|
deploy/aios-v2/systemd/aios-v2.service
|
||||||
|
deploy/aios-v2/env/aios-v2.env.example
|
||||||
|
deploy/aios-v2/docker/Dockerfile
|
||||||
|
deploy/aios-v2/docker/compose.yaml
|
||||||
|
deploy/aios-v2/healthcheck/check.sh
|
||||||
|
deploy/aios-v2/rollback/rollback.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 文档与任务
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/aios-v2/
|
||||||
|
tasks/openclaw-production-deploy-task.yaml
|
||||||
|
tasks/codex-production-supervisor-task.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 多租户与安全设计
|
||||||
|
|
||||||
|
### 5.1 权威上下文
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant_id
|
||||||
|
principal_id
|
||||||
|
session_id
|
||||||
|
trace_id
|
||||||
|
task_id
|
||||||
|
policy_version
|
||||||
|
idempotency_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 OpenClaw
|
||||||
|
|
||||||
|
- HMAC-SHA256 规范化签名;
|
||||||
|
- 签名绑定 tenant/task/trace/action/payload/idempotency/time;
|
||||||
|
- Runtime Result 身份字段必须匹配;
|
||||||
|
- Secret 只通过受保护文件或引用解析。
|
||||||
|
|
||||||
|
### 5.3 Hermes
|
||||||
|
|
||||||
|
- tenant/cell/profile 分区;
|
||||||
|
- Worker Lease 与 Heartbeat;
|
||||||
|
- revision 乐观并发;
|
||||||
|
- Lease 过期自动回到 QUEUED;
|
||||||
|
- Worker 完成前停止 Heartbeat,避免 revision 竞态。
|
||||||
|
|
||||||
|
### 5.4 Web/API
|
||||||
|
|
||||||
|
- 可信代理注入 `X-AIOS-*`;
|
||||||
|
- 浏览器不选择租户;
|
||||||
|
- CSP、no-store、nosniff、DENY frame、no-referrer;
|
||||||
|
- 应用端口不得直接暴露公网。
|
||||||
|
|
||||||
|
### 5.5 GitOps
|
||||||
|
|
||||||
|
- Controller detached + clean;
|
||||||
|
- 禁止 fetch 到已检出本地分支;
|
||||||
|
- 所有修改进入分支、Commit、测试和统一审查;
|
||||||
|
- 明文 Secret 门禁;
|
||||||
|
- 文档变化与代码变化同步门禁。
|
||||||
|
|
||||||
|
## 6. 持久化策略
|
||||||
|
|
||||||
|
候选版:
|
||||||
|
|
||||||
|
- SQLite WAL;
|
||||||
|
- `synchronous=FULL`;
|
||||||
|
- `BEGIN IMMEDIATE`;
|
||||||
|
- 原子 Snapshot;
|
||||||
|
- schema version;
|
||||||
|
- 状态目录与代码版本分离;
|
||||||
|
- Execution Evidence 租户分区原子 JSON。
|
||||||
|
|
||||||
|
已知限制:
|
||||||
|
|
||||||
|
- 单进程候选优先;
|
||||||
|
- 尚无生产 RLS、多实例 HA、PITR 和容量证据;
|
||||||
|
- 后续必须通过新 ADR 迁移到生产数据层。
|
||||||
|
|
||||||
|
## 7. 测试与验收
|
||||||
|
|
||||||
|
### 7.1 文档—代码匹配
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/aios_v2_docs_code_check.py \
|
||||||
|
--base-ref integration/aios-v2r1-cto-production-candidate-20260720
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 候选测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover \
|
||||||
|
-s tests/aios_v2_candidate \
|
||||||
|
-p 'test_*.py' \
|
||||||
|
-v
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
|
||||||
|
- OpenClaw 完整执行;
|
||||||
|
- Hermes 完整执行;
|
||||||
|
- 跨租户拒绝;
|
||||||
|
- SQLite 重启恢复;
|
||||||
|
- Execution Evidence。
|
||||||
|
|
||||||
|
### 7.3 全量验收
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
可选在线健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AIOS_RUN_LIVE_HEALTHCHECK=1 \
|
||||||
|
bash deploy/aios-v2/acceptance/run-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/aios-v2/healthcheck/check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- systemd;
|
||||||
|
- HTTP Health;
|
||||||
|
- 状态目录;
|
||||||
|
- SQLite quick_check;
|
||||||
|
- GitOps detached/clean;
|
||||||
|
- Evidence 文件。
|
||||||
|
|
||||||
|
## 8. 部署方式
|
||||||
|
|
||||||
|
### 8.1 systemd
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/aios-v2/aios-v2.env
|
||||||
|
/opt/aios/current
|
||||||
|
/var/lib/aios-v2
|
||||||
|
```
|
||||||
|
|
||||||
|
运行用户:`aios`。
|
||||||
|
|
||||||
|
安全选项:NoNewPrivileges、ProtectSystem、ProtectHome、PrivateTmp、Capability 限制。
|
||||||
|
|
||||||
|
### 8.2 Docker/Compose
|
||||||
|
|
||||||
|
- 非 root UID/GID;
|
||||||
|
- Read-only root filesystem;
|
||||||
|
- Drop all capabilities;
|
||||||
|
- 独立 State Volume;
|
||||||
|
- HTTP Healthcheck;
|
||||||
|
- Secret 文件只读挂载。
|
||||||
|
|
||||||
|
## 9. 回滚
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/aios-v2/rollback/rollback.sh <previous-release>
|
||||||
|
```
|
||||||
|
|
||||||
|
原则:
|
||||||
|
|
||||||
|
- 回滚前自动备份状态;
|
||||||
|
- 默认只回滚代码;
|
||||||
|
- 状态恢复需显式 `AIOS_V2_RESTORE_STATE=1`;
|
||||||
|
- 回滚后必须执行健康检查;
|
||||||
|
- 目标版本失败时自动恢复 previous release。
|
||||||
|
|
||||||
|
## 10. 无人值守任务
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
|
||||||
|
任务:`tasks/openclaw-production-deploy-task.yaml`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 预发布部署;
|
||||||
|
- 健康检查;
|
||||||
|
- Runtime 联调;
|
||||||
|
- 小型部署与 Runtime Bug 修复;
|
||||||
|
- 证据回写。
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
任务:`tasks/codex-production-supervisor-task.yaml`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 架构监督;
|
||||||
|
- 阻塞分析和跨模块修复;
|
||||||
|
- 文档、测试和安全审查;
|
||||||
|
- 发布建议。
|
||||||
|
|
||||||
|
所有修改和阻塞必须进入 Git 和 Execution Evidence,最终统一审视。
|
||||||
|
|
||||||
|
## 11. 当前已知风险
|
||||||
|
|
||||||
|
| 风险 | 级别 | 当前处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 精确 Candidate Commit 尚未执行全部测试 | 高 | OpenClaw 执行,Codex 监督 |
|
||||||
|
| 真实 OpenClaw Runtime 尚未联调 | 高 | 预发布任务必须完成 |
|
||||||
|
| 候选 SQLite 不支持生产多实例 HA | 高 | Candidate 单实例;后续 ADR 迁移 |
|
||||||
|
| 身份代理 Header 防伪尚未环境验证 | 高 | 预发布安全检查 |
|
||||||
|
| Web Console 为候选只读 MVP | 中 | 生产前补充 E2E 与安全验证 |
|
||||||
|
| 部分策略负向、并发和审计测试待补强 | 中 | Codex 登记 blocker 并补充 |
|
||||||
|
| 本地/Gitea 自动双写状态仍需持续证明 | 中 | 验收记录 OID |
|
||||||
|
| 性能、容量和 SLO 尚无实测 | 中 | 预发布基准测试 |
|
||||||
|
| 外部不可变审计固化尚未实现 | 中 | 生产数据层阶段完成 |
|
||||||
|
|
||||||
|
## 12. 发布 Gate
|
||||||
|
|
||||||
|
### 允许进入预发布
|
||||||
|
|
||||||
|
条件:
|
||||||
|
|
||||||
|
- Candidate 分支可见;
|
||||||
|
- 文档完整;
|
||||||
|
- 任务合同已生成;
|
||||||
|
- 部署资产存在;
|
||||||
|
- 无明文 Secret。
|
||||||
|
|
||||||
|
### 允许进入合并审查
|
||||||
|
|
||||||
|
条件:
|
||||||
|
|
||||||
|
- 全量验收通过;
|
||||||
|
- OpenClaw/Hermes 联调通过;
|
||||||
|
- 重启和回滚通过;
|
||||||
|
- 严重/高风险 blocker 已解决;
|
||||||
|
- Codex 输出正向监督结论;
|
||||||
|
- 本地/Gitea OID 一致。
|
||||||
|
|
||||||
|
### 允许生产发布
|
||||||
|
|
||||||
|
条件:
|
||||||
|
|
||||||
|
- Owner 明确批准;
|
||||||
|
- Merge 与 Tag 通过受控流程;
|
||||||
|
- 生产配置、Secret 和反向代理准备完成;
|
||||||
|
- 发布窗口、观察指标、回滚负责人明确。
|
||||||
|
|
||||||
|
## 13. 当前结论
|
||||||
|
|
||||||
|
**代码和正式中文文档已经达到候选闭环状态,但尚不能宣称生产已验证。**
|
||||||
|
|
||||||
|
下一阶段由 OpenClaw 执行预发布部署和低风险 Bug 修复,由 Codex 监督架构、文档、安全、测试和阻塞。所有过程证据统一进入系统,满足 Gate 后再请求 Owner 批准。
|
||||||
|
|
||||||
|
## 14. 更新规则
|
||||||
|
|
||||||
|
以下变化必须更新本报告:
|
||||||
|
|
||||||
|
- Candidate Commit;
|
||||||
|
- 测试结果;
|
||||||
|
- 部署和回滚结果;
|
||||||
|
- Blocker 和风险;
|
||||||
|
- OpenClaw/Codex 任务状态;
|
||||||
|
- 文档—代码匹配状态;
|
||||||
|
- Gitea OID;
|
||||||
|
- 发布建议和 Owner 决策。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
e4a1f86ef92ec1d3ff724eb75cf9c01758c1cc997e1afadc1c8b561bce5b813a FULL_PACKAGE_MANIFEST.json
|
e4a1f86ef92ec1d3ff724eb75cf9c01758c1cc997e1afadc1c8b561bce5b813a FULL_PACKAGE_MANIFEST.json
|
||||||
c59c0288d4d999fbd79a2f275d4fb8c369029cb8bb721f95408308cc51eaa3a2 README.md
|
c59c0288d4d999fbd79a2f275d4fb8c369029cb8bb721f95408308cc51eaa3a2 README.md
|
||||||
b090062a92aeb14211bbfc1fcd43b5ee4f315d30054b437f3bcdac657210ee95 VERSION
|
1095953d5764d9c72f02e84b1d525e265d3a67a5bdb451ab9ef51a8e8b3032b8 VERSION
|
||||||
a664714bc1e13160cdd0dc32de460c65a83a3a95aa5cdad1833b1b650ab90e9d active-runtime/app/aios_gitops_model_api.py
|
ee6e4fc6bc98a590fbc3585b62707e2562115e5a3f722e69a7c464d966bdc458 active-runtime/app/aios_gitops_model_api.py
|
||||||
0ffadf6ee1f297842bc44c6e753010fd2b7b02b1f5fb0d7e0676b7bec898db08 active-runtime/app/aios_v5_webops_app.py
|
0ffadf6ee1f297842bc44c6e753010fd2b7b02b1f5fb0d7e0676b7bec898db08 active-runtime/app/aios_v5_webops_app.py
|
||||||
0ffadf6ee1f297842bc44c6e753010fd2b7b02b1f5fb0d7e0676b7bec898db08 active-runtime/app/aios_webops_app.py
|
0ffadf6ee1f297842bc44c6e753010fd2b7b02b1f5fb0d7e0676b7bec898db08 active-runtime/app/aios_webops_app.py
|
||||||
0eb12e0161371464605577707c84df413b555277933f9eb4488ab38032ff699b active-runtime/migrations/001_webops_core_sqlite.sql
|
0eb12e0161371464605577707c84df413b555277933f9eb4488ab38032ff699b active-runtime/migrations/001_webops_core_sqlite.sql
|
||||||
@@ -13,7 +13,7 @@ ee6e4fc6bc98a590fbc3585b62707e2562115e5a3f722e69a7c464d966bdc458 adapters/gitop
|
|||||||
013813b58d7844f3860d6f9bb484f84f92487652b573976c474bb35c91df8ae8 adapters/openclaw/openclaw_gitops_commands.py
|
013813b58d7844f3860d6f9bb484f84f92487652b573976c474bb35c91df8ae8 adapters/openclaw/openclaw_gitops_commands.py
|
||||||
7a34ccdfdcdc132fe5daf01cd1acd9d9b7adbd0429d575b65e0771962ccedc62 adapters/webops/gitops_model_menu_fragment.html
|
7a34ccdfdcdc132fe5daf01cd1acd9d9b7adbd0429d575b65e0771962ccedc62 adapters/webops/gitops_model_menu_fragment.html
|
||||||
86b0cb6ee3192565b080672e5305dabba1283486442e630c71244ebac4910916 config/digital_life.yaml
|
86b0cb6ee3192565b080672e5305dabba1283486442e630c71244ebac4910916 config/digital_life.yaml
|
||||||
5a27d48db343dcb723bcc1d9d8222aa3f72094c57cc43640aebe6558963bd20f config/domains.env
|
a3872f24e4cd64288758846ecd0b374db4aa41111feec3b17c41438136d6d5d0 config/domains.env
|
||||||
7a6f82cbddfc5c74bd37229e4f37fe4e73430ae585b4846abd1a80d852f2fed0 config/gitops_short_links.yaml
|
7a6f82cbddfc5c74bd37229e4f37fe4e73430ae585b4846abd1a80d852f2fed0 config/gitops_short_links.yaml
|
||||||
4139d915bdd733171734d5d5d3411e6fc4f8f202c49af4321d706da176da690b config/model_registry.yaml
|
4139d915bdd733171734d5d5d3411e6fc4f8f202c49af4321d706da176da690b config/model_registry.yaml
|
||||||
8d6202d3da60366c96a208f5e5eed1f09fd36d122f4cabdc038c21ce2e72ea16 config/npm_custom_locations.yaml
|
8d6202d3da60366c96a208f5e5eed1f09fd36d122f4cabdc038c21ce2e72ea16 config/npm_custom_locations.yaml
|
||||||
@@ -55,7 +55,7 @@ d8cea35eb6c1300d20804ef480312d26dbdc2ad3a2d26b9430c6ca4808155a95 fixes/patch_ru
|
|||||||
0eb12e0161371464605577707c84df413b555277933f9eb4488ab38032ff699b migrations/001_webops_core_sqlite.sql
|
0eb12e0161371464605577707c84df413b555277933f9eb4488ab38032ff699b migrations/001_webops_core_sqlite.sql
|
||||||
6d61da88e7d6b208e7eee7195b521dc47cb68587f91987d7a0dd1c8801ef168d migrations/002_seed_owner_admin_user.sql
|
6d61da88e7d6b208e7eee7195b521dc47cb68587f91987d7a0dd1c8801ef168d migrations/002_seed_owner_admin_user.sql
|
||||||
a2c0c7699e1eb46cfdeabfc905cb79b71ed14fb6bcf81ecc99ec47975cf05767 migrations/004_gitops_custom_llm_runtime.sql
|
a2c0c7699e1eb46cfdeabfc905cb79b71ed14fb6bcf81ecc99ec47975cf05767 migrations/004_gitops_custom_llm_runtime.sql
|
||||||
ca49f4165b50171968485ffa5535bc33d2158237ea55af10a3888a5fa0f7a535 package_verify_full.sh
|
b77bce1ca78cede27de6594e25f6ce5bb0c4990c0953eeb79f7e2608d2515c32 package_verify_full.sh
|
||||||
f65b7fba5d6bdac3070ff4309830e286cd6319a80ab2e55263e46022fed2bf7f scripts/apply_migrations.py
|
f65b7fba5d6bdac3070ff4309830e286cd6319a80ab2e55263e46022fed2bf7f scripts/apply_migrations.py
|
||||||
bc0452d261def2366cdefd845e62d7177b42806f93ba78294ece29a8a369157e scripts/backup_before_fix.sh
|
bc0452d261def2366cdefd845e62d7177b42806f93ba78294ece29a8a369157e scripts/backup_before_fix.sh
|
||||||
bdb439086c86b3b23f8240cb2bef0f757a6be242d21d13a79eb1e44434efbe2e scripts/create_patch.sh
|
bdb439086c86b3b23f8240cb2bef0f757a6be242d21d13a79eb1e44434efbe2e scripts/create_patch.sh
|
||||||
@@ -63,11 +63,11 @@ bdb439086c86b3b23f8240cb2bef0f757a6be242d21d13a79eb1e44434efbe2e scripts/create
|
|||||||
1fdeecf0315f3ab4ec461c3376128054df9ac48e2fe5a3f64a138cd7d710d44d scripts/generate_release_manifest.py
|
1fdeecf0315f3ab4ec461c3376128054df9ac48e2fe5a3f64a138cd7d710d44d scripts/generate_release_manifest.py
|
||||||
80c6c10f259245681c42932f3f5f9b9a0fbd9cadce6a3f495d82cbdbc8618c73 scripts/healthcheck.sh
|
80c6c10f259245681c42932f3f5f9b9a0fbd9cadce6a3f495d82cbdbc8618c73 scripts/healthcheck.sh
|
||||||
3795cadfa6429a434c955913dbdcbf552181c88301a196b4b458f4f5dd206cd8 scripts/init_gitops_baseline.sh
|
3795cadfa6429a434c955913dbdcbf552181c88301a196b4b458f4f5dd206cd8 scripts/init_gitops_baseline.sh
|
||||||
e76cd986d2387b76439e4a7fe503e48a2de8fc70fb52c45a13641c9d943f108f scripts/install_v1r1c0_gitops_model_overlay.sh
|
4d54a4ed6d35bbb6e9fe2924f3cfceeea9c3eed898017788f6c5ab8041394a47 scripts/install_v1r1c0_gitops_model_overlay.sh
|
||||||
0064539ac1e1bd32dc8062ecc307ad2bc8520895009583a72d46868d1cf44e3c scripts/install_webops_overlay.sh
|
0064539ac1e1bd32dc8062ecc307ad2bc8520895009583a72d46868d1cf44e3c scripts/install_webops_overlay.sh
|
||||||
9fadafc4b4d098c006d159cb3b401eb19cf9ab9c5fa4b5cb109e946501c0b8e1 scripts/owner_wechat_notify_worker.py
|
9fadafc4b4d098c006d159cb3b401eb19cf9ab9c5fa4b5cb109e946501c0b8e1 scripts/owner_wechat_notify_worker.py
|
||||||
f537c58fc08685601f47fac5c0a3a2856e3ce9806c1918848c87a194a49c28a0 scripts/package_contract_check_v1r1c0.sh
|
e0abd8ad44d4f088108f2046fd9d7bbd4465af50f79bd3b9fcbb72a8c61bee65 scripts/package_contract_check_v1r1c0.sh
|
||||||
14ab40a7c342e21e8315def14d552dcff4b214d23f1f4be493e9d3fc2cee1a2f scripts/release_version.sh
|
abef8df642c6d233af4a03c512876b02b326e19c924020b8dd8cc74236d18817 scripts/release_version.sh
|
||||||
bbdb3f9992b203ddc2a6a83241052663de4f021d0705724bd9df05acaa99b022 scripts/rollback_last_delta.sh
|
bbdb3f9992b203ddc2a6a83241052663de4f021d0705724bd9df05acaa99b022 scripts/rollback_last_delta.sh
|
||||||
0814c26ecf6732cf2c24f5f414ad0128a159768cfbbeb21d557274064feb1188 scripts/rollback_version.sh
|
0814c26ecf6732cf2c24f5f414ad0128a159768cfbbeb21d557274064feb1188 scripts/rollback_version.sh
|
||||||
da84a5cc61c1d54a7d1e96e4b0a7186e192edf3ec12d694fc9d7608c3b1086ef scripts/run_acceptance_with_autofix.sh
|
da84a5cc61c1d54a7d1e96e4b0a7186e192edf3ec12d694fc9d7608c3b1086ef scripts/run_acceptance_with_autofix.sh
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Task-scoped continuous authorization controller for the AIOS Candidate writer."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import base64, fcntl, hashlib, json, os, pwd, grp, re, socket, sqlite3, stat, subprocess, sys, tempfile, time, urllib.error, urllib.parse, urllib.request
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
TASK='AIOS-AUTONOMOUS-RELEASE-FULL-IMPLEMENTATION-20260726'
|
|
||||||
AUTH_ID='authz-aios-autonomous-release-20260726'
|
|
||||||
BRANCH='patch/chatgpt-autonomous-completion-contract-20260726'
|
|
||||||
BASE='875d41c3101093149414292067fd700dc214d4d3'
|
|
||||||
REPO='aios/aios'
|
|
||||||
WRITER_AUDIENCE='chatgpt-autonomous-release-writer'
|
|
||||||
ACTOR='chatgpt-gitops-bot'
|
|
||||||
WRITER_INSTANCE='writer-6e32f321-1240-46f8-9693-0b0631d9c77b'
|
|
||||||
CHILD_OPERATION_MAX_USES=20
|
|
||||||
ROOT=Path('/data/aios-gitops')
|
|
||||||
WORKTREE=ROOT/'worktrees/chatgpt-autonomous-completion-contract-20260726'
|
|
||||||
BARE=ROOT/'repo.git'
|
|
||||||
TASK_ROOT=ROOT/'runtime/tasks'/TASK
|
|
||||||
AUTH_DIR=Path('/etc/aios-gitops/task-authorizations')
|
|
||||||
PARENT=AUTH_DIR/'authz-aios-autonomous-release-20260726.json'
|
|
||||||
STATE_DIR=Path('/var/lib/aios-autonomous-release-authorization')
|
|
||||||
STATE=STATE_DIR/'state.json'
|
|
||||||
LOCK=STATE_DIR/'controller.lock'
|
|
||||||
AUDIT=TASK_ROOT/'continuous-authorization.audit.jsonl'
|
|
||||||
SOCKET_DIR=Path('/run/aios-autonomous-release-authorization')
|
|
||||||
SOCKET_PATH=SOCKET_DIR/'controller.sock'
|
|
||||||
PROJECTION=Path('/etc/aios-gitops/credentials/chatgpt-autonomous-release-writer')
|
|
||||||
REGISTRY=Path('/var/lib/aios-chatgpt-autonomous-release-writer/token-registry.sqlite3')
|
|
||||||
WRITER_LEDGER=Path('/var/lib/aios-chatgpt-autonomous-release-writer/operation-ledger.sqlite3')
|
|
||||||
ASKPASS=Path('/usr/libexec/aios-autonomous-release-authorization/gitea_askpass.py')
|
|
||||||
PATH_POLICY=Path('/etc/aios-gitops/policies-chatgpt-autonomous-release/autonomous-release-path-policy.json')
|
|
||||||
ROUTE_BINDING=Path('/etc/aios-gitops/action-route-bindings/chatgpt-autonomous-release-20260726.json')
|
|
||||||
OID_RE=re.compile(r'^[0-9a-f]{40}$')
|
|
||||||
OP_RE=re.compile(r'^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$')
|
|
||||||
TERMINAL={'COMPLETED','CANCELLED','FAILED_FINAL','REVOKED','REVOKED_IDLE_TIMEOUT','REVOKED_WALL_CLOCK','RECONCILIATION_REQUIRED'}
|
|
||||||
|
|
||||||
sys.path.insert(0,'/usr/libexec/aios-chatgpt-autonomous-release-writer-fullprofile-8ac2f90f')
|
|
||||||
from isolated_writer_token_registry import TokenRegistry
|
|
||||||
|
|
||||||
def now() -> datetime: return datetime.now(timezone.utc)
|
|
||||||
def stamp() -> str: return now().replace(microsecond=0).isoformat()
|
|
||||||
def canonical(value: Any) -> bytes: return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=False).encode('utf-8')
|
|
||||||
def fail(code: str, status: int=503, **details: Any) -> None: raise ControlError(code,status,details)
|
|
||||||
class ControlError(RuntimeError):
|
|
||||||
def __init__(self,code:str,status:int=503,details:dict[str,Any]|None=None): self.code,self.status,self.details=code,status,details or {}; super().__init__(code)
|
|
||||||
|
|
||||||
def owned_regular(path:Path, mode:int) -> os.stat_result:
|
|
||||||
try: info=os.lstat(path)
|
|
||||||
except FileNotFoundError: fail('authorization_config_missing')
|
|
||||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid!=0 or stat.S_IMODE(info.st_mode)&~mode: fail('authorization_config_permissions_rejected')
|
|
||||||
return info
|
|
||||||
|
|
||||||
def load_json(path:Path,mode:int=0o600) -> dict[str,Any]:
|
|
||||||
owned_regular(path,mode)
|
|
||||||
try: value=json.loads(path.read_text(encoding='utf-8'))
|
|
||||||
except Exception: fail('authorization_config_json_rejected')
|
|
||||||
if not isinstance(value,dict): fail('authorization_config_schema_rejected')
|
|
||||||
return value
|
|
||||||
|
|
||||||
def atomic_json(path:Path,value:dict[str,Any],mode:int=0o600) -> None:
|
|
||||||
path.parent.mkdir(parents=True,exist_ok=True)
|
|
||||||
raw=canonical(value)+b'\n'
|
|
||||||
fd,tmp=tempfile.mkstemp(prefix='.new-',dir=path.parent)
|
|
||||||
try:
|
|
||||||
os.fchmod(fd,mode); os.write(fd,raw); os.fsync(fd); os.close(fd); os.chown(tmp,0,0); os.replace(tmp,path)
|
|
||||||
os.chmod(path,mode)
|
|
||||||
finally:
|
|
||||||
try: os.close(fd)
|
|
||||||
except OSError: pass
|
|
||||||
if os.path.exists(tmp): os.unlink(tmp)
|
|
||||||
|
|
||||||
def append_audit(event:dict[str,Any]) -> None:
|
|
||||||
TASK_ROOT.mkdir(parents=True,exist_ok=True)
|
|
||||||
flags=os.O_WRONLY|os.O_CREAT|os.O_APPEND|getattr(os,'O_NOFOLLOW',0)
|
|
||||||
fd=os.open(AUDIT,flags,0o600)
|
|
||||||
try: os.write(fd,canonical({'at':stamp(),'task_id':TASK,'secrets_included':False,**event})+b'\n'); os.fsync(fd)
|
|
||||||
finally: os.close(fd)
|
|
||||||
|
|
||||||
def parent() -> dict[str,Any]:
|
|
||||||
p=load_json(PARENT)
|
|
||||||
required={'schema_version','development_authorization_id','authorization_type','task_id','actor_identity','audience','repository','exact_branch','approved_base_oid','allowed_path_roots','allowed_exact_paths','allowed_operations','created_at','valid_until','maximum_wall_clock_seconds','idle_timeout_seconds','manual_reapproval_per_commit','maximum_concurrent_writes','path_policy_sha256','writer_instance_id'}
|
|
||||||
if set(p)!=required or p.get('schema_version')!=1: fail('parent_authorization_schema_rejected')
|
|
||||||
fixed={'development_authorization_id':AUTH_ID,'authorization_type':'TASK_SCOPED_CONTINUOUS_DEVELOPMENT','task_id':TASK,'actor_identity':ACTOR,'audience':WRITER_AUDIENCE,'repository':REPO,'exact_branch':BRANCH,'approved_base_oid':BASE,'manual_reapproval_per_commit':False,'maximum_concurrent_writes':1,'writer_instance_id':WRITER_INSTANCE}
|
|
||||||
if any(p.get(k)!=v for k,v in fixed.items()): fail('parent_authorization_binding_rejected')
|
|
||||||
if p.get('maximum_wall_clock_seconds')!=86400 or p.get('idle_timeout_seconds')!=2700: fail('parent_authorization_lifetime_rejected')
|
|
||||||
if not isinstance(p.get('allowed_path_roots'),list) or not isinstance(p.get('allowed_exact_paths'),list) or not isinstance(p.get('allowed_operations'),list): fail('parent_authorization_scope_rejected')
|
|
||||||
raw=PATH_POLICY.read_bytes()
|
|
||||||
if hashlib.sha256(raw).hexdigest()!=p.get('path_policy_sha256'): fail('parent_authorization_path_policy_changed')
|
|
||||||
return p
|
|
||||||
|
|
||||||
def parse_time(v:Any) -> datetime:
|
|
||||||
try:
|
|
||||||
d=datetime.fromisoformat(str(v).replace('Z','+00:00'))
|
|
||||||
return d.astimezone(timezone.utc)
|
|
||||||
except Exception: fail('authorization_time_rejected')
|
|
||||||
|
|
||||||
def components(path:str)->tuple[str,...]:
|
|
||||||
if not isinstance(path,str) or not path or path!=path.strip() or '\\' in path or '\x00' in path: fail('authorization_path_rejected',403)
|
|
||||||
pp=PurePosixPath(path); wp=PureWindowsPath(path)
|
|
||||||
parts=tuple(path.split('/'))
|
|
||||||
if pp.is_absolute() or wp.is_absolute() or bool(wp.drive) or any(x in {'','.','..'} for x in parts): fail('authorization_path_rejected',403)
|
|
||||||
return parts
|
|
||||||
|
|
||||||
def path_allowed(p:dict[str,Any],path:str)->bool:
|
|
||||||
item=components(path)
|
|
||||||
for root in p['allowed_path_roots']:
|
|
||||||
r=components(str(root).rstrip('/'))
|
|
||||||
if item[:len(r)]==r: return True
|
|
||||||
return any(item==components(x) for x in p['allowed_exact_paths'])
|
|
||||||
|
|
||||||
def git(*args:str,cwd:Path|None=None,env:dict[str,str]|None=None)->str:
|
|
||||||
e=os.environ.copy(); e.update(env or {}); e['GIT_CONFIG_COUNT']='1'; e['GIT_CONFIG_KEY_0']='safe.directory'; e['GIT_CONFIG_VALUE_0']=str(WORKTREE)
|
|
||||||
r=subprocess.run(['git',*args],cwd=cwd,text=True,capture_output=True,env=e)
|
|
||||||
if r.returncode: fail('authorization_git_probe_failed',503)
|
|
||||||
return r.stdout.strip()
|
|
||||||
|
|
||||||
def credential_lines()->list[str]:
|
|
||||||
cp=Path(os.environ.get('CREDENTIALS_DIRECTORY',''))/'gitea_git_http'
|
|
||||||
try:
|
|
||||||
info=os.lstat(cp); lines=cp.read_text(encoding='utf-8').splitlines()
|
|
||||||
except OSError: fail('authorization_gitea_credential_unavailable')
|
|
||||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or len(lines)!=2 or lines[0]!='gitops-bot' or not lines[1]: fail('authorization_gitea_credential_rejected')
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def remote_oid()->str:
|
|
||||||
credential_lines()
|
|
||||||
e={'GIT_ASKPASS':str(ASKPASS),'GIT_TERMINAL_PROMPT':'0'}
|
|
||||||
out=git('ls-remote','--heads','http://127.0.0.1:3000/aios/aios.git',f'refs/heads/{BRANCH}',cwd=WORKTREE,env=e)
|
|
||||||
fields=out.split()
|
|
||||||
if len(fields)!=2 or not OID_RE.fullmatch(fields[0]): fail('authorization_git_ls_remote_rejected')
|
|
||||||
return fields[0]
|
|
||||||
|
|
||||||
def api_oid()->str:
|
|
||||||
user,password=credential_lines(); branch=urllib.parse.quote(BRANCH,safe='')
|
|
||||||
encoded=base64.b64encode((user+':'+password).encode()).decode()
|
|
||||||
request=urllib.request.Request('http://127.0.0.1:3000/api/v1/repos/aios/aios/branches/'+branch,headers={'Authorization':'Basic '+encoded,'Host':'g.cnnetmkt.top'})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request,timeout=10) as response: value=json.loads(response.read())
|
|
||||||
except Exception: fail('authorization_gitea_api_unavailable')
|
|
||||||
oid=str(value.get('commit',{}).get('id',''))
|
|
||||||
if not OID_RE.fullmatch(oid): fail('authorization_gitea_api_oid_rejected')
|
|
||||||
return oid
|
|
||||||
|
|
||||||
def four_way(expected:str)->None:
|
|
||||||
local=git('rev-parse','HEAD',cwd=WORKTREE)
|
|
||||||
bare=git('--git-dir',str(BARE),'rev-parse',f'refs/heads/{BRANCH}')
|
|
||||||
remote=remote_oid(); api=api_oid()
|
|
||||||
if any(x!=expected for x in (local,bare,remote,api)): fail('RECONCILIATION_REQUIRED',409,current_head_oid=local)
|
|
||||||
if git('status','--porcelain',cwd=WORKTREE)!='': fail('authorization_worktree_dirty',409)
|
|
||||||
|
|
||||||
def state()->dict[str,Any]:
|
|
||||||
s=load_json(STATE)
|
|
||||||
required={'schema_version','development_authorization_id','task_id','status','current_head_oid','last_activity_at','writer_child','test_child','inflight','updated_at'}
|
|
||||||
if set(s)!=required or s.get('schema_version')!=1 or s.get('development_authorization_id')!=AUTH_ID or s.get('task_id')!=TASK or not OID_RE.fullmatch(str(s.get('current_head_oid',''))): fail('authorization_state_schema_rejected')
|
|
||||||
return s
|
|
||||||
|
|
||||||
def save_state(s:dict[str,Any])->None: s['updated_at']=stamp(); atomic_json(STATE,s)
|
|
||||||
|
|
||||||
def lock():
|
|
||||||
STATE_DIR.mkdir(parents=True,exist_ok=True); os.chmod(STATE_DIR,0o700)
|
|
||||||
f=open(LOCK,'a+b'); os.chmod(LOCK,0o600); fcntl.flock(f.fileno(),fcntl.LOCK_EX); return f
|
|
||||||
|
|
||||||
def route_binding_valid(p:dict[str,Any])->None:
|
|
||||||
b=load_json(ROUTE_BINDING,0o640)
|
|
||||||
identities=b.get('action_identities')
|
|
||||||
if b.get('task_id')!=TASK or b.get('writer_instance_id')!=WRITER_INSTANCE or b.get('repository')!=REPO or b.get('branch')!=BRANCH or b.get('approved_base_oid')!=BASE or b.get('path_policy_sha256')!=p.get('path_policy_sha256') or not isinstance(identities,list) or len(identities)!=1 or identities[0].get('account')!=ACTOR:
|
|
||||||
fail('authorization_route_binding_changed',409)
|
|
||||||
|
|
||||||
def task_live(p:dict[str,Any],s:dict[str,Any],*,touch:bool)->None:
|
|
||||||
route_binding_valid(p)
|
|
||||||
if s['status'] in TERMINAL: fail('authorization_task_terminal',409)
|
|
||||||
if now()>=parse_time(p['valid_until']):
|
|
||||||
s['status']='REVOKED_WALL_CLOCK'; save_state(s); cleanup(s,'wall_clock'); fail('authorization_task_terminal',409)
|
|
||||||
if (now()-parse_time(s['last_activity_at'])).total_seconds()>p['idle_timeout_seconds']:
|
|
||||||
s['status']='REVOKED_IDLE_TIMEOUT'; save_state(s); cleanup(s,'idle_timeout'); fail('authorization_task_terminal',409)
|
|
||||||
if touch: s['last_activity_at']=stamp(); save_state(s)
|
|
||||||
|
|
||||||
def projection_token()->str|None:
|
|
||||||
if not PROJECTION.exists(): return None
|
|
||||||
try:
|
|
||||||
info=os.lstat(PROJECTION)
|
|
||||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid!=0 or stat.S_IMODE(info.st_mode)!=0o600: fail('authorization_projection_rejected')
|
|
||||||
token=PROJECTION.read_text(encoding='utf-8').strip()
|
|
||||||
return token if len(token.encode())>=32 else None
|
|
||||||
except OSError: fail('authorization_projection_rejected')
|
|
||||||
|
|
||||||
def write_projection(token:str)->None:
|
|
||||||
PROJECTION.parent.mkdir(parents=True,exist_ok=True)
|
|
||||||
fd,tmp=tempfile.mkstemp(prefix='.writer-',dir=PROJECTION.parent)
|
|
||||||
try:
|
|
||||||
os.fchmod(fd,0o600); os.write(fd,(token+'\n').encode()); os.fsync(fd); os.close(fd); os.chown(tmp,0,0); os.replace(tmp,PROJECTION); os.chmod(PROJECTION,0o600)
|
|
||||||
finally:
|
|
||||||
try: os.close(fd)
|
|
||||||
except OSError: pass
|
|
||||||
if os.path.exists(tmp): os.unlink(tmp)
|
|
||||||
|
|
||||||
def post_revoke_401(token:str)->None:
|
|
||||||
req=urllib.request.Request('http://127.0.0.1:19893/api/runtime-status',headers={'Authorization':'Bearer '+token})
|
|
||||||
try: urllib.request.urlopen(req,timeout=5)
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
if e.code==401:return
|
|
||||||
except Exception: pass
|
|
||||||
fail('authorization_old_credential_revocation_unverified')
|
|
||||||
|
|
||||||
def rotate_child(p:dict[str,Any],s:dict[str,Any],reason:str)->str:
|
|
||||||
old=projection_token(); registry=TokenRegistry(REGISTRY)
|
|
||||||
old_record=registry.find(old) if old else None
|
|
||||||
if old_record and (old_record.get('task_id')!=TASK or old_record.get('development_authorization_id')!=AUTH_ID):
|
|
||||||
if parse_time(old_record.get('expires_at'))>=now(): fail('authorization_existing_projection_binding_rejected')
|
|
||||||
if not registry.revoke(str(old_record['token_id'])): fail('authorization_historical_child_revoke_failed')
|
|
||||||
post_revoke_401(old)
|
|
||||||
append_audit({'event':'historical_expired_child_revoked','old_credential_http_status':401})
|
|
||||||
old_record=None
|
|
||||||
issued=now().replace(microsecond=0); expires=issued+timedelta(minutes=20)
|
|
||||||
prefixes=[str(x).rstrip('/')+'/' for x in p['allowed_path_roots']]
|
|
||||||
grant={'token_id':'continuous-child-'+issued.strftime('%Y%m%dT%H%M%SZ')+'-'+os.urandom(3).hex(),'credential_id':'credential-'+issued.strftime('%Y%m%dT%H%M%SZ')+'-'+os.urandom(3).hex(),'development_authorization_id':AUTH_ID,'audience':WRITER_AUDIENCE,'task_id':TASK,'actor_identity':ACTOR,'writer_instance_id':WRITER_INSTANCE,'repository_identity':REPO,'source_cidrs':['127.0.0.1/32'],'allowed_branch':BRANCH,'current_expected_head_oid':s['current_head_oid'],'approved_base_oid':BASE,'allowed_operations':p['allowed_operations'],'allowed_paths':p['allowed_exact_paths'],'allowed_path_prefixes':prefixes,'not_before':issued.isoformat(),'expires_at':expires.isoformat(),'renewable':False,'max_uses':{operation:CHILD_OPERATION_MAX_USES for operation in p['allowed_operations']},'issued_by':'task_authorization_controller'}
|
|
||||||
token,record=registry.issue(grant)
|
|
||||||
try:
|
|
||||||
write_projection(token)
|
|
||||||
if old_record:
|
|
||||||
if not registry.revoke(str(old_record['token_id'])): fail('authorization_old_child_revoke_failed')
|
|
||||||
post_revoke_401(old)
|
|
||||||
except Exception:
|
|
||||||
registry.revoke(str(record['token_id']))
|
|
||||||
try: PROJECTION.unlink()
|
|
||||||
except FileNotFoundError: pass
|
|
||||||
raise
|
|
||||||
s['writer_child']={'credential_id':record['credential_id'],'issued_at':record['not_before'],'expires_at':record['expires_at'],'current_expected_head_oid':s['current_head_oid'],'rotation_reason':reason}
|
|
||||||
save_state(s); append_audit({'event':'child_grant_rotated','reason':reason,'current_head_oid':s['current_head_oid'],'old_grant_revoked':bool(old_record),'old_credential_http_status':401 if old_record else None})
|
|
||||||
return token
|
|
||||||
|
|
||||||
def child_operation_quota_exhausted(rec:dict[str,Any],registry:TokenRegistry)->bool:
|
|
||||||
token_id=str(rec.get('token_id',''))
|
|
||||||
allowed=rec.get('allowed_operations')
|
|
||||||
limits=rec.get('max_uses')
|
|
||||||
if not token_id or not isinstance(allowed,list) or not isinstance(limits,dict): fail('authorization_child_quota_schema_rejected')
|
|
||||||
for operation in allowed:
|
|
||||||
maximum=limits.get(operation)
|
|
||||||
if not isinstance(operation,str) or not isinstance(maximum,int) or maximum<1: fail('authorization_child_quota_schema_rejected')
|
|
||||||
if registry.usage(token_id,operation)>=maximum: return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def ensure_child(p:dict[str,Any],s:dict[str,Any],reason:str)->str:
|
|
||||||
token=projection_token(); meta=s.get('writer_child') or {}
|
|
||||||
expired=True
|
|
||||||
try: expired=(parse_time(meta.get('expires_at'))-now()).total_seconds()<=300
|
|
||||||
except Exception: pass
|
|
||||||
if not token or expired or meta.get('current_expected_head_oid')!=s['current_head_oid']:
|
|
||||||
return rotate_child(p,s,reason)
|
|
||||||
registry=TokenRegistry(REGISTRY)
|
|
||||||
rec=registry.find(token)
|
|
||||||
if not rec or rec.get('revoked') or rec.get('development_authorization_id')!=AUTH_ID or rec.get('current_expected_head_oid')!=s['current_head_oid']:
|
|
||||||
return rotate_child(p,s,reason)
|
|
||||||
if child_operation_quota_exhausted(rec,registry):
|
|
||||||
return rotate_child(p,s,'operation_quota_exhausted')
|
|
||||||
return token
|
|
||||||
|
|
||||||
def ledger_verified(operation_id:str,commit_oid:str)->None:
|
|
||||||
with sqlite3.connect(WRITER_LEDGER) as db:
|
|
||||||
row=db.execute('select operation_id,branch,commit_oid,state,verification_status from writer_operations where operation_id=?',(operation_id,)).fetchone()
|
|
||||||
if not row or row[1]!=BRANCH or row[2]!=commit_oid or row[3]!='COMPLETED' or row[4]!='bare+gitea': fail('authorization_writer_ledger_rejected',409)
|
|
||||||
|
|
||||||
def reconcile(p:dict[str,Any],s:dict[str,Any],previous:str,new:str,operation_id:str)->dict[str,Any]:
|
|
||||||
inflight=s.get('inflight')
|
|
||||||
if not isinstance(inflight,dict) or inflight.get('previous_head_oid')!=previous or inflight.get('operation_id')!=operation_id: fail('authorization_inflight_rejected',409)
|
|
||||||
if previous!=s['current_head_oid'] or not OID_RE.fullmatch(new): fail('authorization_head_transition_rejected',409)
|
|
||||||
ledger_verified(operation_id,new); four_way(new)
|
|
||||||
s['current_head_oid']=new; s['inflight']=None; s['last_activity_at']=stamp(); save_state(s)
|
|
||||||
ensure_child(p,s,'head_advance')
|
|
||||||
append_audit({'event':'authorization_head_advanced','previous_head_oid':previous,'new_commit_oid':new,'four_way_ref_status':True,'worktree_clean':True})
|
|
||||||
return {'authorization_current_head_oid':new,'automatic_head_advance':True,'four_way_ref_status':True}
|
|
||||||
|
|
||||||
def prepare(p:dict[str,Any],s:dict[str,Any],data:dict[str,Any])->dict[str,Any]:
|
|
||||||
required={'expected_current_head_oid','operation_id','idempotency_key','exact_path','content_digest','commit_message_digest','branch','path','message'}
|
|
||||||
if set(data)!=required: fail('authorization_request_schema_rejected',403)
|
|
||||||
if data['branch']!=BRANCH or data['path']!=data['exact_path'] or not path_allowed(p,str(data['path'])): fail('authorization_path_rejected',403)
|
|
||||||
if data['expected_current_head_oid']!=s['current_head_oid'] or not OID_RE.fullmatch(str(data['expected_current_head_oid'])): fail('BRANCH_HEAD_CHANGED',409,current_head_oid=s['current_head_oid'])
|
|
||||||
if not OP_RE.fullmatch(str(data['operation_id'])) or not OP_RE.fullmatch(str(data['idempotency_key'])): fail('authorization_operation_id_rejected',403)
|
|
||||||
if not all(isinstance(data[k],str) and re.fullmatch(r'[0-9a-f]{64}',data[k]) for k in ('content_digest','commit_message_digest')): fail('authorization_digest_rejected',403)
|
|
||||||
if hashlib.sha256(data['message'].encode()).hexdigest()!=data['commit_message_digest']: fail('authorization_commit_message_digest_rejected',403)
|
|
||||||
inflight=s.get('inflight')
|
|
||||||
if inflight:
|
|
||||||
if inflight.get('operation_id')==data['operation_id'] and inflight.get('idempotency_key')==data['idempotency_key']: return {'expected_head_oid':s['current_head_oid'],'idempotency_key':data['idempotency_key']}
|
|
||||||
fail('BRANCH_HEAD_CHANGED',409,current_head_oid=s['current_head_oid'])
|
|
||||||
four_way(s['current_head_oid'])
|
|
||||||
s['inflight']={'operation_id':data['operation_id'],'idempotency_key':data['idempotency_key'],'previous_head_oid':s['current_head_oid'],'path':data['path'],'created_at':stamp()}; s['last_activity_at']=stamp(); save_state(s)
|
|
||||||
append_audit({'event':'write_prepared','operation_id':data['operation_id'],'path':data['path'],'expected_head_oid':s['current_head_oid']})
|
|
||||||
return {'expected_head_oid':s['current_head_oid'],'idempotency_key':data['idempotency_key']}
|
|
||||||
|
|
||||||
def test_authorize(p:dict[str,Any],s:dict[str,Any],operation:str)->dict[str,Any]:
|
|
||||||
allowed={'pytest_dormant','pytest_autonomous_release','compileall_autonomous_release','git_diff_check'}
|
|
||||||
if operation not in allowed: fail('candidate_test_executor_operation_rejected',403)
|
|
||||||
four_way(s['current_head_oid'])
|
|
||||||
child=s.get('test_child') or {}; refresh=True
|
|
||||||
try: refresh=(parse_time(child.get('expires_at'))-now()).total_seconds()<=300
|
|
||||||
except Exception: pass
|
|
||||||
if refresh:
|
|
||||||
issued=now().replace(microsecond=0); child={'test_authorization_id':'test-authz-aios-autonomous-release-20260726','grant_id':'test-child-'+issued.strftime('%Y%m%dT%H%M%SZ'),'issued_at':issued.isoformat(),'expires_at':(issued+timedelta(minutes=20)).isoformat()}; s['test_child']=child
|
|
||||||
s['last_activity_at']=stamp(); save_state(s); append_audit({'event':'test_authorized','operation':operation})
|
|
||||||
return {'test_authorization_id':child['test_authorization_id'],'operation':operation,'expires_at':child['expires_at']}
|
|
||||||
|
|
||||||
def cleanup(s:dict[str,Any],reason:str)->None:
|
|
||||||
old=projection_token()
|
|
||||||
if old:
|
|
||||||
reg=TokenRegistry(REGISTRY); rec=reg.find(old)
|
|
||||||
if rec: reg.revoke(str(rec['token_id']))
|
|
||||||
try: PROJECTION.unlink()
|
|
||||||
except FileNotFoundError: pass
|
|
||||||
try: post_revoke_401(old)
|
|
||||||
except ControlError: pass
|
|
||||||
for unit in ('aios-chatgpt-autonomous-release-route.service','aios-chatgpt-autonomous-release-writer.service'):
|
|
||||||
subprocess.run(['/usr/bin/systemctl','stop',unit],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20)
|
|
||||||
subprocess.run(['/usr/bin/systemctl','stop','aios-autonomous-release-dormant-test-executor@*.service'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20)
|
|
||||||
try: ROUTE_BINDING.unlink()
|
|
||||||
except FileNotFoundError: pass
|
|
||||||
try: SOCKET_PATH.unlink()
|
|
||||||
except FileNotFoundError: pass
|
|
||||||
append_audit({'event':'authorization_cleanup','reason':reason,'projection_removed':not PROJECTION.exists(),'route_overlay_removed':not ROUTE_BINDING.exists(),'task_services_stop_requested':True})
|
|
||||||
|
|
||||||
def request(data:dict[str,Any])->dict[str,Any]:
|
|
||||||
op=data.get('op'); f=lock()
|
|
||||||
try:
|
|
||||||
p=parent(); s=state(); task_live(p,s,touch=False)
|
|
||||||
if op=='describe_authorization':
|
|
||||||
four_way(s['current_head_oid'])
|
|
||||||
return {'development_authorization_id':AUTH_ID,'task_id':TASK,'actor_identity':ACTOR,'audience':WRITER_AUDIENCE,'repository':REPO,'branch':BRANCH,'approved_base_oid':BASE,'authorization_status':s['status'],'current_head_oid':s['current_head_oid']}
|
|
||||||
if op=='get_writer_token':
|
|
||||||
four_way(s['current_head_oid']); token=ensure_child(p,s,'scheduled_or_requested_rotation'); return {'token':token,'current_head_oid':s['current_head_oid']}
|
|
||||||
if op=='prepare_write':
|
|
||||||
ensure_child(p,s,'pre_write_quota_check')
|
|
||||||
return prepare(p,s,data.get('request') if isinstance(data.get('request'),dict) else {})
|
|
||||||
if op=='reconcile_commit':
|
|
||||||
r=data.get('result') if isinstance(data.get('result'),dict) else {}; return reconcile(p,s,str(r.get('previous_head_oid','')),str(r.get('new_commit_oid','')),str(r.get('operation_id','')))
|
|
||||||
if op=='fail_write':
|
|
||||||
r=data.get('result') if isinstance(data.get('result'),dict) else {}; inflight=s.get('inflight')
|
|
||||||
if isinstance(inflight,dict) and inflight.get('operation_id')==r.get('operation_id'): s['inflight']=None; save_state(s)
|
|
||||||
return {'cleared':True}
|
|
||||||
if op=='authorize_test': return test_authorize(p,s,str(data.get('operation','')))
|
|
||||||
fail('authorization_operation_rejected',403)
|
|
||||||
finally:
|
|
||||||
fcntl.flock(f.fileno(),fcntl.LOCK_UN); f.close()
|
|
||||||
|
|
||||||
def peer_allowed(conn:socket.socket)->bool:
|
|
||||||
try:
|
|
||||||
import struct
|
|
||||||
_pid,uid,gid=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
|
|
||||||
return uid==pwd.getpwnam('aiman').pw_uid and gid==grp.getgrnam('gitops').gr_gid
|
|
||||||
except Exception: return False
|
|
||||||
|
|
||||||
def serve()->None:
|
|
||||||
SOCKET_DIR.mkdir(parents=True,exist_ok=True); os.chown(SOCKET_DIR,0,grp.getgrnam('gitops').gr_gid); os.chmod(SOCKET_DIR,0o750)
|
|
||||||
try: SOCKET_PATH.unlink()
|
|
||||||
except FileNotFoundError: pass
|
|
||||||
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
|
|
||||||
server.bind(str(SOCKET_PATH)); os.chown(SOCKET_PATH,0,grp.getgrnam('gitops').gr_gid); os.chmod(SOCKET_PATH,0o660); server.listen(32); server.settimeout(1.0)
|
|
||||||
last=0.0
|
|
||||||
while True:
|
|
||||||
if time.monotonic()-last>30:
|
|
||||||
last=time.monotonic()
|
|
||||||
try:
|
|
||||||
f=lock(); p=parent(); s=state(); task_live(p,s,touch=False); four_way(s['current_head_oid']); ensure_child(p,s,'scheduled_rotation'); fcntl.flock(f.fileno(),fcntl.LOCK_UN); f.close()
|
|
||||||
except ControlError as e:
|
|
||||||
append_audit({'event':'scheduled_check_blocked','error':e.code})
|
|
||||||
if e.code=='authorization_task_terminal': return
|
|
||||||
except Exception: append_audit({'event':'scheduled_check_blocked','error':'authorization_controller_internal_error'})
|
|
||||||
try: conn,_=server.accept()
|
|
||||||
except socket.timeout: continue
|
|
||||||
with conn:
|
|
||||||
if not peer_allowed(conn): conn.sendall(canonical({'ok':False,'status':403,'error':'authorization_peer_rejected'})+b'\n'); continue
|
|
||||||
try:
|
|
||||||
raw=conn.recv(131072); data=json.loads(raw.decode()); result=request(data); conn.sendall(canonical({'ok':True,'result':result})+b'\n')
|
|
||||||
except ControlError as e: conn.sendall(canonical({'ok':False,'status':e.status,'error':e.code,**e.details})+b'\n')
|
|
||||||
except Exception as exc:
|
|
||||||
append_audit({'event':'controller_internal_error','error_type':type(exc).__name__})
|
|
||||||
conn.sendall(canonical({'ok':False,'status':503,'error':'authorization_controller_internal_error'})+b'\n')
|
|
||||||
if __name__=='__main__': serve()
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Task-bound bridge from the legacy ChatGPT Action to an isolated Writer V2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
import socket
|
|
||||||
import stat
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_PATH = Path(os.environ["AIOS_ACTION_ROUTE_BINDING"])
|
|
||||||
POLICY_PATH = Path(os.environ["AIOS_ACTION_ROUTE_PATH_POLICY"])
|
|
||||||
KNOWN_IDENTITIES_PATH = Path(os.environ["AIOS_ACTION_ROUTE_KNOWN_IDENTITIES"])
|
|
||||||
STATE_PATH = Path(os.environ["AIOS_ACTION_ROUTE_STATE"])
|
|
||||||
AUDIT_PATH = Path(os.environ["AIOS_ACTION_ROUTE_AUDIT"])
|
|
||||||
HOST = os.environ.get("AIOS_ACTION_ROUTE_HOST", "127.0.0.1")
|
|
||||||
PORT = int(os.environ.get("AIOS_ACTION_ROUTE_PORT", "19894"))
|
|
||||||
EXPECTED_TASK_ID = os.environ["AIOS_ACTION_ROUTE_TASK_ID"]
|
|
||||||
EXPECTED_ROUTE_HEADER = os.environ["AIOS_ACTION_ROUTE_HEADER"]
|
|
||||||
AUTHORIZATION_SOCKET = Path(os.environ["AIOS_CONTINUOUS_AUTHORIZATION_SOCKET"])
|
|
||||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
|
||||||
SECRET_RE = re.compile(
|
|
||||||
r"(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)"
|
|
||||||
r"\s*[:=]\s*[\"']?([A-Za-z0-9_./+=:-]{16,})"
|
|
||||||
)
|
|
||||||
SECRET_VALUE_RE = re.compile(
|
|
||||||
r"(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,}|"
|
|
||||||
r"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----)"
|
|
||||||
)
|
|
||||||
ALLOWED_REQUEST_FIELDS = frozenset({"base","branch","content","create_branch","message","path","task_id","development_authorization_id","actor_identity","audience","approved_base_oid","expected_current_head_oid","operation_id","idempotency_key","exact_path","content_digest","commit_message_digest"})
|
|
||||||
|
|
||||||
|
|
||||||
class RouteError(RuntimeError):
|
|
||||||
def __init__(self, code: str, status: int = 400, **details: Any):
|
|
||||||
super().__init__(code)
|
|
||||||
self.code = code
|
|
||||||
self.status = status
|
|
||||||
self.details = details
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: Any) -> bytes:
|
|
||||||
return json.dumps(
|
|
||||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def load_root_managed_json(
|
|
||||||
path: Path, *, expected_mode: int = 0o640
|
|
||||||
) -> tuple[dict[str, Any], bytes]:
|
|
||||||
info = os.lstat(path)
|
|
||||||
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
||||||
raise RouteError("writer_route_config_type_rejected", 503)
|
|
||||||
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & ~expected_mode:
|
|
||||||
raise RouteError("writer_route_config_permissions_rejected", 503)
|
|
||||||
raw = path.read_bytes()
|
|
||||||
try:
|
|
||||||
value = json.loads(raw)
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
raise RouteError("writer_route_config_json_rejected", 503) from None
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise RouteError("writer_route_config_object_required", 503)
|
|
||||||
return value, raw
|
|
||||||
|
|
||||||
|
|
||||||
def load_binding() -> dict[str, Any]:
|
|
||||||
binding, _ = load_root_managed_json(CONFIG_PATH)
|
|
||||||
required = {
|
|
||||||
"schema_version",
|
|
||||||
"task_id",
|
|
||||||
"writer_instance_id",
|
|
||||||
"repository",
|
|
||||||
"branch",
|
|
||||||
"approved_base_oid",
|
|
||||||
"writer_url",
|
|
||||||
"path_policy_sha256",
|
|
||||||
"action_identities",
|
|
||||||
}
|
|
||||||
if set(binding) != required or binding.get("schema_version") != 1:
|
|
||||||
raise RouteError("writer_route_binding_schema_rejected", 503)
|
|
||||||
if binding["task_id"] != EXPECTED_TASK_ID:
|
|
||||||
raise RouteError("writer_route_task_binding_rejected", 503)
|
|
||||||
if binding["repository"] != "aios/aios":
|
|
||||||
raise RouteError("writer_route_repository_rejected", 503)
|
|
||||||
if not OID_RE.fullmatch(str(binding["approved_base_oid"])):
|
|
||||||
raise RouteError("writer_route_base_oid_rejected", 503)
|
|
||||||
if binding["writer_url"] != "http://127.0.0.1:19893":
|
|
||||||
raise RouteError("writer_route_upstream_rejected", 503)
|
|
||||||
identities = binding.get("action_identities")
|
|
||||||
if not isinstance(identities, list) or len(identities) != 1:
|
|
||||||
raise RouteError("writer_route_action_identity_rejected", 503)
|
|
||||||
identity = identities[0]
|
|
||||||
if (
|
|
||||||
not isinstance(identity, dict)
|
|
||||||
or set(identity) != {"account", "token_sha256"}
|
|
||||||
or identity.get("account") != "chatgpt-gitops-bot"
|
|
||||||
or not re.fullmatch(
|
|
||||||
r"[0-9a-f]{64}", str(identity.get("token_sha256", ""))
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise RouteError("writer_route_action_identity_rejected", 503)
|
|
||||||
return binding
|
|
||||||
|
|
||||||
|
|
||||||
def allowed_path_scope(binding: dict[str, Any]) -> tuple[set[str], tuple[str, ...]]:
|
|
||||||
policy, raw = load_root_managed_json(POLICY_PATH)
|
|
||||||
actual = hashlib.sha256(raw).hexdigest()
|
|
||||||
if actual != binding["path_policy_sha256"]:
|
|
||||||
raise RouteError("writer_route_path_policy_digest_mismatch", 503)
|
|
||||||
if policy.get("allowed_branch") != binding["branch"]:
|
|
||||||
raise RouteError("writer_route_path_policy_branch_mismatch", 503)
|
|
||||||
operation = (policy.get("allowed_operations") or {}).get("commit_file.atomic")
|
|
||||||
paths = operation.get("exact_paths") if isinstance(operation, dict) else None
|
|
||||||
prefixes = operation.get("prefix_paths") if isinstance(operation, dict) else None
|
|
||||||
if not isinstance(paths, list) or not paths or not all(
|
|
||||||
isinstance(x, str) for x in paths
|
|
||||||
):
|
|
||||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
|
||||||
if not isinstance(prefixes, list) or not prefixes or not all(
|
|
||||||
isinstance(x, str) and x.endswith("/") for x in prefixes
|
|
||||||
):
|
|
||||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
|
||||||
if any(not valid_relative_path(x[:-1]) for x in prefixes):
|
|
||||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
|
||||||
if any(not valid_relative_path(x) for x in paths):
|
|
||||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
|
||||||
return set(paths), tuple(prefixes)
|
|
||||||
|
|
||||||
|
|
||||||
def valid_relative_path(path: str) -> bool:
|
|
||||||
if not isinstance(path, str) or not path or path != path.strip():
|
|
||||||
return False
|
|
||||||
if "\x00" in path or "\\" in path:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
decoded = urllib.parse.unquote(path, errors="strict")
|
|
||||||
except (UnicodeDecodeError, ValueError):
|
|
||||||
return False
|
|
||||||
if decoded != path:
|
|
||||||
return False
|
|
||||||
posix = PurePosixPath(path)
|
|
||||||
windows = PureWindowsPath(path)
|
|
||||||
return not (
|
|
||||||
posix.is_absolute()
|
|
||||||
or windows.is_absolute()
|
|
||||||
or bool(windows.drive)
|
|
||||||
or any(part in {"", ".", ".."} for part in path.split("/"))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def path_allowed(binding: dict[str, Any], path: str) -> bool:
|
|
||||||
if not valid_relative_path(path):
|
|
||||||
return False
|
|
||||||
exact_paths, prefixes = allowed_path_scope(binding)
|
|
||||||
return path in exact_paths or any(path.startswith(prefix) for prefix in prefixes)
|
|
||||||
|
|
||||||
|
|
||||||
def bearer(headers: Any) -> str:
|
|
||||||
value = headers.get("Authorization", "")
|
|
||||||
if not value.startswith("Bearer "):
|
|
||||||
raise RouteError("unauthorized", 401)
|
|
||||||
token = value[7:].strip()
|
|
||||||
if not token:
|
|
||||||
raise RouteError("unauthorized", 401)
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def authorize_action(headers: Any, binding: dict[str, Any]) -> str:
|
|
||||||
digest = hashlib.sha256(bearer(headers).encode("utf-8")).hexdigest()
|
|
||||||
identity = binding["action_identities"][0]
|
|
||||||
if not hmac.compare_digest(identity["token_sha256"], digest):
|
|
||||||
known, _ = load_root_managed_json(KNOWN_IDENTITIES_PATH)
|
|
||||||
identities = known.get("identities")
|
|
||||||
if known.get("schema_version") != 1 or not isinstance(identities, list):
|
|
||||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
|
||||||
observed = "unregistered"
|
|
||||||
allowed_accounts = {
|
|
||||||
"chatgpt-gitops-bot",
|
|
||||||
"gitops-write-bot",
|
|
||||||
"codex-gitops-bot",
|
|
||||||
}
|
|
||||||
for candidate in identities:
|
|
||||||
if not isinstance(candidate, dict) or set(candidate) != {
|
|
||||||
"account",
|
|
||||||
"token_sha256",
|
|
||||||
}:
|
|
||||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
|
||||||
account = str(candidate.get("account", ""))
|
|
||||||
token_sha256 = str(candidate.get("token_sha256", ""))
|
|
||||||
if account not in allowed_accounts or not re.fullmatch(
|
|
||||||
r"[0-9a-f]{64}", token_sha256
|
|
||||||
):
|
|
||||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
|
||||||
if hmac.compare_digest(token_sha256, digest):
|
|
||||||
observed = account
|
|
||||||
raise RouteError(
|
|
||||||
"writer_route_action_identity_rejected",
|
|
||||||
403,
|
|
||||||
accepted_action_identity=str(identity["account"]),
|
|
||||||
observed_action_identity=observed,
|
|
||||||
identity_alias_accepted=False,
|
|
||||||
)
|
|
||||||
return str(identity["account"])
|
|
||||||
|
|
||||||
|
|
||||||
def authorization_call(operation: str, **payload: Any) -> dict[str, Any]:
|
|
||||||
request = {"op": operation, **payload}
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
||||||
client.settimeout(15)
|
|
||||||
client.connect(str(AUTHORIZATION_SOCKET))
|
|
||||||
client.sendall(canonical_json(request))
|
|
||||||
raw = client.recv(131072)
|
|
||||||
response = json.loads(raw.decode("utf-8"))
|
|
||||||
except (OSError, TimeoutError, UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
raise RouteError("authorization_controller_unavailable", 503) from None
|
|
||||||
if not isinstance(response, dict) or not response.get("ok"):
|
|
||||||
if not isinstance(response, dict):
|
|
||||||
raise RouteError("authorization_controller_response_rejected", 503)
|
|
||||||
details = {k: v for k, v in response.items() if k not in {"ok", "status", "error"}}
|
|
||||||
raise RouteError(str(response.get("error") or "authorization_controller_rejected"), int(response.get("status") or 503), **details)
|
|
||||||
result = response.get("result")
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
raise RouteError("authorization_controller_response_rejected", 503)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def writer_token() -> str:
|
|
||||||
value = authorization_call("get_writer_token").get("token")
|
|
||||||
if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
|
|
||||||
raise RouteError("writer_route_credential_rejected", 503)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def writer_call(
|
|
||||||
binding: dict[str, Any],
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
payload: dict[str, Any] | None = None,
|
|
||||||
) -> tuple[int, dict[str, Any]]:
|
|
||||||
data = canonical_json(payload) if payload is not None else None
|
|
||||||
headers = {"Authorization": "Bearer " + writer_token()}
|
|
||||||
if data is not None:
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
request = urllib.request.Request(
|
|
||||||
binding["writer_url"] + path, data=data, method=method, headers=headers
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=90) as response:
|
|
||||||
body = json.loads(response.read())
|
|
||||||
return response.status, body
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
try:
|
|
||||||
body = json.loads(exc.read())
|
|
||||||
except Exception:
|
|
||||||
body = {"error": "writer_route_upstream_non_json_error"}
|
|
||||||
return exc.code, body
|
|
||||||
except (OSError, TimeoutError):
|
|
||||||
raise RouteError("writer_route_upstream_unavailable", 503) from None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_request(data: Any, binding: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise RouteError("writer_route_request_object_required")
|
|
||||||
if not {"branch", "path", "content"}.issubset(data):
|
|
||||||
raise RouteError("writer_route_request_incomplete")
|
|
||||||
if not set(data).issubset(ALLOWED_REQUEST_FIELDS):
|
|
||||||
raise RouteError("writer_route_request_fields_rejected")
|
|
||||||
if data["branch"] != binding["branch"]:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
if data.get("create_branch") not in (None, False):
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
if data.get("base", "main") not in {"main", binding["approved_base_oid"]}:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
path = data["path"]
|
|
||||||
if not isinstance(path, str) or not path_allowed(binding, path):
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
content = data["content"]
|
|
||||||
if not isinstance(content, str) or "\x00" in content or "\r" in content:
|
|
||||||
raise RouteError("writer_route_content_rejected")
|
|
||||||
if len(content.encode("utf-8")) > 1_048_576:
|
|
||||||
raise RouteError("writer_route_content_too_large", 413)
|
|
||||||
if SECRET_RE.search(content) or SECRET_VALUE_RE.search(content):
|
|
||||||
raise RouteError("writer_route_secret_content_rejected", 403)
|
|
||||||
message = data.get("message") or f"chore(chatgpt): update {path}"
|
|
||||||
if not isinstance(message, str) or not 1 <= len(message) <= 200 or "\x00" in message or "\n" in message or "\r" in message:
|
|
||||||
raise RouteError("writer_route_message_rejected")
|
|
||||||
return {"branch": binding["branch"], "path": path, "content": content, "message": message}
|
|
||||||
|
|
||||||
|
|
||||||
def complete_request(data: dict[str, Any], normalized: dict[str, Any], binding: dict[str, Any], account: str) -> dict[str, Any]:
|
|
||||||
authorization = authorization_call("describe_authorization")
|
|
||||||
expected = {
|
|
||||||
"task_id": binding["task_id"],
|
|
||||||
"development_authorization_id": authorization.get("development_authorization_id"),
|
|
||||||
"actor_identity": account,
|
|
||||||
"audience": authorization.get("audience"),
|
|
||||||
"branch": binding["branch"],
|
|
||||||
"approved_base_oid": binding["approved_base_oid"],
|
|
||||||
}
|
|
||||||
required = {
|
|
||||||
"task_id": EXPECTED_TASK_ID,
|
|
||||||
"development_authorization_id": "authz-aios-autonomous-release-20260726",
|
|
||||||
"actor_identity": "chatgpt-gitops-bot",
|
|
||||||
"audience": "chatgpt-autonomous-release-writer",
|
|
||||||
"branch": binding["branch"],
|
|
||||||
"approved_base_oid": binding["approved_base_oid"],
|
|
||||||
}
|
|
||||||
if expected != required or authorization.get("authorization_status") != "ACTIVE" or authorization.get("current_head_oid") is None:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
for key, value in required.items():
|
|
||||||
if key in data and data[key] != value:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
if "exact_path" in data and data["exact_path"] != normalized["path"]:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
current_head = str(authorization["current_head_oid"])
|
|
||||||
if not OID_RE.fullmatch(current_head):
|
|
||||||
raise RouteError("authorization_current_head_rejected", 503)
|
|
||||||
if "expected_current_head_oid" in data and data["expected_current_head_oid"] != current_head:
|
|
||||||
raise RouteError("BRANCH_HEAD_CHANGED", 409, current_head_oid=current_head)
|
|
||||||
content_digest = hashlib.sha256(normalized["content"].encode("utf-8")).hexdigest()
|
|
||||||
message_digest = hashlib.sha256(normalized["message"].encode("utf-8")).hexdigest()
|
|
||||||
if "content_digest" in data and data["content_digest"] != content_digest:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
if "commit_message_digest" in data and data["commit_message_digest"] != message_digest:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
seed = {**required, "expected_current_head_oid": current_head, "path": normalized["path"], "content_digest": content_digest, "commit_message_digest": message_digest}
|
|
||||||
request_digest = hashlib.sha256(canonical_json(seed)).hexdigest()
|
|
||||||
idempotency_key = "action-" + request_digest
|
|
||||||
operation_id = hashlib.sha256((EXPECTED_TASK_ID + "\0" + idempotency_key).encode("utf-8")).hexdigest()
|
|
||||||
if "idempotency_key" in data and data["idempotency_key"] != idempotency_key:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
if "operation_id" in data and data["operation_id"] != operation_id:
|
|
||||||
raise RouteError("writer_route_request_binding_failed", 403)
|
|
||||||
return {**normalized, **required, "expected_current_head_oid": current_head, "operation_id": operation_id, "idempotency_key": idempotency_key, "exact_path": normalized["path"], "content_digest": content_digest, "commit_message_digest": message_digest, "request_digest": request_digest}
|
|
||||||
|
|
||||||
|
|
||||||
def current_writer_head(binding: dict[str, Any]) -> str:
|
|
||||||
status, body = writer_call(binding, "GET", "/api/runtime-status")
|
|
||||||
if status != 200:
|
|
||||||
raise RouteError(
|
|
||||||
"writer_route_runtime_status_failed", 503, upstream_status=status
|
|
||||||
)
|
|
||||||
required = {
|
|
||||||
"service": "chatgpt-autonomous-release-writer",
|
|
||||||
"writes_enabled": True,
|
|
||||||
"allowed_branch": binding["branch"],
|
|
||||||
"approved_base_oid": binding["approved_base_oid"],
|
|
||||||
"path_policy_sha256": binding["path_policy_sha256"],
|
|
||||||
}
|
|
||||||
if any(body.get(key) != value for key, value in required.items()):
|
|
||||||
raise RouteError("writer_route_runtime_binding_mismatch", 503)
|
|
||||||
head = str(body.get("workspace_head_oid", ""))
|
|
||||||
if not OID_RE.fullmatch(head):
|
|
||||||
raise RouteError("writer_route_runtime_head_rejected", 503)
|
|
||||||
return head
|
|
||||||
|
|
||||||
|
|
||||||
def operation_binding(
|
|
||||||
request_digest: str, binding: dict[str, Any]
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with sqlite3.connect(STATE_PATH, timeout=30, isolation_level=None) as db:
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS action_operations (
|
|
||||||
request_digest TEXT PRIMARY KEY,
|
|
||||||
expected_head_oid TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL UNIQUE,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT expected_head_oid,idempotency_key FROM action_operations "
|
|
||||||
"WHERE request_digest=?",
|
|
||||||
(request_digest,),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
return str(row[0]), str(row[1])
|
|
||||||
head = current_writer_head(binding)
|
|
||||||
key = "action-" + request_digest
|
|
||||||
with sqlite3.connect(STATE_PATH, timeout=30, isolation_level=None) as db:
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
db.execute(
|
|
||||||
"INSERT OR IGNORE INTO action_operations"
|
|
||||||
"(request_digest,expected_head_oid,idempotency_key,created_at) "
|
|
||||||
"VALUES(?,?,?,?)",
|
|
||||||
(request_digest, head, key, utc_now()),
|
|
||||||
)
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT expected_head_oid,idempotency_key FROM action_operations "
|
|
||||||
"WHERE request_digest=?",
|
|
||||||
(request_digest,),
|
|
||||||
).fetchone()
|
|
||||||
db.execute("COMMIT")
|
|
||||||
return str(row[0]), str(row[1])
|
|
||||||
|
|
||||||
|
|
||||||
def append_audit(event: dict[str, Any]) -> None:
|
|
||||||
AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
fd = os.open(AUDIT_PATH, flags, 0o600)
|
|
||||||
try:
|
|
||||||
os.write(fd, canonical_json(event) + b"\n")
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
server_version = "AIOSActionTaskRouter/1"
|
|
||||||
|
|
||||||
def log_message(self, _format: str, *_args: Any) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
def send_json(self, status: int, value: dict[str, Any]) -> None:
|
|
||||||
body = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.send_header("Cache-Control", "no-store")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
||||||
if path != "/health":
|
|
||||||
return self.send_json(404, {"error": "not_found"})
|
|
||||||
try:
|
|
||||||
binding = load_binding()
|
|
||||||
self.send_json(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"service": "aios-action-task-router",
|
|
||||||
"task_id": binding["task_id"],
|
|
||||||
"writer_instance_id": binding["writer_instance_id"],
|
|
||||||
"branch": binding["branch"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except RouteError as exc:
|
|
||||||
self.send_json(exc.status, {"error": exc.code, **exc.details})
|
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
||||||
if path != "/api/file":
|
|
||||||
return self.send_json(404, {"error": "not_found"})
|
|
||||||
if self.client_address[0] not in {"127.0.0.1", "::1"}:
|
|
||||||
return self.send_json(403, {"error": "writer_route_source_rejected"})
|
|
||||||
if self.headers.get("X-AIOS-Route-Task") != EXPECTED_ROUTE_HEADER:
|
|
||||||
return self.send_json(403, {"error": "writer_route_header_rejected"})
|
|
||||||
try:
|
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
if not 0 < length <= 1_100_000:
|
|
||||||
raise RouteError("writer_route_content_length_rejected", 413)
|
|
||||||
try:
|
|
||||||
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
raise RouteError("writer_route_request_json_rejected") from None
|
|
||||||
binding = load_binding()
|
|
||||||
account = authorize_action(self.headers, binding)
|
|
||||||
normalized = validate_request(data, binding)
|
|
||||||
normalized = complete_request(data, normalized, binding, account)
|
|
||||||
prepared = authorization_call("prepare_write", request={key: normalized[key] for key in ("expected_current_head_oid", "operation_id", "idempotency_key", "exact_path", "content_digest", "commit_message_digest", "branch", "path", "message")})
|
|
||||||
expected_head = str(prepared["expected_head_oid"])
|
|
||||||
writer_payload = {"branch": normalized["branch"], "path": normalized["path"], "content": normalized["content"], "message": normalized["message"], "expected_head_oid": expected_head, "idempotency_key": str(prepared["idempotency_key"])}
|
|
||||||
request_digest = hashlib.sha256(canonical_json({"task_id": binding["task_id"], "operation_id": normalized["operation_id"], "idempotency_key": normalized["idempotency_key"], "content_digest": normalized["content_digest"], "commit_message_digest": normalized["commit_message_digest"]})).hexdigest()
|
|
||||||
status, result = writer_call(
|
|
||||||
binding, "POST", "/api/commit-file", writer_payload
|
|
||||||
)
|
|
||||||
audit = {
|
|
||||||
"at": utc_now(),
|
|
||||||
"task_id": binding["task_id"],
|
|
||||||
"writer_instance_id": binding["writer_instance_id"],
|
|
||||||
"account": account,
|
|
||||||
"branch": normalized["branch"],
|
|
||||||
"path": normalized["path"],
|
|
||||||
"request_digest": request_digest,
|
|
||||||
"expected_head_oid": expected_head,
|
|
||||||
"status": status,
|
|
||||||
"operation_id": result.get("operation_id"),
|
|
||||||
"commit_oid": result.get("commit_oid"),
|
|
||||||
"error": result.get("error"),
|
|
||||||
"secrets_included": False,
|
|
||||||
}
|
|
||||||
append_audit(audit)
|
|
||||||
if status != 200:
|
|
||||||
try:
|
|
||||||
authorization_call("fail_write", result={"operation_id": normalized["operation_id"]})
|
|
||||||
except RouteError:
|
|
||||||
pass
|
|
||||||
return self.send_json(status, result)
|
|
||||||
advanced = authorization_call("reconcile_commit", result={"previous_head_oid": expected_head, "new_commit_oid": result.get("commit_oid"), "operation_id": result.get("operation_id")})
|
|
||||||
return self.send_json(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"status": "committed",
|
|
||||||
"branch": normalized["branch"],
|
|
||||||
"path": normalized["path"],
|
|
||||||
"commit": {
|
|
||||||
"status": "committed",
|
|
||||||
"branch": normalized["branch"],
|
|
||||||
"rev": result.get("commit_oid"),
|
|
||||||
"previous_head_oid": expected_head,
|
|
||||||
"new_tree_oid": result.get("new_tree_oid"),
|
|
||||||
"push_status": result.get("push_status"),
|
|
||||||
"four_way_ref_status": advanced.get("four_way_ref_status") is True,
|
|
||||||
"sync": {"status": "verified", "target": "bare+git-ls-remote+gitea-api"},
|
|
||||||
},
|
|
||||||
"writer_instance_id": binding["writer_instance_id"],
|
|
||||||
"operation_id": result.get("operation_id"),
|
|
||||||
"idempotent": result.get("idempotent"),
|
|
||||||
"secrets_included": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except RouteError as exc:
|
|
||||||
if exc.code == "writer_route_action_identity_rejected":
|
|
||||||
try:
|
|
||||||
append_audit(
|
|
||||||
{
|
|
||||||
"at": utc_now(),
|
|
||||||
"task_id": EXPECTED_TASK_ID,
|
|
||||||
"event": "action_identity_rejected",
|
|
||||||
"accepted_action_identity": exc.details.get(
|
|
||||||
"accepted_action_identity"
|
|
||||||
),
|
|
||||||
"observed_action_identity": exc.details.get(
|
|
||||||
"observed_action_identity"
|
|
||||||
),
|
|
||||||
"identity_alias_accepted": False,
|
|
||||||
"secrets_included": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.send_json(exc.status, {"error": exc.code, **exc.details})
|
|
||||||
except Exception:
|
|
||||||
self.send_json(500, {"error": "writer_route_internal_error"})
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
load_binding()
|
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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,27 @@
|
|||||||
|
"""AIOS-managed Hermes runtime boundary."""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
HermesAdapter,
|
||||||
|
HermesConflict,
|
||||||
|
HermesNotFound,
|
||||||
|
HermesRuntimeError,
|
||||||
|
InMemoryHermesStore,
|
||||||
|
SQLiteHermesStore,
|
||||||
|
)
|
||||||
|
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
|
||||||
|
from .worker import HermesWorker, WorkerRunResult
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HermesAdapter",
|
||||||
|
"HermesConflict",
|
||||||
|
"HermesHealth",
|
||||||
|
"HermesJob",
|
||||||
|
"HermesJobState",
|
||||||
|
"HermesNotFound",
|
||||||
|
"HermesResult",
|
||||||
|
"HermesRuntimeError",
|
||||||
|
"HermesWorker",
|
||||||
|
"InMemoryHermesStore",
|
||||||
|
"SQLiteHermesStore",
|
||||||
|
"WorkerRunResult",
|
||||||
|
]
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
"""Tenant-partitioned Hermes queue and worker lease boundary."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict, replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Mapping
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
ALLOWED_TRANSITIONS,
|
||||||
|
TERMINAL_STATES,
|
||||||
|
HermesHealth,
|
||||||
|
HermesJob,
|
||||||
|
HermesJobState,
|
||||||
|
HermesResult,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HermesRuntimeError(RuntimeError):
|
||||||
|
"""Base Hermes runtime-boundary failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class HermesNotFound(HermesRuntimeError):
|
||||||
|
"""A tenant-scoped job was not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class HermesConflict(HermesRuntimeError):
|
||||||
|
"""A state, lease, idempotency, or revision conflict occurred."""
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryHermesStore:
|
||||||
|
"""Thread-safe reference store; production backends implement this contract."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.jobs: dict[str, HermesJob] = {}
|
||||||
|
self.results: dict[str, HermesResult] = {}
|
||||||
|
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
|
||||||
|
|
||||||
|
def persist_locked(self) -> None:
|
||||||
|
"""Persist a mutation while ``lock`` is held; memory stores are a no-op."""
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteHermesStore(InMemoryHermesStore):
|
||||||
|
"""Durable snapshot for the existing Hermes queue state machine.
|
||||||
|
|
||||||
|
The adapter remains the sole owner of transitions and lease rules. This
|
||||||
|
store only makes its jobs, results, idempotency keys and worker heartbeats
|
||||||
|
survive a process restart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
super().__init__()
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS hermes_state (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||||
|
payload TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(str(self.path), timeout=30)
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record(value: HermesJob | HermesResult) -> dict[str, object]:
|
||||||
|
record = asdict(value)
|
||||||
|
record["state"] = value.state.value
|
||||||
|
return record
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
with self.lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload FROM hermes_state WHERE singleton = 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
payload = json.loads(str(row[0]))
|
||||||
|
self.jobs = {
|
||||||
|
str(item["job_id"]): HermesJob(**item)
|
||||||
|
for item in payload.get("jobs", [])
|
||||||
|
}
|
||||||
|
self.results = {
|
||||||
|
str(item["job_id"]): HermesResult(**item)
|
||||||
|
for item in payload.get("results", [])
|
||||||
|
}
|
||||||
|
self.idempotency = {
|
||||||
|
(str(item["tenant_id"]), str(item["idempotency_key"])): (
|
||||||
|
str(item["job_id"]),
|
||||||
|
str(item["digest"]),
|
||||||
|
)
|
||||||
|
for item in payload.get("idempotency", [])
|
||||||
|
}
|
||||||
|
self.worker_heartbeats = {
|
||||||
|
str(item["worker_id"]): (
|
||||||
|
str(item["heartbeat_at"]),
|
||||||
|
str(item["job_id"]) if item.get("job_id") is not None else None,
|
||||||
|
)
|
||||||
|
for item in payload.get("worker_heartbeats", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
def persist_locked(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"jobs": [self._record(item) for item in self.jobs.values()],
|
||||||
|
"results": [self._record(item) for item in self.results.values()],
|
||||||
|
"idempotency": [
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"job_id": job_id,
|
||||||
|
"digest": digest,
|
||||||
|
}
|
||||||
|
for (tenant_id, idempotency_key), (job_id, digest)
|
||||||
|
in self.idempotency.items()
|
||||||
|
],
|
||||||
|
"worker_heartbeats": [
|
||||||
|
{
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"heartbeat_at": heartbeat_at,
|
||||||
|
"job_id": job_id,
|
||||||
|
}
|
||||||
|
for worker_id, (heartbeat_at, job_id)
|
||||||
|
in self.worker_heartbeats.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO hermes_state(singleton, payload) VALUES(1, ?)
|
||||||
|
ON CONFLICT(singleton) DO UPDATE SET payload=excluded.payload
|
||||||
|
""",
|
||||||
|
(encoded,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class HermesAdapter:
|
||||||
|
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: InMemoryHermesStore | None = None,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "hermes-runtime",
|
||||||
|
runtime_version: str = "v1",
|
||||||
|
default_lease_seconds: int = 60,
|
||||||
|
) -> None:
|
||||||
|
if default_lease_seconds < 5:
|
||||||
|
raise ValueError("default_lease_seconds must be >= 5")
|
||||||
|
self.store = store or InMemoryHermesStore()
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.runtime_version = runtime_version
|
||||||
|
self.default_lease_seconds = int(default_lease_seconds)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _digest(job: HermesJob) -> str:
|
||||||
|
raw = json.dumps(
|
||||||
|
{
|
||||||
|
"tenant_id": job.tenant_id,
|
||||||
|
"cell_id": job.cell_id,
|
||||||
|
"profile_id": job.profile_id,
|
||||||
|
"task_id": job.task_id,
|
||||||
|
"trace_id": job.trace_id,
|
||||||
|
"action": job.action,
|
||||||
|
"payload": dict(job.payload),
|
||||||
|
"priority": job.priority,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse(value: str | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format(value: datetime) -> str:
|
||||||
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_tenant(job: HermesJob, tenant_id: str) -> None:
|
||||||
|
if job.tenant_id != tenant_id:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
|
||||||
|
def enqueue(self, job: HermesJob) -> HermesJob:
|
||||||
|
if job.state != HermesJobState.QUEUED or job.revision != 1:
|
||||||
|
raise HermesConflict("new job must be QUEUED at revision 1")
|
||||||
|
digest = self._digest(job)
|
||||||
|
idem_ref = (job.tenant_id, job.idempotency_key)
|
||||||
|
with self.store.lock:
|
||||||
|
existing = self.store.jobs.get(job.job_id)
|
||||||
|
if existing is not None:
|
||||||
|
if self._digest(existing) != digest:
|
||||||
|
raise HermesConflict("job_id reused with different content")
|
||||||
|
return existing
|
||||||
|
prior = self.store.idempotency.get(idem_ref)
|
||||||
|
if prior is not None:
|
||||||
|
prior_id, prior_digest = prior
|
||||||
|
if prior_digest != digest:
|
||||||
|
raise HermesConflict("idempotency key reused with different content")
|
||||||
|
return self.store.jobs[prior_id]
|
||||||
|
self.store.jobs[job.job_id] = job
|
||||||
|
self.store.idempotency[idem_ref] = (job.job_id, digest)
|
||||||
|
self.store.persist_locked()
|
||||||
|
return job
|
||||||
|
|
||||||
|
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
|
||||||
|
with self.store.lock:
|
||||||
|
job = self.store.jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(job, tenant_id)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def list_jobs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str | None = None,
|
||||||
|
profile_id: str | None = None,
|
||||||
|
states: Iterable[HermesJobState | str] | None = None,
|
||||||
|
) -> list[HermesJob]:
|
||||||
|
state_filter = {HermesJobState(item) for item in states} if states else None
|
||||||
|
with self.store.lock:
|
||||||
|
if self._requeue_expired_locked():
|
||||||
|
self.store.persist_locked()
|
||||||
|
jobs = [
|
||||||
|
job
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
if job.tenant_id == tenant_id
|
||||||
|
and (cell_id is None or job.cell_id == cell_id)
|
||||||
|
and (profile_id is None or job.profile_id == profile_id)
|
||||||
|
and (state_filter is None or job.state in state_filter)
|
||||||
|
]
|
||||||
|
return sorted(jobs, key=lambda item: (item.priority, item.created_at, item.job_id))
|
||||||
|
|
||||||
|
def claim(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int | None = None,
|
||||||
|
job_id: str | None = None,
|
||||||
|
) -> HermesJob | None:
|
||||||
|
worker_id = str(worker_id or "").strip()
|
||||||
|
if not worker_id:
|
||||||
|
raise ValueError("worker_id is required")
|
||||||
|
duration = int(lease_seconds or self.default_lease_seconds)
|
||||||
|
if duration < 5:
|
||||||
|
raise ValueError("lease_seconds must be >= 5")
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
candidates = [
|
||||||
|
job
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
if job.tenant_id == tenant_id
|
||||||
|
and job.cell_id == cell_id
|
||||||
|
and job.profile_id == profile_id
|
||||||
|
and (job_id is None or job.job_id == job_id)
|
||||||
|
and job.state == HermesJobState.QUEUED
|
||||||
|
]
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||||
|
if not candidates:
|
||||||
|
self.store.persist_locked()
|
||||||
|
return None
|
||||||
|
current = sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda item: (item.priority, item.created_at, item.job_id),
|
||||||
|
)[0]
|
||||||
|
claimed = self._transition_locked(
|
||||||
|
current,
|
||||||
|
HermesJobState.CLAIMED,
|
||||||
|
assigned_worker_id=worker_id,
|
||||||
|
lease_expires_at=self._format(
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
|
||||||
|
self.store.persist_locked()
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
updated = self._transition_locked(current, HermesJobState.RUNNING)
|
||||||
|
self.store.persist_locked()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def heartbeat(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
duration = int(lease_seconds or self.default_lease_seconds)
|
||||||
|
if duration < 5:
|
||||||
|
raise ValueError("lease_seconds must be >= 5")
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
|
||||||
|
raise HermesConflict("job does not accept heartbeat")
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
lease_expires_at=self._format(
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||||
|
),
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.jobs[job_id] = updated
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
|
||||||
|
self.store.persist_locked()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
output: Mapping[str, object],
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
return self._finish(
|
||||||
|
job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
state=HermesJobState.COMPLETED,
|
||||||
|
output=output,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
error: Mapping[str, object],
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
return self._finish(
|
||||||
|
job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
state=HermesJobState.FAILED,
|
||||||
|
error=error,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
current = self.store.jobs.get(job_id)
|
||||||
|
if current is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(current, tenant_id)
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
if current.state in TERMINAL_STATES:
|
||||||
|
result = self.store.results.get(job_id)
|
||||||
|
if result is None:
|
||||||
|
raise HermesConflict("terminal result missing")
|
||||||
|
if result.state != HermesJobState.CANCELLED:
|
||||||
|
raise HermesConflict("job already has a different terminal result")
|
||||||
|
return result
|
||||||
|
updated = self._transition_locked(current, HermesJobState.CANCELLED)
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=updated.job_id,
|
||||||
|
tenant_id=updated.tenant_id,
|
||||||
|
task_id=updated.task_id,
|
||||||
|
trace_id=updated.trace_id,
|
||||||
|
state=HermesJobState.CANCELLED,
|
||||||
|
worker_id=updated.assigned_worker_id,
|
||||||
|
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.results[job_id] = result
|
||||||
|
self.store.persist_locked()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
job = self.store.jobs.get(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(job, tenant_id)
|
||||||
|
result = self.store.results.get(job_id)
|
||||||
|
if result is None:
|
||||||
|
raise HermesConflict("job has no terminal result")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def requeue_expired(self) -> tuple[str, ...]:
|
||||||
|
"""Persistently release expired leases without changing policy state."""
|
||||||
|
with self.store.lock:
|
||||||
|
changed = self._requeue_expired_locked()
|
||||||
|
if changed:
|
||||||
|
self.store.persist_locked()
|
||||||
|
return tuple(sorted(changed))
|
||||||
|
|
||||||
|
def health(self) -> HermesHealth:
|
||||||
|
with self.store.lock:
|
||||||
|
if self._requeue_expired_locked():
|
||||||
|
self.store.persist_locked()
|
||||||
|
active_jobs = sum(
|
||||||
|
1 for job in self.store.jobs.values()
|
||||||
|
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||||
|
)
|
||||||
|
queued_jobs = sum(
|
||||||
|
1 for job in self.store.jobs.values()
|
||||||
|
if job.state == HermesJobState.QUEUED
|
||||||
|
)
|
||||||
|
partitions = {
|
||||||
|
(job.tenant_id, job.cell_id, job.profile_id)
|
||||||
|
for job in self.store.jobs.values()
|
||||||
|
}
|
||||||
|
active_workers = sum(
|
||||||
|
1 for heartbeat, _ in self.store.worker_heartbeats.values()
|
||||||
|
if self._parse(heartbeat)
|
||||||
|
and self._parse(heartbeat) >= datetime.now(timezone.utc) - timedelta(seconds=120)
|
||||||
|
)
|
||||||
|
return HermesHealth(
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
status="healthy",
|
||||||
|
version=self.runtime_version,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
active_workers=active_workers,
|
||||||
|
active_jobs=active_jobs,
|
||||||
|
queued_jobs=queued_jobs,
|
||||||
|
tenant_partitions=len(partitions),
|
||||||
|
profile_isolation_enforced=True,
|
||||||
|
details={"store": type(self.store).__name__},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
state: HermesJobState,
|
||||||
|
output: Mapping[str, object] | None = None,
|
||||||
|
error: Mapping[str, object] | None = None,
|
||||||
|
expected_revision: int | None,
|
||||||
|
) -> HermesResult:
|
||||||
|
with self.store.lock:
|
||||||
|
self._requeue_expired_locked()
|
||||||
|
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||||
|
if current.state in TERMINAL_STATES:
|
||||||
|
existing = self.store.results.get(job_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HermesConflict("terminal result missing")
|
||||||
|
if (
|
||||||
|
existing.state == state
|
||||||
|
and existing.worker_id == worker_id
|
||||||
|
and existing.output == output
|
||||||
|
and existing.error == error
|
||||||
|
):
|
||||||
|
return existing
|
||||||
|
raise HermesConflict("job already has a different terminal result")
|
||||||
|
self._assert_revision(current, expected_revision)
|
||||||
|
updated = self._transition_locked(current, state)
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=updated.job_id,
|
||||||
|
tenant_id=updated.tenant_id,
|
||||||
|
task_id=updated.task_id,
|
||||||
|
trace_id=updated.trace_id,
|
||||||
|
state=state,
|
||||||
|
worker_id=worker_id,
|
||||||
|
output=output,
|
||||||
|
error=error,
|
||||||
|
started_at=current.updated_at,
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.results[job_id] = result
|
||||||
|
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||||
|
self.store.persist_locked()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
|
||||||
|
current = self.store.jobs.get(job_id)
|
||||||
|
if current is None:
|
||||||
|
raise HermesNotFound("job not found")
|
||||||
|
self._assert_tenant(current, tenant_id)
|
||||||
|
if current.assigned_worker_id != worker_id:
|
||||||
|
raise HermesConflict("worker does not own job lease")
|
||||||
|
return current
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_revision(job: HermesJob, expected: int | None) -> None:
|
||||||
|
if expected is not None and job.revision != expected:
|
||||||
|
raise HermesConflict("job revision changed")
|
||||||
|
|
||||||
|
def _transition_locked(
|
||||||
|
self,
|
||||||
|
current: HermesJob,
|
||||||
|
target: HermesJobState,
|
||||||
|
*,
|
||||||
|
assigned_worker_id: str | None = None,
|
||||||
|
lease_expires_at: str | None = None,
|
||||||
|
) -> HermesJob:
|
||||||
|
if target not in ALLOWED_TRANSITIONS[current.state]:
|
||||||
|
raise HermesConflict(
|
||||||
|
f"invalid transition {current.state.value} -> {target.value}"
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
state=target,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
assigned_worker_id=(
|
||||||
|
assigned_worker_id
|
||||||
|
if assigned_worker_id is not None
|
||||||
|
else current.assigned_worker_id
|
||||||
|
),
|
||||||
|
lease_expires_at=(
|
||||||
|
lease_expires_at
|
||||||
|
if lease_expires_at is not None
|
||||||
|
else (None if target in TERMINAL_STATES else current.lease_expires_at)
|
||||||
|
),
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
self.store.jobs[current.job_id] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def _requeue_expired_locked(self) -> set[str]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
changed: set[str] = set()
|
||||||
|
for current in list(self.store.jobs.values()):
|
||||||
|
expiry = self._parse(current.lease_expires_at)
|
||||||
|
if (
|
||||||
|
current.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||||
|
and expiry is not None
|
||||||
|
and expiry <= now
|
||||||
|
):
|
||||||
|
self.store.jobs[current.job_id] = replace(
|
||||||
|
current,
|
||||||
|
state=HermesJobState.QUEUED,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
assigned_worker_id=None,
|
||||||
|
lease_expires_at=None,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
)
|
||||||
|
changed.add(current.job_id)
|
||||||
|
return changed
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Tenant-scoped Hermes runtime contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
class HermesJobState(str, Enum):
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
CLAIMED = "CLAIMED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
|
||||||
|
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
|
||||||
|
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
|
||||||
|
HermesJobState.RUNNING: frozenset(
|
||||||
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||||
|
),
|
||||||
|
HermesJobState.COMPLETED: frozenset(),
|
||||||
|
HermesJobState.FAILED: frozenset(),
|
||||||
|
HermesJobState.CANCELLED: frozenset(),
|
||||||
|
}
|
||||||
|
TERMINAL_STATES = frozenset(
|
||||||
|
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesJob:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
cell_id: str
|
||||||
|
profile_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
idempotency_key: str = ""
|
||||||
|
priority: int = 100
|
||||||
|
state: HermesJobState = HermesJobState.QUEUED
|
||||||
|
revision: int = 1
|
||||||
|
assigned_worker_id: str | None = None
|
||||||
|
lease_expires_at: str | None = None
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
updated_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
|
||||||
|
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||||
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||||
|
if not 0 <= int(self.priority) <= 1000:
|
||||||
|
raise ValueError("priority must be between 0 and 1000")
|
||||||
|
if self.revision < 1:
|
||||||
|
raise ValueError("revision must be >= 1")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
priority: int = 100,
|
||||||
|
) -> "HermesJob":
|
||||||
|
return cls(
|
||||||
|
job_id=f"hermes_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
cell_id=cell_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
task_id=task_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=action,
|
||||||
|
payload=_safe_mapping(payload),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesResult:
|
||||||
|
job_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
state: HermesJobState
|
||||||
|
worker_id: str | None = None
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||||
|
if self.output is not None:
|
||||||
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||||
|
if self.error is not None:
|
||||||
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HermesHealth:
|
||||||
|
runtime_id: str
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
heartbeat_at: str
|
||||||
|
active_workers: int = 0
|
||||||
|
active_jobs: int = 0
|
||||||
|
queued_jobs: int = 0
|
||||||
|
tenant_partitions: int = 0
|
||||||
|
profile_isolation_enforced: bool = True
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("runtime_id", "status", "version", "heartbeat_at"):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
|
||||||
|
if int(getattr(self, name)) < 0:
|
||||||
|
raise ValueError(f"{name} must be >= 0")
|
||||||
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Hermes worker loop with lease renewal and bounded recovery."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
from .adapter import HermesAdapter, HermesConflict
|
||||||
|
from .contract import HermesJob, HermesJobState
|
||||||
|
|
||||||
|
|
||||||
|
JobHandler = Callable[[HermesJob], Mapping[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerRunResult:
|
||||||
|
claimed: bool
|
||||||
|
job_id: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HermesWorker:
|
||||||
|
"""Poll one tenant/cell/profile partition and execute jobs safely."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: HermesAdapter,
|
||||||
|
handler: JobHandler,
|
||||||
|
*,
|
||||||
|
worker_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
cell_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
poll_seconds: float = 1.0,
|
||||||
|
heartbeat_seconds: float = 10.0,
|
||||||
|
) -> None:
|
||||||
|
if poll_seconds <= 0 or heartbeat_seconds <= 0:
|
||||||
|
raise ValueError("poll_seconds and heartbeat_seconds must be positive")
|
||||||
|
for name, value in {
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"cell_id": cell_id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
}.items():
|
||||||
|
if not str(value or "").strip():
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
self.adapter = adapter
|
||||||
|
self.handler = handler
|
||||||
|
self.worker_id = worker_id
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.cell_id = cell_id
|
||||||
|
self.profile_id = profile_id
|
||||||
|
self.poll_seconds = float(poll_seconds)
|
||||||
|
self.heartbeat_seconds = float(heartbeat_seconds)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def run_once(self) -> WorkerRunResult:
|
||||||
|
job = self.adapter.claim(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
cell_id=self.cell_id,
|
||||||
|
profile_id=self.profile_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
)
|
||||||
|
if job is None:
|
||||||
|
return WorkerRunResult(claimed=False)
|
||||||
|
|
||||||
|
running = self.adapter.start(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
expected_revision=job.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
heartbeat_thread = threading.Thread(
|
||||||
|
target=self._heartbeat_loop,
|
||||||
|
args=(running.job_id, heartbeat_stop),
|
||||||
|
name=f"hermes-heartbeat-{self.worker_id}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
heartbeat_thread.start()
|
||||||
|
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
execution_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
output = dict(self.handler(running))
|
||||||
|
except Exception as exc: # runtime boundary: normalize below
|
||||||
|
execution_error = exc
|
||||||
|
finally:
|
||||||
|
# Stop the lease heartbeat before reading the final revision. This
|
||||||
|
# prevents a revision increment racing with complete/fail.
|
||||||
|
heartbeat_stop.set()
|
||||||
|
heartbeat_thread.join(timeout=max(self.heartbeat_seconds * 2, 1.0))
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = self.adapter.get(running.job_id, tenant_id=self.tenant_id)
|
||||||
|
if execution_error is None:
|
||||||
|
result = self.adapter.complete(
|
||||||
|
running.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
output=dict(output or {}),
|
||||||
|
expected_revision=current.revision,
|
||||||
|
)
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=result.job_id,
|
||||||
|
state=result.state.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.adapter.fail(
|
||||||
|
running.job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
error={
|
||||||
|
"code": "worker_error",
|
||||||
|
"message": str(execution_error),
|
||||||
|
},
|
||||||
|
expected_revision=current.revision,
|
||||||
|
)
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=result.job_id,
|
||||||
|
state=result.state.value,
|
||||||
|
error=str(execution_error),
|
||||||
|
)
|
||||||
|
except HermesConflict as exc:
|
||||||
|
return WorkerRunResult(
|
||||||
|
claimed=True,
|
||||||
|
job_id=running.job_id,
|
||||||
|
state=HermesJobState.FAILED.value,
|
||||||
|
error=f"reconciliation_conflict: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_forever(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
result = self.run_once()
|
||||||
|
if not result.claimed:
|
||||||
|
self._stop.wait(self.poll_seconds)
|
||||||
|
|
||||||
|
def _heartbeat_loop(self, job_id: str, stop_event: threading.Event) -> None:
|
||||||
|
while not stop_event.wait(self.heartbeat_seconds):
|
||||||
|
try:
|
||||||
|
self.adapter.heartbeat(
|
||||||
|
job_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
worker_id=self.worker_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
stop_event.set()
|
||||||
|
return
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Future AIOS capabilities isolated from the V2R1C1 release line."""
|
||||||
|
|
||||||
|
__all__: list[str] = []
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Channel-neutral adapter contracts for future evaluation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterHealthStatus(str, Enum):
|
||||||
|
HEALTHY = "healthy"
|
||||||
|
DEGRADED = "degraded"
|
||||||
|
UNAVAILABLE = "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdapterHealth:
|
||||||
|
status: AdapterHealthStatus
|
||||||
|
observed_at: str
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdapterCapability:
|
||||||
|
name: str
|
||||||
|
version: str = "1"
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdapterSession:
|
||||||
|
session_id: str
|
||||||
|
tenant_id: str
|
||||||
|
state: str
|
||||||
|
expires_at: str | None = None
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdapterResult:
|
||||||
|
ok: bool
|
||||||
|
state: str
|
||||||
|
output: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
error: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ChannelAdapter(Protocol):
|
||||||
|
def health(self) -> AdapterHealth: ...
|
||||||
|
|
||||||
|
def capabilities(self) -> Sequence[AdapterCapability]: ...
|
||||||
|
|
||||||
|
def open_session(self, *, tenant_id: str) -> AdapterSession: ...
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self, *, session: AdapterSession, action: str, payload: Mapping[str, Any]
|
||||||
|
) -> AdapterResult: ...
|
||||||
|
|
||||||
|
def cancel(self, *, session: AdapterSession) -> AdapterResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PortalAdapter(ChannelAdapter, Protocol):
|
||||||
|
"""Marker protocol for user-facing portal adapters."""
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Platform-neutral authentication-session state machine."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Mapping, Any
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionState(str, Enum):
|
||||||
|
CREATED = "CREATED"
|
||||||
|
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||||
|
USER_CONFIRMED = "USER_CONFIRMED"
|
||||||
|
AUTHENTICATED = "AUTHENTICATED"
|
||||||
|
EXPIRED = "EXPIRED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
_TRANSITIONS = {
|
||||||
|
AuthSessionState.CREATED: {
|
||||||
|
AuthSessionState.WAITING_FOR_USER,
|
||||||
|
AuthSessionState.CANCELLED,
|
||||||
|
AuthSessionState.FAILED,
|
||||||
|
},
|
||||||
|
AuthSessionState.WAITING_FOR_USER: {
|
||||||
|
AuthSessionState.USER_CONFIRMED,
|
||||||
|
AuthSessionState.EXPIRED,
|
||||||
|
AuthSessionState.CANCELLED,
|
||||||
|
AuthSessionState.FAILED,
|
||||||
|
},
|
||||||
|
AuthSessionState.USER_CONFIRMED: {
|
||||||
|
AuthSessionState.AUTHENTICATED,
|
||||||
|
AuthSessionState.EXPIRED,
|
||||||
|
AuthSessionState.CANCELLED,
|
||||||
|
AuthSessionState.FAILED,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidAuthSessionTransition(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthSession:
|
||||||
|
session_id: str
|
||||||
|
tenant_id: str
|
||||||
|
state: AuthSessionState = AuthSessionState.CREATED
|
||||||
|
expires_at: str | None = None
|
||||||
|
metadata: Mapping[str, Any] | None = None
|
||||||
|
|
||||||
|
def transition(self, target: AuthSessionState) -> "AuthSession":
|
||||||
|
allowed = _TRANSITIONS.get(self.state, set())
|
||||||
|
if target not in allowed:
|
||||||
|
raise InvalidAuthSessionTransition(f"{self.state.value} -> {target.value}")
|
||||||
|
return replace(self, state=target)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def terminal(self) -> bool:
|
||||||
|
return self.state in {
|
||||||
|
AuthSessionState.AUTHENTICATED,
|
||||||
|
AuthSessionState.EXPIRED,
|
||||||
|
AuthSessionState.CANCELLED,
|
||||||
|
AuthSessionState.FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Incubator-only versioned contract primitives.
|
||||||
|
|
||||||
|
This module distills the reusable contract-first practices observed in the
|
||||||
|
Personal Weixin golden-contract work without importing channel-specific fields
|
||||||
|
into the AIOS control plane. It is intentionally not imported by production
|
||||||
|
AIOS V2R1 modules.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
class ContractViolation(ValueError):
|
||||||
|
"""A payload does not satisfy its declared contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class Compatibility(str, Enum):
|
||||||
|
EXACT = "exact"
|
||||||
|
BACKWARD_COMPATIBLE = "backward_compatible"
|
||||||
|
INCOMPATIBLE = "incompatible"
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
dict(value),
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def payload_digest(value: Mapping[str, Any]) -> str:
|
||||||
|
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class ContractVersion:
|
||||||
|
major: int
|
||||||
|
minor: int
|
||||||
|
patch: int = 0
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if min(self.major, self.minor, self.patch) < 0:
|
||||||
|
raise ValueError("contract version components must be non-negative")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, value: str) -> "ContractVersion":
|
||||||
|
parts = str(value or "").strip().split(".")
|
||||||
|
if len(parts) not in {2, 3} or any(not item.isdigit() for item in parts):
|
||||||
|
raise ValueError("contract version must use MAJOR.MINOR[.PATCH]")
|
||||||
|
numbers = [int(item) for item in parts]
|
||||||
|
if len(numbers) == 2:
|
||||||
|
numbers.append(0)
|
||||||
|
return cls(*numbers)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.major}.{self.minor}.{self.patch}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VersionedContract:
|
||||||
|
name: str
|
||||||
|
version: ContractVersion
|
||||||
|
required_fields: Sequence[str]
|
||||||
|
optional_fields: Sequence[str] = ()
|
||||||
|
forbidden_fields: Sequence[str] = ()
|
||||||
|
secret_fields: Sequence[str] = ()
|
||||||
|
allow_unknown_fields: bool = False
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
name = str(self.name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("contract name is required")
|
||||||
|
object.__setattr__(self, "name", name)
|
||||||
|
required = frozenset(str(item).strip() for item in self.required_fields if str(item).strip())
|
||||||
|
optional = frozenset(str(item).strip() for item in self.optional_fields if str(item).strip())
|
||||||
|
forbidden = frozenset(str(item).strip() for item in self.forbidden_fields if str(item).strip())
|
||||||
|
secrets = frozenset(str(item).strip() for item in self.secret_fields if str(item).strip())
|
||||||
|
if required & forbidden:
|
||||||
|
raise ValueError("required fields cannot also be forbidden")
|
||||||
|
if required & optional:
|
||||||
|
raise ValueError("required and optional fields must be disjoint")
|
||||||
|
object.__setattr__(self, "required_fields", tuple(sorted(required)))
|
||||||
|
object.__setattr__(self, "optional_fields", tuple(sorted(optional)))
|
||||||
|
object.__setattr__(self, "forbidden_fields", tuple(sorted(forbidden)))
|
||||||
|
object.__setattr__(self, "secret_fields", tuple(sorted(secrets)))
|
||||||
|
object.__setattr__(self, "metadata", json.loads(json.dumps(dict(self.metadata), default=str)))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed_fields(self) -> frozenset[str]:
|
||||||
|
return frozenset((*self.required_fields, *self.optional_fields))
|
||||||
|
|
||||||
|
def validate(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
normalized = json.loads(json.dumps(dict(payload), ensure_ascii=False, default=str))
|
||||||
|
fields = set(normalized)
|
||||||
|
missing = sorted(set(self.required_fields) - fields)
|
||||||
|
forbidden = sorted(set(self.forbidden_fields) & fields)
|
||||||
|
unknown = sorted(fields - self.allowed_fields)
|
||||||
|
if missing:
|
||||||
|
raise ContractViolation(f"missing required fields: {missing}")
|
||||||
|
if forbidden:
|
||||||
|
raise ContractViolation(f"forbidden fields present: {forbidden}")
|
||||||
|
if unknown and not self.allow_unknown_fields:
|
||||||
|
raise ContractViolation(f"unknown fields present: {unknown}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def redact(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
normalized = json.loads(json.dumps(dict(payload), ensure_ascii=False, default=str))
|
||||||
|
for field_name in self.secret_fields:
|
||||||
|
if field_name in normalized:
|
||||||
|
normalized[field_name] = "***"
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def compatibility_with(self, previous: "VersionedContract") -> Compatibility:
|
||||||
|
if self.name != previous.name:
|
||||||
|
return Compatibility.INCOMPATIBLE
|
||||||
|
if self.version == previous.version and self == previous:
|
||||||
|
return Compatibility.EXACT
|
||||||
|
if self.version.major != previous.version.major:
|
||||||
|
return Compatibility.INCOMPATIBLE
|
||||||
|
if not set(previous.required_fields).issubset(self.allowed_fields):
|
||||||
|
return Compatibility.INCOMPATIBLE
|
||||||
|
if set(previous.allowed_fields) & set(self.forbidden_fields):
|
||||||
|
return Compatibility.INCOMPATIBLE
|
||||||
|
if not self.allow_unknown_fields and not set(previous.allowed_fields).issubset(self.allowed_fields):
|
||||||
|
return Compatibility.INCOMPATIBLE
|
||||||
|
return Compatibility.BACKWARD_COMPATIBLE
|
||||||
|
|
||||||
|
def descriptor(self) -> dict[str, Any]:
|
||||||
|
value = {
|
||||||
|
"name": self.name,
|
||||||
|
"version": str(self.version),
|
||||||
|
"required_fields": list(self.required_fields),
|
||||||
|
"optional_fields": list(self.optional_fields),
|
||||||
|
"forbidden_fields": list(self.forbidden_fields),
|
||||||
|
"secret_fields": list(self.secret_fields),
|
||||||
|
"allow_unknown_fields": self.allow_unknown_fields,
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
value["descriptor_digest"] = payload_digest(value)
|
||||||
|
return value
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Evidence integrity and replacement-audit primitives."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceIntegrityError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(65536), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LogReplacement:
|
||||||
|
artifact: str
|
||||||
|
previous_sha256: str
|
||||||
|
replacement_sha256: str
|
||||||
|
reason: str
|
||||||
|
replaced_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EvidenceManifest:
|
||||||
|
command: Sequence[str]
|
||||||
|
start_time: str
|
||||||
|
end_time: str
|
||||||
|
exit_code: int
|
||||||
|
artifacts: Mapping[str, str]
|
||||||
|
result_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
reviewer_status: str = "PENDING"
|
||||||
|
supersedes: Sequence[str] = ()
|
||||||
|
replacement_audit: Sequence[LogReplacement] = ()
|
||||||
|
|
||||||
|
def validate(self, root: Path) -> None:
|
||||||
|
for relative, expected in self.artifacts.items():
|
||||||
|
path = (root / relative).resolve()
|
||||||
|
if root.resolve() not in path.parents:
|
||||||
|
raise EvidenceIntegrityError("artifact escapes evidence root")
|
||||||
|
if not path.is_file():
|
||||||
|
raise EvidenceIntegrityError(f"artifact missing: {relative}")
|
||||||
|
actual = sha256_file(path)
|
||||||
|
if actual != expected:
|
||||||
|
raise EvidenceIntegrityError(f"artifact hash mismatch: {relative}")
|
||||||
|
|
||||||
|
def replace_artifact(
|
||||||
|
self, *, artifact: str, replacement_sha256: str, reason: str, replaced_at: str
|
||||||
|
) -> "EvidenceManifest":
|
||||||
|
previous = self.artifacts.get(artifact)
|
||||||
|
if previous is None:
|
||||||
|
raise EvidenceIntegrityError("replacement target is not registered")
|
||||||
|
if not reason.strip():
|
||||||
|
raise EvidenceIntegrityError("replacement reason is required")
|
||||||
|
updated = dict(self.artifacts)
|
||||||
|
updated[artifact] = replacement_sha256
|
||||||
|
audit = LogReplacement(
|
||||||
|
artifact=artifact,
|
||||||
|
previous_sha256=previous,
|
||||||
|
replacement_sha256=replacement_sha256,
|
||||||
|
reason=reason,
|
||||||
|
replaced_at=replaced_at,
|
||||||
|
)
|
||||||
|
return replace(
|
||||||
|
self,
|
||||||
|
artifacts=updated,
|
||||||
|
replacement_audit=(*self.replacement_audit, audit),
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"command": list(self.command),
|
||||||
|
"start_time": self.start_time,
|
||||||
|
"end_time": self.end_time,
|
||||||
|
"exit_code": self.exit_code,
|
||||||
|
"artifacts": dict(self.artifacts),
|
||||||
|
"result_summary": json.loads(json.dumps(dict(self.result_summary), default=str)),
|
||||||
|
"reviewer_status": self.reviewer_status,
|
||||||
|
"supersedes": list(self.supersedes),
|
||||||
|
"replacement_audit": [item.__dict__ for item in self.replacement_audit],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Provider-neutral runner lifecycle contracts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerStatus(str, Enum):
|
||||||
|
PROVISIONING = "provisioning"
|
||||||
|
READY = "ready"
|
||||||
|
RUNNING = "running"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
FAILED = "failed"
|
||||||
|
DEPROVISIONED = "deprovisioned"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunnerHandle:
|
||||||
|
runner_id: str
|
||||||
|
provider: str
|
||||||
|
status: RunnerStatus
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunnerHealth:
|
||||||
|
ok: bool
|
||||||
|
status: RunnerStatus
|
||||||
|
observed_at: str
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunnerExecution:
|
||||||
|
execution_id: str
|
||||||
|
runner_id: str
|
||||||
|
status: RunnerStatus
|
||||||
|
exit_code: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunnerEvidence:
|
||||||
|
execution_id: str
|
||||||
|
artifacts: Mapping[str, str]
|
||||||
|
summary: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RunnerProvider(Protocol):
|
||||||
|
def provision(self, request: Mapping[str, Any]) -> RunnerHandle: ...
|
||||||
|
|
||||||
|
def health(self, handle: RunnerHandle) -> RunnerHealth: ...
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self, handle: RunnerHandle, request: Mapping[str, Any]
|
||||||
|
) -> RunnerExecution: ...
|
||||||
|
|
||||||
|
def cancel(self, execution: RunnerExecution) -> RunnerExecution: ...
|
||||||
|
|
||||||
|
def collect_evidence(self, execution: RunnerExecution) -> RunnerEvidence: ...
|
||||||
|
|
||||||
|
def deprovision(self, handle: RunnerHandle) -> RunnerHandle: ...
|
||||||
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Explicit worktree ownership and release-blocking classification."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
class WorktreeState(str, Enum):
|
||||||
|
ACTIVE = "ACTIVE"
|
||||||
|
READY_TO_COMMIT = "READY_TO_COMMIT"
|
||||||
|
COMMITTED = "COMMITTED"
|
||||||
|
ARCHIVED = "ARCHIVED"
|
||||||
|
OWNER_REVIEW_REQUIRED = "OWNER_REVIEW_REQUIRED"
|
||||||
|
TRUSTED_RUNTIME_DIRTY = "TRUSTED_RUNTIME_DIRTY"
|
||||||
|
STALE_CLEANUP_REQUIRED = "STALE_CLEANUP_REQUIRED"
|
||||||
|
REMOVED = "REMOVED"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorktreeRecord:
|
||||||
|
path: str
|
||||||
|
owner: str
|
||||||
|
task_id: str
|
||||||
|
branch: str
|
||||||
|
base_oid: str
|
||||||
|
current_oid: str
|
||||||
|
heartbeat_at: str | None = None
|
||||||
|
dirty_count: int = 0
|
||||||
|
locked: bool = False
|
||||||
|
prunable: bool = False
|
||||||
|
active_runtime: bool = False
|
||||||
|
archive_oid: str | None = None
|
||||||
|
removed_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not PurePosixPath(self.path).is_absolute():
|
||||||
|
raise ValueError("worktree path must be absolute")
|
||||||
|
if not self.owner or not self.task_id or not self.current_oid:
|
||||||
|
raise ValueError("owner, task_id and current_oid are required")
|
||||||
|
if self.dirty_count < 0:
|
||||||
|
raise ValueError("dirty_count cannot be negative")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> WorktreeState:
|
||||||
|
if self.removed_at:
|
||||||
|
return WorktreeState.REMOVED
|
||||||
|
if self.locked or self.prunable:
|
||||||
|
return WorktreeState.STALE_CLEANUP_REQUIRED
|
||||||
|
if self.dirty_count and self.active_runtime:
|
||||||
|
return WorktreeState.TRUSTED_RUNTIME_DIRTY
|
||||||
|
if self.dirty_count:
|
||||||
|
return WorktreeState.OWNER_REVIEW_REQUIRED
|
||||||
|
if self.archive_oid:
|
||||||
|
return WorktreeState.ARCHIVED
|
||||||
|
if self.current_oid != self.base_oid:
|
||||||
|
return WorktreeState.READY_TO_COMMIT
|
||||||
|
return WorktreeState.ACTIVE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def release_blocking(self) -> bool:
|
||||||
|
return self.state in {
|
||||||
|
WorktreeState.OWNER_REVIEW_REQUIRED,
|
||||||
|
WorktreeState.TRUSTED_RUNTIME_DIRTY,
|
||||||
|
WorktreeState.STALE_CLEANUP_REQUIRED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorktreeRegistry:
|
||||||
|
def __init__(self, records: Iterable[WorktreeRecord] = ()) -> None:
|
||||||
|
self._records: dict[str, WorktreeRecord] = {}
|
||||||
|
for record in records:
|
||||||
|
self.upsert(record)
|
||||||
|
|
||||||
|
def upsert(self, record: WorktreeRecord) -> None:
|
||||||
|
self._records[record.path] = record
|
||||||
|
|
||||||
|
def get(self, path: str) -> WorktreeRecord:
|
||||||
|
return self._records[path]
|
||||||
|
|
||||||
|
def mark_archived(self, path: str, archive_oid: str) -> WorktreeRecord:
|
||||||
|
if not archive_oid:
|
||||||
|
raise ValueError("archive_oid is required")
|
||||||
|
current = self.get(path)
|
||||||
|
if current.dirty_count:
|
||||||
|
raise ValueError("dirty worktree cannot be marked archived")
|
||||||
|
updated = replace(current, archive_oid=archive_oid)
|
||||||
|
self.upsert(updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def release_blockers(self) -> tuple[WorktreeRecord, ...]:
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
(record for record in self._records.values() if record.release_blocking),
|
||||||
|
key=lambda item: item.path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshot(self) -> tuple[WorktreeRecord, ...]:
|
||||||
|
return tuple(sorted(self._records.values(), key=lambda item: item.path))
|
||||||
|
|
||||||
@@ -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,23 +0,0 @@
|
|||||||
"""Task-bound Integration Refresh control-plane primitives."""
|
|
||||||
|
|
||||||
from .integration_merge_writer import IntegrationMergeWriter, MergeError, MergeRequest
|
|
||||||
from .integration_refresh import (
|
|
||||||
AppendOnlyAudit,
|
|
||||||
Binding,
|
|
||||||
GiteaBranchReadProjection,
|
|
||||||
GrantStore,
|
|
||||||
IntegrationRefreshProvisioner,
|
|
||||||
ProvisioningError,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AppendOnlyAudit",
|
|
||||||
"Binding",
|
|
||||||
"GiteaBranchReadProjection",
|
|
||||||
"GrantStore",
|
|
||||||
"IntegrationMergeWriter",
|
|
||||||
"IntegrationRefreshProvisioner",
|
|
||||||
"MergeError",
|
|
||||||
"MergeRequest",
|
|
||||||
"ProvisioningError",
|
|
||||||
]
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Constrained integration-only merge writer with rollback and reconciliation states."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
class MergeError(RuntimeError):
|
|
||||||
def __init__(self, code: str, status: int = 409):
|
|
||||||
super().__init__(code); self.code, self.status = code, status
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MergeRequest:
|
|
||||||
integration_branch: str
|
|
||||||
source_branch: str
|
|
||||||
approved_source_oid: str
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationMergeWriter:
|
|
||||||
def __init__(self, workspace: Path | str, remote: str, projection):
|
|
||||||
self.workspace, self.remote, self.projection = Path(workspace), remote, projection
|
|
||||||
|
|
||||||
def _git(self, *args: str, check=True) -> str:
|
|
||||||
p = subprocess.run(["git", *args], cwd=self.workspace, text=True, capture_output=True)
|
|
||||||
if check and p.returncode:
|
|
||||||
raise MergeError("git_operation_failed", 500)
|
|
||||||
return p.stdout.strip()
|
|
||||||
|
|
||||||
def merge(self, request: MergeRequest) -> dict:
|
|
||||||
def bad(value: str) -> bool:
|
|
||||||
return (
|
|
||||||
value == "main"
|
|
||||||
or value.startswith("main/")
|
|
||||||
or value == "governance"
|
|
||||||
or value.startswith("governance/")
|
|
||||||
or value == "approvals"
|
|
||||||
or value.startswith("refs/")
|
|
||||||
or value.startswith("tags/")
|
|
||||||
)
|
|
||||||
if (bad(request.integration_branch) or bad(request.source_branch) or
|
|
||||||
not request.integration_branch.startswith("integration/") or
|
|
||||||
re.fullmatch(r"[0-9a-fA-F]{40}", request.approved_source_oid) is None):
|
|
||||||
raise MergeError("integration_merge_scope_rejected", 403)
|
|
||||||
for branch in (request.integration_branch, request.source_branch):
|
|
||||||
if subprocess.run(["git", "check-ref-format", "--branch", branch], cwd=self.workspace,
|
|
||||||
text=True, capture_output=True).returncode:
|
|
||||||
raise MergeError("integration_merge_scope_rejected", 403)
|
|
||||||
if self._git("rev-parse", "--abbrev-ref", "HEAD") != request.integration_branch:
|
|
||||||
raise MergeError("integration_branch_checkout_required")
|
|
||||||
before = self._git("rev-parse", "HEAD")
|
|
||||||
remote_before = self.projection(request.integration_branch)
|
|
||||||
if remote_before != before:
|
|
||||||
raise MergeError("integration_remote_advanced")
|
|
||||||
source = self._git("rev-parse", "--verify", f"refs/heads/{request.source_branch}^{{commit}}")
|
|
||||||
if source != request.approved_source_oid:
|
|
||||||
raise MergeError("integration_source_not_approved", 403)
|
|
||||||
attempt = subprocess.run(["git", "merge", "--no-ff", "--no-commit", request.source_branch], cwd=self.workspace, text=True, capture_output=True)
|
|
||||||
if attempt.returncode:
|
|
||||||
subprocess.run(["git", "merge", "--abort"], cwd=self.workspace, text=True, capture_output=True)
|
|
||||||
raise MergeError("integration_merge_conflict")
|
|
||||||
try:
|
|
||||||
self._git("commit", "-m", "merge(integration): approved source")
|
|
||||||
merged = self._git("rev-parse", "HEAD")
|
|
||||||
parents = self._git("show", "-s", "--format=%P", "HEAD").split()
|
|
||||||
if parents != [before, source]:
|
|
||||||
raise MergeError("integration_parent_invariant_failed", 500)
|
|
||||||
pushed = subprocess.run(["git", "push", "--porcelain", self.remote, f"HEAD:refs/heads/{request.integration_branch}"], cwd=self.workspace, text=True, capture_output=True)
|
|
||||||
if pushed.returncode:
|
|
||||||
try:
|
|
||||||
observed = self.projection(request.integration_branch)
|
|
||||||
except Exception:
|
|
||||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
|
||||||
if observed == before:
|
|
||||||
self._git("reset", "--hard", before)
|
|
||||||
return {"state": "PUSH_FAILED_RESTORED", "pre_merge_oid": before}
|
|
||||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
|
||||||
try:
|
|
||||||
observed = self.projection(request.integration_branch)
|
|
||||||
except Exception:
|
|
||||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
|
||||||
if observed != merged:
|
|
||||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
|
||||||
return {"state": "COMPLETED", "pre_merge_oid": before, "merge_oid": merged}
|
|
||||||
except MergeError:
|
|
||||||
if self._git("rev-parse", "HEAD") != before:
|
|
||||||
self._git("reset", "--hard", before)
|
|
||||||
raise
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
"""Fail-closed, task-bound integration refresh provisioning primitives.
|
|
||||||
|
|
||||||
This module deliberately has no HTTP or credential-provider dependency. The
|
|
||||||
caller supplies a branch-read projection and an apply callback, making the
|
|
||||||
authorization and recovery boundary independently testable.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import argparse
|
|
||||||
import secrets
|
|
||||||
import sqlite3
|
|
||||||
import threading
|
|
||||||
import re
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
|
|
||||||
class ProvisioningError(RuntimeError):
|
|
||||||
def __init__(self, code: str, status: int = 403):
|
|
||||||
super().__init__(code)
|
|
||||||
self.code, self.status = code, status
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _digest(secret: str) -> str:
|
|
||||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: dict) -> str:
|
|
||||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_oid(value: object) -> bool:
|
|
||||||
return isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]{40}", value) is not None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Binding:
|
|
||||||
task_id: str
|
|
||||||
repository: str
|
|
||||||
branch: str
|
|
||||||
actor_identity: str
|
|
||||||
audience: str
|
|
||||||
paths: tuple[str, ...]
|
|
||||||
quota: int
|
|
||||||
|
|
||||||
|
|
||||||
class AppendOnlyAudit:
|
|
||||||
"""Append only redacted JSONL audit, durable before a result is returned."""
|
|
||||||
def __init__(self, path: Path | str):
|
|
||||||
self.path = Path(path)
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def append(self, event: dict) -> None:
|
|
||||||
with self._lock:
|
|
||||||
safe = {key: value for key, value in event.items() if key not in {"token", "credential", "authorization"}}
|
|
||||||
previous = "0" * 64
|
|
||||||
if self.path.exists():
|
|
||||||
lines = self.path.read_text(encoding="utf-8").splitlines()
|
|
||||||
if lines:
|
|
||||||
previous = json.loads(lines[-1])["event_hash"]
|
|
||||||
safe["previous_event_hash"] = previous
|
|
||||||
safe["event_hash"] = _canonical(safe)
|
|
||||||
encoded = (json.dumps(safe, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
||||||
fd = os.open(self.path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
|
||||||
try:
|
|
||||||
os.write(fd, encoded)
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
class GrantStore:
|
|
||||||
"""Digest-only short grant registry; SQLite transactions serialize quota use."""
|
|
||||||
def __init__(self, path: Path | str):
|
|
||||||
self.path = Path(path)
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with self._db() as db:
|
|
||||||
db.execute("""CREATE TABLE IF NOT EXISTS grants(
|
|
||||||
token_digest TEXT PRIMARY KEY, binding_json TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL, quota INTEGER NOT NULL, uses INTEGER NOT NULL,
|
|
||||||
revoked INTEGER NOT NULL DEFAULT 0)""")
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _db(self):
|
|
||||||
db = sqlite3.connect(self.path, timeout=15, isolation_level=None)
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def issue(self, binding: Binding, *, ttl_seconds: int = 300) -> str:
|
|
||||||
if ttl_seconds < 1 or ttl_seconds > 1800 or binding.quota < 1 or not binding.paths:
|
|
||||||
raise ValueError("invalid_short_grant")
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
payload = json.dumps(binding.__dict__ | {"paths": list(binding.paths)}, sort_keys=True)
|
|
||||||
with self._db() as db:
|
|
||||||
db.execute("INSERT INTO grants VALUES(?,?,?,?,?,0)", (_digest(token), payload,
|
|
||||||
(_now() + timedelta(seconds=ttl_seconds)).isoformat(), binding.quota, 0))
|
|
||||||
return token
|
|
||||||
|
|
||||||
def rotate(self, old_token: str, binding: Binding, *, revocation_probe, ttl_seconds: int = 300) -> str:
|
|
||||||
self.revoke(old_token)
|
|
||||||
try: status = revocation_probe(old_token)
|
|
||||||
except TimeoutError as exc: raise ProvisioningError("revocation_probe_timeout", 503) from exc
|
|
||||||
if status != 401: raise ProvisioningError("revocation_probe_rejected", 503)
|
|
||||||
return self.issue(binding, ttl_seconds=ttl_seconds)
|
|
||||||
|
|
||||||
def revoke(self, token: str) -> None:
|
|
||||||
with self._db() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
db.execute("UPDATE grants SET revoked=1 WHERE token_digest=?", (_digest(token),))
|
|
||||||
db.execute("COMMIT")
|
|
||||||
|
|
||||||
def consume(self, token: str, binding: Binding, path: str) -> None:
|
|
||||||
with self._db() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
row = db.execute("SELECT * FROM grants WHERE token_digest=?", (_digest(token),)).fetchone()
|
|
||||||
if not row or row["revoked"]:
|
|
||||||
db.execute("ROLLBACK"); raise ProvisioningError("credential_rejected", 401)
|
|
||||||
if _now() >= datetime.fromisoformat(row["expires_at"]):
|
|
||||||
db.execute("ROLLBACK"); raise ProvisioningError("credential_expired", 401)
|
|
||||||
stored = json.loads(row["binding_json"])
|
|
||||||
requested = binding.__dict__ | {"paths": list(binding.paths)}
|
|
||||||
if stored != requested or path not in stored["paths"]:
|
|
||||||
db.execute("ROLLBACK"); raise ProvisioningError("grant_binding_rejected")
|
|
||||||
if row["uses"] >= row["quota"]:
|
|
||||||
db.execute("ROLLBACK"); raise ProvisioningError("grant_quota_exhausted")
|
|
||||||
db.execute("UPDATE grants SET uses=uses+1 WHERE token_digest=?", (_digest(token),))
|
|
||||||
db.execute("COMMIT")
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaBranchReadProjection:
|
|
||||||
"""Normalizes a trusted task-scoped Gitea reader; every ambiguity fails closed."""
|
|
||||||
def __init__(self, reader: Callable[[str, str], dict]):
|
|
||||||
self.reader = reader
|
|
||||||
|
|
||||||
def head(self, repository: str, branch: str) -> str:
|
|
||||||
try:
|
|
||||||
reply = self.reader(repository, branch)
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise ProvisioningError("gitea_projection_timeout", 503) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
raise ProvisioningError("gitea_projection_unavailable", 503) from exc
|
|
||||||
if not isinstance(reply, dict) or reply.get("status") in {401, 403}:
|
|
||||||
raise ProvisioningError("gitea_projection_forbidden", 503)
|
|
||||||
oid = reply.get("oid")
|
|
||||||
if reply.get("status") != 200 or not _is_oid(oid):
|
|
||||||
raise ProvisioningError("gitea_projection_invalid", 503)
|
|
||||||
return oid.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationRefreshProvisioner:
|
|
||||||
_locks: dict[tuple[str, str], threading.Lock] = {}
|
|
||||||
_locks_guard = threading.Lock()
|
|
||||||
|
|
||||||
def __init__(self, *, grants: GrantStore, projection: GiteaBranchReadProjection,
|
|
||||||
ledger_path: Path | str, audit: AppendOnlyAudit, approved_binding: Binding):
|
|
||||||
self.grants, self.projection, self.audit, self.approved_binding = grants, projection, audit, approved_binding
|
|
||||||
self.ledger_path = Path(ledger_path); self.ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with self._ledger() as db:
|
|
||||||
db.execute("CREATE TABLE IF NOT EXISTS refreshes(operation_id TEXT PRIMARY KEY, digest TEXT NOT NULL, state TEXT NOT NULL, result_oid TEXT)")
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _ledger(self):
|
|
||||||
db = sqlite3.connect(self.ledger_path)
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def refresh(self, *, token: str, binding: Binding, path: str, operation_id: str,
|
|
||||||
target_head_oid: str, apply: Callable[[], str]) -> dict:
|
|
||||||
if not operation_id or not _is_oid(target_head_oid):
|
|
||||||
raise ProvisioningError("refresh_request_rejected")
|
|
||||||
if binding != self.approved_binding:
|
|
||||||
raise ProvisioningError("grant_binding_rejected")
|
|
||||||
request = {"binding": binding.__dict__ | {"paths": list(binding.paths)}, "path": path, "target": target_head_oid}
|
|
||||||
digest = _canonical(request)
|
|
||||||
with self._locks_guard:
|
|
||||||
lock = self._locks.setdefault((binding.repository, binding.branch), threading.Lock())
|
|
||||||
with lock, self._ledger() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
row = db.execute("SELECT * FROM refreshes WHERE operation_id=?", (operation_id,)).fetchone()
|
|
||||||
new_operation = row is None
|
|
||||||
if row:
|
|
||||||
if row[1] != digest:
|
|
||||||
db.execute("ROLLBACK"); raise ProvisioningError("operation_id_reuse_rejected", 409)
|
|
||||||
if row[2] == "COMPLETED":
|
|
||||||
db.execute("COMMIT")
|
|
||||||
return {"state": row[2], "operation_id": operation_id, "oid": row[3], "idempotent": True}
|
|
||||||
if row[2] not in {"PENDING", "RECOVERABLE"}:
|
|
||||||
db.execute("COMMIT")
|
|
||||||
return {"state": row[2], "operation_id": operation_id, "oid": row[3], "idempotent": True}
|
|
||||||
else:
|
|
||||||
db.execute("INSERT INTO refreshes VALUES(?,?,?,NULL)", (operation_id, digest, "PENDING"))
|
|
||||||
db.execute("COMMIT")
|
|
||||||
if new_operation:
|
|
||||||
self.grants.consume(token, binding, path)
|
|
||||||
current = self.projection.head(binding.repository, binding.branch)
|
|
||||||
if current != target_head_oid.lower():
|
|
||||||
self._state(operation_id, "STALE_TARGET")
|
|
||||||
raise ProvisioningError("stale_target_head", 409)
|
|
||||||
try:
|
|
||||||
oid = apply()
|
|
||||||
if not _is_oid(oid):
|
|
||||||
raise ProvisioningError("apply_result_invalid", 500)
|
|
||||||
# Detect a competing remote advancement before claiming completion.
|
|
||||||
observed = self.projection.head(binding.repository, binding.branch)
|
|
||||||
if observed != oid.lower():
|
|
||||||
self._state(operation_id, "RECONCILIATION_REQUIRED")
|
|
||||||
raise ProvisioningError("remote_concurrent_advance", 409)
|
|
||||||
self._state(operation_id, "COMPLETED", oid)
|
|
||||||
self.audit.append({"operation_id": operation_id, "task_id": binding.task_id, "repository": binding.repository,
|
|
||||||
"branch": binding.branch, "actor_identity": binding.actor_identity, "audience": binding.audience, "path": path, "state": "COMPLETED"})
|
|
||||||
return {"state": "COMPLETED", "operation_id": operation_id, "oid": oid, "idempotent": False}
|
|
||||||
except ProvisioningError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
self._state(operation_id, "RECOVERABLE")
|
|
||||||
self.audit.append({"operation_id": operation_id, "task_id": binding.task_id, "repository": binding.repository,
|
|
||||||
"branch": binding.branch, "actor_identity": binding.actor_identity, "audience": binding.audience, "path": path, "state": "RECOVERABLE"})
|
|
||||||
raise ProvisioningError("refresh_apply_failed", 503) from exc
|
|
||||||
|
|
||||||
def _state(self, operation_id: str, state: str, oid: str | None = None) -> None:
|
|
||||||
with self._ledger() as db:
|
|
||||||
db.execute("UPDATE refreshes SET state=?, result_oid=COALESCE(?,result_oid) WHERE operation_id=?", (state, oid, operation_id))
|
|
||||||
|
|
||||||
|
|
||||||
def _sandbox_preflight(state_dir: Path) -> int:
|
|
||||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED") != "false":
|
|
||||||
raise ProvisioningError("global_gitops_writes_must_remain_disabled", 500)
|
|
||||||
state_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
audit = AppendOnlyAudit(state_dir / "preflight-audit.jsonl")
|
|
||||||
audit.append({"event": "SANDBOX_PREFLIGHT", "writes_enabled": False})
|
|
||||||
print(json.dumps({"status": "READY", "mode": "sandbox", "writes_enabled": False}, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--sandbox-preflight", action="store_true", required=True)
|
|
||||||
parser.add_argument("--state-dir", type=Path, required=True)
|
|
||||||
options = parser.parse_args()
|
|
||||||
raise SystemExit(_sandbox_preflight(options.state_dir))
|
|
||||||
@@ -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,45 @@
|
|||||||
|
"""AIOS-managed OpenClaw Ed25519 execution adapter."""
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
AdapterError,
|
||||||
|
CommandConflict,
|
||||||
|
CommandNotFound,
|
||||||
|
CommandRejected,
|
||||||
|
InMemoryCallbackSink,
|
||||||
|
OpenClawAdapter,
|
||||||
|
)
|
||||||
|
from .contract import (
|
||||||
|
CONTRACT_VERSION,
|
||||||
|
SIGNATURE_ALGORITHM,
|
||||||
|
CommandState,
|
||||||
|
Ed25519CommandSigner,
|
||||||
|
Ed25519CommandVerifier,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
sign_command,
|
||||||
|
verify_command_signature,
|
||||||
|
)
|
||||||
|
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AdapterError",
|
||||||
|
"CONTRACT_VERSION",
|
||||||
|
"CommandConflict",
|
||||||
|
"CommandNotFound",
|
||||||
|
"CommandRejected",
|
||||||
|
"CommandState",
|
||||||
|
"Ed25519CommandSigner",
|
||||||
|
"Ed25519CommandVerifier",
|
||||||
|
"HTTPRuntimeTransport",
|
||||||
|
"InMemoryCallbackSink",
|
||||||
|
"InProcessRuntimeTransport",
|
||||||
|
"OpenClawAdapter",
|
||||||
|
"OpenClawCommand",
|
||||||
|
"OpenClawResult",
|
||||||
|
"RuntimeHealth",
|
||||||
|
"RuntimeTransport",
|
||||||
|
"SIGNATURE_ALGORITHM",
|
||||||
|
"sign_command",
|
||||||
|
"verify_command_signature",
|
||||||
|
]
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Signed, tenant-isolated execution adapter for OpenClaw runtimes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Callable, Mapping, Protocol
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
utc_now,
|
||||||
|
verify_command_signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterError(RuntimeError):
|
||||||
|
"""Base adapter failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRejected(AdapterError):
|
||||||
|
"""A command failed signature or contract validation."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandNotFound(AdapterError):
|
||||||
|
"""A tenant-scoped command is not visible."""
|
||||||
|
|
||||||
|
|
||||||
|
class CommandConflict(AdapterError):
|
||||||
|
"""A command or idempotency key conflicts with an existing execution."""
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeTransport(Protocol):
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
||||||
|
def cancel(self, command_id: str) -> bool: ...
|
||||||
|
def health(self) -> RuntimeHealth: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackSink(Protocol):
|
||||||
|
def on_started(self, result: OpenClawResult) -> None: ...
|
||||||
|
def on_completed(self, result: OpenClawResult) -> None: ...
|
||||||
|
def on_failed(self, result: OpenClawResult) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryCallbackSink:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.started: list[OpenClawResult] = []
|
||||||
|
self.completed: list[OpenClawResult] = []
|
||||||
|
self.failed: list[OpenClawResult] = []
|
||||||
|
|
||||||
|
def on_started(self, result: OpenClawResult) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.started.append(result)
|
||||||
|
|
||||||
|
def on_completed(self, result: OpenClawResult) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.completed.append(result)
|
||||||
|
|
||||||
|
def on_failed(self, result: OpenClawResult) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.failed.append(result)
|
||||||
|
|
||||||
|
|
||||||
|
SigningMaterialResolver = Callable[[str, str], bytes | str]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawAdapter:
|
||||||
|
"""Verify, de-duplicate, execute, cancel and reconcile commands."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
transport: RuntimeTransport,
|
||||||
|
signing_material_resolver: SigningMaterialResolver,
|
||||||
|
*,
|
||||||
|
callback_sink: CallbackSink | None = None,
|
||||||
|
default_timeout_seconds: float = 300.0,
|
||||||
|
) -> None:
|
||||||
|
if default_timeout_seconds <= 0:
|
||||||
|
raise ValueError("default_timeout_seconds must be positive")
|
||||||
|
self.transport = transport
|
||||||
|
self.signing_material_resolver = signing_material_resolver
|
||||||
|
self.callback_sink = callback_sink or InMemoryCallbackSink()
|
||||||
|
self.default_timeout_seconds = float(default_timeout_seconds)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._commands: dict[str, OpenClawCommand] = {}
|
||||||
|
self._results: dict[str, OpenClawResult] = {}
|
||||||
|
self._idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
|
||||||
|
def _verify(self, command: OpenClawCommand) -> None:
|
||||||
|
try:
|
||||||
|
key_material = self.signing_material_resolver(command.tenant_id, command.key_id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise CommandRejected("signing material unavailable") from exc
|
||||||
|
if not verify_command_signature(command, key_material):
|
||||||
|
raise CommandRejected("invalid or expired command signature")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_result(command: OpenClawCommand, result: OpenClawResult) -> None:
|
||||||
|
if (
|
||||||
|
result.command_id != command.command_id
|
||||||
|
or result.tenant_id != command.tenant_id
|
||||||
|
or result.task_id != command.task_id
|
||||||
|
or result.trace_id != command.trace_id
|
||||||
|
):
|
||||||
|
raise AdapterError("runtime returned mismatched command identity")
|
||||||
|
if result.state not in {
|
||||||
|
CommandState.COMPLETED,
|
||||||
|
CommandState.FAILED,
|
||||||
|
CommandState.CANCELLED,
|
||||||
|
CommandState.TIMED_OUT,
|
||||||
|
}:
|
||||||
|
raise AdapterError("runtime returned a non-terminal result")
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
command: OpenClawCommand,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
) -> OpenClawResult:
|
||||||
|
timeout = self.default_timeout_seconds if timeout_seconds is None else float(timeout_seconds)
|
||||||
|
if timeout <= 0:
|
||||||
|
raise ValueError("timeout_seconds must be positive")
|
||||||
|
self._verify(command)
|
||||||
|
digest = command.signature
|
||||||
|
idem_ref = (command.tenant_id, command.idempotency_key)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
prior_command = self._commands.get(command.command_id)
|
||||||
|
if prior_command is not None:
|
||||||
|
if prior_command.signature != command.signature:
|
||||||
|
raise CommandConflict("command_id reused with different content")
|
||||||
|
return self._results[command.command_id]
|
||||||
|
prior_idem = self._idempotency.get(idem_ref)
|
||||||
|
if prior_idem is not None:
|
||||||
|
prior_id, prior_digest = prior_idem
|
||||||
|
if prior_digest != digest:
|
||||||
|
raise CommandConflict("idempotency key reused with different content")
|
||||||
|
return self._results[prior_id]
|
||||||
|
running = OpenClawResult(
|
||||||
|
command_id=command.command_id,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
task_id=command.task_id,
|
||||||
|
trace_id=command.trace_id,
|
||||||
|
state=CommandState.RUNNING,
|
||||||
|
started_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._commands[command.command_id] = command
|
||||||
|
self._results[command.command_id] = running
|
||||||
|
self._idempotency[idem_ref] = (command.command_id, digest)
|
||||||
|
|
||||||
|
self.callback_sink.on_started(running)
|
||||||
|
try:
|
||||||
|
result = self.transport.execute(command, timeout)
|
||||||
|
self._validate_result(command, result)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
result = replace(
|
||||||
|
running,
|
||||||
|
state=CommandState.TIMED_OUT,
|
||||||
|
error={"code": "runtime_timeout", "message": str(exc) or "runtime timeout"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
result = replace(
|
||||||
|
running,
|
||||||
|
state=CommandState.FAILED,
|
||||||
|
error={"code": "runtime_error", "message": str(exc)},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.started_at is None:
|
||||||
|
result = replace(result, started_at=running.started_at)
|
||||||
|
if result.finished_at is None:
|
||||||
|
result = replace(result, finished_at=utc_now())
|
||||||
|
with self._lock:
|
||||||
|
self._results[command.command_id] = result
|
||||||
|
if result.state == CommandState.COMPLETED:
|
||||||
|
self.callback_sink.on_completed(result)
|
||||||
|
else:
|
||||||
|
self.callback_sink.on_failed(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def status(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
|
||||||
|
with self._lock:
|
||||||
|
command = self._commands.get(command_id)
|
||||||
|
result = self._results.get(command_id)
|
||||||
|
if command is None or result is None or command.tenant_id != tenant_id:
|
||||||
|
raise CommandNotFound("command not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cancel(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
|
||||||
|
with self._lock:
|
||||||
|
command = self._commands.get(command_id)
|
||||||
|
current = self._results.get(command_id)
|
||||||
|
if command is None or current is None or command.tenant_id != tenant_id:
|
||||||
|
raise CommandNotFound("command not found")
|
||||||
|
if current.state in {
|
||||||
|
CommandState.COMPLETED,
|
||||||
|
CommandState.FAILED,
|
||||||
|
CommandState.CANCELLED,
|
||||||
|
CommandState.TIMED_OUT,
|
||||||
|
CommandState.REJECTED,
|
||||||
|
}:
|
||||||
|
return current
|
||||||
|
if not self.transport.cancel(command_id):
|
||||||
|
raise AdapterError("runtime rejected cancellation")
|
||||||
|
cancelled = replace(
|
||||||
|
current,
|
||||||
|
state=CommandState.CANCELLED,
|
||||||
|
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._results[command_id] = cancelled
|
||||||
|
self.callback_sink.on_failed(cancelled)
|
||||||
|
return cancelled
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealth:
|
||||||
|
return self.transport.health()
|
||||||
|
|
||||||
|
def snapshot(self, *, tenant_id: str) -> Mapping[str, OpenClawResult]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
command_id: result
|
||||||
|
for command_id, result in self._results.items()
|
||||||
|
if self._commands[command_id].tenant_id == tenant_id
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Audit-oriented lifecycle callbacks for OpenClaw execution."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
from .contract import OpenClawResult, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
class EventPublisher(Protocol):
|
||||||
|
def publish(self, event: Mapping[str, Any]) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionEvent:
|
||||||
|
event_type: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
command_id: str
|
||||||
|
state: str
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"event_type": self.event_type,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"trace_id": self.trace_id,
|
||||||
|
"command_id": self.command_id,
|
||||||
|
"state": self.state,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"details": json.loads(
|
||||||
|
json.dumps(dict(self.details), ensure_ascii=False, default=str)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryEventPublisher:
|
||||||
|
"""Thread-safe publisher used by tests and first integration slice."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def publish(self, event: Mapping[str, Any]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.events.append(
|
||||||
|
json.loads(json.dumps(dict(event), ensure_ascii=False, default=str))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditCallbackSink:
|
||||||
|
"""Convert runtime callbacks into tenant-scoped audit events."""
|
||||||
|
|
||||||
|
def __init__(self, publisher: EventPublisher) -> None:
|
||||||
|
self.publisher = publisher
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event(
|
||||||
|
event_type: str,
|
||||||
|
result: OpenClawResult,
|
||||||
|
) -> ExecutionEvent:
|
||||||
|
details: dict[str, Any] = {
|
||||||
|
"runtime_id": result.runtime_id,
|
||||||
|
"attempts": result.attempts,
|
||||||
|
}
|
||||||
|
if result.error:
|
||||||
|
details["error"] = dict(result.error)
|
||||||
|
if result.output:
|
||||||
|
details["output_present"] = True
|
||||||
|
return ExecutionEvent(
|
||||||
|
event_type=event_type,
|
||||||
|
tenant_id=result.tenant_id,
|
||||||
|
task_id=result.task_id,
|
||||||
|
trace_id=result.trace_id,
|
||||||
|
command_id=result.command_id,
|
||||||
|
state=result.state.value,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_started(self, result: OpenClawResult) -> None:
|
||||||
|
self.publisher.publish(self._event("OPENCLAW_STARTED", result).as_dict())
|
||||||
|
|
||||||
|
def on_completed(self, result: OpenClawResult) -> None:
|
||||||
|
self.publisher.publish(self._event("OPENCLAW_COMPLETED", result).as_dict())
|
||||||
|
|
||||||
|
def on_failed(self, result: OpenClawResult) -> None:
|
||||||
|
event_type = {
|
||||||
|
"CANCELLED": "OPENCLAW_CANCELLED",
|
||||||
|
"TIMED_OUT": "OPENCLAW_TIMED_OUT",
|
||||||
|
"REJECTED": "OPENCLAW_REJECTED",
|
||||||
|
}.get(result.state.value, "OPENCLAW_FAILED")
|
||||||
|
self.publisher.publish(self._event(event_type, result).as_dict())
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
CONTRACT_VERSION = "aios.openclaw.command.v2"
|
||||||
|
SIGNATURE_ALGORITHM = "Ed25519"
|
||||||
|
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: str) -> datetime:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("timestamp is required")
|
||||||
|
if normalized.endswith("Z"):
|
||||||
|
normalized = normalized[:-1] + "+00:00"
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include a timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(
|
||||||
|
json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||||
|
Ed25519PrivateKey,
|
||||||
|
Ed25519PublicKey,
|
||||||
|
)
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("cryptography with Ed25519 support is required") from exc
|
||||||
|
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
|
|
||||||
|
class CommandSigner(Protocol):
|
||||||
|
key_id: str
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
def sign(self, payload: bytes) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CommandVerifier(Protocol):
|
||||||
|
key_id: str
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
def verify(self, payload: bytes, signature: bytes) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Ed25519CommandSigner:
|
||||||
|
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||||
|
serialization, private_cls, _ = _load_crypto()
|
||||||
|
if not isinstance(private_key, private_cls):
|
||||||
|
raise ValueError("private key is not Ed25519")
|
||||||
|
self.private_key = private_key
|
||||||
|
self.key_id = _required(key_id, "key_id")
|
||||||
|
raw = private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandSigner":
|
||||||
|
serialization, private_cls, _ = _load_crypto()
|
||||||
|
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||||
|
if raw.startswith(b"-----BEGIN"):
|
||||||
|
key = serialization.load_pem_private_key(raw, password=None)
|
||||||
|
elif len(raw) == 32:
|
||||||
|
key = private_cls.from_private_bytes(raw)
|
||||||
|
else:
|
||||||
|
raise ValueError("Ed25519 private key must be PEM or a raw 32-byte seed")
|
||||||
|
return cls(key, key_id=key_id)
|
||||||
|
|
||||||
|
def sign(self, payload: bytes) -> bytes:
|
||||||
|
return self.private_key.sign(bytes(payload))
|
||||||
|
|
||||||
|
def verifier(self) -> "Ed25519CommandVerifier":
|
||||||
|
return Ed25519CommandVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
class Ed25519CommandVerifier:
|
||||||
|
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||||
|
serialization, _, public_cls = _load_crypto()
|
||||||
|
if not isinstance(public_key, public_cls):
|
||||||
|
raise ValueError("public key is not Ed25519")
|
||||||
|
self.public_key = public_key
|
||||||
|
self.key_id = _required(key_id, "key_id")
|
||||||
|
raw = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandVerifier":
|
||||||
|
serialization, private_cls, public_cls = _load_crypto()
|
||||||
|
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||||
|
if raw.startswith(b"-----BEGIN"):
|
||||||
|
try:
|
||||||
|
key = serialization.load_pem_public_key(raw)
|
||||||
|
except ValueError:
|
||||||
|
private = serialization.load_pem_private_key(raw, password=None)
|
||||||
|
if not isinstance(private, private_cls):
|
||||||
|
raise ValueError("key is not Ed25519")
|
||||||
|
key = private.public_key()
|
||||||
|
elif len(raw) == 32:
|
||||||
|
key = public_cls.from_public_bytes(raw)
|
||||||
|
else:
|
||||||
|
raise ValueError("Ed25519 public key must be PEM or raw 32-byte key")
|
||||||
|
return cls(key, key_id=key_id)
|
||||||
|
|
||||||
|
def verify(self, payload: bytes, signature: bytes) -> bool:
|
||||||
|
try:
|
||||||
|
self.public_key.verify(bytes(signature), bytes(payload))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CommandState(str, Enum):
|
||||||
|
ACCEPTED = "ACCEPTED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
TIMED_OUT = "TIMED_OUT"
|
||||||
|
REJECTED = "REJECTED"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenClawCommand:
|
||||||
|
command_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
idempotency_key: str = ""
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
expires_at: str | None = None
|
||||||
|
contract_version: str = CONTRACT_VERSION
|
||||||
|
signature_algorithm: str = SIGNATURE_ALGORITHM
|
||||||
|
key_id: str = "default"
|
||||||
|
public_key_fingerprint: str = ""
|
||||||
|
signature: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in (
|
||||||
|
"command_id",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"trace_id",
|
||||||
|
"action",
|
||||||
|
"idempotency_key",
|
||||||
|
"created_at",
|
||||||
|
"contract_version",
|
||||||
|
"signature_algorithm",
|
||||||
|
"key_id",
|
||||||
|
):
|
||||||
|
object.__setattr__(
|
||||||
|
self, field_name, _required(getattr(self, field_name), field_name)
|
||||||
|
)
|
||||||
|
if self.contract_version != CONTRACT_VERSION:
|
||||||
|
raise ValueError(f"unsupported contract_version: {self.contract_version}")
|
||||||
|
if self.signature_algorithm != SIGNATURE_ALGORITHM:
|
||||||
|
raise ValueError(
|
||||||
|
f"unsupported signature_algorithm: {self.signature_algorithm}"
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||||
|
object.__setattr__(
|
||||||
|
self, "public_key_fingerprint", str(self.public_key_fingerprint or "").strip()
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||||
|
_parse_timestamp(self.created_at)
|
||||||
|
if self.expires_at is not None:
|
||||||
|
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
||||||
|
if _parse_timestamp(self.expires_at) <= _parse_timestamp(self.created_at):
|
||||||
|
raise ValueError("expires_at must be later than created_at")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
idempotency_key: str,
|
||||||
|
expires_at: str | None = None,
|
||||||
|
key_id: str = "default",
|
||||||
|
command_id: str | None = None,
|
||||||
|
) -> "OpenClawCommand":
|
||||||
|
return cls(
|
||||||
|
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=action,
|
||||||
|
payload=_safe_mapping(payload),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
expires_at=expires_at,
|
||||||
|
key_id=key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def unsigned_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"command_id": self.command_id,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"trace_id": self.trace_id,
|
||||||
|
"action": self.action,
|
||||||
|
"payload": _safe_mapping(self.payload),
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
"contract_version": self.contract_version,
|
||||||
|
"signature_algorithm": self.signature_algorithm,
|
||||||
|
"key_id": self.key_id,
|
||||||
|
"public_key_fingerprint": self.public_key_fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenClawResult:
|
||||||
|
command_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
trace_id: str
|
||||||
|
state: CommandState
|
||||||
|
output: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
runtime_id: str | None = None
|
||||||
|
attempts: int = 1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
|
||||||
|
object.__setattr__(
|
||||||
|
self, field_name, _required(getattr(self, field_name), field_name)
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "state", CommandState(self.state))
|
||||||
|
if self.output is not None:
|
||||||
|
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||||
|
if self.error is not None:
|
||||||
|
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||||
|
if self.started_at is not None:
|
||||||
|
_parse_timestamp(self.started_at)
|
||||||
|
if self.finished_at is not None:
|
||||||
|
_parse_timestamp(self.finished_at)
|
||||||
|
if self.attempts < 1:
|
||||||
|
raise ValueError("attempts must be >= 1")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeHealth:
|
||||||
|
runtime: str
|
||||||
|
runtime_id: str
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
heartbeat_at: str
|
||||||
|
active_tasks: int = 0
|
||||||
|
workspace_isolated: bool = False
|
||||||
|
credential_ref_configured: bool = False
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in (
|
||||||
|
"runtime",
|
||||||
|
"runtime_id",
|
||||||
|
"status",
|
||||||
|
"version",
|
||||||
|
"heartbeat_at",
|
||||||
|
):
|
||||||
|
object.__setattr__(
|
||||||
|
self, field_name, _required(getattr(self, field_name), field_name)
|
||||||
|
)
|
||||||
|
_parse_timestamp(self.heartbeat_at)
|
||||||
|
if self.active_tasks < 0:
|
||||||
|
raise ValueError("active_tasks must be >= 0")
|
||||||
|
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
command.unsigned_dict(),
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_signer(command: OpenClawCommand, value: Any) -> CommandSigner:
|
||||||
|
if hasattr(value, "sign") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||||
|
return value
|
||||||
|
return Ed25519CommandSigner.from_bytes(value, key_id=command.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_verifier(command: OpenClawCommand, value: Any) -> CommandVerifier:
|
||||||
|
if isinstance(value, Ed25519CommandSigner):
|
||||||
|
return value.verifier()
|
||||||
|
if hasattr(value, "verify") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||||
|
return value
|
||||||
|
return Ed25519CommandVerifier.from_bytes(value, key_id=command.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_command(command: OpenClawCommand, signing_key: Any) -> OpenClawCommand:
|
||||||
|
signer = _as_signer(command, signing_key)
|
||||||
|
if signer.key_id != command.key_id:
|
||||||
|
raise ValueError("command key_id does not match signer")
|
||||||
|
prepared = replace(
|
||||||
|
command,
|
||||||
|
signature_algorithm=SIGNATURE_ALGORITHM,
|
||||||
|
public_key_fingerprint=signer.fingerprint,
|
||||||
|
signature="",
|
||||||
|
)
|
||||||
|
signature = base64.b64encode(
|
||||||
|
signer.sign(canonical_command_bytes(prepared))
|
||||||
|
).decode("ascii")
|
||||||
|
return replace(prepared, signature=signature)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_command_signature(
|
||||||
|
command: OpenClawCommand,
|
||||||
|
verification_key: Any,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
if (
|
||||||
|
not command.signature
|
||||||
|
or command.contract_version != CONTRACT_VERSION
|
||||||
|
or command.signature_algorithm != SIGNATURE_ALGORITHM
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
verifier = _as_verifier(command, verification_key)
|
||||||
|
signature = base64.b64decode(command.signature, validate=True)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if verifier.key_id != command.key_id:
|
||||||
|
return False
|
||||||
|
if not command.public_key_fingerprint or not hmac.compare_digest(
|
||||||
|
command.public_key_fingerprint, verifier.fingerprint
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if not verifier.verify(canonical_command_bytes(command), signature):
|
||||||
|
return False
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
created = _parse_timestamp(command.created_at)
|
||||||
|
if created.timestamp() - current.timestamp() > clock_skew_seconds:
|
||||||
|
return False
|
||||||
|
if command.expires_at is not None:
|
||||||
|
expires = _parse_timestamp(command.expires_at)
|
||||||
|
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Health policy and self-healing decisions for OpenClaw runtimes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .contract import RuntimeHealth
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(value: str) -> datetime:
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("heartbeat timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthPolicy:
|
||||||
|
max_heartbeat_age_seconds: int = 90
|
||||||
|
require_workspace_isolation: bool = True
|
||||||
|
require_credential_ref: bool = True
|
||||||
|
accepted_statuses: frozenset[str] = field(
|
||||||
|
default_factory=lambda: frozenset({"healthy", "ready"})
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.max_heartbeat_age_seconds < 5:
|
||||||
|
raise ValueError("max_heartbeat_age_seconds must be >= 5")
|
||||||
|
if not self.accepted_statuses:
|
||||||
|
raise ValueError("accepted_statuses must not be empty")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthAssessment:
|
||||||
|
healthy: bool
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
recoverable: bool
|
||||||
|
recommended_action: str
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeHealthMonitor:
|
||||||
|
"""Evaluate runtime health and recommend bounded recovery actions."""
|
||||||
|
|
||||||
|
def __init__(self, policy: HealthPolicy | None = None) -> None:
|
||||||
|
self.policy = policy or HealthPolicy()
|
||||||
|
|
||||||
|
def assess(
|
||||||
|
self,
|
||||||
|
health: RuntimeHealth,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> HealthAssessment:
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
heartbeat = _parse(health.heartbeat_at)
|
||||||
|
age = max(0.0, current.timestamp() - heartbeat.timestamp())
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if health.status.lower() not in self.policy.accepted_statuses:
|
||||||
|
reasons.append("runtime_status_unhealthy")
|
||||||
|
if age > self.policy.max_heartbeat_age_seconds:
|
||||||
|
reasons.append("heartbeat_stale")
|
||||||
|
if self.policy.require_workspace_isolation and not health.workspace_isolated:
|
||||||
|
reasons.append("workspace_not_isolated")
|
||||||
|
if self.policy.require_credential_ref and not health.credential_ref_configured:
|
||||||
|
reasons.append("credential_reference_missing")
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
return HealthAssessment(
|
||||||
|
healthy=True,
|
||||||
|
reasons=(),
|
||||||
|
recoverable=True,
|
||||||
|
recommended_action="none",
|
||||||
|
details={"heartbeat_age_seconds": age},
|
||||||
|
)
|
||||||
|
|
||||||
|
non_recoverable = {
|
||||||
|
"workspace_not_isolated",
|
||||||
|
"credential_reference_missing",
|
||||||
|
}.intersection(reasons)
|
||||||
|
if non_recoverable:
|
||||||
|
action = "quarantine_runtime"
|
||||||
|
recoverable = False
|
||||||
|
elif "heartbeat_stale" in reasons:
|
||||||
|
action = "restart_runtime"
|
||||||
|
recoverable = True
|
||||||
|
else:
|
||||||
|
action = "drain_and_restart"
|
||||||
|
recoverable = True
|
||||||
|
|
||||||
|
return HealthAssessment(
|
||||||
|
healthy=False,
|
||||||
|
reasons=tuple(reasons),
|
||||||
|
recoverable=recoverable,
|
||||||
|
recommended_action=action,
|
||||||
|
details={
|
||||||
|
"heartbeat_age_seconds": age,
|
||||||
|
"runtime_id": health.runtime_id,
|
||||||
|
"active_tasks": health.active_tasks,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Runtime transport implementations for the OpenClaw adapter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Callable, Mapping, Protocol
|
||||||
|
|
||||||
|
from .contract import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
RuntimeHealth,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeTransport(Protocol):
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
||||||
|
def cancel(self, command_id: str) -> bool: ...
|
||||||
|
def health(self) -> RuntimeHealth: ...
|
||||||
|
|
||||||
|
|
||||||
|
class TransportError(RuntimeError):
|
||||||
|
"""Runtime transport or protocol failure."""
|
||||||
|
|
||||||
|
|
||||||
|
RuntimeHandler = Callable[[OpenClawCommand], Mapping[str, Any] | OpenClawResult]
|
||||||
|
|
||||||
|
|
||||||
|
class InProcessRuntimeTransport:
|
||||||
|
"""Deterministic runtime used by tests and controlled local integrations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
handler: RuntimeHandler,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "openclaw-in-process",
|
||||||
|
version: str = "v1",
|
||||||
|
workspace_isolated: bool = True,
|
||||||
|
credential_ref_configured: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.handler = handler
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.version = version
|
||||||
|
self.workspace_isolated = workspace_isolated
|
||||||
|
self.credential_ref_configured = credential_ref_configured
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._running: set[str] = set()
|
||||||
|
self._cancelled: set[str] = set()
|
||||||
|
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise ValueError("timeout_seconds must be positive")
|
||||||
|
with self._lock:
|
||||||
|
if command.command_id in self._cancelled:
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=command.command_id,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
task_id=command.task_id,
|
||||||
|
trace_id=command.trace_id,
|
||||||
|
state=CommandState.CANCELLED,
|
||||||
|
error={"code": "cancelled", "message": "cancelled before execution"},
|
||||||
|
finished_at=utc_now(),
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
)
|
||||||
|
self._running.add(command.command_id)
|
||||||
|
try:
|
||||||
|
raw = self.handler(command)
|
||||||
|
if isinstance(raw, OpenClawResult):
|
||||||
|
return raw
|
||||||
|
if not isinstance(raw, Mapping):
|
||||||
|
raise TransportError("runtime handler must return a mapping or OpenClawResult")
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=command.command_id,
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
task_id=command.task_id,
|
||||||
|
trace_id=command.trace_id,
|
||||||
|
state=CommandState(str(raw.get("state", "COMPLETED"))),
|
||||||
|
output=raw.get("output"),
|
||||||
|
error=raw.get("error"),
|
||||||
|
started_at=str(raw.get("started_at") or utc_now()),
|
||||||
|
finished_at=str(raw.get("finished_at") or utc_now()),
|
||||||
|
runtime_id=str(raw.get("runtime_id") or self.runtime_id),
|
||||||
|
attempts=int(raw.get("attempts", 1)),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._running.discard(command.command_id)
|
||||||
|
|
||||||
|
def cancel(self, command_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
self._cancelled.add(command_id)
|
||||||
|
return command_id in self._running
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealth:
|
||||||
|
with self._lock:
|
||||||
|
active = len(self._running)
|
||||||
|
return RuntimeHealth(
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_id=self.runtime_id,
|
||||||
|
status="healthy",
|
||||||
|
version=self.version,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
active_tasks=active,
|
||||||
|
workspace_isolated=self.workspace_isolated,
|
||||||
|
credential_ref_configured=self.credential_ref_configured,
|
||||||
|
details={"transport": "in_process"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPRuntimeTransport:
|
||||||
|
"""Private HTTP transport for an OpenClaw runtime service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
runtime_id: str = "openclaw-http",
|
||||||
|
credential_ref: str | None = None,
|
||||||
|
max_response_bytes: int = 2_097_152,
|
||||||
|
) -> None:
|
||||||
|
normalized = str(base_url).rstrip("/")
|
||||||
|
if not normalized.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("base_url must use http or https")
|
||||||
|
self.base_url = normalized
|
||||||
|
self.runtime_id = runtime_id
|
||||||
|
self.credential_ref = credential_ref
|
||||||
|
self.max_response_bytes = int(max_response_bytes)
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
payload: Mapping[str, Any] | None,
|
||||||
|
timeout_seconds: float,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
body = None
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "AIOS-OpenClaw/1"}
|
||||||
|
if payload is not None:
|
||||||
|
body = json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if self.credential_ref:
|
||||||
|
headers["X-AIOS-Credential-Ref"] = self.credential_ref
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base_url}{path}",
|
||||||
|
data=body,
|
||||||
|
method=method,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||||
|
raw = response.read(self.max_response_bytes + 1)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise TransportError(f"runtime returned HTTP {exc.code}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise TransportError(f"runtime unavailable: {exc.reason}") from exc
|
||||||
|
if len(raw) > self.max_response_bytes:
|
||||||
|
raise TransportError("runtime response exceeded size limit")
|
||||||
|
decoded = json.loads(raw.decode("utf-8"))
|
||||||
|
if not isinstance(decoded, Mapping):
|
||||||
|
raise TransportError("runtime response must be a JSON object")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _command_payload(command: OpenClawCommand) -> dict[str, Any]:
|
||||||
|
return {**command.unsigned_dict(), "signature": command.signature}
|
||||||
|
|
||||||
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||||
|
value = self._request(
|
||||||
|
"POST",
|
||||||
|
"/v1/execute",
|
||||||
|
payload=self._command_payload(command),
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
return OpenClawResult(
|
||||||
|
command_id=str(value["command_id"]),
|
||||||
|
tenant_id=str(value["tenant_id"]),
|
||||||
|
task_id=str(value["task_id"]),
|
||||||
|
trace_id=str(value["trace_id"]),
|
||||||
|
state=CommandState(str(value["state"])),
|
||||||
|
output=value.get("output"),
|
||||||
|
error=value.get("error"),
|
||||||
|
started_at=value.get("started_at"),
|
||||||
|
finished_at=value.get("finished_at"),
|
||||||
|
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
||||||
|
attempts=int(value.get("attempts", 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(self, command_id: str) -> bool:
|
||||||
|
value = self._request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel",
|
||||||
|
payload={},
|
||||||
|
timeout_seconds=10.0,
|
||||||
|
)
|
||||||
|
return bool(value.get("accepted"))
|
||||||
|
|
||||||
|
def health(self) -> RuntimeHealth:
|
||||||
|
value = self._request("GET", "/v1/health", payload=None, timeout_seconds=5.0)
|
||||||
|
return RuntimeHealth(
|
||||||
|
runtime=str(value.get("runtime") or "openclaw"),
|
||||||
|
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
||||||
|
status=str(value.get("status") or "unknown"),
|
||||||
|
version=str(value.get("version") or "unknown"),
|
||||||
|
heartbeat_at=str(value.get("heartbeat_at") or utc_now()),
|
||||||
|
active_tasks=int(value.get("active_tasks", 0)),
|
||||||
|
workspace_isolated=bool(value.get("workspace_isolated", False)),
|
||||||
|
credential_ref_configured=bool(value.get("credential_ref_configured", False)),
|
||||||
|
details=value.get("details") if isinstance(value.get("details"), Mapping) else {},
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""AIOS V2 控制面、执行编排与可信运行来源证明。"""
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
AuditEvent,
|
||||||
|
ControlPlaneError,
|
||||||
|
ControlPlaneService,
|
||||||
|
IdempotencyConflict,
|
||||||
|
InMemoryControlPlaneStore,
|
||||||
|
InvalidTransition,
|
||||||
|
NotFound,
|
||||||
|
PolicyDenied,
|
||||||
|
RequestContext,
|
||||||
|
RevisionConflict,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
TenantIsolationError,
|
||||||
|
TenantRecord,
|
||||||
|
Unauthorized,
|
||||||
|
)
|
||||||
|
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||||
|
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
|
||||||
|
from .provenance import (
|
||||||
|
Ed25519PrivateKeySigner,
|
||||||
|
Ed25519PublicKeyVerifier,
|
||||||
|
ExecutionPath,
|
||||||
|
GitOperation,
|
||||||
|
PRODUCTION_POLICY_VERSION,
|
||||||
|
ProvenanceAuthorization,
|
||||||
|
ProvenanceControlledWriter,
|
||||||
|
ProvenanceDenied,
|
||||||
|
ProvenanceError,
|
||||||
|
ProvenanceExpired,
|
||||||
|
ProvenanceIssuer,
|
||||||
|
ProvenanceNotFound,
|
||||||
|
ProvenanceReplay,
|
||||||
|
ProvenanceRevoked,
|
||||||
|
ProvenanceSignatureError,
|
||||||
|
ProvenanceStatus,
|
||||||
|
ProvenanceVerifier,
|
||||||
|
RunProvenance,
|
||||||
|
SQLiteProvenanceStore,
|
||||||
|
digest_payload,
|
||||||
|
)
|
||||||
|
from .provenance_git import (
|
||||||
|
GitCommandResult,
|
||||||
|
GitExecutionContext,
|
||||||
|
ProvenanceGitError,
|
||||||
|
ProvenanceGitWriter,
|
||||||
|
)
|
||||||
|
from .provenance_operations import (
|
||||||
|
OperationContext,
|
||||||
|
OperationReceipt,
|
||||||
|
ProvenanceOperationGate,
|
||||||
|
build_provenance_writer_from_env,
|
||||||
|
context_from_environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentRecord",
|
||||||
|
"AuditEvent",
|
||||||
|
"ControlPlaneError",
|
||||||
|
"ControlPlaneService",
|
||||||
|
"DispatchRecord",
|
||||||
|
"Ed25519PrivateKeySigner",
|
||||||
|
"Ed25519PublicKeyVerifier",
|
||||||
|
"ExecutionEvidence",
|
||||||
|
"ExecutionOrchestrator",
|
||||||
|
"ExecutionPath",
|
||||||
|
"ExecutionTracker",
|
||||||
|
"GitCommandResult",
|
||||||
|
"GitExecutionContext",
|
||||||
|
"GitOperation",
|
||||||
|
"IdempotencyConflict",
|
||||||
|
"InMemoryControlPlaneStore",
|
||||||
|
"InvalidTransition",
|
||||||
|
"NotFound",
|
||||||
|
"OperationContext",
|
||||||
|
"OperationReceipt",
|
||||||
|
"OrchestrationError",
|
||||||
|
"PRODUCTION_POLICY_VERSION",
|
||||||
|
"PolicyDenied",
|
||||||
|
"ProvenanceAuthorization",
|
||||||
|
"ProvenanceControlledWriter",
|
||||||
|
"ProvenanceDenied",
|
||||||
|
"ProvenanceError",
|
||||||
|
"ProvenanceExpired",
|
||||||
|
"ProvenanceGitError",
|
||||||
|
"ProvenanceGitWriter",
|
||||||
|
"ProvenanceIssuer",
|
||||||
|
"ProvenanceNotFound",
|
||||||
|
"ProvenanceOperationGate",
|
||||||
|
"ProvenanceReplay",
|
||||||
|
"ProvenanceRevoked",
|
||||||
|
"ProvenanceSignatureError",
|
||||||
|
"ProvenanceStatus",
|
||||||
|
"ProvenanceVerifier",
|
||||||
|
"RequestContext",
|
||||||
|
"RevisionConflict",
|
||||||
|
"RunProvenance",
|
||||||
|
"SQLiteProvenanceStore",
|
||||||
|
"TaskRecord",
|
||||||
|
"TaskState",
|
||||||
|
"TenantIsolationError",
|
||||||
|
"TenantRecord",
|
||||||
|
"Unauthorized",
|
||||||
|
"build_provenance_writer_from_env",
|
||||||
|
"context_from_environment",
|
||||||
|
"digest_payload",
|
||||||
|
]
|
||||||
@@ -0,0 +1,807 @@
|
|||||||
|
"""AIOS V2 production-candidate application entrypoint."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import urllib.parse
|
||||||
|
from enum import Enum
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_hermes import HermesAdapter, HermesJobState, HermesResult, SQLiteHermesStore
|
||||||
|
from aios_openclaw import (
|
||||||
|
Ed25519CommandSigner,
|
||||||
|
HTTPRuntimeTransport,
|
||||||
|
OpenClawAdapter,
|
||||||
|
)
|
||||||
|
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
||||||
|
|
||||||
|
from .console import CONSOLE_HTML
|
||||||
|
from .control_plane import AuditEvent, RequestContext, Unauthorized
|
||||||
|
from .dispatch_store import SQLiteDispatchRepository
|
||||||
|
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||||
|
from .gateway import GatewayApplication, error_response
|
||||||
|
from .gitops_status import collect_gitops_status
|
||||||
|
from .orchestrator import ExecutionOrchestrator
|
||||||
|
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||||
|
from .provenance_operations import build_provenance_writer_from_env
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: Any) -> Any:
|
||||||
|
if dataclasses.is_dataclass(value):
|
||||||
|
return {
|
||||||
|
item.name: _json_value(getattr(value, item.name))
|
||||||
|
for item in dataclasses.fields(value)
|
||||||
|
}
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {str(key): _json_value(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return [_json_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(value: str | None) -> bool:
|
||||||
|
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
||||||
|
target = name.lower()
|
||||||
|
for key, value in headers.items():
|
||||||
|
if str(key).lower() == target:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
return normalized or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _provenance_context(
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
header_provenance = _header(headers, "X-AIOS-Provenance-ID")
|
||||||
|
header_run = _header(headers, "X-AIOS-Run-ID")
|
||||||
|
body_provenance = str(payload.get("provenance_id") or "").strip() or None
|
||||||
|
body_run = str(payload.get("run_id") or "").strip() or None
|
||||||
|
if header_provenance and body_provenance and header_provenance != body_provenance:
|
||||||
|
raise ValueError("provenance_id header/body mismatch")
|
||||||
|
if header_run and body_run and header_run != body_run:
|
||||||
|
raise ValueError("run_id header/body mismatch")
|
||||||
|
return header_provenance or body_provenance, header_run or body_run
|
||||||
|
|
||||||
|
|
||||||
|
def _read_protected_file(path: Path, *, private: bool) -> bytes:
|
||||||
|
flags = os.O_RDONLY
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(str(path), flags)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError("key reference is not a regular file")
|
||||||
|
denied = 0o077 if private else 0o022
|
||||||
|
if metadata.st_mode & denied:
|
||||||
|
raise RuntimeError(
|
||||||
|
"private key file permissions must be 0600 or stricter"
|
||||||
|
if private
|
||||||
|
else "public key file must not be group/world writable"
|
||||||
|
)
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
while True:
|
||||||
|
chunk = os.read(descriptor, 65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
value = b"".join(chunks)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError("key file is empty")
|
||||||
|
return value
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
class FileCommandSignerResolver:
|
||||||
|
"""Resolve tenant/key-id scoped OpenClaw Ed25519 signing material."""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path, *, configured_key_id: str) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.configured_key_id = str(configured_key_id or "").strip()
|
||||||
|
if not self.configured_key_id:
|
||||||
|
raise ValueError("configured_key_id is required")
|
||||||
|
|
||||||
|
def __call__(self, tenant_id: str, key_id: str) -> Ed25519CommandSigner:
|
||||||
|
if not str(tenant_id or "").strip():
|
||||||
|
raise RuntimeError("tenant_id is required for command signing")
|
||||||
|
if key_id != self.configured_key_id:
|
||||||
|
raise RuntimeError("OpenClaw signing key_id mismatch")
|
||||||
|
return Ed25519CommandSigner.from_bytes(
|
||||||
|
_read_protected_file(self.path, private=True),
|
||||||
|
key_id=key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneEventPublisher(EventPublisher):
|
||||||
|
"""Append Runtime callback events to the tenant audit stream.
|
||||||
|
|
||||||
|
Callback audit remains informational. Authoritative task reconciliation is
|
||||||
|
performed only by the orchestrator after dispatch/result validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, service: DurableControlPlaneService) -> None:
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
def publish(self, event: Mapping[str, Any]) -> None:
|
||||||
|
tenant_id = str(event.get("tenant_id") or "").strip()
|
||||||
|
trace_id = str(event.get("trace_id") or "").strip()
|
||||||
|
command_id = str(event.get("command_id") or "").strip()
|
||||||
|
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
|
||||||
|
if not tenant_id or not trace_id or not command_id:
|
||||||
|
raise ValueError("runtime audit event is missing tenant identity")
|
||||||
|
state_value = str(event.get("state") or "unknown").lower()
|
||||||
|
outcome = "allowed" if state_value == "completed" else state_value
|
||||||
|
runtime = str(event.get("runtime") or "openclaw").strip().lower()
|
||||||
|
principal_id = str(
|
||||||
|
event.get("principal_id") or f"{runtime}-runtime"
|
||||||
|
).strip()
|
||||||
|
audit = AuditEvent(
|
||||||
|
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
action=event_type,
|
||||||
|
resource_type=f"{runtime}_execution",
|
||||||
|
resource_id=command_id,
|
||||||
|
outcome=outcome,
|
||||||
|
policy_version="runtime-v2",
|
||||||
|
details=dict(event.get("details") or {}),
|
||||||
|
)
|
||||||
|
with self.service.store.lock:
|
||||||
|
self.service.store.audit_events.append(audit)
|
||||||
|
self.service.repository.save(self.service._snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateApplication:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
gateway: GatewayApplication,
|
||||||
|
orchestrator: ExecutionOrchestrator,
|
||||||
|
tracker: ExecutionTracker,
|
||||||
|
hermes: HermesAdapter,
|
||||||
|
openclaw: OpenClawAdapter | None,
|
||||||
|
*,
|
||||||
|
hermes_lifecycle_api_enabled: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.gateway = gateway
|
||||||
|
self.orchestrator = orchestrator
|
||||||
|
self.tracker = tracker
|
||||||
|
self.hermes = hermes
|
||||||
|
self.openclaw = openclaw
|
||||||
|
self.hermes_lifecycle_api_enabled = bool(hermes_lifecycle_api_enabled)
|
||||||
|
self.runtime_events = ControlPlaneEventPublisher(orchestrator.control_plane)
|
||||||
|
|
||||||
|
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||||
|
return self.gateway.context_from_headers(headers)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_runtime_role(context: RequestContext) -> None:
|
||||||
|
if not ({"runtime", "platform_admin"} & set(context.roles)):
|
||||||
|
raise Unauthorized("runtime reconciliation role is required")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_control_plane_cancel(context: RequestContext) -> None:
|
||||||
|
required = {"runtime", "platform_admin"}
|
||||||
|
if not required.issubset(set(context.roles)):
|
||||||
|
raise Unauthorized(
|
||||||
|
"Hermes cancellation requires trusted AIOS control-plane authority"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _required_identity(payload: Mapping[str, Any], name: str) -> str:
|
||||||
|
value = str(payload.get(name) or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _record_hermes_lifecycle(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
dispatch: Any,
|
||||||
|
job: Any,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
worker_id: str | None,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
) -> None:
|
||||||
|
executor = worker_id or "aios-control-plane"
|
||||||
|
evidence = ExecutionEvidence(
|
||||||
|
execution_id=job.job_id,
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
task_id=job.task_id,
|
||||||
|
agent_id=dispatch.agent_id,
|
||||||
|
executor=executor,
|
||||||
|
branch="runtime/hermes",
|
||||||
|
status=job.state.value.lower(),
|
||||||
|
progress_completed=0,
|
||||||
|
progress_total=3,
|
||||||
|
current_step=action,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
current_evidence = self.tracker.claim(
|
||||||
|
evidence,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
if job.state in {
|
||||||
|
HermesJobState.COMPLETED,
|
||||||
|
HermesJobState.FAILED,
|
||||||
|
HermesJobState.CANCELLED,
|
||||||
|
}:
|
||||||
|
if current_evidence.completed_at is None:
|
||||||
|
self.tracker.complete(
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
execution_id=job.job_id,
|
||||||
|
status=job.state.value.lower(),
|
||||||
|
test_status="runtime_terminal",
|
||||||
|
test_summary={"lifecycle_action": action},
|
||||||
|
result_summary={
|
||||||
|
"state": job.state.value,
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"dispatch_id": dispatch.dispatch_id,
|
||||||
|
},
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.tracker.heartbeat(
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
execution_id=job.job_id,
|
||||||
|
status=job.state.value.lower(),
|
||||||
|
current_step=action,
|
||||||
|
progress_completed=(1 if job.state == HermesJobState.RUNNING else 0),
|
||||||
|
progress_total=3,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
self.runtime_events.publish(
|
||||||
|
{
|
||||||
|
"runtime": "hermes",
|
||||||
|
"tenant_id": job.tenant_id,
|
||||||
|
"principal_id": context.principal_id,
|
||||||
|
"trace_id": job.trace_id,
|
||||||
|
"command_id": job.job_id,
|
||||||
|
"event_type": f"HERMES_{action.upper()}",
|
||||||
|
"state": job.state.value,
|
||||||
|
"details": {
|
||||||
|
"dispatch_id": dispatch.dispatch_id,
|
||||||
|
"task_id": job.task_id,
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"cell_id": job.cell_id,
|
||||||
|
"profile_id": job.profile_id,
|
||||||
|
"revision": job.revision,
|
||||||
|
"provenance_id": provenance_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
raw_path: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: Any,
|
||||||
|
) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
parsed = urllib.parse.urlparse(raw_path)
|
||||||
|
path = parsed.path.rstrip("/") or "/"
|
||||||
|
method = method.upper()
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/gitops":
|
||||||
|
context = self.context(headers)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"tenant_id": context.tenant_id,
|
||||||
|
"gitops": collect_gitops_status().as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/runtime":
|
||||||
|
context = self.context(headers)
|
||||||
|
runtimes: list[dict[str, Any]] = []
|
||||||
|
if self.openclaw is None:
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": "disabled",
|
||||||
|
"details": {"reason": "runtime_not_configured"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
health = self.openclaw.health()
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": health.status,
|
||||||
|
"details": _json_value(health),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": "unavailable",
|
||||||
|
"details": {"message": str(exc)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hermes_health = self.hermes.health()
|
||||||
|
runtimes.append(
|
||||||
|
{
|
||||||
|
"name": "hermes",
|
||||||
|
"status": hermes_health.status,
|
||||||
|
"details": _json_value(hermes_health),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"tenant_id": context.tenant_id,
|
||||||
|
"runtimes": runtimes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/executions":
|
||||||
|
context = self.context(headers)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"executions": _json_value(
|
||||||
|
self.tracker.list(tenant_id=context.tenant_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_match = __import__("re").fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/dispatch", path
|
||||||
|
)
|
||||||
|
if method == "POST" and dispatch_match:
|
||||||
|
context = self.context(headers)
|
||||||
|
task_id = urllib.parse.unquote(dispatch_match.group(1))
|
||||||
|
payload = body if isinstance(body, Mapping) else {}
|
||||||
|
timeout = payload.get("timeout_seconds")
|
||||||
|
provenance_id, run_id = _provenance_context(headers, payload)
|
||||||
|
record = self.orchestrator.dispatch(
|
||||||
|
context,
|
||||||
|
task_id=task_id,
|
||||||
|
timeout_seconds=float(timeout) if timeout is not None else None,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
||||||
|
|
||||||
|
lifecycle_match = __import__("re").fullmatch(
|
||||||
|
r"/api/v2/internal/runtime/hermes/lifecycle/"
|
||||||
|
r"(claim|start|heartbeat|complete|fail|cancel|status)",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
if method == "POST" and lifecycle_match:
|
||||||
|
if not self.hermes_lifecycle_api_enabled:
|
||||||
|
return HTTPStatus.NOT_FOUND, {
|
||||||
|
"error": "not_found",
|
||||||
|
"message": "route not found",
|
||||||
|
}
|
||||||
|
context = self.context(headers)
|
||||||
|
self._require_runtime_role(context)
|
||||||
|
if not isinstance(body, Mapping):
|
||||||
|
raise ValueError("JSON body must be an object")
|
||||||
|
payload = body
|
||||||
|
action = lifecycle_match.group(1)
|
||||||
|
identity = {
|
||||||
|
name: self._required_identity(payload, name)
|
||||||
|
for name in (
|
||||||
|
"dispatch_id",
|
||||||
|
"job_id",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"trace_id",
|
||||||
|
"principal_id",
|
||||||
|
"provenance_id",
|
||||||
|
"run_id",
|
||||||
|
"cell_id",
|
||||||
|
"profile_id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
header_provenance, header_run = _provenance_context(headers, payload)
|
||||||
|
if header_provenance != identity["provenance_id"]:
|
||||||
|
raise ValueError("provenance_id context mismatch")
|
||||||
|
if header_run != identity["run_id"]:
|
||||||
|
raise ValueError("run_id context mismatch")
|
||||||
|
requeued_job_ids = self.hermes.requeue_expired()
|
||||||
|
dispatch, job = self.orchestrator.hermes_lifecycle_context(
|
||||||
|
context,
|
||||||
|
dispatch_id=identity["dispatch_id"],
|
||||||
|
job_id=identity["job_id"],
|
||||||
|
tenant_id=identity["tenant_id"],
|
||||||
|
task_id=identity["task_id"],
|
||||||
|
trace_id=identity["trace_id"],
|
||||||
|
principal_id=identity["principal_id"],
|
||||||
|
provenance_id=identity["provenance_id"],
|
||||||
|
run_id=identity["run_id"],
|
||||||
|
)
|
||||||
|
if job.cell_id != identity["cell_id"]:
|
||||||
|
raise ValueError("cell_id mismatch")
|
||||||
|
if job.profile_id != identity["profile_id"]:
|
||||||
|
raise ValueError("profile_id mismatch")
|
||||||
|
|
||||||
|
if action == "status":
|
||||||
|
if job.job_id in requeued_job_ids:
|
||||||
|
self._record_hermes_lifecycle(
|
||||||
|
context,
|
||||||
|
dispatch,
|
||||||
|
job,
|
||||||
|
action="lease_expired_requeue",
|
||||||
|
worker_id=None,
|
||||||
|
provenance_id=identity["provenance_id"],
|
||||||
|
run_id=identity["run_id"],
|
||||||
|
)
|
||||||
|
task = self.orchestrator.control_plane.get_task(
|
||||||
|
context, dispatch.task_id
|
||||||
|
)
|
||||||
|
response: dict[str, Any] = {
|
||||||
|
"job": _json_value(job),
|
||||||
|
"dispatch": _json_value(dispatch),
|
||||||
|
"task": _json_value(task),
|
||||||
|
}
|
||||||
|
if job.state in {
|
||||||
|
HermesJobState.COMPLETED,
|
||||||
|
HermesJobState.FAILED,
|
||||||
|
HermesJobState.CANCELLED,
|
||||||
|
}:
|
||||||
|
response["result"] = _json_value(
|
||||||
|
self.hermes.result(job.job_id, tenant_id=job.tenant_id)
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, response
|
||||||
|
|
||||||
|
expected_revision = payload.get("expected_revision")
|
||||||
|
expected = (
|
||||||
|
int(expected_revision) if expected_revision is not None else None
|
||||||
|
)
|
||||||
|
if action == "cancel":
|
||||||
|
self._require_control_plane_cancel(context)
|
||||||
|
if payload.get("worker_id") is not None:
|
||||||
|
raise Unauthorized("Hermes workers may observe but not initiate cancel")
|
||||||
|
if payload.get("cancellation_source") != "aios-control-plane":
|
||||||
|
raise ValueError(
|
||||||
|
"cancellation_source must be aios-control-plane"
|
||||||
|
)
|
||||||
|
result = self.hermes.cancel(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
expected_revision=expected,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
worker_id = self._required_identity(payload, "worker_id")
|
||||||
|
if action == "claim":
|
||||||
|
claimed = self.hermes.claim(
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
cell_id=job.cell_id,
|
||||||
|
profile_id=job.profile_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
lease_seconds=(
|
||||||
|
int(payload["lease_seconds"])
|
||||||
|
if payload.get("lease_seconds") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
job_id=job.job_id,
|
||||||
|
)
|
||||||
|
if claimed is None:
|
||||||
|
raise ValueError("Hermes job is not claimable")
|
||||||
|
bound = self.orchestrator.bind_hermes_worker(
|
||||||
|
context,
|
||||||
|
claimed,
|
||||||
|
dispatch_id=dispatch.dispatch_id,
|
||||||
|
provenance_id=identity["provenance_id"],
|
||||||
|
run_id=identity["run_id"],
|
||||||
|
worker_id=worker_id,
|
||||||
|
)
|
||||||
|
self._record_hermes_lifecycle(
|
||||||
|
context,
|
||||||
|
bound,
|
||||||
|
claimed,
|
||||||
|
action="claim",
|
||||||
|
worker_id=worker_id,
|
||||||
|
provenance_id=identity["provenance_id"],
|
||||||
|
run_id=identity["run_id"],
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"job": _json_value(claimed),
|
||||||
|
"dispatch": _json_value(bound),
|
||||||
|
}
|
||||||
|
if action == "start":
|
||||||
|
started = self.hermes.start(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
expected_revision=expected,
|
||||||
|
)
|
||||||
|
self._record_hermes_lifecycle(
|
||||||
|
context,
|
||||||
|
dispatch,
|
||||||
|
started,
|
||||||
|
action="start",
|
||||||
|
worker_id=worker_id,
|
||||||
|
provenance_id=identity["provenance_id"],
|
||||||
|
run_id=identity["run_id"],
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"job": _json_value(started)}
|
||||||
|
if action == "heartbeat":
|
||||||
|
heartbeat = self.hermes.heartbeat(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
lease_seconds=(
|
||||||
|
int(payload["lease_seconds"])
|
||||||
|
if payload.get("lease_seconds") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._record_hermes_lifecycle(
|
||||||
|
context,
|
||||||
|
dispatch,
|
||||||
|
heartbeat,
|
||||||
|
action="heartbeat",
|
||||||
|
worker_id=worker_id,
|
||||||
|
provenance_id=identity["provenance_id"],
|
||||||
|
run_id=identity["run_id"],
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"job": _json_value(heartbeat)}
|
||||||
|
if action == "complete":
|
||||||
|
output = payload.get("output")
|
||||||
|
if not isinstance(output, Mapping):
|
||||||
|
raise ValueError("output must be an object")
|
||||||
|
result = self.hermes.complete(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
output=output,
|
||||||
|
expected_revision=expected,
|
||||||
|
)
|
||||||
|
elif action == "fail":
|
||||||
|
error = payload.get("error")
|
||||||
|
if not isinstance(error, Mapping):
|
||||||
|
raise ValueError("error must be an object")
|
||||||
|
result = self.hermes.fail(
|
||||||
|
job.job_id,
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
worker_id=worker_id,
|
||||||
|
error=error,
|
||||||
|
expected_revision=expected,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("unsupported Hermes lifecycle action")
|
||||||
|
|
||||||
|
task = self.orchestrator.reconcile_hermes(
|
||||||
|
context,
|
||||||
|
result,
|
||||||
|
dispatch_id=dispatch.dispatch_id,
|
||||||
|
provenance_id=identity["provenance_id"],
|
||||||
|
run_id=identity["run_id"],
|
||||||
|
)
|
||||||
|
updated_dispatch = self.orchestrator.dispatch_repository.get(
|
||||||
|
dispatch.dispatch_id, tenant_id=dispatch.tenant_id
|
||||||
|
)
|
||||||
|
terminal_job = self.hermes.get(job.job_id, tenant_id=job.tenant_id)
|
||||||
|
self._record_hermes_lifecycle(
|
||||||
|
context,
|
||||||
|
updated_dispatch,
|
||||||
|
terminal_job,
|
||||||
|
action=action,
|
||||||
|
worker_id=result.worker_id,
|
||||||
|
provenance_id=identity["provenance_id"],
|
||||||
|
run_id=identity["run_id"],
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"result": _json_value(result),
|
||||||
|
"task": _json_value(task),
|
||||||
|
"dispatch": _json_value(updated_dispatch),
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
|
||||||
|
context = self.context(headers)
|
||||||
|
self._require_runtime_role(context)
|
||||||
|
if not isinstance(body, Mapping):
|
||||||
|
raise ValueError("JSON body must be an object")
|
||||||
|
provenance_id, run_id = _provenance_context(headers, body)
|
||||||
|
if not provenance_id or not run_id:
|
||||||
|
raise ValueError("provenance_id and run_id are required")
|
||||||
|
dispatch_id = str(body.get("dispatch_id") or "").strip()
|
||||||
|
if not dispatch_id:
|
||||||
|
raise ValueError("dispatch_id is required")
|
||||||
|
result = HermesResult(
|
||||||
|
job_id=str(body["job_id"]),
|
||||||
|
tenant_id=str(body["tenant_id"]),
|
||||||
|
task_id=str(body["task_id"]),
|
||||||
|
trace_id=str(body["trace_id"]),
|
||||||
|
state=HermesJobState(str(body["state"])),
|
||||||
|
worker_id=(
|
||||||
|
str(body["worker_id"])
|
||||||
|
if body.get("worker_id") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
output=body.get("output"),
|
||||||
|
error=body.get("error"),
|
||||||
|
started_at=body.get("started_at"),
|
||||||
|
finished_at=body.get("finished_at"),
|
||||||
|
)
|
||||||
|
task = self.orchestrator.reconcile_hermes(
|
||||||
|
context,
|
||||||
|
result,
|
||||||
|
dispatch_id=dispatch_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||||
|
return HTTPStatus.GONE, {
|
||||||
|
"error": "RuntimeReconcileEndpointMoved",
|
||||||
|
"message": "use /api/v2/internal/runtime/hermes/reconcile",
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.gateway.handle(method, raw_path, headers, body)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "AIOSV2Candidate/2.0"
|
||||||
|
max_body_bytes = 1_048_576
|
||||||
|
|
||||||
|
@property
|
||||||
|
def application(self) -> CandidateApplication:
|
||||||
|
return self.server.application # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def _read_body(self) -> Any:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
if length < 0 or length > self.max_body_bytes:
|
||||||
|
raise ValueError("request body is too large")
|
||||||
|
if length == 0:
|
||||||
|
return {}
|
||||||
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||||
|
if content_type != "application/json":
|
||||||
|
raise ValueError("Content-Type must be application/json")
|
||||||
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
|
||||||
|
def _security_headers(self) -> None:
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
self.send_response(int(status_code))
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self._security_headers()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _send_console(self) -> None:
|
||||||
|
encoded = CONSOLE_HTML.encode("utf-8")
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self._security_headers()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _dispatch(self, method: str) -> None:
|
||||||
|
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||||
|
if method == "GET" and path in {"/", "/console"}:
|
||||||
|
self._send_console()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = self._read_body() if method == "POST" else {}
|
||||||
|
status_code, payload = self.application.handle_json(
|
||||||
|
method,
|
||||||
|
self.path,
|
||||||
|
{key: value for key, value in self.headers.items()},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status_code, payload = error_response(exc)
|
||||||
|
self._send_json(status_code, payload)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("GET")
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("POST")
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
|
||||||
|
self.application = application
|
||||||
|
super().__init__(address, CandidateRequestHandler)
|
||||||
|
|
||||||
|
|
||||||
|
def build_application() -> CandidateApplication:
|
||||||
|
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||||
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
|
||||||
|
dispatch_repository = SQLiteDispatchRepository(state_dir / "dispatches.sqlite3")
|
||||||
|
service = DurableControlPlaneService(repository)
|
||||||
|
require_provenance = _enabled(os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0"))
|
||||||
|
provenance_writer = build_provenance_writer_from_env(
|
||||||
|
state_dir=state_dir, required=require_provenance
|
||||||
|
)
|
||||||
|
tracker = ExecutionTracker(
|
||||||
|
state_dir / "executions",
|
||||||
|
provenance_writer=provenance_writer,
|
||||||
|
require_provenance=require_provenance,
|
||||||
|
)
|
||||||
|
hermes = HermesAdapter(store=SQLiteHermesStore(state_dir / "hermes.sqlite3"))
|
||||||
|
|
||||||
|
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
||||||
|
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||||
|
signing_key_id = os.getenv(
|
||||||
|
"AIOS_OPENCLAW_SIGNING_KEY_ID", "openclaw-v2r1-candidate-ed25519"
|
||||||
|
).strip()
|
||||||
|
openclaw = None
|
||||||
|
resolver = None
|
||||||
|
if openclaw_url and signing_file:
|
||||||
|
resolver = FileCommandSignerResolver(
|
||||||
|
signing_file,
|
||||||
|
configured_key_id=signing_key_id,
|
||||||
|
)
|
||||||
|
transport = HTTPRuntimeTransport(
|
||||||
|
openclaw_url,
|
||||||
|
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
||||||
|
)
|
||||||
|
openclaw = OpenClawAdapter(
|
||||||
|
transport,
|
||||||
|
resolver,
|
||||||
|
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway = GatewayApplication(service)
|
||||||
|
orchestrator = ExecutionOrchestrator(
|
||||||
|
service,
|
||||||
|
openclaw=openclaw,
|
||||||
|
hermes=hermes,
|
||||||
|
openclaw_signing_material_resolver=resolver,
|
||||||
|
openclaw_key_id=signing_key_id,
|
||||||
|
provenance_writer=provenance_writer,
|
||||||
|
require_provenance=require_provenance,
|
||||||
|
dispatch_repository=dispatch_repository,
|
||||||
|
)
|
||||||
|
return CandidateApplication(
|
||||||
|
gateway,
|
||||||
|
orchestrator,
|
||||||
|
tracker,
|
||||||
|
hermes,
|
||||||
|
openclaw,
|
||||||
|
hermes_lifecycle_api_enabled=_enabled(
|
||||||
|
os.getenv("AIOS_HERMES_LIFECYCLE_API_ENABLED", "0")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("AIOS_V2_PORT", "18920"))
|
||||||
|
server = CandidateHTTPServer((host, port), build_application())
|
||||||
|
print(f"AIOS V2 candidate listening on {host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dependency-free AIOS V2 web console assets.
|
||||||
|
|
||||||
|
Authentication and tenant identity are injected by the trusted reverse proxy.
|
||||||
|
The browser never selects a tenant and never calls OpenClaw or Hermes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONSOLE_HTML = r'''<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>AIOS V2 Console</title>
|
||||||
|
<style>
|
||||||
|
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#152033;background:#f4f7fb}
|
||||||
|
*{box-sizing:border-box} body{margin:0} header{background:#111827;color:white;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
header h1{font-size:20px;margin:0} header span{font-size:12px;color:#cbd5e1}
|
||||||
|
main{padding:24px;max-width:1440px;margin:auto}.notice{background:#fff7ed;border:1px solid #fed7aa;padding:12px 14px;border-radius:10px;margin-bottom:18px;font-size:13px}
|
||||||
|
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin-bottom:20px}.card,.panel{background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||||
|
.card{padding:16px}.card b{display:block;font-size:28px;margin-top:8px}.card small{color:#64748b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:16px}.panel{padding:16px;overflow:auto}.panel h2{font-size:16px;margin:0 0 12px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;border-bottom:1px solid #edf0f4;padding:8px;vertical-align:top}th{color:#64748b;font-weight:600}.state{font-weight:700}.ok{color:#047857}.bad{color:#b91c1c}.muted{color:#64748b}
|
||||||
|
button{border:0;border-radius:8px;padding:9px 13px;background:#2563eb;color:white;cursor:pointer}button:disabled{opacity:.5}.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px}.error{color:#b91c1c;white-space:pre-wrap}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><h1>AIOS V2 Control Plane</h1><span id="updated">Not loaded</span></header>
|
||||||
|
<main>
|
||||||
|
<div class="notice">Read-only candidate console. Identity and tenant context must be injected by the trusted reverse proxy. The console never calls OpenClaw or Hermes directly.</div>
|
||||||
|
<div class="toolbar"><button id="refresh">Refresh</button><span id="error" class="error"></span></div>
|
||||||
|
<section class="cards">
|
||||||
|
<div class="card"><small>Agents</small><b id="agentCount">—</b></div>
|
||||||
|
<div class="card"><small>Tasks</small><b id="taskCount">—</b></div>
|
||||||
|
<div class="card"><small>Audit events</small><b id="auditCount">—</b></div>
|
||||||
|
<div class="card"><small>Executions</small><b id="executionCount">—</b></div>
|
||||||
|
</section>
|
||||||
|
<section class="grid">
|
||||||
|
<div class="panel"><h2>Runtime health</h2><div id="runtime"></div></div>
|
||||||
|
<div class="panel"><h2>Tasks</h2><div id="tasks"></div></div>
|
||||||
|
<div class="panel"><h2>Agents</h2><div id="agents"></div></div>
|
||||||
|
<div class="panel"><h2>Execution evidence</h2><div id="executions"></div></div>
|
||||||
|
<div class="panel"><h2>Audit</h2><div id="audit"></div></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const table=(cols,rows)=>`<table><thead><tr>${cols.map(c=>`<th>${esc(c[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(c=>`<td>${esc(c[1](r))}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||||
|
async function api(path){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json'}});const data=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${data.message||data.error||path}`);return data}
|
||||||
|
async function refresh(){
|
||||||
|
const btn=document.getElementById('refresh');btn.disabled=true;document.getElementById('error').textContent='';
|
||||||
|
try{
|
||||||
|
const [agents,tasks,audit,runtime,executions]=await Promise.all([
|
||||||
|
api('/api/v2/agents'),api('/api/v2/tasks'),api('/api/v2/audit'),api('/api/v2/runtime'),api('/api/v2/executions')
|
||||||
|
]);
|
||||||
|
document.getElementById('agentCount').textContent=agents.agents.length;
|
||||||
|
document.getElementById('taskCount').textContent=tasks.tasks.length;
|
||||||
|
document.getElementById('auditCount').textContent=audit.events.length;
|
||||||
|
document.getElementById('executionCount').textContent=executions.executions.length;
|
||||||
|
document.getElementById('agents').innerHTML=table([['ID',x=>x.agent_id],['Runtime',x=>x.runtime],['Enabled',x=>x.enabled]],agents.agents);
|
||||||
|
document.getElementById('tasks').innerHTML=table([['Task',x=>x.task_id],['Action',x=>x.action],['State',x=>x.state],['Rev',x=>x.revision]],tasks.tasks);
|
||||||
|
document.getElementById('audit').innerHTML=table([['Time',x=>x.created_at],['Action',x=>x.action],['Outcome',x=>x.outcome],['Resource',x=>x.resource_id]],audit.events.slice(-100).reverse());
|
||||||
|
document.getElementById('executions').innerHTML=table([['Executor',x=>x.executor],['Task',x=>x.task_id],['Status',x=>x.status],['Progress',x=>`${x.progress_completed}/${x.progress_total}`],['Commit',x=>x.last_commit||'']],executions.executions);
|
||||||
|
document.getElementById('runtime').innerHTML=table([['Runtime',x=>x.name],['Status',x=>x.status],['Details',x=>JSON.stringify(x.details)]],runtime.runtimes);
|
||||||
|
document.getElementById('updated').textContent=`Updated ${new Date().toLocaleString()}`;
|
||||||
|
}catch(e){document.getElementById('error').textContent=e.message||String(e)}finally{btn.disabled=false}
|
||||||
|
}
|
||||||
|
document.getElementById('refresh').addEventListener('click',refresh);refresh();setInterval(refresh,30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
"""Tenant-isolated AIOS V2 control-plane domain service.
|
||||||
|
|
||||||
|
This module deliberately contains no OpenClaw or Hermes implementation details.
|
||||||
|
It owns authoritative tenant, agent, task-lifecycle, idempotency and audit state.
|
||||||
|
Runtime adapters consume approved tasks through a separate boundary.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _new_id(prefix: str) -> str:
|
||||||
|
return f"{prefix}_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_digest(value: Mapping[str, Any]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneError(RuntimeError):
|
||||||
|
"""Base error returned by the control-plane domain."""
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(ControlPlaneError):
|
||||||
|
"""The caller is not authenticated or lacks the required role."""
|
||||||
|
|
||||||
|
|
||||||
|
class TenantIsolationError(ControlPlaneError):
|
||||||
|
"""A request attempted to cross a tenant boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(ControlPlaneError):
|
||||||
|
"""A tenant-scoped resource does not exist or is not visible."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransition(ControlPlaneError):
|
||||||
|
"""A task state transition violates the lifecycle contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyConflict(ControlPlaneError):
|
||||||
|
"""An idempotency key was reused for a different command."""
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionConflict(ControlPlaneError):
|
||||||
|
"""Optimistic task revision does not match current state."""
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyDenied(ControlPlaneError):
|
||||||
|
"""The policy gate denied a control-plane operation."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RequestContext:
|
||||||
|
"""Authenticated request identity propagated across all AIOS layers."""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
principal_id: str
|
||||||
|
trace_id: str
|
||||||
|
roles: frozenset[str] = field(default_factory=frozenset)
|
||||||
|
session_id: str | None = None
|
||||||
|
policy_version: str = "v1"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in ("tenant_id", "principal_id", "trace_id"):
|
||||||
|
if not str(getattr(self, name, "")).strip():
|
||||||
|
raise ValueError(f"{name} is required")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
roles: Iterable[str] = (),
|
||||||
|
trace_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
policy_version: str = "v1",
|
||||||
|
) -> "RequestContext":
|
||||||
|
return cls(
|
||||||
|
tenant_id=tenant_id.strip(),
|
||||||
|
principal_id=principal_id.strip(),
|
||||||
|
trace_id=trace_id or _new_id("trace"),
|
||||||
|
roles=frozenset(str(role).strip() for role in roles if str(role).strip()),
|
||||||
|
session_id=session_id,
|
||||||
|
policy_version=policy_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TenantRecord:
|
||||||
|
tenant_id: str
|
||||||
|
display_name: str
|
||||||
|
status: str = "active"
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentRecord:
|
||||||
|
agent_id: str
|
||||||
|
tenant_id: str
|
||||||
|
display_name: str
|
||||||
|
runtime: str
|
||||||
|
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
enabled: bool = True
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskState(str, Enum):
|
||||||
|
CREATED = "CREATED"
|
||||||
|
APPROVED = "APPROVED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_TASK_STATES = frozenset(
|
||||||
|
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
ALLOWED_TASK_TRANSITIONS: Mapping[TaskState, frozenset[TaskState]] = {
|
||||||
|
TaskState.CREATED: frozenset({TaskState.APPROVED, TaskState.CANCELLED}),
|
||||||
|
TaskState.APPROVED: frozenset({TaskState.RUNNING, TaskState.CANCELLED}),
|
||||||
|
TaskState.RUNNING: frozenset(
|
||||||
|
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||||
|
),
|
||||||
|
TaskState.COMPLETED: frozenset(),
|
||||||
|
TaskState.FAILED: frozenset(),
|
||||||
|
TaskState.CANCELLED: frozenset(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskRecord:
|
||||||
|
task_id: str
|
||||||
|
tenant_id: str
|
||||||
|
agent_id: str
|
||||||
|
action: str
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
state: TaskState = TaskState.CREATED
|
||||||
|
revision: int = 1
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
request_digest: str = ""
|
||||||
|
result: Mapping[str, Any] | None = None
|
||||||
|
error: Mapping[str, Any] | None = None
|
||||||
|
created_by: str = ""
|
||||||
|
approved_by: str | None = None
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
updated_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuditEvent:
|
||||||
|
event_id: str
|
||||||
|
tenant_id: str
|
||||||
|
principal_id: str
|
||||||
|
trace_id: str
|
||||||
|
action: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
outcome: str
|
||||||
|
policy_version: str
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=_utc_now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PolicyDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
policy_version: str
|
||||||
|
|
||||||
|
|
||||||
|
PolicyEvaluator = Callable[[RequestContext, str, Mapping[str, Any]], PolicyDecision]
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryControlPlaneStore:
|
||||||
|
"""Thread-safe reference store used by tests and the first gateway slice.
|
||||||
|
|
||||||
|
Production backends must preserve the same tenant-scoped interface and add
|
||||||
|
database row-level security, migrations, backup and HA evidence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.tenants: dict[str, TenantRecord] = {}
|
||||||
|
self.agents: dict[str, AgentRecord] = {}
|
||||||
|
self.tasks: dict[str, TaskRecord] = {}
|
||||||
|
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
|
self.audit_events: list[AuditEvent] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneService:
|
||||||
|
"""Authoritative tenant, agent and task lifecycle service."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: InMemoryControlPlaneStore | None = None,
|
||||||
|
*,
|
||||||
|
policy_evaluator: PolicyEvaluator | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.store = store or InMemoryControlPlaneStore()
|
||||||
|
self.policy_evaluator = policy_evaluator or self._default_policy
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_policy(
|
||||||
|
context: RequestContext,
|
||||||
|
action: str,
|
||||||
|
resource: Mapping[str, Any],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
del resource
|
||||||
|
role_requirements = {
|
||||||
|
"tenant.register": {"platform_admin"},
|
||||||
|
"tenant.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"agent.register": {"tenant_admin", "operator", "platform_admin"},
|
||||||
|
"agent.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"task.create": {"tenant_admin", "operator", "platform_admin"},
|
||||||
|
"task.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||||
|
"task.approve": {"tenant_admin", "approver", "platform_admin"},
|
||||||
|
"task.start": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.complete": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.fail": {"runtime", "operator", "platform_admin"},
|
||||||
|
"task.cancel": {"tenant_admin", "operator", "approver", "platform_admin"},
|
||||||
|
"audit.read": {"tenant_admin", "auditor", "platform_admin"},
|
||||||
|
}
|
||||||
|
required = role_requirements.get(action, {"platform_admin"})
|
||||||
|
allowed = bool(context.roles.intersection(required))
|
||||||
|
return PolicyDecision(
|
||||||
|
allowed=allowed,
|
||||||
|
reason="role_allowed" if allowed else "required_role_missing",
|
||||||
|
policy_version=context.policy_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_context(self, context: RequestContext) -> None:
|
||||||
|
if not context.principal_id or not context.tenant_id or not context.trace_id:
|
||||||
|
raise Unauthorized("authenticated tenant context is required")
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
action: str,
|
||||||
|
resource: Mapping[str, Any],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
self._require_context(context)
|
||||||
|
decision = self.policy_evaluator(context, action, resource)
|
||||||
|
if not decision.allowed:
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action=action,
|
||||||
|
resource_type=str(resource.get("resource_type", "policy")),
|
||||||
|
resource_id=str(resource.get("resource_id", "unknown")),
|
||||||
|
outcome="denied",
|
||||||
|
details={"reason": decision.reason},
|
||||||
|
)
|
||||||
|
raise PolicyDenied(f"{action} denied: {decision.reason}")
|
||||||
|
return decision
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_same_tenant(context: RequestContext, tenant_id: str) -> None:
|
||||||
|
if context.tenant_id != tenant_id and "platform_admin" not in context.roles:
|
||||||
|
raise TenantIsolationError("cross-tenant access denied")
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
outcome: str,
|
||||||
|
details: Mapping[str, Any] | None = None,
|
||||||
|
) -> AuditEvent:
|
||||||
|
event = AuditEvent(
|
||||||
|
event_id=_new_id("audit"),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
outcome=outcome,
|
||||||
|
policy_version=context.policy_version,
|
||||||
|
details=copy.deepcopy(dict(details or {})),
|
||||||
|
)
|
||||||
|
self.store.audit_events.append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def register_tenant(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
display_name: str,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> TenantRecord:
|
||||||
|
tenant_id = tenant_id.strip()
|
||||||
|
if not tenant_id or not display_name.strip():
|
||||||
|
raise ValueError("tenant_id and display_name are required")
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"tenant.register",
|
||||||
|
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||||
|
)
|
||||||
|
self._assert_same_tenant(context, tenant_id)
|
||||||
|
with self.store.lock:
|
||||||
|
if tenant_id in self.store.tenants:
|
||||||
|
raise ControlPlaneError(f"tenant already exists: {tenant_id}")
|
||||||
|
tenant = TenantRecord(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
metadata=copy.deepcopy(dict(metadata or {})),
|
||||||
|
)
|
||||||
|
self.store.tenants[tenant_id] = tenant
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="tenant.register",
|
||||||
|
resource_type="tenant",
|
||||||
|
resource_id=tenant_id,
|
||||||
|
outcome="allowed",
|
||||||
|
)
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
def get_tenant(self, context: RequestContext, tenant_id: str | None = None) -> TenantRecord:
|
||||||
|
tenant_id = tenant_id or context.tenant_id
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"tenant.read",
|
||||||
|
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||||
|
)
|
||||||
|
self._assert_same_tenant(context, tenant_id)
|
||||||
|
with self.store.lock:
|
||||||
|
tenant = self.store.tenants.get(tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise NotFound("tenant not found")
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
def register_agent(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
display_name: str,
|
||||||
|
runtime: str,
|
||||||
|
capabilities: Iterable[str] = (),
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> AgentRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.register",
|
||||||
|
{"resource_type": "agent", "resource_id": agent_id},
|
||||||
|
)
|
||||||
|
tenant = self.get_tenant(context)
|
||||||
|
if tenant.status != "active":
|
||||||
|
raise ControlPlaneError("tenant is not active")
|
||||||
|
normalized_capabilities = tuple(
|
||||||
|
sorted({str(item).strip() for item in capabilities if str(item).strip()})
|
||||||
|
)
|
||||||
|
if not agent_id.strip() or not display_name.strip() or not runtime.strip():
|
||||||
|
raise ValueError("agent_id, display_name and runtime are required")
|
||||||
|
with self.store.lock:
|
||||||
|
existing = self.store.agents.get(agent_id)
|
||||||
|
if existing is not None and existing.tenant_id != context.tenant_id:
|
||||||
|
raise TenantIsolationError("agent id belongs to another tenant")
|
||||||
|
if existing is not None:
|
||||||
|
raise ControlPlaneError(f"agent already exists: {agent_id}")
|
||||||
|
agent = AgentRecord(
|
||||||
|
agent_id=agent_id.strip(),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
display_name=display_name.strip(),
|
||||||
|
runtime=runtime.strip(),
|
||||||
|
capabilities=normalized_capabilities,
|
||||||
|
metadata=copy.deepcopy(dict(metadata or {})),
|
||||||
|
)
|
||||||
|
self.store.agents[agent.agent_id] = agent
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="agent.register",
|
||||||
|
resource_type="agent",
|
||||||
|
resource_id=agent.agent_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={"runtime": agent.runtime},
|
||||||
|
)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def get_agent(self, context: RequestContext, agent_id: str) -> AgentRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.read",
|
||||||
|
{"resource_type": "agent", "resource_id": agent_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
agent = self.store.agents.get(agent_id)
|
||||||
|
if agent is None:
|
||||||
|
raise NotFound("agent not found")
|
||||||
|
self._assert_same_tenant(context, agent.tenant_id)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def list_agents(self, context: RequestContext) -> list[AgentRecord]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"agent.read",
|
||||||
|
{"resource_type": "agent_collection", "resource_id": context.tenant_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
agent
|
||||||
|
for agent in self.store.agents.values()
|
||||||
|
if agent.tenant_id == context.tenant_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: Mapping[str, Any] | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.create",
|
||||||
|
{"resource_type": "task", "resource_id": idempotency_key or "new"},
|
||||||
|
)
|
||||||
|
agent = self.get_agent(context, agent_id)
|
||||||
|
if not agent.enabled:
|
||||||
|
raise ControlPlaneError("agent is disabled")
|
||||||
|
if action not in agent.capabilities and "*" not in agent.capabilities:
|
||||||
|
raise PolicyDenied(f"agent does not advertise capability: {action}")
|
||||||
|
normalized_payload = copy.deepcopy(dict(payload or {}))
|
||||||
|
request_digest = _canonical_digest(
|
||||||
|
{"agent_id": agent_id, "action": action, "payload": normalized_payload}
|
||||||
|
)
|
||||||
|
normalized_key = idempotency_key.strip() if idempotency_key else None
|
||||||
|
with self.store.lock:
|
||||||
|
if normalized_key:
|
||||||
|
idempotency_ref = (context.tenant_id, normalized_key)
|
||||||
|
existing = self.store.idempotency.get(idempotency_ref)
|
||||||
|
if existing is not None:
|
||||||
|
existing_task_id, existing_digest = existing
|
||||||
|
if existing_digest != request_digest:
|
||||||
|
raise IdempotencyConflict(
|
||||||
|
"idempotency key was reused with a different request"
|
||||||
|
)
|
||||||
|
return self.store.tasks[existing_task_id]
|
||||||
|
task = TaskRecord(
|
||||||
|
task_id=_new_id("task"),
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
action=action,
|
||||||
|
payload=normalized_payload,
|
||||||
|
idempotency_key=normalized_key,
|
||||||
|
request_digest=request_digest,
|
||||||
|
created_by=context.principal_id,
|
||||||
|
)
|
||||||
|
self.store.tasks[task.task_id] = task
|
||||||
|
if normalized_key:
|
||||||
|
self.store.idempotency[(context.tenant_id, normalized_key)] = (
|
||||||
|
task.task_id,
|
||||||
|
request_digest,
|
||||||
|
)
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action="task.create",
|
||||||
|
resource_type="task",
|
||||||
|
resource_id=task.task_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={"agent_id": agent_id, "action": action},
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def get_task(self, context: RequestContext, task_id: str) -> TaskRecord:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.read",
|
||||||
|
{"resource_type": "task", "resource_id": task_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
task = self.store.tasks.get(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise NotFound("task not found")
|
||||||
|
self._assert_same_tenant(context, task.tenant_id)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
states: Iterable[TaskState | str] | None = None,
|
||||||
|
) -> list[TaskRecord]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"task.read",
|
||||||
|
{"resource_type": "task_collection", "resource_id": context.tenant_id},
|
||||||
|
)
|
||||||
|
state_filter = (
|
||||||
|
{TaskState(item) for item in states}
|
||||||
|
if states is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
task
|
||||||
|
for task in self.store.tasks.values()
|
||||||
|
if task.tenant_id == context.tenant_id
|
||||||
|
and (state_filter is None or task.state in state_filter)
|
||||||
|
]
|
||||||
|
|
||||||
|
def transition_task(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
target_state: TaskState | str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
result: Mapping[str, Any] | None = None,
|
||||||
|
error: Mapping[str, Any] | None = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
target = TaskState(target_state)
|
||||||
|
action_name = {
|
||||||
|
TaskState.APPROVED: "task.approve",
|
||||||
|
TaskState.RUNNING: "task.start",
|
||||||
|
TaskState.COMPLETED: "task.complete",
|
||||||
|
TaskState.FAILED: "task.fail",
|
||||||
|
TaskState.CANCELLED: "task.cancel",
|
||||||
|
}.get(target)
|
||||||
|
if action_name is None:
|
||||||
|
raise InvalidTransition(f"cannot transition to {target.value}")
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
action_name,
|
||||||
|
{"resource_type": "task", "resource_id": task_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
current = self.store.tasks.get(task_id)
|
||||||
|
if current is None:
|
||||||
|
raise NotFound("task not found")
|
||||||
|
self._assert_same_tenant(context, current.tenant_id)
|
||||||
|
if expected_revision is not None and current.revision != expected_revision:
|
||||||
|
raise RevisionConflict(
|
||||||
|
f"expected revision {expected_revision}, current {current.revision}"
|
||||||
|
)
|
||||||
|
if target not in ALLOWED_TASK_TRANSITIONS[current.state]:
|
||||||
|
raise InvalidTransition(
|
||||||
|
f"invalid transition {current.state.value} -> {target.value}"
|
||||||
|
)
|
||||||
|
if target == TaskState.COMPLETED and result is None:
|
||||||
|
raise ValueError("completed task requires result")
|
||||||
|
if target == TaskState.FAILED and error is None:
|
||||||
|
raise ValueError("failed task requires error")
|
||||||
|
updated = TaskRecord(
|
||||||
|
task_id=current.task_id,
|
||||||
|
tenant_id=current.tenant_id,
|
||||||
|
agent_id=current.agent_id,
|
||||||
|
action=current.action,
|
||||||
|
payload=current.payload,
|
||||||
|
state=target,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
idempotency_key=current.idempotency_key,
|
||||||
|
request_digest=current.request_digest,
|
||||||
|
result=copy.deepcopy(dict(result)) if result is not None else current.result,
|
||||||
|
error=copy.deepcopy(dict(error)) if error is not None else current.error,
|
||||||
|
created_by=current.created_by,
|
||||||
|
approved_by=(
|
||||||
|
context.principal_id
|
||||||
|
if target == TaskState.APPROVED
|
||||||
|
else current.approved_by
|
||||||
|
),
|
||||||
|
created_at=current.created_at,
|
||||||
|
updated_at=_utc_now(),
|
||||||
|
)
|
||||||
|
self.store.tasks[task_id] = updated
|
||||||
|
self._audit(
|
||||||
|
context,
|
||||||
|
action=action_name,
|
||||||
|
resource_type="task",
|
||||||
|
resource_id=task_id,
|
||||||
|
outcome="allowed",
|
||||||
|
details={
|
||||||
|
"from": current.state.value,
|
||||||
|
"to": target.value,
|
||||||
|
"revision": updated.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list_audit_events(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
) -> list[AuditEvent]:
|
||||||
|
self._authorize(
|
||||||
|
context,
|
||||||
|
"audit.read",
|
||||||
|
{"resource_type": "audit", "resource_id": resource_id or context.tenant_id},
|
||||||
|
)
|
||||||
|
with self.store.lock:
|
||||||
|
return [
|
||||||
|
event
|
||||||
|
for event in self.store.audit_events
|
||||||
|
if event.tenant_id == context.tenant_id
|
||||||
|
and (resource_id is None or event.resource_id == resource_id)
|
||||||
|
]
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""AIOS V2 持久化 Dispatch 记录。
|
||||||
|
|
||||||
|
Dispatch 是 AIOS Task 与 Runtime command/job 之间的唯一可信绑定。所有结果回写
|
||||||
|
必须匹配 tenant/task/trace/runtime_execution/provenance/run/principal。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchState(str, Enum):
|
||||||
|
PREPARED = "PREPARED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_DISPATCH_STATES = frozenset(
|
||||||
|
{DispatchState.COMPLETED, DispatchState.FAILED, DispatchState.CANCELLED}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DispatchRecord:
|
||||||
|
dispatch_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
runtime: str
|
||||||
|
runtime_execution_id: str
|
||||||
|
trace_id: str
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
authorization_id: str
|
||||||
|
state: DispatchState = DispatchState.PREPARED
|
||||||
|
expected_worker_id: str | None = None
|
||||||
|
result_digest: str | None = None
|
||||||
|
created_at: str = ""
|
||||||
|
updated_at: str = ""
|
||||||
|
completed_at: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"dispatch_id",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"principal_id",
|
||||||
|
"runtime",
|
||||||
|
"runtime_execution_id",
|
||||||
|
"trace_id",
|
||||||
|
"provenance_id",
|
||||||
|
"run_id",
|
||||||
|
"authorization_id",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
object.__setattr__(self, "state", DispatchState(self.state))
|
||||||
|
now = utc_now()
|
||||||
|
object.__setattr__(self, "created_at", self.created_at or now)
|
||||||
|
object.__setattr__(self, "updated_at", self.updated_at or now)
|
||||||
|
if self.expected_worker_id is not None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"expected_worker_id",
|
||||||
|
_required(self.expected_worker_id, "expected_worker_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
runtime: str,
|
||||||
|
runtime_execution_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
authorization_id: str,
|
||||||
|
) -> "DispatchRecord":
|
||||||
|
return cls(
|
||||||
|
dispatch_id=f"dispatch_{uuid.uuid4().hex}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
runtime=runtime,
|
||||||
|
runtime_execution_id=runtime_execution_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
value = asdict(self)
|
||||||
|
value["state"] = self.state.value
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: Mapping[str, Any]) -> "DispatchRecord":
|
||||||
|
return cls(**dict(value))
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchConflict(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchNotFound(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchRepository(Protocol):
|
||||||
|
def create(self, record: DispatchRecord) -> DispatchRecord: ...
|
||||||
|
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord: ...
|
||||||
|
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None: ...
|
||||||
|
def get_by_runtime_execution(
|
||||||
|
self, runtime_execution_id: str, *, tenant_id: str
|
||||||
|
) -> DispatchRecord | None: ...
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
dispatch_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
state: DispatchState | str | None = None,
|
||||||
|
expected_worker_id: str | None = None,
|
||||||
|
result_digest: str | None = None,
|
||||||
|
) -> DispatchRecord: ...
|
||||||
|
def list(self, *, tenant_id: str) -> list[DispatchRecord]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryDispatchRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._records: dict[str, DispatchRecord] = {}
|
||||||
|
self._by_task: dict[tuple[str, str], str] = {}
|
||||||
|
self._by_runtime: dict[tuple[str, str], str] = {}
|
||||||
|
|
||||||
|
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||||
|
with self._lock:
|
||||||
|
task_ref = (record.tenant_id, record.task_id)
|
||||||
|
runtime_ref = (record.tenant_id, record.runtime_execution_id)
|
||||||
|
if task_ref in self._by_task or runtime_ref in self._by_runtime:
|
||||||
|
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||||
|
if existing == record:
|
||||||
|
return record
|
||||||
|
raise DispatchConflict("task or runtime execution already has a dispatch")
|
||||||
|
self._records[record.dispatch_id] = record
|
||||||
|
self._by_task[task_ref] = record.dispatch_id
|
||||||
|
self._by_runtime[runtime_ref] = record.dispatch_id
|
||||||
|
return record
|
||||||
|
|
||||||
|
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||||
|
with self._lock:
|
||||||
|
record = self._records.get(dispatch_id)
|
||||||
|
if record is None or record.tenant_id != tenant_id:
|
||||||
|
raise DispatchNotFound("dispatch not found")
|
||||||
|
return record
|
||||||
|
|
||||||
|
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||||
|
with self._lock:
|
||||||
|
dispatch_id = self._by_task.get((tenant_id, task_id))
|
||||||
|
return self._records.get(dispatch_id) if dispatch_id else None
|
||||||
|
|
||||||
|
def get_by_runtime_execution(
|
||||||
|
self, runtime_execution_id: str, *, tenant_id: str
|
||||||
|
) -> DispatchRecord | None:
|
||||||
|
with self._lock:
|
||||||
|
dispatch_id = self._by_runtime.get((tenant_id, runtime_execution_id))
|
||||||
|
return self._records.get(dispatch_id) if dispatch_id else None
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
dispatch_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
state: DispatchState | str | None = None,
|
||||||
|
expected_worker_id: str | None = None,
|
||||||
|
result_digest: str | None = None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
with self._lock:
|
||||||
|
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||||
|
target = DispatchState(state) if state is not None else current.state
|
||||||
|
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||||
|
raise DispatchConflict("terminal dispatch cannot transition")
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
state=target,
|
||||||
|
expected_worker_id=(
|
||||||
|
expected_worker_id
|
||||||
|
if expected_worker_id is not None
|
||||||
|
else current.expected_worker_id
|
||||||
|
),
|
||||||
|
result_digest=result_digest or current.result_digest,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||||
|
)
|
||||||
|
self._records[dispatch_id] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||||
|
with self._lock:
|
||||||
|
return sorted(
|
||||||
|
[item for item in self._records.values() if item.tenant_id == tenant_id],
|
||||||
|
key=lambda item: (item.created_at, item.dispatch_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteDispatchRepository:
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS dispatch_records (
|
||||||
|
dispatch_id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
runtime_execution_id TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(tenant_id, task_id),
|
||||||
|
UNIQUE(tenant_id, runtime_execution_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dispatch_tenant_state
|
||||||
|
ON dispatch_records(tenant_id, state, updated_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode(record: DispatchRecord) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
record.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode(row: sqlite3.Row) -> DispatchRecord:
|
||||||
|
value = json.loads(row["payload_json"])
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise RuntimeError("invalid dispatch record")
|
||||||
|
return DispatchRecord.from_dict(value)
|
||||||
|
|
||||||
|
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO dispatch_records VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
record.dispatch_id,
|
||||||
|
record.tenant_id,
|
||||||
|
record.task_id,
|
||||||
|
record.runtime_execution_id,
|
||||||
|
self._encode(record),
|
||||||
|
record.state.value,
|
||||||
|
record.updated_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return record
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
connection.rollback()
|
||||||
|
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||||
|
if existing is not None and existing == record:
|
||||||
|
return existing
|
||||||
|
raise DispatchConflict("task or runtime execution already has a dispatch") from exc
|
||||||
|
|
||||||
|
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload_json FROM dispatch_records WHERE dispatch_id = ? AND tenant_id = ?",
|
||||||
|
(_required(dispatch_id, "dispatch_id"), _required(tenant_id, "tenant_id")),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise DispatchNotFound("dispatch not found")
|
||||||
|
return self._decode(row)
|
||||||
|
|
||||||
|
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload_json FROM dispatch_records WHERE task_id = ? AND tenant_id = ?",
|
||||||
|
(_required(task_id, "task_id"), _required(tenant_id, "tenant_id")),
|
||||||
|
).fetchone()
|
||||||
|
return self._decode(row) if row is not None else None
|
||||||
|
|
||||||
|
def get_by_runtime_execution(
|
||||||
|
self, runtime_execution_id: str, *, tenant_id: str
|
||||||
|
) -> DispatchRecord | None:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT payload_json FROM dispatch_records WHERE runtime_execution_id = ? AND tenant_id = ?",
|
||||||
|
(
|
||||||
|
_required(runtime_execution_id, "runtime_execution_id"),
|
||||||
|
_required(tenant_id, "tenant_id"),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return self._decode(row) if row is not None else None
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
dispatch_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
state: DispatchState | str | None = None,
|
||||||
|
expected_worker_id: str | None = None,
|
||||||
|
result_digest: str | None = None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||||
|
target = DispatchState(state) if state is not None else current.state
|
||||||
|
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||||
|
raise DispatchConflict("terminal dispatch cannot transition")
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
state=target,
|
||||||
|
expected_worker_id=(
|
||||||
|
expected_worker_id if expected_worker_id is not None else current.expected_worker_id
|
||||||
|
),
|
||||||
|
result_digest=result_digest or current.result_digest,
|
||||||
|
updated_at=utc_now(),
|
||||||
|
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||||
|
)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE dispatch_records SET payload_json = ?, state = ?, updated_at = ? WHERE dispatch_id = ? AND tenant_id = ?",
|
||||||
|
(
|
||||||
|
self._encode(updated),
|
||||||
|
updated.state.value,
|
||||||
|
updated.updated_at,
|
||||||
|
updated.dispatch_id,
|
||||||
|
updated.tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT payload_json FROM dispatch_records WHERE tenant_id = ? ORDER BY updated_at, dispatch_id",
|
||||||
|
(_required(tenant_id, "tenant_id"),),
|
||||||
|
).fetchall()
|
||||||
|
return [self._decode(row) for row in rows]
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""AIOS V2 Agent 执行证据。
|
||||||
|
|
||||||
|
Evidence 按租户隔离并使用原子 JSON 写入。生产模式下,claim、heartbeat
|
||||||
|
和 complete 在落盘前必须通过最小权限 Evidence Grant 授权。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
from .provenance import ProvenanceDenied, digest_payload
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceAuthorizationWriter(Protocol):
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionEvidence:
|
||||||
|
execution_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
executor: str
|
||||||
|
branch: str
|
||||||
|
status: str = "claimed"
|
||||||
|
progress_completed: int = 0
|
||||||
|
progress_total: int = 1
|
||||||
|
current_step: str = "claim"
|
||||||
|
last_commit: str | None = None
|
||||||
|
test_status: str = "not_run"
|
||||||
|
test_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
result_summary: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
claimed_at: str = field(default_factory=utc_now)
|
||||||
|
heartbeat_at: str = field(default_factory=utc_now)
|
||||||
|
completed_at: str | None = None
|
||||||
|
provenance_id: str | None = None
|
||||||
|
run_id: str | None = None
|
||||||
|
last_authorization_id: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"execution_id",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"executor",
|
||||||
|
"branch",
|
||||||
|
"status",
|
||||||
|
"current_step",
|
||||||
|
"claimed_at",
|
||||||
|
"heartbeat_at",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||||
|
if self.progress_completed < 0 or self.progress_total < 1:
|
||||||
|
raise ValueError("invalid progress values")
|
||||||
|
if self.progress_completed > self.progress_total:
|
||||||
|
raise ValueError("progress_completed cannot exceed progress_total")
|
||||||
|
object.__setattr__(self, "test_summary", _safe(self.test_summary))
|
||||||
|
object.__setattr__(self, "result_summary", _safe(self.result_summary))
|
||||||
|
for optional in ("provenance_id", "run_id", "last_authorization_id"):
|
||||||
|
value = getattr(self, optional)
|
||||||
|
object.__setattr__(self, optional, str(value).strip() if value else None)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionTracker:
|
||||||
|
"""每个租户、每次执行持久保存一份证据记录。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root: str | Path,
|
||||||
|
*,
|
||||||
|
provenance_writer: EvidenceAuthorizationWriter | None = None,
|
||||||
|
require_provenance: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.root = Path(root).resolve()
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.provenance_writer = provenance_writer
|
||||||
|
self.require_provenance = bool(require_provenance)
|
||||||
|
if self.require_provenance and self.provenance_writer is None:
|
||||||
|
raise ValueError("production evidence tracking requires a provenance writer")
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = _required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ValueError("invalid evidence path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _path(self, tenant_id: str, execution_id: str) -> Path:
|
||||||
|
tenant = self._component(tenant_id)
|
||||||
|
execution = self._component(execution_id)
|
||||||
|
return self.root / tenant / f"{execution}.json"
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
self,
|
||||||
|
evidence: ExecutionEvidence,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
principal_id: str | None,
|
||||||
|
provenance_id: str | None,
|
||||||
|
run_id: str | None,
|
||||||
|
changes: Mapping[str, Any],
|
||||||
|
) -> str | None:
|
||||||
|
if not self.require_provenance:
|
||||||
|
return None
|
||||||
|
if self.provenance_writer is None:
|
||||||
|
raise RuntimeError("Run Provenance writer is not configured")
|
||||||
|
effective_provenance = provenance_id or evidence.provenance_id
|
||||||
|
effective_run = run_id or evidence.run_id
|
||||||
|
if not effective_provenance or not effective_run or not principal_id:
|
||||||
|
raise RuntimeError(
|
||||||
|
"production evidence write requires provenance_id, run_id and principal_id"
|
||||||
|
)
|
||||||
|
request_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"operation": operation,
|
||||||
|
"execution_id": evidence.execution_id,
|
||||||
|
"tenant_id": evidence.tenant_id,
|
||||||
|
"task_id": evidence.task_id,
|
||||||
|
"agent_id": evidence.agent_id,
|
||||||
|
"executor": evidence.executor,
|
||||||
|
"branch": evidence.branch,
|
||||||
|
"changes": dict(changes),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
authorization = self.provenance_writer.authorize_evidence_write(
|
||||||
|
provenance_id=effective_provenance,
|
||||||
|
run_id=effective_run,
|
||||||
|
tenant_id=evidence.tenant_id,
|
||||||
|
task_id=evidence.task_id,
|
||||||
|
agent_id=evidence.agent_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
request_digest=request_digest,
|
||||||
|
)
|
||||||
|
except ProvenanceDenied as exc:
|
||||||
|
raise RuntimeError(f"Run Provenance denied evidence write: {exc}") from exc
|
||||||
|
return authorization.authorization_id
|
||||||
|
|
||||||
|
def claim(
|
||||||
|
self,
|
||||||
|
evidence: ExecutionEvidence,
|
||||||
|
*,
|
||||||
|
principal_id: str | None = None,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(evidence.tenant_id, evidence.execution_id)
|
||||||
|
with self._lock:
|
||||||
|
if path.exists():
|
||||||
|
current = self._read(path)
|
||||||
|
if current.task_id != evidence.task_id or current.agent_id != evidence.agent_id:
|
||||||
|
raise RuntimeError("execution_id already belongs to another task")
|
||||||
|
if self.require_provenance and (
|
||||||
|
current.provenance_id != (provenance_id or evidence.provenance_id)
|
||||||
|
or current.run_id != (run_id or evidence.run_id)
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"execution_id already belongs to another provenance context"
|
||||||
|
)
|
||||||
|
return current
|
||||||
|
effective_provenance = provenance_id or evidence.provenance_id
|
||||||
|
effective_run = run_id or evidence.run_id
|
||||||
|
authorization_id = self._authorize(
|
||||||
|
evidence,
|
||||||
|
operation="claim",
|
||||||
|
principal_id=principal_id,
|
||||||
|
provenance_id=effective_provenance,
|
||||||
|
run_id=effective_run,
|
||||||
|
changes={"status": evidence.status, "current_step": evidence.current_step},
|
||||||
|
)
|
||||||
|
persisted = replace(
|
||||||
|
evidence,
|
||||||
|
provenance_id=effective_provenance,
|
||||||
|
run_id=effective_run,
|
||||||
|
last_authorization_id=authorization_id,
|
||||||
|
)
|
||||||
|
self._write(path, persisted)
|
||||||
|
return persisted
|
||||||
|
|
||||||
|
def heartbeat(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
execution_id: str,
|
||||||
|
status: str | None = None,
|
||||||
|
current_step: str | None = None,
|
||||||
|
progress_completed: int | None = None,
|
||||||
|
progress_total: int | None = None,
|
||||||
|
last_commit: str | None = None,
|
||||||
|
principal_id: str | None = None,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(tenant_id, execution_id)
|
||||||
|
with self._lock:
|
||||||
|
current = self._read(path)
|
||||||
|
changes = {
|
||||||
|
"status": status or current.status,
|
||||||
|
"current_step": current_step or current.current_step,
|
||||||
|
"progress_completed": (
|
||||||
|
progress_completed
|
||||||
|
if progress_completed is not None
|
||||||
|
else current.progress_completed
|
||||||
|
),
|
||||||
|
"progress_total": (
|
||||||
|
progress_total if progress_total is not None else current.progress_total
|
||||||
|
),
|
||||||
|
"last_commit": last_commit or current.last_commit,
|
||||||
|
}
|
||||||
|
authorization_id = self._authorize(
|
||||||
|
current,
|
||||||
|
operation="heartbeat",
|
||||||
|
principal_id=principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
**changes,
|
||||||
|
last_authorization_id=authorization_id or current.last_authorization_id,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._write(path, updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
execution_id: str,
|
||||||
|
status: str,
|
||||||
|
test_status: str,
|
||||||
|
test_summary: Mapping[str, Any] | None = None,
|
||||||
|
result_summary: Mapping[str, Any] | None = None,
|
||||||
|
last_commit: str | None = None,
|
||||||
|
principal_id: str | None = None,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> ExecutionEvidence:
|
||||||
|
path = self._path(tenant_id, execution_id)
|
||||||
|
with self._lock:
|
||||||
|
current = self._read(path)
|
||||||
|
changes = {
|
||||||
|
"status": _required(status, "status"),
|
||||||
|
"current_step": "complete",
|
||||||
|
"progress_completed": current.progress_total,
|
||||||
|
"last_commit": last_commit or current.last_commit,
|
||||||
|
"test_status": _required(test_status, "test_status"),
|
||||||
|
"test_summary": _safe(test_summary),
|
||||||
|
"result_summary": _safe(result_summary),
|
||||||
|
}
|
||||||
|
authorization_id = self._authorize(
|
||||||
|
current,
|
||||||
|
operation="complete",
|
||||||
|
principal_id=principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
current,
|
||||||
|
**changes,
|
||||||
|
last_authorization_id=authorization_id or current.last_authorization_id,
|
||||||
|
heartbeat_at=utc_now(),
|
||||||
|
completed_at=utc_now(),
|
||||||
|
)
|
||||||
|
self._write(path, updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def get(self, *, tenant_id: str, execution_id: str) -> ExecutionEvidence:
|
||||||
|
return self._read(self._path(tenant_id, execution_id))
|
||||||
|
|
||||||
|
def list(self, *, tenant_id: str) -> list[ExecutionEvidence]:
|
||||||
|
tenant_dir = self.root / self._component(tenant_id)
|
||||||
|
if not tenant_dir.exists():
|
||||||
|
return []
|
||||||
|
records = [self._read(path) for path in tenant_dir.glob("*.json")]
|
||||||
|
return sorted(records, key=lambda item: (item.heartbeat_at, item.execution_id), reverse=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read(path: Path) -> ExecutionEvidence:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise KeyError("execution evidence not found") from exc
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise RuntimeError("invalid execution evidence file")
|
||||||
|
return ExecutionEvidence(**dict(value))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write(path: Path, evidence: ExecutionEvidence) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, path)
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"""HTTP/JSON gateway for the AIOS V2 control plane.
|
||||||
|
|
||||||
|
The public gateway owns user-facing control operations only. Runtime lifecycle
|
||||||
|
transitions are deliberately absent: external callers cannot directly move a
|
||||||
|
task to RUNNING, COMPLETED or FAILED. Those transitions are available only
|
||||||
|
through the provenance-protected orchestrator and internal reconciliation API.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
from enum import Enum
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_hermes import HermesConflict, HermesNotFound
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
ControlPlaneError,
|
||||||
|
ControlPlaneService,
|
||||||
|
IdempotencyConflict,
|
||||||
|
InvalidTransition,
|
||||||
|
NotFound,
|
||||||
|
PolicyDenied,
|
||||||
|
RequestContext,
|
||||||
|
RevisionConflict,
|
||||||
|
TaskState,
|
||||||
|
TenantIsolationError,
|
||||||
|
Unauthorized,
|
||||||
|
)
|
||||||
|
from .dispatch_store import DispatchConflict, DispatchNotFound
|
||||||
|
from .orchestrator import OrchestrationError
|
||||||
|
from .provenance import ProvenanceDenied, ProvenanceReplay
|
||||||
|
|
||||||
|
TENANT_HEADER = "X-AIOS-Tenant-ID"
|
||||||
|
PRINCIPAL_HEADER = "X-AIOS-Principal-ID"
|
||||||
|
ROLES_HEADER = "X-AIOS-Roles"
|
||||||
|
TRACE_HEADER = "X-AIOS-Trace-ID"
|
||||||
|
SESSION_HEADER = "X-AIOS-Session-ID"
|
||||||
|
POLICY_HEADER = "X-AIOS-Policy-Version"
|
||||||
|
AUTHENTICATED_HEADER = "X-AIOS-Authenticated"
|
||||||
|
|
||||||
|
_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: Any) -> Any:
|
||||||
|
if dataclasses.is_dataclass(value):
|
||||||
|
return {
|
||||||
|
field.name: _json_value(getattr(value, field.name))
|
||||||
|
for field in dataclasses.fields(value)
|
||||||
|
}
|
||||||
|
if isinstance(value, Enum):
|
||||||
|
return value.value
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {str(key): _json_value(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return [_json_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_mapping(value: Any, *, label: str = "JSON body") -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_resource_id(value: Any, *, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not _RESOURCE_ID_RE.fullmatch(normalized):
|
||||||
|
raise ValueError(f"invalid {label}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayApplication:
|
||||||
|
"""Pure user-facing request router that can be tested without a socket."""
|
||||||
|
|
||||||
|
def __init__(self, service: ControlPlaneService | None = None) -> None:
|
||||||
|
self.service = service or ControlPlaneService()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def context_from_headers(headers: Mapping[str, str]) -> RequestContext:
|
||||||
|
normalized_headers = {
|
||||||
|
str(key).strip().lower(): value for key, value in headers.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def header(name: str, default: str = "") -> str:
|
||||||
|
return str(normalized_headers.get(name.lower(), default))
|
||||||
|
|
||||||
|
authenticated = header(AUTHENTICATED_HEADER).strip().lower()
|
||||||
|
if authenticated not in {"1", "true", "yes"}:
|
||||||
|
raise Unauthorized("trusted authentication assertion is required")
|
||||||
|
roles = [
|
||||||
|
role.strip()
|
||||||
|
for role in header(ROLES_HEADER).split(",")
|
||||||
|
if role.strip()
|
||||||
|
]
|
||||||
|
return RequestContext.create(
|
||||||
|
tenant_id=header(TENANT_HEADER),
|
||||||
|
principal_id=header(PRINCIPAL_HEADER),
|
||||||
|
trace_id=header(TRACE_HEADER).strip() or None,
|
||||||
|
session_id=header(SESSION_HEADER).strip() or None,
|
||||||
|
policy_version=header(POLICY_HEADER, "v1").strip() or "v1",
|
||||||
|
roles=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
raw_path: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
body: Any = None,
|
||||||
|
) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
parsed = urllib.parse.urlparse(raw_path)
|
||||||
|
path = parsed.path.rstrip("/") or "/"
|
||||||
|
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=False)
|
||||||
|
method = method.upper()
|
||||||
|
|
||||||
|
if method == "GET" and path in {"/healthz", "/api/v2/health"}:
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"service": "aios-v2-control-plane",
|
||||||
|
"status": "ok",
|
||||||
|
"store": type(self.service.store).__name__,
|
||||||
|
}
|
||||||
|
|
||||||
|
context = self.context_from_headers(headers)
|
||||||
|
payload = _require_mapping(body or {})
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/tenants":
|
||||||
|
tenant = self.service.register_tenant(
|
||||||
|
context,
|
||||||
|
tenant_id=_require_resource_id(payload.get("tenant_id"), label="tenant_id"),
|
||||||
|
display_name=str(payload.get("display_name", "")),
|
||||||
|
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/tenants/current":
|
||||||
|
return HTTPStatus.OK, {"tenant": _json_value(self.service.get_tenant(context))}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/agents":
|
||||||
|
agent = self.service.register_agent(
|
||||||
|
context,
|
||||||
|
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
||||||
|
display_name=str(payload.get("display_name", "")),
|
||||||
|
runtime=str(payload.get("runtime", "")),
|
||||||
|
capabilities=payload.get("capabilities", ()),
|
||||||
|
metadata=_require_mapping(payload.get("metadata", {}), label="metadata"),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"agent": _json_value(agent)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/agents":
|
||||||
|
return HTTPStatus.OK, {"agents": _json_value(self.service.list_agents(context))}
|
||||||
|
|
||||||
|
agent_match = re.fullmatch(r"/api/v2/agents/([^/]+)", path)
|
||||||
|
if method == "GET" and agent_match:
|
||||||
|
agent_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(agent_match.group(1)), label="agent_id"
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"agent": _json_value(self.service.get_agent(context, agent_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "POST" and path == "/api/v2/tasks":
|
||||||
|
task = self.service.create_task(
|
||||||
|
context,
|
||||||
|
agent_id=_require_resource_id(payload.get("agent_id"), label="agent_id"),
|
||||||
|
action=str(payload.get("action", "")).strip(),
|
||||||
|
payload=_require_mapping(payload.get("payload", {}), label="payload"),
|
||||||
|
idempotency_key=(
|
||||||
|
str(payload["idempotency_key"]).strip()
|
||||||
|
if payload.get("idempotency_key") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/tasks":
|
||||||
|
tasks = self.service.list_tasks(context, states=query.get("state"))
|
||||||
|
return HTTPStatus.OK, {"tasks": _json_value(tasks)}
|
||||||
|
|
||||||
|
task_match = re.fullmatch(r"/api/v2/tasks/([^/]+)", path)
|
||||||
|
if method == "GET" and task_match:
|
||||||
|
task_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(task_match.group(1)), label="task_id"
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {
|
||||||
|
"task": _json_value(self.service.get_task(context, task_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
bypass_match = re.fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/(start|complete|fail)", path
|
||||||
|
)
|
||||||
|
if method == "POST" and bypass_match:
|
||||||
|
return HTTPStatus.FORBIDDEN, {
|
||||||
|
"error": "RuntimeLifecycleBypassDenied",
|
||||||
|
"message": (
|
||||||
|
"RUNNING, COMPLETED and FAILED transitions are internal; "
|
||||||
|
"use the provenance-protected dispatch/reconcile flow"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
transition_match = re.fullmatch(
|
||||||
|
r"/api/v2/tasks/([^/]+)/(approve|cancel)", path
|
||||||
|
)
|
||||||
|
if method == "POST" and transition_match:
|
||||||
|
task_id = _require_resource_id(
|
||||||
|
urllib.parse.unquote(transition_match.group(1)), label="task_id"
|
||||||
|
)
|
||||||
|
target = (
|
||||||
|
TaskState.APPROVED
|
||||||
|
if transition_match.group(2) == "approve"
|
||||||
|
else TaskState.CANCELLED
|
||||||
|
)
|
||||||
|
expected_revision = payload.get("expected_revision")
|
||||||
|
task = self.service.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task_id,
|
||||||
|
target_state=target,
|
||||||
|
expected_revision=(
|
||||||
|
int(expected_revision) if expected_revision is not None else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||||
|
|
||||||
|
if method == "GET" and path == "/api/v2/audit":
|
||||||
|
resource_id = query.get("resource_id", [None])[0]
|
||||||
|
events = self.service.list_audit_events(
|
||||||
|
context,
|
||||||
|
resource_id=(
|
||||||
|
_require_resource_id(resource_id, label="resource_id")
|
||||||
|
if resource_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, {"events": _json_value(events)}
|
||||||
|
|
||||||
|
return HTTPStatus.NOT_FOUND, {
|
||||||
|
"error": "not_found",
|
||||||
|
"message": "route not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
|
||||||
|
(Unauthorized, HTTPStatus.UNAUTHORIZED),
|
||||||
|
(PolicyDenied, HTTPStatus.FORBIDDEN),
|
||||||
|
(ProvenanceDenied, HTTPStatus.FORBIDDEN),
|
||||||
|
(TenantIsolationError, HTTPStatus.NOT_FOUND),
|
||||||
|
(NotFound, HTTPStatus.NOT_FOUND),
|
||||||
|
(DispatchNotFound, HTTPStatus.NOT_FOUND),
|
||||||
|
(HermesNotFound, HTTPStatus.NOT_FOUND),
|
||||||
|
(ProvenanceReplay, HTTPStatus.CONFLICT),
|
||||||
|
(IdempotencyConflict, HTTPStatus.CONFLICT),
|
||||||
|
(RevisionConflict, HTTPStatus.CONFLICT),
|
||||||
|
(InvalidTransition, HTTPStatus.CONFLICT),
|
||||||
|
(DispatchConflict, HTTPStatus.CONFLICT),
|
||||||
|
(HermesConflict, HTTPStatus.CONFLICT),
|
||||||
|
(OrchestrationError, HTTPStatus.CONFLICT),
|
||||||
|
(ValueError, HTTPStatus.BAD_REQUEST),
|
||||||
|
(ControlPlaneError, HTTPStatus.BAD_REQUEST),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(exc: BaseException) -> tuple[int, Mapping[str, Any]]:
|
||||||
|
for error_type, status in _ERROR_STATUS:
|
||||||
|
if isinstance(exc, error_type):
|
||||||
|
return status, {"error": error_type.__name__, "message": str(exc)}
|
||||||
|
return HTTPStatus.INTERNAL_SERVER_ERROR, {
|
||||||
|
"error": "InternalServerError",
|
||||||
|
"message": "internal server error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "AIOSV2Gateway/1.0"
|
||||||
|
max_body_bytes = 1_048_576
|
||||||
|
|
||||||
|
@property
|
||||||
|
def application(self) -> GatewayApplication:
|
||||||
|
return self.server.application # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def _read_body(self) -> Any:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
if length < 0 or length > self.max_body_bytes:
|
||||||
|
raise ValueError("request body is too large")
|
||||||
|
if length == 0:
|
||||||
|
return {}
|
||||||
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||||
|
if content_type != "application/json":
|
||||||
|
raise ValueError("Content-Type must be application/json")
|
||||||
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
|
||||||
|
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
self.send_response(int(status))
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def _dispatch(self, method: str) -> None:
|
||||||
|
try:
|
||||||
|
body = self._read_body() if method in {"POST", "PUT", "PATCH"} else {}
|
||||||
|
status, payload = self.application.handle(
|
||||||
|
method,
|
||||||
|
self.path,
|
||||||
|
{key: value for key, value in self.headers.items()},
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status, payload = error_response(exc)
|
||||||
|
self._send_json(status, payload)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("GET")
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
self._dispatch("POST")
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
|
super().log_message(format, *args)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayHTTPServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_address: tuple[str, int],
|
||||||
|
application: GatewayApplication,
|
||||||
|
) -> None:
|
||||||
|
self.application = application
|
||||||
|
super().__init__(server_address, GatewayRequestHandler)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("AIOS_V2_GATEWAY_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("AIOS_V2_GATEWAY_PORT", "18920"))
|
||||||
|
server = GatewayHTTPServer((host, port), GatewayApplication())
|
||||||
|
print(f"AIOS V2 control-plane gateway listening on {host}:{port}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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,599 @@
|
|||||||
|
"""AIOS V2 execution orchestrator.
|
||||||
|
|
||||||
|
The orchestrator is the only component allowed to translate an approved AIOS
|
||||||
|
Task into an OpenClaw command or Hermes job. Every dispatch is persisted before
|
||||||
|
Runtime side effects. Runtime results must bind to the exact dispatch,
|
||||||
|
command/job, tenant, task, trace, principal and provenance context.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
from aios_hermes import HermesAdapter, HermesJob, HermesJobState, HermesResult
|
||||||
|
from aios_openclaw import (
|
||||||
|
CommandState,
|
||||||
|
OpenClawAdapter,
|
||||||
|
OpenClawCommand,
|
||||||
|
OpenClawResult,
|
||||||
|
sign_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
ControlPlaneService,
|
||||||
|
RequestContext,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
)
|
||||||
|
from .dispatch_store import (
|
||||||
|
DispatchConflict,
|
||||||
|
DispatchRecord,
|
||||||
|
DispatchRepository,
|
||||||
|
DispatchState,
|
||||||
|
InMemoryDispatchRepository,
|
||||||
|
TERMINAL_DISPATCH_STATES,
|
||||||
|
)
|
||||||
|
from .provenance import ProvenanceDenied, digest_payload
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeAuthorizationWriter(Protocol):
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class OrchestrationError(RuntimeError):
|
||||||
|
"""AIOS failed to dispatch or reconcile a runtime execution."""
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionOrchestrator:
|
||||||
|
"""Dispatch approved AIOS tasks and strictly reconcile Runtime results."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
control_plane: ControlPlaneService,
|
||||||
|
*,
|
||||||
|
openclaw: OpenClawAdapter | None = None,
|
||||||
|
hermes: HermesAdapter | None = None,
|
||||||
|
openclaw_signing_material_resolver=None,
|
||||||
|
openclaw_key_id: str = "default",
|
||||||
|
default_cell_id: str = "default",
|
||||||
|
default_profile_id: str = "default",
|
||||||
|
provenance_writer: RuntimeAuthorizationWriter | None = None,
|
||||||
|
require_provenance: bool = False,
|
||||||
|
dispatch_repository: DispatchRepository | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.control_plane = control_plane
|
||||||
|
self.openclaw = openclaw
|
||||||
|
self.hermes = hermes
|
||||||
|
self.openclaw_signing_material_resolver = openclaw_signing_material_resolver
|
||||||
|
self.openclaw_key_id = str(openclaw_key_id or "").strip() or "default"
|
||||||
|
self.default_cell_id = default_cell_id
|
||||||
|
self.default_profile_id = default_profile_id
|
||||||
|
self.provenance_writer = provenance_writer
|
||||||
|
self.require_provenance = bool(require_provenance)
|
||||||
|
self.dispatch_repository = dispatch_repository or InMemoryDispatchRepository()
|
||||||
|
if self.require_provenance and self.provenance_writer is None:
|
||||||
|
raise ValueError("production orchestration requires a provenance writer")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record_provenance(value: str | None, *, required: bool, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if required and not normalized:
|
||||||
|
raise OrchestrationError(f"production dispatch requires {label}")
|
||||||
|
return normalized or f"{label}-not-required"
|
||||||
|
|
||||||
|
def _authorize_dispatch(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task: TaskRecord,
|
||||||
|
agent: AgentRecord,
|
||||||
|
provenance_id: str | None,
|
||||||
|
run_id: str | None,
|
||||||
|
timeout_seconds: float | None,
|
||||||
|
) -> str:
|
||||||
|
if not self.require_provenance:
|
||||||
|
return "authorization-not-required"
|
||||||
|
if self.provenance_writer is None:
|
||||||
|
raise OrchestrationError("Run Provenance writer is not configured")
|
||||||
|
if not provenance_id or not run_id:
|
||||||
|
raise OrchestrationError(
|
||||||
|
"production dispatch requires provenance_id and run_id"
|
||||||
|
)
|
||||||
|
request_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"operation": "dispatch",
|
||||||
|
"tenant_id": task.tenant_id,
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"agent_id": agent.agent_id,
|
||||||
|
"principal_id": context.principal_id,
|
||||||
|
"runtime": agent.runtime.strip().lower(),
|
||||||
|
"action": task.action,
|
||||||
|
"payload": task.payload,
|
||||||
|
"trace_id": context.trace_id,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
"task_revision": task.revision,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
authorization = self.provenance_writer.authorize_runtime_execute(
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
request_digest=request_digest,
|
||||||
|
)
|
||||||
|
return authorization.authorization_id
|
||||||
|
except ProvenanceDenied as exc:
|
||||||
|
raise OrchestrationError(f"Run Provenance denied dispatch: {exc}") from exc
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
task = self.control_plane.get_task(context, task_id)
|
||||||
|
agent = self.control_plane.get_agent(context, task.agent_id)
|
||||||
|
persisted_provenance = self._record_provenance(
|
||||||
|
provenance_id, required=self.require_provenance, label="provenance_id"
|
||||||
|
)
|
||||||
|
persisted_run = self._record_provenance(
|
||||||
|
run_id, required=self.require_provenance, label="run_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = self.dispatch_repository.get_by_task(
|
||||||
|
task.task_id, tenant_id=task.tenant_id
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if (
|
||||||
|
existing.provenance_id != persisted_provenance
|
||||||
|
or existing.run_id != persisted_run
|
||||||
|
or existing.principal_id != context.principal_id
|
||||||
|
):
|
||||||
|
raise OrchestrationError(
|
||||||
|
"existing dispatch belongs to another provenance or principal context"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
if task.state != TaskState.APPROVED:
|
||||||
|
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||||
|
|
||||||
|
authorization_id = self._authorize_dispatch(
|
||||||
|
context,
|
||||||
|
task=task,
|
||||||
|
agent=agent,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
runtime = agent.runtime.strip().lower()
|
||||||
|
prepared: DispatchRecord | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if runtime == "openclaw":
|
||||||
|
command = self._create_openclaw_command(context, task)
|
||||||
|
prepared = self.dispatch_repository.create(
|
||||||
|
DispatchRecord.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_execution_id=command.command_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
provenance_id=persisted_provenance,
|
||||||
|
run_id=persisted_run,
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.RUNNING,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
prepared.dispatch_id,
|
||||||
|
tenant_id=prepared.tenant_id,
|
||||||
|
state=DispatchState.RUNNING,
|
||||||
|
)
|
||||||
|
if self.openclaw is None:
|
||||||
|
raise OrchestrationError("OpenClaw runtime is not configured")
|
||||||
|
result = self.openclaw.execute(
|
||||||
|
command, timeout_seconds=timeout_seconds
|
||||||
|
)
|
||||||
|
self.reconcile_openclaw(
|
||||||
|
context,
|
||||||
|
result,
|
||||||
|
dispatch_id=prepared.dispatch_id,
|
||||||
|
provenance_id=persisted_provenance,
|
||||||
|
run_id=persisted_run,
|
||||||
|
)
|
||||||
|
return self.dispatch_repository.get(
|
||||||
|
prepared.dispatch_id, tenant_id=prepared.tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if runtime == "hermes":
|
||||||
|
if self.hermes is None:
|
||||||
|
raise OrchestrationError("Hermes runtime is not configured")
|
||||||
|
job = self._create_hermes_job(context, agent, task)
|
||||||
|
prepared = self.dispatch_repository.create(
|
||||||
|
DispatchRecord.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
runtime="hermes",
|
||||||
|
runtime_execution_id=job.job_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
provenance_id=persisted_provenance,
|
||||||
|
run_id=persisted_run,
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.RUNNING,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
)
|
||||||
|
queued = self.hermes.enqueue(job)
|
||||||
|
return self.dispatch_repository.update(
|
||||||
|
prepared.dispatch_id,
|
||||||
|
tenant_id=prepared.tenant_id,
|
||||||
|
state=DispatchState.QUEUED,
|
||||||
|
result_digest=digest_payload(
|
||||||
|
{
|
||||||
|
"queued_job_id": queued.job_id,
|
||||||
|
"state": queued.state.value,
|
||||||
|
"revision": queued.revision,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
|
||||||
|
except Exception as exc:
|
||||||
|
if prepared is not None:
|
||||||
|
try:
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
prepared.dispatch_id,
|
||||||
|
tenant_id=prepared.tenant_id,
|
||||||
|
state=DispatchState.FAILED,
|
||||||
|
result_digest=digest_payload(
|
||||||
|
{
|
||||||
|
"code": "dispatch_failed",
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
current = self.control_plane.get_task(context, task.task_id)
|
||||||
|
if current.state == TaskState.RUNNING:
|
||||||
|
self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=TaskState.FAILED,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
error={"code": "dispatch_failed", "message": str(exc)},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _create_openclaw_command(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
task: TaskRecord,
|
||||||
|
) -> OpenClawCommand:
|
||||||
|
if self.openclaw_signing_material_resolver is None:
|
||||||
|
raise OrchestrationError("OpenClaw signing material is not configured")
|
||||||
|
key_material = self.openclaw_signing_material_resolver(
|
||||||
|
task.tenant_id,
|
||||||
|
self.openclaw_key_id,
|
||||||
|
)
|
||||||
|
command = OpenClawCommand.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=task.action,
|
||||||
|
payload=task.payload,
|
||||||
|
idempotency_key=task.idempotency_key or task.task_id,
|
||||||
|
key_id=self.openclaw_key_id,
|
||||||
|
)
|
||||||
|
return sign_command(command, key_material)
|
||||||
|
|
||||||
|
def _create_hermes_job(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
agent: AgentRecord,
|
||||||
|
task: TaskRecord,
|
||||||
|
) -> HermesJob:
|
||||||
|
metadata = dict(agent.metadata)
|
||||||
|
return HermesJob.create(
|
||||||
|
tenant_id=task.tenant_id,
|
||||||
|
cell_id=str(metadata.get("cell_id") or self.default_cell_id),
|
||||||
|
profile_id=str(metadata.get("profile_id") or self.default_profile_id),
|
||||||
|
task_id=task.task_id,
|
||||||
|
trace_id=context.trace_id,
|
||||||
|
action=task.action,
|
||||||
|
payload=task.payload,
|
||||||
|
idempotency_key=task.idempotency_key or task.task_id,
|
||||||
|
priority=int(metadata.get("priority", 100)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_dispatch_context(
|
||||||
|
dispatch: DispatchRecord,
|
||||||
|
*,
|
||||||
|
runtime: str,
|
||||||
|
runtime_execution_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
) -> None:
|
||||||
|
expected = {
|
||||||
|
"runtime": runtime,
|
||||||
|
"runtime_execution_id": runtime_execution_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"principal_id": principal_id,
|
||||||
|
"provenance_id": provenance_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
}
|
||||||
|
for field_name, expected_value in expected.items():
|
||||||
|
if getattr(dispatch, field_name) != expected_value:
|
||||||
|
raise OrchestrationError(f"dispatch {field_name} mismatch")
|
||||||
|
|
||||||
|
def hermes_lifecycle_context(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
*,
|
||||||
|
dispatch_id: str,
|
||||||
|
job_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
trace_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
) -> tuple[DispatchRecord, HermesJob]:
|
||||||
|
"""Resolve a runtime lifecycle request against its durable dispatch."""
|
||||||
|
dispatch = self.dispatch_repository.get(
|
||||||
|
dispatch_id, tenant_id=context.tenant_id
|
||||||
|
)
|
||||||
|
self._assert_dispatch_context(
|
||||||
|
dispatch,
|
||||||
|
runtime="hermes",
|
||||||
|
runtime_execution_id=job_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
trace_id=trace_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
if context.tenant_id != tenant_id:
|
||||||
|
raise OrchestrationError("runtime context tenant_id mismatch")
|
||||||
|
if context.principal_id != principal_id:
|
||||||
|
raise OrchestrationError("runtime context principal_id mismatch")
|
||||||
|
if context.trace_id != trace_id:
|
||||||
|
raise OrchestrationError("runtime context trace_id mismatch")
|
||||||
|
if self.hermes is None:
|
||||||
|
raise OrchestrationError("Hermes runtime is not configured")
|
||||||
|
job = self.hermes.get(job_id, tenant_id=tenant_id)
|
||||||
|
if job.task_id != task_id or job.trace_id != trace_id:
|
||||||
|
raise OrchestrationError("Hermes job identity mismatch")
|
||||||
|
return dispatch, job
|
||||||
|
|
||||||
|
def bind_hermes_worker(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
job: HermesJob,
|
||||||
|
*,
|
||||||
|
dispatch_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
) -> DispatchRecord:
|
||||||
|
dispatch, current = self.hermes_lifecycle_context(
|
||||||
|
context,
|
||||||
|
dispatch_id=dispatch_id,
|
||||||
|
job_id=job.job_id,
|
||||||
|
tenant_id=job.tenant_id,
|
||||||
|
task_id=job.task_id,
|
||||||
|
trace_id=job.trace_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
if current.assigned_worker_id != worker_id:
|
||||||
|
raise OrchestrationError("Hermes worker does not own claimed job")
|
||||||
|
return self.dispatch_repository.update(
|
||||||
|
dispatch.dispatch_id,
|
||||||
|
tenant_id=dispatch.tenant_id,
|
||||||
|
expected_worker_id=worker_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reconcile_openclaw(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
result: OpenClawResult,
|
||||||
|
*,
|
||||||
|
dispatch_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
) -> TaskRecord:
|
||||||
|
dispatch = self.dispatch_repository.get(
|
||||||
|
dispatch_id, tenant_id=context.tenant_id
|
||||||
|
)
|
||||||
|
self._assert_dispatch_context(
|
||||||
|
dispatch,
|
||||||
|
runtime="openclaw",
|
||||||
|
runtime_execution_id=result.command_id,
|
||||||
|
tenant_id=result.tenant_id,
|
||||||
|
task_id=result.task_id,
|
||||||
|
trace_id=result.trace_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
result_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"command_id": result.command_id,
|
||||||
|
"state": result.state.value,
|
||||||
|
"output": result.output,
|
||||||
|
"error": result.error,
|
||||||
|
"runtime_id": result.runtime_id,
|
||||||
|
"attempts": result.attempts,
|
||||||
|
"finished_at": result.finished_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if dispatch.state in TERMINAL_DISPATCH_STATES:
|
||||||
|
if dispatch.result_digest != result_digest:
|
||||||
|
raise DispatchConflict("terminal dispatch received a different result")
|
||||||
|
return self.control_plane.get_task(context, dispatch.task_id)
|
||||||
|
|
||||||
|
task = self.control_plane.get_task(context, dispatch.task_id)
|
||||||
|
if task.state != TaskState.RUNNING:
|
||||||
|
raise OrchestrationError("task is not RUNNING for active dispatch")
|
||||||
|
if result.state == CommandState.COMPLETED:
|
||||||
|
target = TaskState.COMPLETED
|
||||||
|
values = {"result": result.output or {}}
|
||||||
|
dispatch_state = DispatchState.COMPLETED
|
||||||
|
elif result.state in {
|
||||||
|
CommandState.FAILED,
|
||||||
|
CommandState.TIMED_OUT,
|
||||||
|
CommandState.REJECTED,
|
||||||
|
}:
|
||||||
|
target = TaskState.FAILED
|
||||||
|
values = {
|
||||||
|
"error": result.error or {"code": result.state.value.lower()}
|
||||||
|
}
|
||||||
|
dispatch_state = DispatchState.FAILED
|
||||||
|
elif result.state == CommandState.CANCELLED:
|
||||||
|
target = TaskState.CANCELLED
|
||||||
|
values = {}
|
||||||
|
dispatch_state = DispatchState.CANCELLED
|
||||||
|
else:
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
dispatch.dispatch_id,
|
||||||
|
tenant_id=dispatch.tenant_id,
|
||||||
|
state=DispatchState.RUNNING,
|
||||||
|
result_digest=result_digest,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
updated_task = self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=target,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
dispatch.dispatch_id,
|
||||||
|
tenant_id=dispatch.tenant_id,
|
||||||
|
state=dispatch_state,
|
||||||
|
result_digest=result_digest,
|
||||||
|
)
|
||||||
|
return updated_task
|
||||||
|
|
||||||
|
def reconcile_hermes(
|
||||||
|
self,
|
||||||
|
context: RequestContext,
|
||||||
|
result: HermesResult,
|
||||||
|
*,
|
||||||
|
dispatch_id: str,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
) -> TaskRecord:
|
||||||
|
dispatch = self.dispatch_repository.get(
|
||||||
|
dispatch_id, tenant_id=context.tenant_id
|
||||||
|
)
|
||||||
|
self._assert_dispatch_context(
|
||||||
|
dispatch,
|
||||||
|
runtime="hermes",
|
||||||
|
runtime_execution_id=result.job_id,
|
||||||
|
tenant_id=result.tenant_id,
|
||||||
|
task_id=result.task_id,
|
||||||
|
trace_id=result.trace_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
if self.hermes is None:
|
||||||
|
raise OrchestrationError("Hermes runtime is not configured")
|
||||||
|
job = self.hermes.get(result.job_id, tenant_id=result.tenant_id)
|
||||||
|
if result.state in {HermesJobState.COMPLETED, HermesJobState.FAILED}:
|
||||||
|
if not result.worker_id:
|
||||||
|
raise OrchestrationError("terminal Hermes result requires worker_id")
|
||||||
|
if job.assigned_worker_id != result.worker_id:
|
||||||
|
raise OrchestrationError("Hermes result worker does not own job lease")
|
||||||
|
if (
|
||||||
|
dispatch.expected_worker_id is not None
|
||||||
|
and dispatch.expected_worker_id != result.worker_id
|
||||||
|
):
|
||||||
|
raise OrchestrationError("Hermes result worker mismatch")
|
||||||
|
result_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"job_id": result.job_id,
|
||||||
|
"worker_id": result.worker_id,
|
||||||
|
"state": result.state.value,
|
||||||
|
"output": result.output,
|
||||||
|
"error": result.error,
|
||||||
|
"finished_at": result.finished_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if dispatch.state in TERMINAL_DISPATCH_STATES:
|
||||||
|
if dispatch.result_digest != result_digest:
|
||||||
|
raise DispatchConflict("terminal dispatch received a different result")
|
||||||
|
return self.control_plane.get_task(context, dispatch.task_id)
|
||||||
|
|
||||||
|
task = self.control_plane.get_task(context, dispatch.task_id)
|
||||||
|
if task.state != TaskState.RUNNING:
|
||||||
|
raise OrchestrationError("task is not RUNNING for active dispatch")
|
||||||
|
if result.state == HermesJobState.COMPLETED:
|
||||||
|
target = TaskState.COMPLETED
|
||||||
|
values = {"result": result.output or {}}
|
||||||
|
dispatch_state = DispatchState.COMPLETED
|
||||||
|
elif result.state == HermesJobState.FAILED:
|
||||||
|
target = TaskState.FAILED
|
||||||
|
values = {"error": result.error or {"code": "hermes_failed"}}
|
||||||
|
dispatch_state = DispatchState.FAILED
|
||||||
|
elif result.state == HermesJobState.CANCELLED:
|
||||||
|
target = TaskState.CANCELLED
|
||||||
|
values = {}
|
||||||
|
dispatch_state = DispatchState.CANCELLED
|
||||||
|
else:
|
||||||
|
raise OrchestrationError("Hermes reconcile only accepts terminal results")
|
||||||
|
|
||||||
|
updated_task = self.control_plane.transition_task(
|
||||||
|
context,
|
||||||
|
task_id=task.task_id,
|
||||||
|
target_state=target,
|
||||||
|
expected_revision=task.revision,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
self.dispatch_repository.update(
|
||||||
|
dispatch.dispatch_id,
|
||||||
|
tenant_id=dispatch.tenant_id,
|
||||||
|
state=dispatch_state,
|
||||||
|
expected_worker_id=result.worker_id,
|
||||||
|
result_digest=result_digest,
|
||||||
|
)
|
||||||
|
return updated_task
|
||||||
|
|
||||||
|
def dispatch_snapshot(self, *, tenant_id: str) -> Mapping[str, DispatchRecord]:
|
||||||
|
return {
|
||||||
|
item.task_id: item
|
||||||
|
for item in self.dispatch_repository.list(tenant_id=tenant_id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Durable single-process persistence for the AIOS V2 control plane.
|
||||||
|
|
||||||
|
The candidate uses an atomic SQLite snapshot so a gateway restart does not lose
|
||||||
|
tenants, agents, task state, idempotency records, or audit events. The domain
|
||||||
|
service remains storage-agnostic; a later HA backend can replace this module
|
||||||
|
without changing API or runtime-adapter contracts.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .control_plane import (
|
||||||
|
AgentRecord,
|
||||||
|
AuditEvent,
|
||||||
|
ControlPlaneService,
|
||||||
|
InMemoryControlPlaneStore,
|
||||||
|
TaskRecord,
|
||||||
|
TaskState,
|
||||||
|
TenantRecord,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteSnapshotRepository:
|
||||||
|
"""Store one versioned control-plane snapshot in SQLite."""
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS control_plane_snapshot (
|
||||||
|
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
|
||||||
|
schema_version INTEGER NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def load(self) -> dict[str, Any] | None:
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT schema_version, payload_json FROM control_plane_snapshot WHERE singleton_id = 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
schema_version, payload_json = row
|
||||||
|
if int(schema_version) != self.SCHEMA_VERSION:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unsupported control-plane snapshot version: {schema_version}"
|
||||||
|
)
|
||||||
|
decoded = json.loads(str(payload_json))
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
raise RuntimeError("invalid control-plane snapshot")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
def save(self, payload: dict[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
with self._lock, self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO control_plane_snapshot (
|
||||||
|
singleton_id, schema_version, payload_json, updated_at
|
||||||
|
) VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(singleton_id) DO UPDATE SET
|
||||||
|
schema_version = excluded.schema_version,
|
||||||
|
payload_json = excluded.payload_json,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(self.SCHEMA_VERSION, encoded),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class DurableControlPlaneService(ControlPlaneService):
|
||||||
|
"""Control-plane service that persists after every successful mutation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: SQLiteSnapshotRepository,
|
||||||
|
*,
|
||||||
|
policy_evaluator=None,
|
||||||
|
) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
store = InMemoryControlPlaneStore()
|
||||||
|
self._restore(store, repository.load())
|
||||||
|
super().__init__(store, policy_evaluator=policy_evaluator)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _restore(
|
||||||
|
store: InMemoryControlPlaneStore,
|
||||||
|
snapshot: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
if not snapshot:
|
||||||
|
return
|
||||||
|
for item in snapshot.get("tenants", []):
|
||||||
|
record = TenantRecord(**item)
|
||||||
|
store.tenants[record.tenant_id] = record
|
||||||
|
for item in snapshot.get("agents", []):
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["capabilities"] = tuple(normalized.get("capabilities", ()))
|
||||||
|
record = AgentRecord(**normalized)
|
||||||
|
store.agents[record.agent_id] = record
|
||||||
|
for item in snapshot.get("tasks", []):
|
||||||
|
normalized = dict(item)
|
||||||
|
normalized["state"] = TaskState(normalized["state"])
|
||||||
|
record = TaskRecord(**normalized)
|
||||||
|
store.tasks[record.task_id] = record
|
||||||
|
store.audit_events.extend(AuditEvent(**item) for item in snapshot.get("audit", []))
|
||||||
|
for item in snapshot.get("idempotency", []):
|
||||||
|
store.idempotency[(item["tenant_id"], item["key"])] = (
|
||||||
|
item["task_id"],
|
||||||
|
item["digest"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _snapshot(self) -> dict[str, Any]:
|
||||||
|
with self.store.lock:
|
||||||
|
return {
|
||||||
|
"schema_version": self.repository.SCHEMA_VERSION,
|
||||||
|
"tenants": [asdict(item) for item in self.store.tenants.values()],
|
||||||
|
"agents": [asdict(item) for item in self.store.agents.values()],
|
||||||
|
"tasks": [
|
||||||
|
{**asdict(item), "state": item.state.value}
|
||||||
|
for item in self.store.tasks.values()
|
||||||
|
],
|
||||||
|
"audit": [asdict(item) for item in self.store.audit_events],
|
||||||
|
"idempotency": [
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"key": key,
|
||||||
|
"task_id": task_id,
|
||||||
|
"digest": digest,
|
||||||
|
}
|
||||||
|
for (tenant_id, key), (task_id, digest)
|
||||||
|
in self.store.idempotency.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _mutate(self, operation: Callable[[], Any]) -> Any:
|
||||||
|
with self.store.lock:
|
||||||
|
result = operation()
|
||||||
|
self.repository.save(self._snapshot())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def register_tenant(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).register_tenant(*args, **kwargs))
|
||||||
|
|
||||||
|
def register_agent(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).register_agent(*args, **kwargs))
|
||||||
|
|
||||||
|
def create_task(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).create_task(*args, **kwargs))
|
||||||
|
|
||||||
|
def transition_task(self, *args, **kwargs):
|
||||||
|
return self._mutate(lambda: super(DurableControlPlaneService, self).transition_task(*args, **kwargs))
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
"""AIOS V2 可信 Run Provenance 签发、持久化与受控写入。
|
||||||
|
|
||||||
|
生产策略要求 Provenance 同时覆盖七类路径:代码变更、Git 操作、部署、
|
||||||
|
Runtime 执行、Evidence 写入、回滚和审计。任何缺失、篡改、过期、撤销、
|
||||||
|
重放或上下文不匹配都默认拒绝。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
PROVENANCE_VERSION = "aios.v2.run-provenance.v1"
|
||||||
|
PRODUCTION_POLICY_VERSION = "aios.v2.production.v1"
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(value: str) -> datetime:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("timestamp is required")
|
||||||
|
if normalized.endswith("Z"):
|
||||||
|
normalized = normalized[:-1] + "+00:00"
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def required(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{label} is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
dict(value),
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def digest_payload(value: Mapping[str, Any]) -> str:
|
||||||
|
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_digest(value: str) -> str:
|
||||||
|
normalized = required(value, "request_digest").lower()
|
||||||
|
if len(normalized) != 64 or any(ch not in "0123456789abcdef" for ch in normalized):
|
||||||
|
raise ValueError("request_digest must be a SHA-256 hexadecimal digest")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPath(str, Enum):
|
||||||
|
CODE_CHANGE = "code_change"
|
||||||
|
GIT_OPERATION = "git_operation"
|
||||||
|
DEPLOYMENT = "deployment"
|
||||||
|
RUNTIME_EXECUTE = "runtime_execute"
|
||||||
|
EVIDENCE_WRITE = "evidence_write"
|
||||||
|
ROLLBACK = "rollback"
|
||||||
|
AUDIT = "audit"
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_PRODUCTION_PATHS = frozenset(item.value for item in ExecutionPath)
|
||||||
|
|
||||||
|
|
||||||
|
class GitOperation(str, Enum):
|
||||||
|
READ_REF = "read_ref"
|
||||||
|
FETCH = "fetch"
|
||||||
|
CREATE_BRANCH = "create_branch"
|
||||||
|
COMMIT = "commit"
|
||||||
|
MERGE = "merge"
|
||||||
|
PUSH = "push"
|
||||||
|
UPDATE_EVIDENCE = "update_evidence"
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceStatus(str, Enum):
|
||||||
|
ISSUED = "issued"
|
||||||
|
VERIFIED = "verified"
|
||||||
|
USED = "used"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceError(RuntimeError):
|
||||||
|
"""Run Provenance 基础错误。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceDenied(ProvenanceError):
|
||||||
|
"""策略拒绝当前操作。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceNotFound(ProvenanceDenied):
|
||||||
|
"""未找到 Provenance。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceSignatureError(ProvenanceDenied):
|
||||||
|
"""签名无效。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceExpired(ProvenanceDenied):
|
||||||
|
"""Provenance 已过期。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceRevoked(ProvenanceDenied):
|
||||||
|
"""Provenance 已撤销。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceReplay(ProvenanceDenied):
|
||||||
|
"""检测到相同授权摘要重放。"""
|
||||||
|
|
||||||
|
|
||||||
|
class Signer(Protocol):
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
def sign(self, payload: bytes) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureVerifier(Protocol):
|
||||||
|
key_id: str
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
def verify(self, payload: bytes, signature: str) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||||
|
Ed25519PrivateKey,
|
||||||
|
Ed25519PublicKey,
|
||||||
|
)
|
||||||
|
except ImportError as exc: # pragma: no cover - depends on deployment image
|
||||||
|
raise RuntimeError(
|
||||||
|
"cryptography package with Ed25519 support is required"
|
||||||
|
) from exc
|
||||||
|
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
|
|
||||||
|
def _check_private_key_file(path: Path) -> bytes:
|
||||||
|
resolved = path.resolve()
|
||||||
|
metadata = resolved.stat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError("private key reference is not a regular file")
|
||||||
|
if metadata.st_mode & 0o077:
|
||||||
|
raise RuntimeError("private key file permissions must be 0600 or stricter")
|
||||||
|
return resolved.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_public_key_file(path: Path) -> bytes:
|
||||||
|
resolved = path.resolve()
|
||||||
|
metadata = resolved.stat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RuntimeError("public key reference is not a regular file")
|
||||||
|
if metadata.st_mode & 0o022:
|
||||||
|
raise RuntimeError("public key file must not be group/world writable")
|
||||||
|
return resolved.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
class Ed25519PrivateKeySigner:
|
||||||
|
"""Ed25519 Owner/Control Plane 签发器。"""
|
||||||
|
|
||||||
|
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||||
|
self.private_key = private_key
|
||||||
|
self.key_id = required(key_id, "key_id")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PrivateKeySigner":
|
||||||
|
serialization, private_cls, _ = _load_crypto()
|
||||||
|
value = _check_private_key_file(Path(path))
|
||||||
|
if value.startswith(b"-----BEGIN"):
|
||||||
|
key = serialization.load_pem_private_key(value, password=None)
|
||||||
|
elif len(value) == 32:
|
||||||
|
key = private_cls.from_private_bytes(value)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("private key must be PEM or a raw 32-byte Ed25519 seed")
|
||||||
|
if not isinstance(key, private_cls):
|
||||||
|
raise RuntimeError("private key is not Ed25519")
|
||||||
|
return cls(key, key_id=key_id)
|
||||||
|
|
||||||
|
def sign(self, payload: bytes) -> str:
|
||||||
|
return base64.b64encode(self.private_key.sign(bytes(payload))).decode("ascii")
|
||||||
|
|
||||||
|
def public_verifier(self) -> "Ed25519PublicKeyVerifier":
|
||||||
|
return Ed25519PublicKeyVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
class Ed25519PublicKeyVerifier:
|
||||||
|
"""Ed25519 签名验证器。"""
|
||||||
|
|
||||||
|
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||||
|
serialization, _, public_cls = _load_crypto()
|
||||||
|
if not isinstance(public_key, public_cls):
|
||||||
|
raise RuntimeError("public key is not Ed25519")
|
||||||
|
self.public_key = public_key
|
||||||
|
self.key_id = required(key_id, "key_id")
|
||||||
|
raw = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.Raw,
|
||||||
|
format=serialization.PublicFormat.Raw,
|
||||||
|
)
|
||||||
|
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PublicKeyVerifier":
|
||||||
|
serialization, _, public_cls = _load_crypto()
|
||||||
|
value = _check_public_key_file(Path(path))
|
||||||
|
if value.startswith(b"-----BEGIN"):
|
||||||
|
key = serialization.load_pem_public_key(value)
|
||||||
|
elif len(value) == 32:
|
||||||
|
key = public_cls.from_public_bytes(value)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("public key must be PEM or raw 32-byte Ed25519 key")
|
||||||
|
if not isinstance(key, public_cls):
|
||||||
|
raise RuntimeError("public key is not Ed25519")
|
||||||
|
return cls(key, key_id=key_id)
|
||||||
|
|
||||||
|
def verify(self, payload: bytes, signature: str) -> bool:
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(required(signature, "signature"), validate=True)
|
||||||
|
self.public_key.verify(decoded, bytes(payload))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunProvenance:
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
issuer: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
allowed_paths: Sequence[str]
|
||||||
|
allowed_git_operations: Sequence[str]
|
||||||
|
issued_at: str
|
||||||
|
expires_at: str
|
||||||
|
policy_version: str
|
||||||
|
key_id: str
|
||||||
|
nonce: str
|
||||||
|
signature: str = ""
|
||||||
|
schema_version: str = PROVENANCE_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"provenance_id",
|
||||||
|
"run_id",
|
||||||
|
"issuer",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"principal_id",
|
||||||
|
"issued_at",
|
||||||
|
"expires_at",
|
||||||
|
"policy_version",
|
||||||
|
"key_id",
|
||||||
|
"nonce",
|
||||||
|
"schema_version",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||||
|
if self.schema_version != PROVENANCE_VERSION:
|
||||||
|
raise ValueError(f"unsupported schema_version: {self.schema_version}")
|
||||||
|
issued = parse_timestamp(self.issued_at)
|
||||||
|
expires = parse_timestamp(self.expires_at)
|
||||||
|
if expires <= issued:
|
||||||
|
raise ValueError("expires_at must be later than issued_at")
|
||||||
|
paths = tuple(sorted({ExecutionPath(item).value for item in self.allowed_paths}))
|
||||||
|
operations = tuple(
|
||||||
|
sorted({GitOperation(item).value for item in self.allowed_git_operations})
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "allowed_paths", paths)
|
||||||
|
object.__setattr__(self, "allowed_git_operations", operations)
|
||||||
|
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||||
|
|
||||||
|
def unsigned_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": self.schema_version,
|
||||||
|
"provenance_id": self.provenance_id,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"issuer": self.issuer,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"principal_id": self.principal_id,
|
||||||
|
"allowed_paths": list(self.allowed_paths),
|
||||||
|
"allowed_git_operations": list(self.allowed_git_operations),
|
||||||
|
"issued_at": self.issued_at,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
"policy_version": self.policy_version,
|
||||||
|
"key_id": self.key_id,
|
||||||
|
"nonce": self.nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
def canonical_bytes(self) -> bytes:
|
||||||
|
return canonical_json_bytes(self.unsigned_dict())
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
value = self.unsigned_dict()
|
||||||
|
value["signature"] = self.signature
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: Mapping[str, Any]) -> "RunProvenance":
|
||||||
|
return cls(**dict(value))
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceIssuer:
|
||||||
|
"""只由 Owner 或 AIOS Control Plane 持有的签发服务。"""
|
||||||
|
|
||||||
|
def __init__(self, signer: Signer, store: "SQLiteProvenanceStore") -> None:
|
||||||
|
self.signer = signer
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def issue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
issuer: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
allowed_paths: Sequence[str] = tuple(REQUIRED_PRODUCTION_PATHS),
|
||||||
|
allowed_git_operations: Sequence[str] = tuple(item.value for item in GitOperation),
|
||||||
|
ttl_seconds: int = 3600,
|
||||||
|
policy_version: str = PRODUCTION_POLICY_VERSION,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
nonce: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RunProvenance:
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
raise ValueError("ttl_seconds must be positive")
|
||||||
|
normalized_paths = frozenset(ExecutionPath(item).value for item in allowed_paths)
|
||||||
|
if policy_version == PRODUCTION_POLICY_VERSION and normalized_paths != REQUIRED_PRODUCTION_PATHS:
|
||||||
|
missing = sorted(REQUIRED_PRODUCTION_PATHS - normalized_paths)
|
||||||
|
extra = sorted(normalized_paths - REQUIRED_PRODUCTION_PATHS)
|
||||||
|
raise ProvenanceDenied(
|
||||||
|
f"production provenance must cover exactly seven paths; missing={missing}, extra={extra}"
|
||||||
|
)
|
||||||
|
normalized_operations = tuple(
|
||||||
|
sorted({GitOperation(item).value for item in allowed_git_operations})
|
||||||
|
)
|
||||||
|
if ExecutionPath.GIT_OPERATION.value in normalized_paths and not normalized_operations:
|
||||||
|
raise ProvenanceDenied("git_operation path requires explicit allowed Git operations")
|
||||||
|
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
unsigned = RunProvenance(
|
||||||
|
provenance_id=provenance_id or f"prov_{uuid.uuid4().hex}",
|
||||||
|
run_id=run_id,
|
||||||
|
issuer=issuer,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
allowed_paths=tuple(normalized_paths),
|
||||||
|
allowed_git_operations=normalized_operations,
|
||||||
|
issued_at=issued.isoformat().replace("+00:00", "Z"),
|
||||||
|
expires_at=(issued + timedelta(seconds=ttl_seconds))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
policy_version=policy_version,
|
||||||
|
key_id=self.signer.key_id,
|
||||||
|
nonce=nonce or uuid.uuid4().hex,
|
||||||
|
)
|
||||||
|
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
|
||||||
|
self.store.save_issued(signed)
|
||||||
|
return signed
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProvenanceAuthorization:
|
||||||
|
authorization_id: str
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
execution_path: str
|
||||||
|
git_operation: str | None
|
||||||
|
request_digest: str
|
||||||
|
policy_version: str
|
||||||
|
key_id: str
|
||||||
|
public_key_fingerprint: str
|
||||||
|
authorized_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteProvenanceStore:
|
||||||
|
"""持久化 Provenance、状态、授权使用和不可变事件。"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._connection = sqlite3.connect(
|
||||||
|
str(self.path), timeout=30.0, check_same_thread=False
|
||||||
|
)
|
||||||
|
self._connection.row_factory = sqlite3.Row
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
self._connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_records (
|
||||||
|
provenance_id TEXT PRIMARY KEY,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
signature_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_usage (
|
||||||
|
authorization_id TEXT PRIMARY KEY,
|
||||||
|
provenance_id TEXT NOT NULL,
|
||||||
|
execution_path TEXT NOT NULL,
|
||||||
|
git_operation TEXT NOT NULL DEFAULT '',
|
||||||
|
request_digest TEXT NOT NULL,
|
||||||
|
authorized_at TEXT NOT NULL,
|
||||||
|
receipt_json TEXT NOT NULL,
|
||||||
|
UNIQUE(provenance_id, execution_path, git_operation, request_digest),
|
||||||
|
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
provenance_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_provenance_usage_id
|
||||||
|
ON provenance_usage(provenance_id, authorized_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_provenance_events_id
|
||||||
|
ON provenance_events(provenance_id, created_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.close()
|
||||||
|
|
||||||
|
def _event(
|
||||||
|
self,
|
||||||
|
provenance_id: str,
|
||||||
|
event_type: str,
|
||||||
|
details: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_events VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
f"pevt_{uuid.uuid4().hex}",
|
||||||
|
provenance_id,
|
||||||
|
event_type,
|
||||||
|
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
|
||||||
|
utc_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_issued(self, provenance: RunProvenance) -> None:
|
||||||
|
payload = json.dumps(
|
||||||
|
provenance.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
prior = self._connection.execute(
|
||||||
|
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
|
||||||
|
(provenance.provenance_id,),
|
||||||
|
).fetchone()
|
||||||
|
if prior is not None:
|
||||||
|
if prior["payload_json"] != payload:
|
||||||
|
raise ProvenanceDenied("provenance_id reused with different payload")
|
||||||
|
self._connection.rollback()
|
||||||
|
return
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_records VALUES (?, ?, ?, 0, ?, ?)",
|
||||||
|
(
|
||||||
|
provenance.provenance_id,
|
||||||
|
payload,
|
||||||
|
ProvenanceStatus.ISSUED.value,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._event(provenance.provenance_id, "issued")
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get(self, provenance_id: str) -> RunProvenance:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
|
||||||
|
(required(provenance_id, "provenance_id"),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ProvenanceNotFound("provenance not found")
|
||||||
|
value = json.loads(row["payload_json"])
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ProvenanceError("stored provenance payload is invalid")
|
||||||
|
return RunProvenance.from_dict(value)
|
||||||
|
|
||||||
|
def status(self, provenance_id: str) -> ProvenanceStatus:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT status FROM provenance_records WHERE provenance_id = ?",
|
||||||
|
(required(provenance_id, "provenance_id"),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ProvenanceNotFound("provenance not found")
|
||||||
|
return ProvenanceStatus(row["status"])
|
||||||
|
|
||||||
|
def mark_verified(self, provenance_id: str, *, fingerprint: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(provenance_id)
|
||||||
|
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
|
||||||
|
raise ProvenanceDenied(f"cannot verify provenance in state {current.value}")
|
||||||
|
next_status = (
|
||||||
|
ProvenanceStatus.USED.value
|
||||||
|
if current == ProvenanceStatus.USED
|
||||||
|
else ProvenanceStatus.VERIFIED.value
|
||||||
|
)
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_records SET status = ?, signature_verified = 1, updated_at = ? WHERE provenance_id = ?",
|
||||||
|
(next_status, utc_now(), provenance_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
provenance_id,
|
||||||
|
"verified",
|
||||||
|
{"public_key_fingerprint": fingerprint},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def mark_expired(self, provenance_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(provenance_id)
|
||||||
|
if current == ProvenanceStatus.REVOKED:
|
||||||
|
self._connection.rollback()
|
||||||
|
return
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||||
|
(ProvenanceStatus.EXPIRED.value, utc_now(), provenance_id),
|
||||||
|
)
|
||||||
|
self._event(provenance_id, "expired")
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def revoke(self, provenance_id: str, *, reason: str, principal_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
self.get(provenance_id)
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||||
|
(ProvenanceStatus.REVOKED.value, utc_now(), provenance_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
provenance_id,
|
||||||
|
"revoked",
|
||||||
|
{
|
||||||
|
"reason": required(reason, "reason"),
|
||||||
|
"principal_id": required(principal_id, "principal_id"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def record_authorization(self, authorization: ProvenanceAuthorization) -> None:
|
||||||
|
receipt = json.dumps(
|
||||||
|
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(authorization.provenance_id)
|
||||||
|
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
|
||||||
|
raise ProvenanceDenied(f"provenance state is {current.value}")
|
||||||
|
try:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
authorization.authorization_id,
|
||||||
|
authorization.provenance_id,
|
||||||
|
authorization.execution_path,
|
||||||
|
authorization.git_operation or "",
|
||||||
|
authorization.request_digest,
|
||||||
|
authorization.authorized_at,
|
||||||
|
receipt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise ProvenanceReplay(
|
||||||
|
"identical provenance path/operation/request digest already used"
|
||||||
|
) from exc
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
|
||||||
|
(
|
||||||
|
ProvenanceStatus.USED.value,
|
||||||
|
authorization.authorized_at,
|
||||||
|
authorization.provenance_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
authorization.provenance_id,
|
||||||
|
"used",
|
||||||
|
{
|
||||||
|
"authorization_id": authorization.authorization_id,
|
||||||
|
"execution_path": authorization.execution_path,
|
||||||
|
"git_operation": authorization.git_operation,
|
||||||
|
"request_digest": authorization.request_digest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def authorization_count(self, provenance_id: str) -> int:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT COUNT(*) AS total FROM provenance_usage WHERE provenance_id = ?",
|
||||||
|
(required(provenance_id, "provenance_id"),),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["total"])
|
||||||
|
|
||||||
|
def events(self, provenance_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self._connection.execute(
|
||||||
|
"SELECT event_type, details_json, created_at FROM provenance_events WHERE provenance_id = ? ORDER BY created_at, event_id",
|
||||||
|
(required(provenance_id, "provenance_id"),),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"event_type": row["event_type"],
|
||||||
|
"details": json.loads(row["details_json"]),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceVerifier:
|
||||||
|
"""验证 Owner 签名、上下文、路径、Git 操作、时效和重放。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: SQLiteProvenanceStore,
|
||||||
|
verifiers: Mapping[str, SignatureVerifier],
|
||||||
|
) -> None:
|
||||||
|
if not verifiers:
|
||||||
|
raise ValueError("at least one public key verifier is required")
|
||||||
|
self.store = store
|
||||||
|
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
execution_path: str,
|
||||||
|
request_digest: str,
|
||||||
|
git_operation: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ProvenanceAuthorization:
|
||||||
|
provenance = self.store.get(provenance_id)
|
||||||
|
status = self.store.status(provenance.provenance_id)
|
||||||
|
if status == ProvenanceStatus.REVOKED:
|
||||||
|
raise ProvenanceRevoked("provenance is revoked")
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
if current >= parse_timestamp(provenance.expires_at):
|
||||||
|
self.store.mark_expired(provenance.provenance_id)
|
||||||
|
raise ProvenanceExpired("provenance is expired")
|
||||||
|
verifier = self.verifiers.get(provenance.key_id)
|
||||||
|
if verifier is None:
|
||||||
|
raise ProvenanceSignatureError("no trusted public key for provenance key_id")
|
||||||
|
if not provenance.signature or not verifier.verify(
|
||||||
|
provenance.canonical_bytes(), provenance.signature
|
||||||
|
):
|
||||||
|
raise ProvenanceSignatureError("invalid provenance signature")
|
||||||
|
expected = {
|
||||||
|
"run_id": required(run_id, "run_id"),
|
||||||
|
"tenant_id": required(tenant_id, "tenant_id"),
|
||||||
|
"task_id": required(task_id, "task_id"),
|
||||||
|
"agent_id": required(agent_id, "agent_id"),
|
||||||
|
"principal_id": required(principal_id, "principal_id"),
|
||||||
|
}
|
||||||
|
for field_name, expected_value in expected.items():
|
||||||
|
if getattr(provenance, field_name) != expected_value:
|
||||||
|
raise ProvenanceDenied(f"provenance {field_name} mismatch")
|
||||||
|
path = ExecutionPath(execution_path).value
|
||||||
|
if path not in provenance.allowed_paths:
|
||||||
|
raise ProvenanceDenied(f"execution path is not allowed: {path}")
|
||||||
|
operation: str | None = None
|
||||||
|
if path == ExecutionPath.GIT_OPERATION.value:
|
||||||
|
if git_operation is None:
|
||||||
|
raise ProvenanceDenied("git operation is required for git_operation path")
|
||||||
|
operation = GitOperation(git_operation).value
|
||||||
|
if operation not in provenance.allowed_git_operations:
|
||||||
|
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
|
||||||
|
elif git_operation is not None:
|
||||||
|
raise ProvenanceDenied("git_operation is only valid for git_operation path")
|
||||||
|
digest = _validate_digest(request_digest)
|
||||||
|
self.store.mark_verified(
|
||||||
|
provenance.provenance_id, fingerprint=verifier.fingerprint
|
||||||
|
)
|
||||||
|
authorization = ProvenanceAuthorization(
|
||||||
|
authorization_id=f"pauth_{uuid.uuid4().hex}",
|
||||||
|
provenance_id=provenance.provenance_id,
|
||||||
|
run_id=provenance.run_id,
|
||||||
|
tenant_id=provenance.tenant_id,
|
||||||
|
task_id=provenance.task_id,
|
||||||
|
agent_id=provenance.agent_id,
|
||||||
|
principal_id=provenance.principal_id,
|
||||||
|
execution_path=path,
|
||||||
|
git_operation=operation,
|
||||||
|
request_digest=digest,
|
||||||
|
policy_version=provenance.policy_version,
|
||||||
|
key_id=provenance.key_id,
|
||||||
|
public_key_fingerprint=verifier.fingerprint,
|
||||||
|
)
|
||||||
|
self.store.record_authorization(authorization)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceControlledWriter:
|
||||||
|
"""七路径统一策略入口并持久写入授权回执。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
verifier: ProvenanceVerifier,
|
||||||
|
receipt_root: str | Path,
|
||||||
|
*,
|
||||||
|
writer_id: str = "aios-v2-provenance-writer",
|
||||||
|
) -> None:
|
||||||
|
self.verifier = verifier
|
||||||
|
self.receipt_root = Path(receipt_root).resolve()
|
||||||
|
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.writer_id = required(writer_id, "writer_id")
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ProvenanceDenied("invalid receipt path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def authorize(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
authorization = self.verifier.authorize(**values)
|
||||||
|
tenant = self._component(authorization.tenant_id)
|
||||||
|
provenance_id = self._component(authorization.provenance_id)
|
||||||
|
target_dir = self.receipt_root / tenant / provenance_id
|
||||||
|
target = target_dir / f"{authorization.authorization_id}.json"
|
||||||
|
payload = authorization.as_dict()
|
||||||
|
payload["writer_id"] = self.writer_id
|
||||||
|
with self._lock:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
def authorize_code_change(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
|
||||||
|
|
||||||
|
def authorize_git_operation(
|
||||||
|
self, *, git_operation: str, **values: Any
|
||||||
|
) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||||
|
git_operation=git_operation,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_deployment(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
|
||||||
|
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
|
||||||
|
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
|
||||||
|
|
||||||
|
def authorize_rollback(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
|
||||||
|
|
||||||
|
def authorize_audit(self, **values: Any) -> ProvenanceAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AIOS V2 Run Provenance 与最小权限 Grant 运维 CLI。
|
||||||
|
|
||||||
|
私钥只用于签发;授权命令只加载一个与 ``--path`` 对应的 Grant。所有输出均为
|
||||||
|
不含 Secret 的 JSON。旧 ``issue`` 仅为候选兼容,生产必须使用
|
||||||
|
``issue-workflow`` 与 ``issue-grant``。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from .provenance import (
|
||||||
|
Ed25519PrivateKeySigner,
|
||||||
|
Ed25519PublicKeyVerifier,
|
||||||
|
ExecutionPath,
|
||||||
|
GitOperation,
|
||||||
|
ProvenanceIssuer,
|
||||||
|
SQLiteProvenanceStore,
|
||||||
|
)
|
||||||
|
from .provenance_grants import (
|
||||||
|
GrantIssuer,
|
||||||
|
GrantType,
|
||||||
|
GrantVerifier,
|
||||||
|
SQLiteGrantStore,
|
||||||
|
WorkflowProvenanceIssuer,
|
||||||
|
)
|
||||||
|
from .provenance_operations import (
|
||||||
|
OperationContext,
|
||||||
|
ProvenanceOperationGate,
|
||||||
|
context_from_environment,
|
||||||
|
load_request_file,
|
||||||
|
)
|
||||||
|
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object(value: str | None, file_path: str | None) -> Mapping[str, Any]:
|
||||||
|
if value and file_path:
|
||||||
|
raise ValueError("request-json and request-file are mutually exclusive")
|
||||||
|
if file_path:
|
||||||
|
return load_request_file(file_path)
|
||||||
|
parsed = json.loads(value or "{}")
|
||||||
|
if not isinstance(parsed, Mapping):
|
||||||
|
raise ValueError("request JSON must be an object")
|
||||||
|
return dict(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv(value: str | None) -> tuple[str, ...]:
|
||||||
|
return tuple(item.strip() for item in str(value or "").split(",") if item.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: str | None, payload: Mapping[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||||
|
if path:
|
||||||
|
target = Path(path).resolve()
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(encoded, encoding="utf-8")
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
print(encoded, end="")
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_store(args: argparse.Namespace) -> SQLiteProvenanceStore:
|
||||||
|
return SQLiteProvenanceStore(args.store_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_store(args: argparse.Namespace) -> SQLiteGrantStore:
|
||||||
|
return SQLiteGrantStore(args.store_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _context(args: argparse.Namespace) -> OperationContext:
|
||||||
|
if args.context_from_env:
|
||||||
|
return context_from_environment()
|
||||||
|
values = {
|
||||||
|
"provenance_id": args.provenance_id,
|
||||||
|
"run_id": args.run_id,
|
||||||
|
"tenant_id": args.tenant_id,
|
||||||
|
"task_id": args.task_id,
|
||||||
|
"agent_id": args.agent_id,
|
||||||
|
"principal_id": args.principal_id,
|
||||||
|
}
|
||||||
|
missing = [name for name, value in values.items() if not str(value or "").strip()]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"missing context arguments: {missing}")
|
||||||
|
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
|
||||||
|
|
||||||
|
|
||||||
|
def command_issue_legacy(args: argparse.Namespace) -> int:
|
||||||
|
store = _parent_store(args)
|
||||||
|
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||||
|
provenance = ProvenanceIssuer(signer, store).issue(
|
||||||
|
run_id=args.run_id,
|
||||||
|
issuer=args.issuer,
|
||||||
|
tenant_id=args.tenant_id,
|
||||||
|
task_id=args.task_id,
|
||||||
|
agent_id=args.agent_id,
|
||||||
|
principal_id=args.principal_id,
|
||||||
|
allowed_paths=_csv(args.allowed_paths) or tuple(item.value for item in ExecutionPath),
|
||||||
|
allowed_git_operations=_csv(args.allowed_git_operations)
|
||||||
|
or tuple(item.value for item in GitOperation),
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
policy_version=args.policy_version,
|
||||||
|
)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{
|
||||||
|
"mode": "legacy-full-rights-candidate-only",
|
||||||
|
"provenance": provenance.as_dict(),
|
||||||
|
"status": store.status(provenance.provenance_id).value,
|
||||||
|
"secret_value_stored": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_issue_workflow(args: argparse.Namespace) -> int:
|
||||||
|
store = _parent_store(args)
|
||||||
|
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||||
|
workflow = WorkflowProvenanceIssuer(signer, store).issue(
|
||||||
|
run_id=args.run_id,
|
||||||
|
issuer=args.issuer,
|
||||||
|
tenant_id=args.tenant_id,
|
||||||
|
task_id=args.task_id,
|
||||||
|
agent_id=args.agent_id,
|
||||||
|
principal_id=args.principal_id,
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{
|
||||||
|
"mode": "least-privilege-workflow",
|
||||||
|
"workflow_provenance": workflow.as_dict(),
|
||||||
|
"status": store.status(workflow.provenance_id).value,
|
||||||
|
"secret_value_stored": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_issue_grant(args: argparse.Namespace) -> int:
|
||||||
|
parent_store = _parent_store(args)
|
||||||
|
grant_store = _grant_store(args)
|
||||||
|
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
|
||||||
|
grant = GrantIssuer(signer, parent_store, grant_store).issue(
|
||||||
|
parent_provenance_id=args.parent_provenance_id,
|
||||||
|
grant_type=GrantType(args.grant_type),
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
allowed_git_operations=_csv(args.allowed_git_operations),
|
||||||
|
)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{
|
||||||
|
"grant": grant.as_dict(),
|
||||||
|
"status": grant_store.status(grant.grant_id).value,
|
||||||
|
"secret_value_stored": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _single_scope_writer(args: argparse.Namespace) -> ScopedLeastPrivilegeProvenanceWriter:
|
||||||
|
path = ExecutionPath(args.path).value
|
||||||
|
parent_store = _parent_store(args)
|
||||||
|
grant_store = _grant_store(args)
|
||||||
|
verifier = Ed25519PublicKeyVerifier.from_file(args.public_key_file, key_id=args.key_id)
|
||||||
|
trusted = GrantVerifier(parent_store, grant_store, {args.key_id: verifier})
|
||||||
|
return ScopedLeastPrivilegeProvenanceWriter(
|
||||||
|
trusted,
|
||||||
|
args.receipt_dir,
|
||||||
|
{path: args.grant_id},
|
||||||
|
required_paths=(path,),
|
||||||
|
writer_id=f"aios-v2-cli-{path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command_authorize(args: argparse.Namespace) -> int:
|
||||||
|
writer = _single_scope_writer(args)
|
||||||
|
context = _context(args)
|
||||||
|
receipt = ProvenanceOperationGate(writer).authorize(
|
||||||
|
context,
|
||||||
|
execution_path=args.path,
|
||||||
|
operation=args.operation,
|
||||||
|
request=_json_object(args.request_json, args.request_file),
|
||||||
|
)
|
||||||
|
_write_json(args.output, {"authorization": receipt.as_dict()})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_revoke(args: argparse.Namespace) -> int:
|
||||||
|
store = _parent_store(args)
|
||||||
|
store.revoke(args.provenance_id, reason=args.reason, principal_id=args.principal_id)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{"provenance_id": args.provenance_id, "status": store.status(args.provenance_id).value},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_status(args: argparse.Namespace) -> int:
|
||||||
|
store = _parent_store(args)
|
||||||
|
provenance = store.get(args.provenance_id)
|
||||||
|
grant_store = _grant_store(args)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{
|
||||||
|
"provenance": provenance.as_dict(),
|
||||||
|
"status": store.status(args.provenance_id).value,
|
||||||
|
"authorization_count": store.authorization_count(args.provenance_id),
|
||||||
|
"events": store.events(args.provenance_id),
|
||||||
|
"grants": [item.as_dict() for item in grant_store.list_for_parent(args.provenance_id)],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_grant_revoke(args: argparse.Namespace) -> int:
|
||||||
|
store = _grant_store(args)
|
||||||
|
store.revoke(args.grant_id, reason=args.reason, principal_id=args.principal_id)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{"grant_id": args.grant_id, "status": store.status(args.grant_id).value},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_grant_status(args: argparse.Namespace) -> int:
|
||||||
|
store = _grant_store(args)
|
||||||
|
grant = store.get(args.grant_id)
|
||||||
|
_write_json(
|
||||||
|
args.output,
|
||||||
|
{"grant": grant.as_dict(), "status": store.status(args.grant_id).value},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def add_common_store(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--store-file",
|
||||||
|
default=os.getenv(
|
||||||
|
"AIOS_PROVENANCE_STORE_FILE",
|
||||||
|
"/var/lib/aios-v2/run-provenance.sqlite3",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def add_signing_context(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("--private-key-file", required=True)
|
||||||
|
parser.add_argument("--key-id", required=True)
|
||||||
|
|
||||||
|
|
||||||
|
def add_workflow_identity(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("--run-id", required=True)
|
||||||
|
parser.add_argument("--issuer", required=True)
|
||||||
|
parser.add_argument("--tenant-id", required=True)
|
||||||
|
parser.add_argument("--task-id", required=True)
|
||||||
|
parser.add_argument("--agent-id", required=True)
|
||||||
|
parser.add_argument("--principal-id", required=True)
|
||||||
|
|
||||||
|
|
||||||
|
def add_context(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("--context-from-env", action="store_true")
|
||||||
|
parser.add_argument("--provenance-id")
|
||||||
|
parser.add_argument("--run-id")
|
||||||
|
parser.add_argument("--tenant-id")
|
||||||
|
parser.add_argument("--task-id")
|
||||||
|
parser.add_argument("--agent-id")
|
||||||
|
parser.add_argument("--principal-id")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="AIOS V2 Run Provenance CLI")
|
||||||
|
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
issue = subcommands.add_parser("issue", help="候选兼容:签发全权限 Provenance;生产禁止")
|
||||||
|
add_common_store(issue)
|
||||||
|
add_signing_context(issue)
|
||||||
|
add_workflow_identity(issue)
|
||||||
|
issue.add_argument("--allowed-paths")
|
||||||
|
issue.add_argument("--allowed-git-operations")
|
||||||
|
issue.add_argument("--ttl-seconds", type=int, default=3600)
|
||||||
|
issue.add_argument("--policy-version", default="aios.v2.candidate.legacy")
|
||||||
|
issue.set_defaults(handler=command_issue_legacy)
|
||||||
|
|
||||||
|
workflow = subcommands.add_parser(
|
||||||
|
"issue-workflow", help="签发不含操作权限的父 Workflow Provenance"
|
||||||
|
)
|
||||||
|
add_common_store(workflow)
|
||||||
|
add_signing_context(workflow)
|
||||||
|
add_workflow_identity(workflow)
|
||||||
|
workflow.add_argument("--ttl-seconds", type=int, default=14400)
|
||||||
|
workflow.set_defaults(handler=command_issue_workflow)
|
||||||
|
|
||||||
|
grant = subcommands.add_parser("issue-grant", help="签发单一路径最小权限 Grant")
|
||||||
|
add_common_store(grant)
|
||||||
|
add_signing_context(grant)
|
||||||
|
grant.add_argument("--parent-provenance-id", required=True)
|
||||||
|
grant.add_argument("--grant-type", choices=[item.value for item in GrantType], required=True)
|
||||||
|
grant.add_argument("--allowed-git-operations")
|
||||||
|
grant.add_argument("--ttl-seconds", type=int, default=900)
|
||||||
|
grant.set_defaults(handler=command_issue_grant)
|
||||||
|
|
||||||
|
authorize = subcommands.add_parser("authorize", help="使用单一子 Grant 授权一次运维操作")
|
||||||
|
add_common_store(authorize)
|
||||||
|
add_context(authorize)
|
||||||
|
authorize.add_argument("--public-key-file", required=True)
|
||||||
|
authorize.add_argument("--key-id", required=True)
|
||||||
|
authorize.add_argument("--grant-id", required=True)
|
||||||
|
authorize.add_argument(
|
||||||
|
"--receipt-dir",
|
||||||
|
default=os.getenv(
|
||||||
|
"AIOS_PROVENANCE_RECEIPT_DIR",
|
||||||
|
"/var/lib/aios-v2/provenance-receipts",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
authorize.add_argument(
|
||||||
|
"--path",
|
||||||
|
required=True,
|
||||||
|
choices=[
|
||||||
|
ExecutionPath.CODE_CHANGE.value,
|
||||||
|
ExecutionPath.DEPLOYMENT.value,
|
||||||
|
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||||
|
ExecutionPath.EVIDENCE_WRITE.value,
|
||||||
|
ExecutionPath.ROLLBACK.value,
|
||||||
|
ExecutionPath.AUDIT.value,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
authorize.add_argument("--operation", required=True)
|
||||||
|
request_group = authorize.add_mutually_exclusive_group()
|
||||||
|
request_group.add_argument("--request-json")
|
||||||
|
request_group.add_argument("--request-file")
|
||||||
|
authorize.set_defaults(handler=command_authorize)
|
||||||
|
|
||||||
|
revoke = subcommands.add_parser("revoke", help="撤销父 Workflow Provenance")
|
||||||
|
add_common_store(revoke)
|
||||||
|
revoke.add_argument("--provenance-id", required=True)
|
||||||
|
revoke.add_argument("--reason", required=True)
|
||||||
|
revoke.add_argument("--principal-id", required=True)
|
||||||
|
revoke.set_defaults(handler=command_revoke)
|
||||||
|
|
||||||
|
status = subcommands.add_parser("status", help="查询父 Workflow 与子 Grant")
|
||||||
|
add_common_store(status)
|
||||||
|
status.add_argument("--provenance-id", required=True)
|
||||||
|
status.set_defaults(handler=command_status)
|
||||||
|
|
||||||
|
grant_revoke = subcommands.add_parser("grant-revoke", help="单独撤销子 Grant")
|
||||||
|
add_common_store(grant_revoke)
|
||||||
|
grant_revoke.add_argument("--grant-id", required=True)
|
||||||
|
grant_revoke.add_argument("--reason", required=True)
|
||||||
|
grant_revoke.add_argument("--principal-id", required=True)
|
||||||
|
grant_revoke.set_defaults(handler=command_grant_revoke)
|
||||||
|
|
||||||
|
grant_status = subcommands.add_parser("grant-status", help="查询子 Grant 状态")
|
||||||
|
add_common_store(grant_status)
|
||||||
|
grant_status.add_argument("--grant-id", required=True)
|
||||||
|
grant_status.set_defaults(handler=command_grant_status)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
return int(args.handler(args))
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"status": "denied", "error": type(exc).__name__, "message": str(exc)},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
"""受最小权限 Run Provenance Grant 约束的 Git 写入器。
|
||||||
|
|
||||||
|
所有 Git 操作都使用参数数组执行。对于 Commit 和 Evidence Commit,先计算
|
||||||
|
工作树内容摘要并完成 Provenance 授权,之后才允许执行 ``git add``,避免未授权
|
||||||
|
请求改变 Git Index。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
from .provenance import GitOperation, ProvenanceDenied, digest_payload, utc_now
|
||||||
|
|
||||||
|
_BRANCH_PATTERN = re.compile(r"^(?![-./])(?!.*\.\.)(?!.*//)[A-Za-z0-9._/-]{1,240}$")
|
||||||
|
_SECRET_URL = re.compile(r"(?i)(https?://)([^/@\s]+)@")
|
||||||
|
_TOKENISH = re.compile(r"(?i)(token|password|secret|authorization)([=:]\s*)([^\s]+)")
|
||||||
|
|
||||||
|
|
||||||
|
class GitAuthorizationWriter(Protocol):
|
||||||
|
def authorize_code_change(self, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
def authorize_git_operation(self, *, git_operation: str, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceGitError(RuntimeError):
|
||||||
|
"""Git 操作被拒绝或执行失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitExecutionContext:
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitCommandResult:
|
||||||
|
operation_id: str
|
||||||
|
operation: str
|
||||||
|
authorization_ids: Sequence[str]
|
||||||
|
command: Sequence[str]
|
||||||
|
exit_code: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
head_before: str | None
|
||||||
|
head_after: str | None
|
||||||
|
started_at: str
|
||||||
|
completed_at: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceGitWriter:
|
||||||
|
"""执行经过单路径 Grant 授权的窄范围 Git 操作。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repo: str | Path,
|
||||||
|
writer: GitAuthorizationWriter,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 120.0,
|
||||||
|
output_limit: int = 16000,
|
||||||
|
) -> None:
|
||||||
|
self.repo = Path(repo).resolve()
|
||||||
|
self.writer = writer
|
||||||
|
self.timeout_seconds = float(timeout_seconds)
|
||||||
|
self.output_limit = int(output_limit)
|
||||||
|
if self.timeout_seconds <= 0 or self.output_limit < 1024:
|
||||||
|
raise ValueError("invalid Git writer limits")
|
||||||
|
self._run_raw(["rev-parse", "--is-inside-work-tree"], check=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_branch(branch: str) -> str:
|
||||||
|
normalized = str(branch or "").strip()
|
||||||
|
if not _BRANCH_PATTERN.fullmatch(normalized) or normalized.endswith((".", "/")):
|
||||||
|
raise ProvenanceGitError("invalid Git branch name")
|
||||||
|
if normalized in {"main", "master"}:
|
||||||
|
raise ProvenanceGitError("direct main/master operation is prohibited")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _validate_path(self, value: str | Path) -> str:
|
||||||
|
raw = Path(value)
|
||||||
|
candidate = (self.repo / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
||||||
|
try:
|
||||||
|
relative = candidate.relative_to(self.repo)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ProvenanceGitError("Git path escapes repository") from exc
|
||||||
|
if str(relative) in {"", "."}:
|
||||||
|
raise ProvenanceGitError("repository root is not a valid file path")
|
||||||
|
return relative.as_posix()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redact(value: str, limit: int) -> str:
|
||||||
|
text = _SECRET_URL.sub(r"\1***@", str(value or ""))
|
||||||
|
text = _TOKENISH.sub(r"\1\2***", text)
|
||||||
|
if len(text) > limit:
|
||||||
|
return text[:limit] + "\n...[truncated]"
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _head(self) -> str | None:
|
||||||
|
process = self._run_raw(["rev-parse", "HEAD"], check=False)
|
||||||
|
return process.stdout.strip() if process.returncode == 0 else None
|
||||||
|
|
||||||
|
def _run_raw(
|
||||||
|
self,
|
||||||
|
args: Sequence[str],
|
||||||
|
*,
|
||||||
|
check: bool,
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
command = ["git", "-C", str(self.repo), *[str(item) for item in args]]
|
||||||
|
safe_env = {
|
||||||
|
**os.environ,
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"GIT_CONFIG_NOSYSTEM": os.environ.get("GIT_CONFIG_NOSYSTEM", "1"),
|
||||||
|
}
|
||||||
|
if env:
|
||||||
|
safe_env.update({str(key): str(value) for key, value in env.items()})
|
||||||
|
process = subprocess.run(
|
||||||
|
command,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
env=safe_env,
|
||||||
|
)
|
||||||
|
if check and process.returncode != 0:
|
||||||
|
raise ProvenanceGitError(
|
||||||
|
self._redact(process.stderr or process.stdout, self.output_limit)
|
||||||
|
or f"Git command failed with exit code {process.returncode}"
|
||||||
|
)
|
||||||
|
return process
|
||||||
|
|
||||||
|
def _path_digest(self, relative: str) -> str:
|
||||||
|
target = self.repo / relative
|
||||||
|
if target.exists():
|
||||||
|
if not target.is_file():
|
||||||
|
raise ProvenanceGitError("Git writer accepts file paths only")
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
with target.open("rb") as handle:
|
||||||
|
while True:
|
||||||
|
block = handle.read(65536)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
hasher.update(block)
|
||||||
|
return hasher.hexdigest()
|
||||||
|
tracked = self._run_raw(["ls-files", "--error-unmatch", "--", relative], check=False)
|
||||||
|
if tracked.returncode != 0:
|
||||||
|
raise ProvenanceGitError(f"path does not exist and is not tracked: {relative}")
|
||||||
|
return "deleted"
|
||||||
|
|
||||||
|
def _content_manifest(self, paths: Sequence[str]) -> Mapping[str, str]:
|
||||||
|
return {path: self._path_digest(path) for path in sorted(set(paths))}
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
operation: GitOperation,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
code_change: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
request_digest = digest_payload(
|
||||||
|
{
|
||||||
|
"operation": operation.value,
|
||||||
|
"repo": self.repo.name,
|
||||||
|
"request": dict(request),
|
||||||
|
"head": self._head(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ids: list[str] = []
|
||||||
|
try:
|
||||||
|
if code_change:
|
||||||
|
code_authorization = self.writer.authorize_code_change(
|
||||||
|
provenance_id=context.provenance_id,
|
||||||
|
run_id=context.run_id,
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
task_id=context.task_id,
|
||||||
|
agent_id=context.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
request_digest=digest_payload(
|
||||||
|
{
|
||||||
|
"git_operation": operation.value,
|
||||||
|
"request_digest": request_digest,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ids.append(code_authorization.authorization_id)
|
||||||
|
git_authorization = self.writer.authorize_git_operation(
|
||||||
|
provenance_id=context.provenance_id,
|
||||||
|
run_id=context.run_id,
|
||||||
|
tenant_id=context.tenant_id,
|
||||||
|
task_id=context.task_id,
|
||||||
|
agent_id=context.agent_id,
|
||||||
|
principal_id=context.principal_id,
|
||||||
|
git_operation=operation.value,
|
||||||
|
request_digest=request_digest,
|
||||||
|
)
|
||||||
|
ids.append(git_authorization.authorization_id)
|
||||||
|
return ids
|
||||||
|
except ProvenanceDenied as exc:
|
||||||
|
raise ProvenanceGitError(f"Run Provenance denied Git operation: {exc}") from exc
|
||||||
|
|
||||||
|
def _run_authorized(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation: GitOperation,
|
||||||
|
args: Sequence[str],
|
||||||
|
authorization_ids: Sequence[str],
|
||||||
|
) -> GitCommandResult:
|
||||||
|
started = utc_now()
|
||||||
|
before = self._head()
|
||||||
|
process = self._run_raw(args, check=False)
|
||||||
|
after = self._head()
|
||||||
|
result = GitCommandResult(
|
||||||
|
operation_id=f"gitop_{uuid.uuid4().hex}",
|
||||||
|
operation=operation.value,
|
||||||
|
authorization_ids=tuple(authorization_ids),
|
||||||
|
command=tuple(["git", "-C", "<repo>", *[str(item) for item in args]]),
|
||||||
|
exit_code=int(process.returncode),
|
||||||
|
stdout=self._redact(process.stdout, self.output_limit),
|
||||||
|
stderr=self._redact(process.stderr, self.output_limit),
|
||||||
|
head_before=before,
|
||||||
|
head_after=after,
|
||||||
|
started_at=started,
|
||||||
|
completed_at=utc_now(),
|
||||||
|
)
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise ProvenanceGitError(
|
||||||
|
result.stderr or result.stdout or f"Git {operation.value} failed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _execute(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
operation: GitOperation,
|
||||||
|
args: Sequence[str],
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
code_change: bool,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
authorization_ids = self._authorize(
|
||||||
|
context,
|
||||||
|
operation=operation,
|
||||||
|
request=request,
|
||||||
|
code_change=code_change,
|
||||||
|
)
|
||||||
|
return self._run_authorized(
|
||||||
|
operation=operation,
|
||||||
|
args=args,
|
||||||
|
authorization_ids=authorization_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_ref(self, context: GitExecutionContext, ref: str) -> GitCommandResult:
|
||||||
|
normalized = str(ref or "").strip()
|
||||||
|
if not normalized or any(ch.isspace() for ch in normalized):
|
||||||
|
raise ProvenanceGitError("invalid Git ref")
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.READ_REF,
|
||||||
|
args=["rev-parse", "--verify", normalized],
|
||||||
|
request={"ref": normalized},
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
refspecs: Sequence[str],
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized_remote = str(remote or "").strip()
|
||||||
|
normalized_refspecs = tuple(str(item).strip() for item in refspecs if str(item).strip())
|
||||||
|
if not normalized_remote or not normalized_refspecs:
|
||||||
|
raise ProvenanceGitError("remote and refspecs are required")
|
||||||
|
if any(item.startswith("+") and ":refs/heads/" in item for item in normalized_refspecs):
|
||||||
|
raise ProvenanceGitError(
|
||||||
|
"fetching directly into local branch refs is prohibited; use remote-tracking refs"
|
||||||
|
)
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.FETCH,
|
||||||
|
args=["fetch", "--prune", normalized_remote, *normalized_refspecs],
|
||||||
|
request={"remote": normalized_remote, "refspecs": normalized_refspecs},
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_branch(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
branch: str,
|
||||||
|
start_point: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized = self._validate_branch(branch)
|
||||||
|
start = str(start_point or "").strip()
|
||||||
|
if not start:
|
||||||
|
raise ProvenanceGitError("start_point is required")
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.CREATE_BRANCH,
|
||||||
|
args=["branch", normalized, start],
|
||||||
|
request={"branch": normalized, "start_point": start},
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def commit(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
paths: Sequence[str | Path],
|
||||||
|
message: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized_paths = tuple(self._validate_path(item) for item in paths)
|
||||||
|
normalized_message = str(message or "").strip()
|
||||||
|
if not normalized_paths or not normalized_message:
|
||||||
|
raise ProvenanceGitError("commit paths and message are required")
|
||||||
|
if len(normalized_message) > 500:
|
||||||
|
raise ProvenanceGitError("commit message is too long")
|
||||||
|
request = {
|
||||||
|
"paths": normalized_paths,
|
||||||
|
"message": normalized_message,
|
||||||
|
"content_manifest": self._content_manifest(normalized_paths),
|
||||||
|
}
|
||||||
|
authorization_ids = self._authorize(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.COMMIT,
|
||||||
|
request=request,
|
||||||
|
code_change=True,
|
||||||
|
)
|
||||||
|
self._run_raw(["add", "--", *normalized_paths], check=True)
|
||||||
|
return self._run_authorized(
|
||||||
|
operation=GitOperation.COMMIT,
|
||||||
|
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
|
||||||
|
authorization_ids=authorization_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def merge(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
source_ref: str,
|
||||||
|
message: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
source = str(source_ref or "").strip()
|
||||||
|
normalized_message = str(message or "").strip()
|
||||||
|
if not source or not normalized_message:
|
||||||
|
raise ProvenanceGitError("source_ref and message are required")
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.MERGE,
|
||||||
|
args=["merge", "--no-ff", "--no-edit", "-m", normalized_message, source],
|
||||||
|
request={"source_ref": source, "message": normalized_message},
|
||||||
|
code_change=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def push(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
branch: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized_remote = str(remote or "").strip()
|
||||||
|
normalized_branch = self._validate_branch(branch)
|
||||||
|
if not normalized_remote:
|
||||||
|
raise ProvenanceGitError("remote is required")
|
||||||
|
return self._execute(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.PUSH,
|
||||||
|
args=["push", normalized_remote, f"refs/heads/{normalized_branch}:refs/heads/{normalized_branch}"],
|
||||||
|
request={"remote": normalized_remote, "branch": normalized_branch},
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_evidence(
|
||||||
|
self,
|
||||||
|
context: GitExecutionContext,
|
||||||
|
*,
|
||||||
|
paths: Sequence[str | Path],
|
||||||
|
message: str,
|
||||||
|
) -> GitCommandResult:
|
||||||
|
normalized_paths = tuple(self._validate_path(item) for item in paths)
|
||||||
|
normalized_message = str(message or "").strip()
|
||||||
|
if not normalized_paths or not normalized_message:
|
||||||
|
raise ProvenanceGitError("evidence paths and message are required")
|
||||||
|
if any(not path.startswith(".agent/") for path in normalized_paths):
|
||||||
|
raise ProvenanceGitError("UPDATE_EVIDENCE may only commit .agent/ paths")
|
||||||
|
request = {
|
||||||
|
"paths": normalized_paths,
|
||||||
|
"message": normalized_message,
|
||||||
|
"content_manifest": self._content_manifest(normalized_paths),
|
||||||
|
}
|
||||||
|
authorization_ids = self._authorize(
|
||||||
|
context,
|
||||||
|
operation=GitOperation.UPDATE_EVIDENCE,
|
||||||
|
request=request,
|
||||||
|
code_change=False,
|
||||||
|
)
|
||||||
|
self._run_raw(["add", "--", *normalized_paths], check=True)
|
||||||
|
return self._run_authorized(
|
||||||
|
operation=GitOperation.UPDATE_EVIDENCE,
|
||||||
|
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
|
||||||
|
authorization_ids=authorization_ids,
|
||||||
|
)
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
"""AIOS V2 最小权限 Run Provenance 子 Grant。
|
||||||
|
|
||||||
|
父 Workflow Provenance 只绑定 run/tenant/task/agent/principal 身份,不直接授予
|
||||||
|
操作权限。每个子 Grant 只允许一个 ExecutionPath;Git Grant 还必须限定具体
|
||||||
|
GitOperation。子 Grant 具备独立 TTL、nonce、状态、撤销和重放防护。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from .provenance import (
|
||||||
|
ExecutionPath,
|
||||||
|
GitOperation,
|
||||||
|
ProvenanceDenied,
|
||||||
|
ProvenanceExpired,
|
||||||
|
ProvenanceIssuer,
|
||||||
|
ProvenanceNotFound,
|
||||||
|
ProvenanceReplay,
|
||||||
|
ProvenanceRevoked,
|
||||||
|
ProvenanceSignatureError,
|
||||||
|
ProvenanceStatus,
|
||||||
|
ProvenanceVerifier,
|
||||||
|
RunProvenance,
|
||||||
|
SQLiteProvenanceStore,
|
||||||
|
Signer,
|
||||||
|
SignatureVerifier,
|
||||||
|
_validate_digest,
|
||||||
|
canonical_json_bytes,
|
||||||
|
parse_timestamp,
|
||||||
|
required,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
WORKFLOW_POLICY_VERSION = "aios.v2.workflow.v1"
|
||||||
|
GRANT_SCHEMA_VERSION = "aios.v2.provenance-grant.v1"
|
||||||
|
|
||||||
|
|
||||||
|
class GrantType(str, Enum):
|
||||||
|
CODE_CHANGE = "code_change"
|
||||||
|
GIT = "git"
|
||||||
|
DEPLOYMENT = "deployment"
|
||||||
|
RUNTIME = "runtime"
|
||||||
|
EVIDENCE = "evidence"
|
||||||
|
ROLLBACK = "rollback"
|
||||||
|
AUDIT = "audit"
|
||||||
|
|
||||||
|
|
||||||
|
GRANT_PATHS: Mapping[GrantType, ExecutionPath] = {
|
||||||
|
GrantType.CODE_CHANGE: ExecutionPath.CODE_CHANGE,
|
||||||
|
GrantType.GIT: ExecutionPath.GIT_OPERATION,
|
||||||
|
GrantType.DEPLOYMENT: ExecutionPath.DEPLOYMENT,
|
||||||
|
GrantType.RUNTIME: ExecutionPath.RUNTIME_EXECUTE,
|
||||||
|
GrantType.EVIDENCE: ExecutionPath.EVIDENCE_WRITE,
|
||||||
|
GrantType.ROLLBACK: ExecutionPath.ROLLBACK,
|
||||||
|
GrantType.AUDIT: ExecutionPath.AUDIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GrantStatus(str, Enum):
|
||||||
|
ISSUED = "issued"
|
||||||
|
VERIFIED = "verified"
|
||||||
|
USED = "used"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProvenanceGrant:
|
||||||
|
grant_id: str
|
||||||
|
parent_provenance_id: str
|
||||||
|
grant_type: GrantType
|
||||||
|
execution_path: ExecutionPath
|
||||||
|
run_id: str
|
||||||
|
issuer: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
allowed_git_operations: Sequence[str]
|
||||||
|
issued_at: str
|
||||||
|
expires_at: str
|
||||||
|
policy_version: str
|
||||||
|
key_id: str
|
||||||
|
nonce: str
|
||||||
|
signature: str = ""
|
||||||
|
schema_version: str = GRANT_SCHEMA_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"grant_id",
|
||||||
|
"parent_provenance_id",
|
||||||
|
"run_id",
|
||||||
|
"issuer",
|
||||||
|
"tenant_id",
|
||||||
|
"task_id",
|
||||||
|
"agent_id",
|
||||||
|
"principal_id",
|
||||||
|
"issued_at",
|
||||||
|
"expires_at",
|
||||||
|
"policy_version",
|
||||||
|
"key_id",
|
||||||
|
"nonce",
|
||||||
|
"schema_version",
|
||||||
|
):
|
||||||
|
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||||
|
if self.schema_version != GRANT_SCHEMA_VERSION:
|
||||||
|
raise ValueError(f"unsupported grant schema: {self.schema_version}")
|
||||||
|
grant_type = GrantType(self.grant_type)
|
||||||
|
path = ExecutionPath(self.execution_path)
|
||||||
|
if GRANT_PATHS[grant_type] != path:
|
||||||
|
raise ValueError("grant_type does not match execution_path")
|
||||||
|
object.__setattr__(self, "grant_type", grant_type)
|
||||||
|
object.__setattr__(self, "execution_path", path)
|
||||||
|
operations = tuple(
|
||||||
|
sorted({GitOperation(item).value for item in self.allowed_git_operations})
|
||||||
|
)
|
||||||
|
if grant_type == GrantType.GIT and not operations:
|
||||||
|
raise ValueError("Git Grant requires explicit Git operations")
|
||||||
|
if grant_type != GrantType.GIT and operations:
|
||||||
|
raise ValueError("only Git Grant may contain Git operations")
|
||||||
|
object.__setattr__(self, "allowed_git_operations", operations)
|
||||||
|
issued = parse_timestamp(self.issued_at)
|
||||||
|
expires = parse_timestamp(self.expires_at)
|
||||||
|
if expires <= issued:
|
||||||
|
raise ValueError("grant expires_at must be later than issued_at")
|
||||||
|
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||||
|
|
||||||
|
def unsigned_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": self.schema_version,
|
||||||
|
"grant_id": self.grant_id,
|
||||||
|
"parent_provenance_id": self.parent_provenance_id,
|
||||||
|
"grant_type": self.grant_type.value,
|
||||||
|
"execution_path": self.execution_path.value,
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"issuer": self.issuer,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"principal_id": self.principal_id,
|
||||||
|
"allowed_git_operations": list(self.allowed_git_operations),
|
||||||
|
"issued_at": self.issued_at,
|
||||||
|
"expires_at": self.expires_at,
|
||||||
|
"policy_version": self.policy_version,
|
||||||
|
"key_id": self.key_id,
|
||||||
|
"nonce": self.nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
def canonical_bytes(self) -> bytes:
|
||||||
|
return canonical_json_bytes(self.unsigned_dict())
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
value = self.unsigned_dict()
|
||||||
|
value["signature"] = self.signature
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: Mapping[str, Any]) -> "ProvenanceGrant":
|
||||||
|
return cls(**dict(value))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GrantAuthorization:
|
||||||
|
authorization_id: str
|
||||||
|
grant_id: str
|
||||||
|
parent_provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
execution_path: str
|
||||||
|
git_operation: str | None
|
||||||
|
request_digest: str
|
||||||
|
policy_version: str
|
||||||
|
key_id: str
|
||||||
|
public_key_fingerprint: str
|
||||||
|
authorized_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provenance_id(self) -> str:
|
||||||
|
return self.parent_provenance_id
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowProvenanceIssuer:
|
||||||
|
"""签发不携带操作权限的父 Workflow Provenance。"""
|
||||||
|
|
||||||
|
def __init__(self, signer: Signer, store: SQLiteProvenanceStore) -> None:
|
||||||
|
self._issuer = ProvenanceIssuer(signer, store)
|
||||||
|
|
||||||
|
def issue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
issuer: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
ttl_seconds: int = 14400,
|
||||||
|
provenance_id: str | None = None,
|
||||||
|
nonce: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RunProvenance:
|
||||||
|
return self._issuer.issue(
|
||||||
|
run_id=run_id,
|
||||||
|
issuer=issuer,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
task_id=task_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
allowed_paths=(),
|
||||||
|
allowed_git_operations=(),
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
policy_version=WORKFLOW_POLICY_VERSION,
|
||||||
|
provenance_id=provenance_id,
|
||||||
|
nonce=nonce,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteGrantStore:
|
||||||
|
"""在 Provenance SQLite 数据库中持久化子 Grant 与使用记录。"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path).resolve()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._connection = sqlite3.connect(
|
||||||
|
str(self.path), timeout=30.0, check_same_thread=False
|
||||||
|
)
|
||||||
|
self._connection.row_factory = sqlite3.Row
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._connection.execute("PRAGMA synchronous=FULL")
|
||||||
|
self._connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
self._connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_grants (
|
||||||
|
grant_id TEXT PRIMARY KEY,
|
||||||
|
parent_provenance_id TEXT NOT NULL,
|
||||||
|
grant_type TEXT NOT NULL,
|
||||||
|
execution_path TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
signature_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grants_parent
|
||||||
|
ON provenance_grants(parent_provenance_id, grant_type, status);
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_grant_usage (
|
||||||
|
authorization_id TEXT PRIMARY KEY,
|
||||||
|
grant_id TEXT NOT NULL,
|
||||||
|
execution_path TEXT NOT NULL,
|
||||||
|
git_operation TEXT NOT NULL DEFAULT '',
|
||||||
|
request_digest TEXT NOT NULL,
|
||||||
|
authorized_at TEXT NOT NULL,
|
||||||
|
receipt_json TEXT NOT NULL,
|
||||||
|
UNIQUE(grant_id, execution_path, git_operation, request_digest),
|
||||||
|
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance_grant_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
grant_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.close()
|
||||||
|
|
||||||
|
def _event(
|
||||||
|
self,
|
||||||
|
grant_id: str,
|
||||||
|
event_type: str,
|
||||||
|
details: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_grant_events VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
f"gevt_{uuid.uuid4().hex}",
|
||||||
|
grant_id,
|
||||||
|
event_type,
|
||||||
|
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
|
||||||
|
utc_now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_issued(self, grant: ProvenanceGrant) -> None:
|
||||||
|
payload = json.dumps(
|
||||||
|
grant.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_grants VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||||
|
(
|
||||||
|
grant.grant_id,
|
||||||
|
grant.parent_provenance_id,
|
||||||
|
grant.grant_type.value,
|
||||||
|
grant.execution_path.value,
|
||||||
|
payload,
|
||||||
|
GrantStatus.ISSUED.value,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._event(grant.grant_id, "issued")
|
||||||
|
self._connection.commit()
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise ProvenanceDenied("grant_id already exists") from exc
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get(self, grant_id: str) -> ProvenanceGrant:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT payload_json FROM provenance_grants WHERE grant_id = ?",
|
||||||
|
(required(grant_id, "grant_id"),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ProvenanceNotFound("provenance grant not found")
|
||||||
|
value = json.loads(row["payload_json"])
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ProvenanceDenied("stored grant is invalid")
|
||||||
|
return ProvenanceGrant.from_dict(value)
|
||||||
|
|
||||||
|
def status(self, grant_id: str) -> GrantStatus:
|
||||||
|
with self._lock:
|
||||||
|
row = self._connection.execute(
|
||||||
|
"SELECT status FROM provenance_grants WHERE grant_id = ?",
|
||||||
|
(required(grant_id, "grant_id"),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ProvenanceNotFound("provenance grant not found")
|
||||||
|
return GrantStatus(row["status"])
|
||||||
|
|
||||||
|
def mark_verified(self, grant_id: str, *, fingerprint: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(grant_id)
|
||||||
|
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
|
||||||
|
raise ProvenanceDenied(f"cannot verify grant in state {current.value}")
|
||||||
|
next_status = (
|
||||||
|
GrantStatus.USED.value
|
||||||
|
if current == GrantStatus.USED
|
||||||
|
else GrantStatus.VERIFIED.value
|
||||||
|
)
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_grants SET status = ?, signature_verified = 1, updated_at = ? WHERE grant_id = ?",
|
||||||
|
(next_status, utc_now(), grant_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
grant_id,
|
||||||
|
"verified",
|
||||||
|
{"public_key_fingerprint": fingerprint},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def mark_expired(self, grant_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
if self.status(grant_id) == GrantStatus.REVOKED:
|
||||||
|
self._connection.rollback()
|
||||||
|
return
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||||
|
(GrantStatus.EXPIRED.value, utc_now(), grant_id),
|
||||||
|
)
|
||||||
|
self._event(grant_id, "expired")
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def revoke(self, grant_id: str, *, reason: str, principal_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
self.get(grant_id)
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||||
|
(GrantStatus.REVOKED.value, utc_now(), grant_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
grant_id,
|
||||||
|
"revoked",
|
||||||
|
{
|
||||||
|
"reason": required(reason, "reason"),
|
||||||
|
"principal_id": required(principal_id, "principal_id"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def record_authorization(self, authorization: GrantAuthorization) -> None:
|
||||||
|
receipt = json.dumps(
|
||||||
|
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
current = self.status(authorization.grant_id)
|
||||||
|
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
|
||||||
|
raise ProvenanceDenied(f"grant state is {current.value}")
|
||||||
|
try:
|
||||||
|
self._connection.execute(
|
||||||
|
"INSERT INTO provenance_grant_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
authorization.authorization_id,
|
||||||
|
authorization.grant_id,
|
||||||
|
authorization.execution_path,
|
||||||
|
authorization.git_operation or "",
|
||||||
|
authorization.request_digest,
|
||||||
|
authorization.authorized_at,
|
||||||
|
receipt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError as exc:
|
||||||
|
raise ProvenanceReplay(
|
||||||
|
"identical grant path/operation/request digest already used"
|
||||||
|
) from exc
|
||||||
|
self._connection.execute(
|
||||||
|
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
|
||||||
|
(GrantStatus.USED.value, authorization.authorized_at, authorization.grant_id),
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
authorization.grant_id,
|
||||||
|
"used",
|
||||||
|
{
|
||||||
|
"authorization_id": authorization.authorization_id,
|
||||||
|
"execution_path": authorization.execution_path,
|
||||||
|
"git_operation": authorization.git_operation,
|
||||||
|
"request_digest": authorization.request_digest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._connection.commit()
|
||||||
|
except Exception:
|
||||||
|
self._connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list_for_parent(self, parent_provenance_id: str) -> list[ProvenanceGrant]:
|
||||||
|
with self._lock:
|
||||||
|
rows = self._connection.execute(
|
||||||
|
"SELECT payload_json FROM provenance_grants WHERE parent_provenance_id = ? ORDER BY grant_type, grant_id",
|
||||||
|
(required(parent_provenance_id, "parent_provenance_id"),),
|
||||||
|
).fetchall()
|
||||||
|
return [ProvenanceGrant.from_dict(json.loads(row["payload_json"])) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
class GrantIssuer:
|
||||||
|
"""从父 Workflow Provenance 派生单一路径最小权限 Grant。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
signer: Signer,
|
||||||
|
parent_store: SQLiteProvenanceStore,
|
||||||
|
grant_store: SQLiteGrantStore,
|
||||||
|
) -> None:
|
||||||
|
self.signer = signer
|
||||||
|
self.parent_store = parent_store
|
||||||
|
self.grant_store = grant_store
|
||||||
|
|
||||||
|
def issue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
parent_provenance_id: str,
|
||||||
|
grant_type: GrantType | str,
|
||||||
|
ttl_seconds: int = 900,
|
||||||
|
allowed_git_operations: Sequence[str] = (),
|
||||||
|
grant_id: str | None = None,
|
||||||
|
nonce: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ProvenanceGrant:
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
raise ValueError("grant ttl_seconds must be positive")
|
||||||
|
parent = self.parent_store.get(parent_provenance_id)
|
||||||
|
if parent.policy_version != WORKFLOW_POLICY_VERSION:
|
||||||
|
raise ProvenanceDenied("child grants require a workflow provenance parent")
|
||||||
|
if parent.allowed_paths or parent.allowed_git_operations:
|
||||||
|
raise ProvenanceDenied("workflow provenance parent must not contain operation rights")
|
||||||
|
parent_status = self.parent_store.status(parent.provenance_id)
|
||||||
|
if parent_status == ProvenanceStatus.REVOKED:
|
||||||
|
raise ProvenanceRevoked("workflow provenance is revoked")
|
||||||
|
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
parent_expiry = parse_timestamp(parent.expires_at)
|
||||||
|
expires = min(issued + timedelta(seconds=ttl_seconds), parent_expiry)
|
||||||
|
if expires <= issued:
|
||||||
|
raise ProvenanceExpired("workflow provenance has expired")
|
||||||
|
normalized_type = GrantType(grant_type)
|
||||||
|
operations = tuple(allowed_git_operations)
|
||||||
|
unsigned = ProvenanceGrant(
|
||||||
|
grant_id=grant_id or f"grant_{uuid.uuid4().hex}",
|
||||||
|
parent_provenance_id=parent.provenance_id,
|
||||||
|
grant_type=normalized_type,
|
||||||
|
execution_path=GRANT_PATHS[normalized_type],
|
||||||
|
run_id=parent.run_id,
|
||||||
|
issuer=parent.issuer,
|
||||||
|
tenant_id=parent.tenant_id,
|
||||||
|
task_id=parent.task_id,
|
||||||
|
agent_id=parent.agent_id,
|
||||||
|
principal_id=parent.principal_id,
|
||||||
|
allowed_git_operations=operations,
|
||||||
|
issued_at=issued.isoformat().replace("+00:00", "Z"),
|
||||||
|
expires_at=expires.isoformat().replace("+00:00", "Z"),
|
||||||
|
policy_version=f"{WORKFLOW_POLICY_VERSION}.{normalized_type.value}",
|
||||||
|
key_id=self.signer.key_id,
|
||||||
|
nonce=nonce or uuid.uuid4().hex,
|
||||||
|
)
|
||||||
|
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
|
||||||
|
self.grant_store.save_issued(signed)
|
||||||
|
return signed
|
||||||
|
|
||||||
|
|
||||||
|
class GrantVerifier:
|
||||||
|
"""验证父 Workflow 与子 Grant,确保单一路径最小权限。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent_store: SQLiteProvenanceStore,
|
||||||
|
grant_store: SQLiteGrantStore,
|
||||||
|
verifiers: Mapping[str, SignatureVerifier],
|
||||||
|
) -> None:
|
||||||
|
if not verifiers:
|
||||||
|
raise ValueError("at least one trusted verifier is required")
|
||||||
|
self.parent_store = parent_store
|
||||||
|
self.grant_store = grant_store
|
||||||
|
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
|
||||||
|
|
||||||
|
def _verify_parent(self, parent: RunProvenance, *, now: datetime) -> SignatureVerifier:
|
||||||
|
status = self.parent_store.status(parent.provenance_id)
|
||||||
|
if status == ProvenanceStatus.REVOKED:
|
||||||
|
raise ProvenanceRevoked("workflow provenance is revoked")
|
||||||
|
if now >= parse_timestamp(parent.expires_at):
|
||||||
|
self.parent_store.mark_expired(parent.provenance_id)
|
||||||
|
raise ProvenanceExpired("workflow provenance is expired")
|
||||||
|
verifier = self.verifiers.get(parent.key_id)
|
||||||
|
if verifier is None or not parent.signature or not verifier.verify(
|
||||||
|
parent.canonical_bytes(), parent.signature
|
||||||
|
):
|
||||||
|
raise ProvenanceSignatureError("invalid workflow provenance signature")
|
||||||
|
if parent.policy_version != WORKFLOW_POLICY_VERSION:
|
||||||
|
raise ProvenanceDenied("parent is not a workflow provenance")
|
||||||
|
if parent.allowed_paths or parent.allowed_git_operations:
|
||||||
|
raise ProvenanceDenied("workflow provenance must not contain operation rights")
|
||||||
|
return verifier
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
grant_id: str,
|
||||||
|
parent_provenance_id: str,
|
||||||
|
run_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
task_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
principal_id: str,
|
||||||
|
execution_path: str,
|
||||||
|
request_digest: str,
|
||||||
|
git_operation: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> GrantAuthorization:
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
parent = self.parent_store.get(parent_provenance_id)
|
||||||
|
self._verify_parent(parent, now=current)
|
||||||
|
grant = self.grant_store.get(grant_id)
|
||||||
|
grant_status = self.grant_store.status(grant.grant_id)
|
||||||
|
if grant_status == GrantStatus.REVOKED:
|
||||||
|
raise ProvenanceRevoked("provenance grant is revoked")
|
||||||
|
if current >= parse_timestamp(grant.expires_at):
|
||||||
|
self.grant_store.mark_expired(grant.grant_id)
|
||||||
|
raise ProvenanceExpired("provenance grant is expired")
|
||||||
|
verifier = self.verifiers.get(grant.key_id)
|
||||||
|
if verifier is None or not grant.signature or not verifier.verify(
|
||||||
|
grant.canonical_bytes(), grant.signature
|
||||||
|
):
|
||||||
|
raise ProvenanceSignatureError("invalid provenance grant signature")
|
||||||
|
expected = {
|
||||||
|
"parent_provenance_id": parent.provenance_id,
|
||||||
|
"run_id": required(run_id, "run_id"),
|
||||||
|
"tenant_id": required(tenant_id, "tenant_id"),
|
||||||
|
"task_id": required(task_id, "task_id"),
|
||||||
|
"agent_id": required(agent_id, "agent_id"),
|
||||||
|
"principal_id": required(principal_id, "principal_id"),
|
||||||
|
}
|
||||||
|
for field_name, expected_value in expected.items():
|
||||||
|
if getattr(grant, field_name) != expected_value:
|
||||||
|
raise ProvenanceDenied(f"grant {field_name} mismatch")
|
||||||
|
if field_name != "parent_provenance_id" and getattr(parent, field_name) != expected_value:
|
||||||
|
raise ProvenanceDenied(f"workflow {field_name} mismatch")
|
||||||
|
path = ExecutionPath(execution_path)
|
||||||
|
if grant.execution_path != path:
|
||||||
|
raise ProvenanceDenied(
|
||||||
|
f"grant permits {grant.execution_path.value}, not {path.value}"
|
||||||
|
)
|
||||||
|
operation: str | None = None
|
||||||
|
if path == ExecutionPath.GIT_OPERATION:
|
||||||
|
if git_operation is None:
|
||||||
|
raise ProvenanceDenied("Git operation is required")
|
||||||
|
operation = GitOperation(git_operation).value
|
||||||
|
if operation not in grant.allowed_git_operations:
|
||||||
|
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
|
||||||
|
elif git_operation is not None:
|
||||||
|
raise ProvenanceDenied("git_operation is only valid for a Git Grant")
|
||||||
|
digest = _validate_digest(request_digest)
|
||||||
|
self.grant_store.mark_verified(grant.grant_id, fingerprint=verifier.fingerprint)
|
||||||
|
authorization = GrantAuthorization(
|
||||||
|
authorization_id=f"gauth_{uuid.uuid4().hex}",
|
||||||
|
grant_id=grant.grant_id,
|
||||||
|
parent_provenance_id=parent.provenance_id,
|
||||||
|
run_id=grant.run_id,
|
||||||
|
tenant_id=grant.tenant_id,
|
||||||
|
task_id=grant.task_id,
|
||||||
|
agent_id=grant.agent_id,
|
||||||
|
principal_id=grant.principal_id,
|
||||||
|
execution_path=path.value,
|
||||||
|
git_operation=operation,
|
||||||
|
request_digest=digest,
|
||||||
|
policy_version=grant.policy_version,
|
||||||
|
key_id=grant.key_id,
|
||||||
|
public_key_fingerprint=verifier.fingerprint,
|
||||||
|
)
|
||||||
|
self.grant_store.record_authorization(authorization)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
|
||||||
|
class LeastPrivilegeProvenanceWriter:
|
||||||
|
"""按路径选择独立子 Grant 的生产 Writer。
|
||||||
|
|
||||||
|
``provenance_id`` 参数始终指父 Workflow Provenance;Grant ID 来自受保护
|
||||||
|
配置映射,调用方无法通过请求体临时扩大权限。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
verifier: GrantVerifier,
|
||||||
|
receipt_root: str | Path,
|
||||||
|
grant_ids: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
writer_id: str = "aios-v2-least-privilege-writer",
|
||||||
|
) -> None:
|
||||||
|
self.verifier = verifier
|
||||||
|
self.receipt_root = Path(receipt_root).resolve()
|
||||||
|
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.grant_ids = {
|
||||||
|
ExecutionPath(path).value: required(grant_id, f"grant_id[{path}]")
|
||||||
|
for path, grant_id in grant_ids.items()
|
||||||
|
}
|
||||||
|
missing = sorted(path.value for path in ExecutionPath if path.value not in self.grant_ids)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"missing least-privilege Grant mappings: {missing}")
|
||||||
|
self.writer_id = required(writer_id, "writer_id")
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ProvenanceDenied("invalid receipt path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def authorize(self, **values: Any) -> GrantAuthorization:
|
||||||
|
path = ExecutionPath(values["execution_path"]).value
|
||||||
|
grant_id = self.grant_ids[path]
|
||||||
|
parent_provenance_id = values.pop("provenance_id")
|
||||||
|
authorization = self.verifier.authorize(
|
||||||
|
grant_id=grant_id,
|
||||||
|
parent_provenance_id=parent_provenance_id,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
tenant = self._component(authorization.tenant_id)
|
||||||
|
parent = self._component(authorization.parent_provenance_id)
|
||||||
|
grant = self._component(authorization.grant_id)
|
||||||
|
target_dir = self.receipt_root / tenant / parent / grant
|
||||||
|
target = target_dir / f"{authorization.authorization_id}.json"
|
||||||
|
payload = authorization.as_dict()
|
||||||
|
payload["writer_id"] = self.writer_id
|
||||||
|
with self._lock:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
|
||||||
|
|
||||||
|
def authorize_git_operation(
|
||||||
|
self, *, git_operation: str, **values: Any
|
||||||
|
) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||||
|
git_operation=git_operation,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
|
||||||
|
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
|
||||||
|
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
|
||||||
|
|
||||||
|
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
|
||||||
|
|
||||||
|
def authorize_audit(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""AIOS V2 运维操作 Provenance Gate 与服务级 Scoped Writer 工厂。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
from .provenance import (
|
||||||
|
Ed25519PublicKeyVerifier,
|
||||||
|
ExecutionPath,
|
||||||
|
ProvenanceAuthorization,
|
||||||
|
ProvenanceControlledWriter,
|
||||||
|
ProvenanceDenied,
|
||||||
|
ProvenanceVerifier,
|
||||||
|
SQLiteProvenanceStore,
|
||||||
|
digest_payload,
|
||||||
|
)
|
||||||
|
from .provenance_grants import GrantAuthorization, GrantVerifier, SQLiteGrantStore
|
||||||
|
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||||
|
|
||||||
|
|
||||||
|
APPLICATION_PROVENANCE_SCOPE = (
|
||||||
|
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||||
|
ExecutionPath.EVIDENCE_WRITE.value,
|
||||||
|
ExecutionPath.AUDIT.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationWriter(Protocol):
|
||||||
|
def authorize_deployment(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_rollback(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_audit(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_code_change(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> Any: ...
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OperationContext:
|
||||||
|
provenance_id: str
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OperationReceipt:
|
||||||
|
operation: str
|
||||||
|
request_digest: str
|
||||||
|
authorization_id: str
|
||||||
|
provenance_id: str
|
||||||
|
grant_id: str | None
|
||||||
|
run_id: str
|
||||||
|
tenant_id: str
|
||||||
|
task_id: str
|
||||||
|
agent_id: str
|
||||||
|
principal_id: str
|
||||||
|
execution_path: str
|
||||||
|
authorized_at: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceOperationGate:
|
||||||
|
"""部署、回滚、审计和运行操作的统一授权入口。"""
|
||||||
|
|
||||||
|
def __init__(self, writer: AuthorizationWriter) -> None:
|
||||||
|
self.writer = writer
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
context: OperationContext,
|
||||||
|
*,
|
||||||
|
execution_path: str,
|
||||||
|
operation: str,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
) -> OperationReceipt:
|
||||||
|
path = ExecutionPath(execution_path).value
|
||||||
|
normalized_operation = str(operation or "").strip()
|
||||||
|
if not normalized_operation:
|
||||||
|
raise ValueError("operation is required")
|
||||||
|
digest = digest_payload(
|
||||||
|
{
|
||||||
|
"operation": normalized_operation,
|
||||||
|
"execution_path": path,
|
||||||
|
"request": dict(request),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
values = {
|
||||||
|
"provenance_id": context.provenance_id,
|
||||||
|
"run_id": context.run_id,
|
||||||
|
"tenant_id": context.tenant_id,
|
||||||
|
"task_id": context.task_id,
|
||||||
|
"agent_id": context.agent_id,
|
||||||
|
"principal_id": context.principal_id,
|
||||||
|
"request_digest": digest,
|
||||||
|
}
|
||||||
|
if path == ExecutionPath.DEPLOYMENT.value:
|
||||||
|
authorization = self.writer.authorize_deployment(**values)
|
||||||
|
elif path == ExecutionPath.ROLLBACK.value:
|
||||||
|
authorization = self.writer.authorize_rollback(**values)
|
||||||
|
elif path == ExecutionPath.AUDIT.value:
|
||||||
|
authorization = self.writer.authorize_audit(**values)
|
||||||
|
elif path == ExecutionPath.CODE_CHANGE.value:
|
||||||
|
authorization = self.writer.authorize_code_change(**values)
|
||||||
|
elif path == ExecutionPath.EVIDENCE_WRITE.value:
|
||||||
|
authorization = self.writer.authorize_evidence_write(**values)
|
||||||
|
elif path == ExecutionPath.RUNTIME_EXECUTE.value:
|
||||||
|
authorization = self.writer.authorize_runtime_execute(**values)
|
||||||
|
else:
|
||||||
|
raise ProvenanceDenied(
|
||||||
|
"generic operation gate does not authorize git_operation; use ProvenanceGitWriter"
|
||||||
|
)
|
||||||
|
return self._receipt(normalized_operation, digest, authorization)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _receipt(
|
||||||
|
operation: str,
|
||||||
|
request_digest: str,
|
||||||
|
authorization: ProvenanceAuthorization | GrantAuthorization | Any,
|
||||||
|
) -> OperationReceipt:
|
||||||
|
return OperationReceipt(
|
||||||
|
operation=operation,
|
||||||
|
request_digest=request_digest,
|
||||||
|
authorization_id=authorization.authorization_id,
|
||||||
|
provenance_id=authorization.provenance_id,
|
||||||
|
grant_id=getattr(authorization, "grant_id", None),
|
||||||
|
run_id=authorization.run_id,
|
||||||
|
tenant_id=authorization.tenant_id,
|
||||||
|
task_id=authorization.task_id,
|
||||||
|
agent_id=authorization.agent_id,
|
||||||
|
principal_id=authorization.principal_id,
|
||||||
|
execution_path=authorization.execution_path,
|
||||||
|
authorized_at=authorization.authorized_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_GRANT_ENV_BY_PATH: Mapping[str, str] = {
|
||||||
|
ExecutionPath.CODE_CHANGE.value: "AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID",
|
||||||
|
ExecutionPath.GIT_OPERATION.value: "AIOS_PROVENANCE_GRANT_GIT_ID",
|
||||||
|
ExecutionPath.DEPLOYMENT.value: "AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID",
|
||||||
|
ExecutionPath.RUNTIME_EXECUTE.value: "AIOS_PROVENANCE_GRANT_RUNTIME_ID",
|
||||||
|
ExecutionPath.EVIDENCE_WRITE.value: "AIOS_PROVENANCE_GRANT_EVIDENCE_ID",
|
||||||
|
ExecutionPath.ROLLBACK.value: "AIOS_PROVENANCE_GRANT_ROLLBACK_ID",
|
||||||
|
ExecutionPath.AUDIT.value: "AIOS_PROVENANCE_GRANT_AUDIT_ID",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def grant_ids_from_environment(
|
||||||
|
*,
|
||||||
|
required_paths: Sequence[str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
paths = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
ExecutionPath(path).value
|
||||||
|
for path in (required_paths or tuple(item.value for item in ExecutionPath))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
values = {path: os.getenv(_GRANT_ENV_BY_PATH[path], "").strip() for path in paths}
|
||||||
|
missing = [_GRANT_ENV_BY_PATH[path] for path, value in values.items() if not value]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"missing scoped Provenance Grant configuration: {missing}")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def build_provenance_writer_from_env(
|
||||||
|
*,
|
||||||
|
state_dir: str | Path | None = None,
|
||||||
|
required: bool | None = None,
|
||||||
|
required_paths: Sequence[str] | None = None,
|
||||||
|
writer_id: str = "aios-v2-application-grant-writer",
|
||||||
|
) -> AuthorizationWriter | None:
|
||||||
|
"""从环境构建服务级最小权限 Writer。
|
||||||
|
|
||||||
|
生产应用默认只加载 Runtime、Evidence、Audit Grant。Git、部署和回滚进程
|
||||||
|
必须显式传入自己的 ``required_paths``,不能继承应用默认范围。旧全权限 Writer
|
||||||
|
只允许在显式关闭强制模式的候选/测试环境中使用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
require_value = (
|
||||||
|
bool(required)
|
||||||
|
if required is not None
|
||||||
|
else os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0").strip().lower()
|
||||||
|
in {"1", "true", "yes", "on"}
|
||||||
|
)
|
||||||
|
public_key_file = os.getenv("AIOS_PROVENANCE_PUBLIC_KEY_FILE", "").strip()
|
||||||
|
key_id = os.getenv("AIOS_PROVENANCE_KEY_ID", "").strip()
|
||||||
|
base = Path(state_dir or os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||||
|
store_file = os.getenv(
|
||||||
|
"AIOS_PROVENANCE_STORE_FILE", str(base / "run-provenance.sqlite3")
|
||||||
|
).strip()
|
||||||
|
receipt_root = os.getenv(
|
||||||
|
"AIOS_PROVENANCE_RECEIPT_DIR", str(base / "provenance-receipts")
|
||||||
|
).strip()
|
||||||
|
configured = bool(public_key_file and key_id)
|
||||||
|
if not configured:
|
||||||
|
if require_value:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Run Provenance is required but AIOS_PROVENANCE_PUBLIC_KEY_FILE or AIOS_PROVENANCE_KEY_ID is missing"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
verifier = Ed25519PublicKeyVerifier.from_file(public_key_file, key_id=key_id)
|
||||||
|
parent_store = SQLiteProvenanceStore(store_file)
|
||||||
|
normalized_paths = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
ExecutionPath(path).value
|
||||||
|
for path in (
|
||||||
|
required_paths
|
||||||
|
if required_paths is not None
|
||||||
|
else APPLICATION_PROVENANCE_SCOPE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if require_value:
|
||||||
|
if not normalized_paths:
|
||||||
|
raise RuntimeError("production Run Provenance requires a non-empty service scope")
|
||||||
|
grant_ids = grant_ids_from_environment(required_paths=normalized_paths)
|
||||||
|
grant_store = SQLiteGrantStore(store_file)
|
||||||
|
trusted_grants = GrantVerifier(parent_store, grant_store, {key_id: verifier})
|
||||||
|
return ScopedLeastPrivilegeProvenanceWriter(
|
||||||
|
trusted_grants,
|
||||||
|
receipt_root,
|
||||||
|
grant_ids,
|
||||||
|
required_paths=normalized_paths,
|
||||||
|
writer_id=writer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy = ProvenanceVerifier(parent_store, {key_id: verifier})
|
||||||
|
return ProvenanceControlledWriter(legacy, receipt_root)
|
||||||
|
|
||||||
|
|
||||||
|
def context_from_environment() -> OperationContext:
|
||||||
|
values = {
|
||||||
|
"provenance_id": os.getenv("AIOS_PROVENANCE_ID", ""),
|
||||||
|
"run_id": os.getenv("AIOS_RUN_ID", ""),
|
||||||
|
"tenant_id": os.getenv("AIOS_TENANT_ID", ""),
|
||||||
|
"task_id": os.getenv("AIOS_TASK_ID", ""),
|
||||||
|
"agent_id": os.getenv("AIOS_AGENT_ID", ""),
|
||||||
|
"principal_id": os.getenv("AIOS_PRINCIPAL_ID", ""),
|
||||||
|
}
|
||||||
|
missing = [name for name, value in values.items() if not str(value).strip()]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"missing Provenance context environment variables: {missing}")
|
||||||
|
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
|
||||||
|
|
||||||
|
|
||||||
|
def load_request_file(path: str | Path) -> Mapping[str, Any]:
|
||||||
|
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError("request file must contain a JSON object")
|
||||||
|
return dict(value)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""按服务范围加载的最小权限 Provenance Writer。
|
||||||
|
|
||||||
|
该 Writer 只持有调用进程需要的 Grant ID。未配置路径的方法即使被调用也会
|
||||||
|
默认拒绝,避免应用进程获得 Git、部署或回滚权限。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from .provenance import ExecutionPath, ProvenanceDenied, required
|
||||||
|
from .provenance_grants import GrantAuthorization, GrantVerifier
|
||||||
|
|
||||||
|
|
||||||
|
class ScopedLeastPrivilegeProvenanceWriter:
|
||||||
|
"""只暴露指定 ExecutionPath 的最小权限 Writer。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
verifier: GrantVerifier,
|
||||||
|
receipt_root: str | Path,
|
||||||
|
grant_ids: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
required_paths: Sequence[str],
|
||||||
|
writer_id: str = "aios-v2-scoped-grant-writer",
|
||||||
|
) -> None:
|
||||||
|
self.verifier = verifier
|
||||||
|
self.receipt_root = Path(receipt_root).resolve()
|
||||||
|
self.receipt_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
normalized_required = tuple(
|
||||||
|
sorted({ExecutionPath(path).value for path in required_paths})
|
||||||
|
)
|
||||||
|
if not normalized_required:
|
||||||
|
raise ValueError("required_paths must not be empty")
|
||||||
|
self.required_paths = frozenset(normalized_required)
|
||||||
|
self.grant_ids = {
|
||||||
|
path: required(grant_ids.get(path, ""), f"grant_id[{path}]")
|
||||||
|
for path in normalized_required
|
||||||
|
}
|
||||||
|
unexpected = sorted(set(grant_ids) - self.required_paths)
|
||||||
|
if unexpected:
|
||||||
|
raise ValueError(
|
||||||
|
f"scoped writer received unnecessary Grant mappings: {unexpected}"
|
||||||
|
)
|
||||||
|
self.writer_id = required(writer_id, "writer_id")
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _component(value: str) -> str:
|
||||||
|
normalized = required(value, "path component")
|
||||||
|
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
|
||||||
|
raise ProvenanceDenied("invalid receipt path component")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _grant_for(self, path: str) -> str:
|
||||||
|
normalized = ExecutionPath(path).value
|
||||||
|
grant_id = self.grant_ids.get(normalized)
|
||||||
|
if grant_id is None:
|
||||||
|
raise ProvenanceDenied(
|
||||||
|
f"this process is not allowed to authorize {normalized}"
|
||||||
|
)
|
||||||
|
return grant_id
|
||||||
|
|
||||||
|
def authorize(self, **values: Any) -> GrantAuthorization:
|
||||||
|
path = ExecutionPath(values["execution_path"]).value
|
||||||
|
grant_id = self._grant_for(path)
|
||||||
|
parent_provenance_id = values.pop("provenance_id")
|
||||||
|
authorization = self.verifier.authorize(
|
||||||
|
grant_id=grant_id,
|
||||||
|
parent_provenance_id=parent_provenance_id,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
tenant = self._component(authorization.tenant_id)
|
||||||
|
parent = self._component(authorization.parent_provenance_id)
|
||||||
|
grant = self._component(authorization.grant_id)
|
||||||
|
target_dir = self.receipt_root / tenant / parent / grant
|
||||||
|
target = target_dir / f"{authorization.authorization_id}.json"
|
||||||
|
payload = authorization.as_dict()
|
||||||
|
payload["writer_id"] = self.writer_id
|
||||||
|
payload["scope"] = sorted(self.required_paths)
|
||||||
|
with self._lock:
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_suffix(f".tmp-{os.getpid()}")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
return authorization
|
||||||
|
|
||||||
|
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.CODE_CHANGE.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_git_operation(
|
||||||
|
self, *, git_operation: str, **values: Any
|
||||||
|
) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.GIT_OPERATION.value,
|
||||||
|
git_operation=git_operation,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.DEPLOYMENT.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(
|
||||||
|
execution_path=ExecutionPath.ROLLBACK.value, **values
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_audit(self, **values: Any) -> GrantAuthorization:
|
||||||
|
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
|
||||||
@@ -1,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)]
|
|
||||||
+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,13 +0,0 @@
|
|||||||
{
|
|
||||||
"approval_plan_id": "v2r1c4-security-v3-candidate-plan",
|
|
||||||
"expected_base_oid": "473b86a2f1f5a5fe3b8ad42a6da26c29c78c4170",
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
"port": 8080,
|
|
||||||
"release_branch": "patch/v2r1c4-security-invariants-v3-20260729",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"root": "/var/lib/aios-v2r1c4-candidate",
|
|
||||||
"tag_oid": "1111111111111111111111111111111111111111",
|
|
||||||
"target_oid": "473b86a2f1f5a5fe3b8ad42a6da26c29c78c4170",
|
|
||||||
"target_tree_oid": "c6d1ceb28b0519fe460a26a6a2a7e3c0086b3a1f",
|
|
||||||
"version": "V2R1C4"
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"actor_identity": "codex-chartgpt-bridge",
|
|
||||||
"allowed_branch_patterns": [
|
|
||||||
"patch/**",
|
|
||||||
"feature/**",
|
|
||||||
"fix/**",
|
|
||||||
"release-candidate/**"
|
|
||||||
],
|
|
||||||
"allowed_operations": [
|
|
||||||
"commit_file.atomic"
|
|
||||||
],
|
|
||||||
"allowed_path_patterns": [
|
|
||||||
"active-runtime/**",
|
|
||||||
"services/**",
|
|
||||||
"deploy/**",
|
|
||||||
"tests/**",
|
|
||||||
"docs/**",
|
|
||||||
"versions/**",
|
|
||||||
"release/**"
|
|
||||||
],
|
|
||||||
"denied_branch_patterns": [
|
|
||||||
"main",
|
|
||||||
"master",
|
|
||||||
"governance/approvals",
|
|
||||||
"integration/**",
|
|
||||||
"production/**"
|
|
||||||
],
|
|
||||||
"denied_operations": [
|
|
||||||
"force_push",
|
|
||||||
"history_rewrite",
|
|
||||||
"branch_delete",
|
|
||||||
"tag.write",
|
|
||||||
"registry.write",
|
|
||||||
"baseline.write",
|
|
||||||
"production.deploy",
|
|
||||||
"approval.submit",
|
|
||||||
"secret.read"
|
|
||||||
],
|
|
||||||
"expires_at": "2027-07-29T00:00:00Z",
|
|
||||||
"policy_id": "STANDING-GOVERNED-DEVELOPMENT-WRITER-V1",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"writer_audience": "aios-governed-development-writer"
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"actor_identity": "codex-chartgpt-bridge",
|
|
||||||
"allowed_operations": ["commit_file.atomic"],
|
|
||||||
"allowed_paths": ["services/**", "deploy/**", "tests/**", "docs/**", "release/**", "evidence/**"],
|
|
||||||
"branch": "patch/v2r1c4-security-invariants-v3-20260729",
|
|
||||||
"credential_name": "gitea_http",
|
|
||||||
"credential_token_id": 1,
|
|
||||||
"credential_username": "aios-governed-development-writer",
|
|
||||||
"expected_base_oid": "473b86a2f1f5a5fe3b8ad42a6da26c29c78c4170",
|
|
||||||
"expected_index_sha256": "e8571df23aba77fd393b4d98beb0b94676dc547fdc25d6492a43b43ed90aee94",
|
|
||||||
"expected_policy_sha256": "462c850d0476266a33bf1227f4989d13e03d807fd8ebb684cbe389f4d49e92fe",
|
|
||||||
"expected_tree_oid": "c6d1ceb28b0519fe460a26a6a2a7e3c0086b3a1f",
|
|
||||||
"expires_at": "2026-08-05T00:00:00Z",
|
|
||||||
"git_common_dir": "/data/aios-gitops/repo.git",
|
|
||||||
"git_user_email": "chatgpt-final-release-writer@cnnetmkt.top",
|
|
||||||
"git_user_name": "chatgpt-final-release-writer",
|
|
||||||
"grant_id": "grant-v2r1c4-security-v3-candidate",
|
|
||||||
"max_operations": 200,
|
|
||||||
"max_total_bytes": 67108864,
|
|
||||||
"push_ref": "refs/heads/patch/v2r1c4-security-invariants-v3-20260729",
|
|
||||||
"remote_name": "gitea-local-candidate",
|
|
||||||
"remote_url": "http://127.0.0.1:3000/aios/aios.git",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"task_id": "V2R1C4-SECURITY-INVARIANTS-V3",
|
|
||||||
"worktree_path": "/data/aios-gitops/worktrees/v2r1c4-security-invariants-v3-20260729",
|
|
||||||
"writer_audience": "aios-governed-development-writer"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"authoritative_git_dir": "/data/aios-gitops/repo.git",
|
|
||||||
"poll_seconds": 5,
|
|
||||||
"receipts_dir": "/var/lib/aios-gitops/autonomous-control-plane/mirror/receipts",
|
|
||||||
"requests_dir": "/var/lib/aios-gitops/autonomous-control-plane/mirror/requests",
|
|
||||||
"source_git_dir": "/data/aios-gitops/runtime/gitea-bin/data/gitea-repositories/aios/aios.git"
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"allowed_git_dir_roots": ["/data/aios-gitops/repo.git"],
|
|
||||||
"allowed_peer_uids": [0],
|
|
||||||
"audit_path": "/var/lib/aios-governance-publisher-evidence/audit.jsonl",
|
|
||||||
"base_branch": "governance/approvals",
|
|
||||||
"credential_name": "gitea_http",
|
|
||||||
"gitea_base_url": "http://127.0.0.1:3000",
|
|
||||||
"head_prefix": "governance/request/",
|
|
||||||
"operation_ledger": "/var/lib/aios-governance-publisher-state/operations.sqlite",
|
|
||||||
"owner_reviewer": "owner",
|
|
||||||
"remote_url": "http://127.0.0.1:3000/aios/aios.git",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"socket_path": "/run/aios-governance-publisher-runtime/publisher.sock"
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
{
|
|
||||||
"authoritative_git_dir": "/data/aios-gitops/repo.git",
|
|
||||||
"input_bindings": {
|
|
||||||
"expected_input_owner_uid": 0,
|
|
||||||
"release_root_path": "/var/lib/aios-gitops/release-inputs/V2R1C4-release-root.json",
|
|
||||||
"approval_plan_path": "/var/lib/aios-gitops/release-inputs/V2R1C4-approval-plan.json",
|
|
||||||
"artifact_path": "/var/lib/aios-gitops/artifacts/V2R1C4.tar.zst",
|
|
||||||
"rollback_artifact_path": "/var/lib/aios-gitops/artifacts/V2R1C4-rollback.tar.zst",
|
|
||||||
"sbom_path": "/var/lib/aios-gitops/release-inputs/V2R1C4-sbom.json",
|
|
||||||
"test_evidence_path": "/var/lib/aios-gitops/release-inputs/V2R1C4-test-evidence.json",
|
|
||||||
"tag_plan_path": "/var/lib/aios-gitops/release-inputs/V2R1C4-tag-plan.json",
|
|
||||||
"deployment_plan_path": "/var/lib/aios-gitops/release-inputs/V2R1C4-deployment-plan.json",
|
|
||||||
"rollback_plan_path": "/var/lib/aios-gitops/release-inputs/V2R1C4-rollback-plan.json",
|
|
||||||
"registry_plan_path": "/var/lib/aios-gitops/autonomous-control-plane/release/registry-plan.json",
|
|
||||||
"baseline_plan_path": "/var/lib/aios-gitops/autonomous-control-plane/release/baseline-plan.txt",
|
|
||||||
"evidence_closure_path": "evidence/releases/V2R1C4/evidence-closure.json"
|
|
||||||
},
|
|
||||||
"approval": {
|
|
||||||
"gitea_db_path": "/data/aios-gitops/runtime/gitea-bin/data/gitea.db",
|
|
||||||
"repo_id": 1,
|
|
||||||
"owner_user_id": 2,
|
|
||||||
"governance_ref": "refs/heads/governance/approvals",
|
|
||||||
"required_machine_contexts": [
|
|
||||||
"aios/branch-protection",
|
|
||||||
"aios/evidence-closure",
|
|
||||||
"aios/secret-scan",
|
|
||||||
"aios/state-machine",
|
|
||||||
"aios/validator"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"merge_transport": {
|
|
||||||
"transport": "unix_json",
|
|
||||||
"socket_path": "/run/aios-gitops/integration-merge-writer.sock",
|
|
||||||
"timeout_seconds": 120
|
|
||||||
},
|
|
||||||
"tag_transport": {
|
|
||||||
"transport": "http_json",
|
|
||||||
"url": "http://127.0.0.1:8080/api/create-tag",
|
|
||||||
"credential_name": "tag_api",
|
|
||||||
"timeout_seconds": 120,
|
|
||||||
"expected_statuses": [
|
|
||||||
200
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"remote_tag": {
|
|
||||||
"remote_url": "https://g.cnnetmkt.top/git/aios/aios.git",
|
|
||||||
"gitea_base_url": "https://g.cnnetmkt.top/git",
|
|
||||||
"repo_owner": "aios",
|
|
||||||
"repo_name": "aios",
|
|
||||||
"credential_name": "gitea_http"
|
|
||||||
},
|
|
||||||
"deployment": {
|
|
||||||
"artifact_path": "/var/lib/aios-gitops/artifacts/V2R1C4.tar.zst",
|
|
||||||
"artifact_manifest_member": "artifact-manifest.json",
|
|
||||||
"release_root": "/opt/aios/releases",
|
|
||||||
"current_symlink": "/opt/aios/current",
|
|
||||||
"previous_symlink": "/opt/aios/previous",
|
|
||||||
"state_path": "/var/lib/aios-gitops/autonomous-control-plane/release/deployment-state.json",
|
|
||||||
"service_units": [
|
|
||||||
"aios-gitops-model-api.service"
|
|
||||||
],
|
|
||||||
"health_checks": [
|
|
||||||
{
|
|
||||||
"url": "http://127.0.0.1:8080/health",
|
|
||||||
"expected_status": 200,
|
|
||||||
"timeout_seconds": 10
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"registry_path": "/var/lib/aios-gitops/release/versions/V2R1C4.json",
|
|
||||||
"baseline_path": "/var/lib/aios-gitops/release/PRODUCTION_BASELINE",
|
|
||||||
"registry_plan_path": "/var/lib/aios-gitops/autonomous-control-plane/release/registry-plan.json",
|
|
||||||
"baseline_plan_path": "/var/lib/aios-gitops/autonomous-control-plane/release/baseline-plan.txt",
|
|
||||||
"metadata_snapshot_root": "/var/lib/aios-gitops/autonomous-control-plane/release/metadata-snapshots"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"approval_expires_at": "2026-08-05T00:00:00Z",
|
|
||||||
"approval_head_oid": "<40_HEX_OID>",
|
|
||||||
"approval_manifest_path": "approvals/release/V2R1C4-PRODUCTION-RELEASE-ENVELOPE.json",
|
|
||||||
"approval_manifest_sha256": "<64_HEX_SHA256>",
|
|
||||||
"approval_merge_oid": "<40_HEX_OID>",
|
|
||||||
"approval_plan_id": "<OWNER_APPROVED_PLAN_ID>",
|
|
||||||
"approval_plan_sha256": "<64_HEX_SHA256>",
|
|
||||||
"approval_pr_number": 1,
|
|
||||||
"artifact_sha256": "<64_HEX_SHA256>",
|
|
||||||
"baseline_plan_sha256": "<64_HEX_SHA256>",
|
|
||||||
"deployment_plan_sha256": "<64_HEX_SHA256>",
|
|
||||||
"evidence_closure_oid": "<40_HEX_OID>",
|
|
||||||
"evidence_closure_sha256": "<64_HEX_SHA256>",
|
|
||||||
"expected_base_oid": "<40_HEX_BASE_OID>",
|
|
||||||
"owner_review_oid": 1,
|
|
||||||
"publisher_identity": "aios-governance-request-publisher",
|
|
||||||
"release_branch": "patch/v2r1c4-security-invariants-v3-20260729",
|
|
||||||
"release_root_sha256": "<64_HEX_SHA256>",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"registry_plan_sha256": "<64_HEX_SHA256>",
|
|
||||||
"rollback_artifact_sha256": "<64_HEX_SHA256>",
|
|
||||||
"rollback_plan_sha256": "<64_HEX_SHA256>",
|
|
||||||
"sbom_sha256": "<64_HEX_SHA256>",
|
|
||||||
"tag_plan_sha256": "<64_HEX_SHA256>",
|
|
||||||
"target_oid": "<40_HEX_TARGET_OID>",
|
|
||||||
"target_tree_oid": "<40_HEX_TREE_OID>",
|
|
||||||
"target_environment": "production",
|
|
||||||
"test_evidence_sha256": "<64_HEX_SHA256>",
|
|
||||||
"version": "V2R1C4",
|
|
||||||
"writer_identity": "aios-governed-development-writer"
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
{
|
|
||||||
"state_path": "/var/lib/aios-gitops/autonomous-control-plane/release/state.json",
|
|
||||||
"audit_path": "/var/lib/aios-gitops/autonomous-control-plane/release/audit.jsonl",
|
|
||||||
"hooks": {
|
|
||||||
"verify_owner_approval": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"verify_owner_approval",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"merge_release": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"merge_release",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"create_tag": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"create_tag",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"verify_remote_tag": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"verify_remote_tag",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"deploy": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"deploy",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"health_check": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"health_check",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"rollback": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"rollback",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"verify_previous": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"verify_previous",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"restore": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"restore",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"verify_restored": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"verify_restored",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"update_registry": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"update_registry",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"update_baseline": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"update_baseline",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"verify_consistency": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"verify_consistency",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"snapshot_metadata": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"snapshot_metadata",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
],
|
|
||||||
"restore_metadata": [
|
|
||||||
"/usr/bin/aios-release-hook",
|
|
||||||
"restore_metadata",
|
|
||||||
"--config",
|
|
||||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"allowed_output_roots": [
|
|
||||||
"/var/lib/aios-gitops/autonomous-control-plane/projection/releases"
|
|
||||||
],
|
|
||||||
"poll_seconds": 5,
|
|
||||||
"receipts_dir": "/var/lib/aios-gitops/autonomous-control-plane/projection/receipts",
|
|
||||||
"requests_dir": "/var/lib/aios-gitops/autonomous-control-plane/projection/requests",
|
|
||||||
"source_git_dir": "/data/aios-gitops/runtime/gitea-bin/data/gitea-repositories/aios/aios.git"
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"poll_seconds": 10,
|
|
||||||
"receipts_dir": "/var/lib/aios-gitops/autonomous-control-plane/renewal/receipts",
|
|
||||||
"requests_dir": "/var/lib/aios-gitops/autonomous-control-plane/renewal/requests"
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"admin_credential_name": "admin_gitea_http",
|
|
||||||
"expected_writer_username": "aios-governed-development-writer",
|
|
||||||
"gitea_base_url": "http://127.0.0.1:3000",
|
|
||||||
"grant_path": "/var/lib/aios-development-writer-state/grant.json",
|
|
||||||
"journal_path": "/var/lib/aios-development-writer-state/revocation-journal.json",
|
|
||||||
"receipt_path": "/var/lib/aios-development-writer-evidence/revocation-receipt.json",
|
|
||||||
"revoked_grant_path": "/var/lib/aios-development-writer-state/grant.revoked.json",
|
|
||||||
"terminal_trigger_path": "/var/lib/aios-development-writer-state/terminal-revocation.json",
|
|
||||||
"writer_credential_name": "writer_gitea_http",
|
|
||||||
"writer_unit": "aios-governed-development-writer.service"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"exact_paths": [
|
|
||||||
"/usr/libexec/aios-autonomous-release-authorization/controller.py",
|
|
||||||
"/usr/libexec/aios-chatgpt-action-task-router-continuous-20260726/action_task_router.py"
|
|
||||||
],
|
|
||||||
"output_path": "/var/lib/aios-gitops/autonomous-control-plane/projection/runtime-source-evidence.json",
|
|
||||||
"task_id": "V2R1C4-RELEASE-COMPLETION"
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user