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和回滚能力,生产业务未变化。
|
||||
```
|
||||
@@ -1,486 +0,0 @@
|
||||
# Agent Production Deployment Requirements
|
||||
|
||||
## Status entering deployment
|
||||
|
||||
```yaml
|
||||
code_development: COMPLETED
|
||||
repository_commit: NOT_CREATED
|
||||
production_deployment: NOT_STARTED
|
||||
reason_repository_commit_missing:
|
||||
current ChatGPT GitOps runtime has AIOS_GITOPS_WRITES_ENABLED=false
|
||||
```
|
||||
|
||||
The code bundle is an input artifact. The Agent must place it on a non-main
|
||||
integration branch, review it, run acceptance tests, merge it through protected
|
||||
main and deploy the API adapter. The Agent must not regenerate or materially
|
||||
rewrite the supplied implementation without recording the new diff and tests.
|
||||
|
||||
## Frozen production state
|
||||
|
||||
Before any change, verify:
|
||||
|
||||
```yaml
|
||||
main_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
|
||||
V2R1C5:
|
||||
tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
peeled_target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
baseline_version: V2R1C5
|
||||
baseline_status: PRODUCTION_DEPLOYED
|
||||
target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
evidence_closure_oid: 5e2cf8276f5093f3352b9a3252c701a44c5ab807
|
||||
```
|
||||
|
||||
If authoritative `main` no longer equals the approved base OID, stop with
|
||||
`MANUAL_OWNER_REVIEW_REQUIRED / AUTHORIZED_BASE_OID_DRIFT`. Do not silently
|
||||
rebase the bundle onto a different base.
|
||||
|
||||
## Bootstrap sequence — no additional Owner interaction
|
||||
|
||||
Use the following order so that the approved V2R1C6 base OID remains valid and
|
||||
the bridge does not create its own authorization deadlock:
|
||||
|
||||
1. Verify the supplied archive/patch SHA-256.
|
||||
2. Install the bundle into a temporary root-owned staging directory outside the
|
||||
Git worktrees.
|
||||
3. Start a localhost-only temporary instance of
|
||||
`aios_authorized_task_writer_api.py` on a dedicated port or Unix socket.
|
||||
4. Bind it to the existing authenticated Owner registry and the exact
|
||||
`writer:provision` scope. Do not expose it through the public gateway.
|
||||
5. Execute the live V2R1C6 provisioning request while `main` still equals
|
||||
`875d41c3101093149414292067fd700dc214d4d3`.
|
||||
6. Verify `READY_FOR_CODE_AUTHORING`, the exact branch OID, writer scope and
|
||||
negative policy tests.
|
||||
7. Use the newly created scoped writer to commit this reviewed bundle onto:
|
||||
`agent/v2r1c6-mainline-reconciliation-20260805`.
|
||||
8. Stop and remove the temporary bootstrap service and revoke its temporary
|
||||
runtime credential. Preserve its signed grant receipt and audit logs.
|
||||
9. Open a protected PR from the V2R1C6 branch. Deploy the permanent bridge only
|
||||
from the reviewed merge commit.
|
||||
|
||||
The temporary service is a control-plane bootstrap, not a production release.
|
||||
It must retain:
|
||||
|
||||
```yaml
|
||||
listen_scope: localhost_only
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
This order avoids asking the Owner to approve a second base OID merely because
|
||||
the bridge implementation itself advanced `main`.
|
||||
|
||||
### Alternative platform-maintenance path
|
||||
|
||||
If policy prohibits a temporary localhost bootstrap service, use an existing
|
||||
Owner-approved platform-maintenance executor to create:
|
||||
|
||||
```yaml
|
||||
branch: patch/owner-authorization-writer-bridge-20260806
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
direct_main_push: false
|
||||
force_push: false
|
||||
tag_write: false
|
||||
baseline_write: false
|
||||
production_deploy_during_integration: false
|
||||
```
|
||||
|
||||
Merging that branch before V2R1C6 provisioning changes the authoritative base
|
||||
OID. In that alternative only, a new exact Owner base-OID amendment is required.
|
||||
The Agent must never substitute the newer `main` OID automatically.
|
||||
|
||||
## Files to install on the branch
|
||||
|
||||
Copy with the same relative paths:
|
||||
|
||||
```text
|
||||
active-runtime/app/authorized_task_writer_provisioning.py
|
||||
active-runtime/app/aios_authorized_task_writer_api.py
|
||||
docs/gitops/authorized-task-writer-provisioning.md
|
||||
docs/gitops/openapi-authorized-task-writer-fragment.yaml
|
||||
deploy/systemd/aios-gitops-model-api-authorized-writer-bridge.conf.example
|
||||
scripts/smoke_authorized_task_writer_provisioning.py
|
||||
tests/test_authorized_task_writer_provisioning.py
|
||||
```
|
||||
|
||||
Merge the OpenAPI fragment into:
|
||||
|
||||
```text
|
||||
docs/gitops/openapi-chatgpt-actions.yaml
|
||||
```
|
||||
|
||||
The final schema must expose:
|
||||
|
||||
```yaml
|
||||
operationId: provisionAuthorizedTaskWriter
|
||||
path: POST /api/task-writer/provision-authorized
|
||||
x-openai-isConsequential: true
|
||||
```
|
||||
|
||||
Do not deploy the fragment as a second standalone OpenAPI document.
|
||||
|
||||
## Required review of existing API integration
|
||||
|
||||
The adapter expects the existing API handler to provide:
|
||||
|
||||
```python
|
||||
handler.need(scope)
|
||||
handler.body()
|
||||
handler.sendj(value, status=200)
|
||||
api.H.do_POST
|
||||
api.main()
|
||||
```
|
||||
|
||||
Confirm these contracts against the branch before merge. If the live API has a
|
||||
different authentication object shape, make the smallest adapter-only change
|
||||
and rerun every test. The core provisioning policy must remain unchanged.
|
||||
|
||||
The API authorization registry must grant `writer:provision` only to the
|
||||
authenticated Owner role. Agents and ordinary writer tokens must not receive
|
||||
this scope.
|
||||
|
||||
## Privilege boundary
|
||||
|
||||
The API service must remain non-root.
|
||||
|
||||
Install a root-owned sudoers rule that allows the API service identity to run
|
||||
only these exact commands with `sudo -n --`:
|
||||
|
||||
```text
|
||||
/usr/local/libexec/aios-grant-signer-adapter
|
||||
/usr/local/libexec/aios-grant-validator
|
||||
/usr/local/libexec/aios-task-writer-provisioner
|
||||
```
|
||||
|
||||
Do not allow:
|
||||
|
||||
```text
|
||||
/bin/sh
|
||||
/bin/bash
|
||||
/usr/bin/env
|
||||
/usr/bin/git
|
||||
wildcard executable paths
|
||||
SETENV
|
||||
arbitrary environment preservation
|
||||
```
|
||||
|
||||
The governed tools themselves must validate their `--grant-dir` and
|
||||
`--result-json` paths. Their binaries must be root-owned, non-symlink, regular,
|
||||
executable and not group/world writable.
|
||||
|
||||
The signer must access its private signing material only through a runtime
|
||||
secret reference. No signing key or token may enter Git, API output, the
|
||||
provisioning receipt or systemd environment text.
|
||||
|
||||
## Runtime paths and permissions
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/authorized-writer-bridge
|
||||
/var/lib/aios-gitops/authorized-writer-bridge/receipts
|
||||
/var/lib/aios-gitops/authorized-writer-bridge/locks
|
||||
```
|
||||
|
||||
Required properties:
|
||||
|
||||
```yaml
|
||||
owner: API service identity
|
||||
mode: "0700"
|
||||
symlink: false
|
||||
persistent: true
|
||||
```
|
||||
|
||||
The directory contains redacted receipts only. Temporary signed grant
|
||||
directories are deleted after success or failure.
|
||||
|
||||
## Required staging environment
|
||||
|
||||
Configure the API service:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
AIOS_GITOPS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
AIOS_GITOPS_AUTHORIZED_WRITER_STATE_ROOT=/var/lib/aios-gitops/authorized-writer-bridge
|
||||
AIOS_GITOPS_GRANT_SIGNER_ADAPTER=/usr/local/libexec/aios-grant-signer-adapter
|
||||
AIOS_GITOPS_GRANT_VALIDATOR=/usr/local/libexec/aios-grant-validator
|
||||
AIOS_GITOPS_TASK_WRITER_PROVISIONER=/usr/local/libexec/aios-task-writer-provisioner
|
||||
AIOS_GITOPS_OWNER_PROVISION_SCOPE=writer:provision
|
||||
AIOS_GITOPS_GOVERNED_USE_SUDO=true
|
||||
AIOS_GITOPS_SUDO_PATH=/usr/bin/sudo
|
||||
```
|
||||
|
||||
Use `aios_authorized_task_writer_api.py` as the API entrypoint. It delegates
|
||||
all pre-existing routes and adds only the new endpoint.
|
||||
|
||||
## Pre-merge tests
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v tests/test_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py
|
||||
```
|
||||
|
||||
Required result:
|
||||
|
||||
```yaml
|
||||
unit_tests:
|
||||
total: 9
|
||||
failures: 0
|
||||
errors: 0
|
||||
smoke: PASS
|
||||
py_compile: PASS
|
||||
```
|
||||
|
||||
Also run all existing GitOps API and isolated writer tests affected by:
|
||||
|
||||
```text
|
||||
aios_chatgpt_actions_api
|
||||
aios_isolated_writer_api
|
||||
isolated_writer_authz
|
||||
isolated_writer_token_registry
|
||||
single_owner_governance
|
||||
OpenAPI schema validation
|
||||
```
|
||||
|
||||
Any regression blocks merge.
|
||||
|
||||
## Staging API acceptance
|
||||
|
||||
### Negative authentication
|
||||
|
||||
An Agent or non-Owner credential calling the endpoint must receive:
|
||||
|
||||
```yaml
|
||||
http_status: 403
|
||||
error: OWNER_AUTHORIZATION_REQUIRED
|
||||
```
|
||||
|
||||
### Negative scope tests
|
||||
|
||||
Requests enabling any of the following must be denied before signer execution:
|
||||
|
||||
```text
|
||||
production_change_authorized=true
|
||||
tag_change_authorized=true
|
||||
baseline_change_authorized=true
|
||||
main_write_authorized=true
|
||||
allowed_operations containing tag/main/baseline/admin operations
|
||||
missing required denied operation
|
||||
one_time_use=false
|
||||
expiry over 1800 seconds
|
||||
```
|
||||
|
||||
### Drift and conflict tests
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
wrong_base_oid:
|
||||
http_status: 409
|
||||
error: AUTHORIZED_BASE_OID_DRIFT
|
||||
|
||||
existing_unreceipted_target_branch:
|
||||
http_status: 409
|
||||
error: EXISTING_BRANCH_OID_CONFLICT
|
||||
```
|
||||
|
||||
### Governed tool failures
|
||||
|
||||
Independently inject and verify fail-closed behavior for:
|
||||
|
||||
```text
|
||||
signer unavailable
|
||||
signer signature absent
|
||||
validator signature_valid=false
|
||||
validator signer_trusted=false
|
||||
validator plan expired
|
||||
provisioner grant_consumed=false
|
||||
provisioner writer scope enables main/tag/baseline/force push
|
||||
any required negative policy test false
|
||||
```
|
||||
|
||||
None may create or modify a Git ref.
|
||||
|
||||
## Live V2R1C6 provisioning request
|
||||
|
||||
After protected merge and API deployment, authenticate as the Owner and submit
|
||||
exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING",
|
||||
"repository": "aios/aios",
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"target_branch": "agent/v2r1c6-mainline-reconciliation-20260805",
|
||||
"owner_authorization_reference": "chat-owner-authorization:v2r1c6:20260806",
|
||||
"one_time_use": true,
|
||||
"expires_in_seconds": 900,
|
||||
"production_change_authorized": false,
|
||||
"tag_change_authorized": false,
|
||||
"baseline_change_authorized": false,
|
||||
"main_write_authorized": false,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch"
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This request must be executed by the localhost-only bootstrap instance before
|
||||
the bridge code advances `main`. The endpoint must never infer “current main”.
|
||||
|
||||
If the Agent chooses the alternative merge-first path, it must stop after the
|
||||
merge and obtain an exact Owner amendment for the new merge OID. It must not
|
||||
silently replace the approved OID.
|
||||
|
||||
## Success response
|
||||
|
||||
Require:
|
||||
|
||||
```yaml
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
grant_consumed: true
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
branch_oid: <exact amended authorized base OID>
|
||||
base_oid: <same exact OID>
|
||||
main_unchanged: true
|
||||
|
||||
writer_scope:
|
||||
workspace_role: task-scoped-isolated-writer
|
||||
writes_enabled: true
|
||||
allowed_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
allowed_base_oid: <exact amended authorized base OID>
|
||||
allowed_routes:
|
||||
- POST /api/commit-file
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
All seven negative policy results must be true.
|
||||
|
||||
Submit the identical request a second time. It must return the same grant and
|
||||
branch attestation with:
|
||||
|
||||
```yaml
|
||||
idempotent: true
|
||||
```
|
||||
|
||||
It must not run the provisioner again.
|
||||
|
||||
## Production postchecks
|
||||
|
||||
After API deployment and writer provisioning, independently verify:
|
||||
|
||||
```yaml
|
||||
existing_routes_regression: PASS
|
||||
new_endpoint: PASS
|
||||
main_ref: expected reviewed merge commit
|
||||
V2R1C5_tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
V2R1C5_peeled_target: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
V2R1C5_baseline_unchanged: true
|
||||
new_release_tag_created: false
|
||||
production_baseline_changed: false
|
||||
```
|
||||
|
||||
The deployment of this control-plane bridge does not authorize a V2R1C6
|
||||
production release. It only enables scoped code authoring.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the new API route fails:
|
||||
|
||||
1. Stop accepting new provisioning calls.
|
||||
2. Restore the previous API entrypoint.
|
||||
3. Restart and verify all existing routes.
|
||||
4. Preserve receipts and logs.
|
||||
5. Revoke any task writer capability already minted.
|
||||
6. Do not delete a branch or rewrite a tag.
|
||||
7. Revert the protected-main bridge commit through an ordinary reviewed revert
|
||||
PR if source rollback is required.
|
||||
|
||||
## Required final Agent report
|
||||
|
||||
```yaml
|
||||
STATUS: COMPLETED | PARTIALLY_COMPLETED | BLOCKED | FAILED
|
||||
|
||||
INTEGRATION:
|
||||
branch:
|
||||
commit:
|
||||
protected_pr:
|
||||
merge_commit:
|
||||
files_changed:
|
||||
|
||||
TESTS:
|
||||
unit:
|
||||
smoke:
|
||||
py_compile:
|
||||
regression:
|
||||
fault_injection:
|
||||
|
||||
DEPLOYMENT:
|
||||
service:
|
||||
entrypoint:
|
||||
runtime_status:
|
||||
endpoint_status:
|
||||
rollback_verified:
|
||||
|
||||
PROVISIONING:
|
||||
owner_authorization_reference:
|
||||
approval_plan_id:
|
||||
grant_id:
|
||||
grant_digest_sha256:
|
||||
signing_key_identity:
|
||||
branch:
|
||||
branch_oid:
|
||||
workspace_path:
|
||||
grant_consumed:
|
||||
idempotent_replay:
|
||||
|
||||
CONSISTENCY:
|
||||
main_oid:
|
||||
V2R1C5_tag_oid:
|
||||
V2R1C5_peeled_target_oid:
|
||||
baseline_version:
|
||||
baseline_changed:
|
||||
tags_changed:
|
||||
|
||||
SECRETS:
|
||||
runtime_secret_reference_used:
|
||||
plaintext_secret_logged:
|
||||
plaintext_secret_committed:
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
@@ -1,401 +0,0 @@
|
||||
# Agent Deployment Requirements: Owner Authorization Broker V2
|
||||
|
||||
## Objective
|
||||
|
||||
Deploy the audited Owner Authorization Broker that closes the bootstrap gap
|
||||
without issuing a new general-purpose Owner credential and without modifying
|
||||
`/data/aios-gitops/runtime/secrets/gitops.tokens`.
|
||||
|
||||
The broker reuses one existing, non-wildcard Owner credential only to
|
||||
authenticate the Owner identity through one pre-existing exact scope. It then
|
||||
records an immutable task authorization event and directly invokes the
|
||||
existing signed grant -> validator -> one-shot provisioner chain.
|
||||
|
||||
Production deployment of V2R1C6 is **not** authorized by this task.
|
||||
|
||||
## Fixed bindings
|
||||
|
||||
```yaml
|
||||
task_id: V2R1C6_TASK_SCOPED_WRITER_PROVISIONING
|
||||
repository: aios/aios
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
The existing V2R1C5 objects are frozen:
|
||||
|
||||
```yaml
|
||||
tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
peeled_target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
baseline_version: V2R1C5
|
||||
baseline_status: PRODUCTION_DEPLOYED
|
||||
```
|
||||
|
||||
## 1. Package verification
|
||||
|
||||
Verify `MANIFEST.sha256` before installation. Reject any mismatch.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v \
|
||||
tests/test_authorized_task_writer_provisioning.py \
|
||||
tests/test_owner_authorization_broker.py
|
||||
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_owner_authorization_broker.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py \
|
||||
active-runtime/app/owner_authorization_broker.py \
|
||||
active-runtime/app/aios_owner_authorization_broker_api.py
|
||||
```
|
||||
|
||||
All checks must pass.
|
||||
|
||||
## 2. Existing Owner credential selection
|
||||
|
||||
Read only the non-secret registry metadata. Select one existing credential
|
||||
that satisfies all of the following:
|
||||
|
||||
```yaml
|
||||
role: owner
|
||||
wildcard_scope: false
|
||||
account: exact known Owner identity
|
||||
contains_exact_intake_scope: true
|
||||
```
|
||||
|
||||
Use one existing exact scope such as `commit:create` or `file:write`. Prefer
|
||||
the narrowest scope already assigned to the intended Owner.
|
||||
|
||||
Do not:
|
||||
|
||||
- use the historical wildcard Owner credential;
|
||||
- add `writer:provision` to any bearer token;
|
||||
- create or modify a bearer credential;
|
||||
- print, copy or commit bearer-token material.
|
||||
|
||||
Record only:
|
||||
|
||||
```yaml
|
||||
owner_account: <non-secret account>
|
||||
credential_id: <non-secret ID, when available>
|
||||
intake_scope: <one exact existing scope>
|
||||
scope_readback_available: true|false
|
||||
```
|
||||
|
||||
If `handler.need()` returns the exact scope set, configure the Owner account
|
||||
allowlist and leave the credential ID allowlist empty.
|
||||
|
||||
If scope metadata is not returned, configure the exact non-secret credential
|
||||
ID allowlist. If neither exact scope metadata nor a stable non-secret
|
||||
credential ID can be proven, stop:
|
||||
|
||||
```yaml
|
||||
STATUS: BLOCKED
|
||||
REASON: OWNER_CREDENTIAL_SCOPE_NOT_PROVABLE
|
||||
```
|
||||
|
||||
## 3. Staging installation
|
||||
|
||||
Install the four application modules in a root-owned staging directory:
|
||||
|
||||
```text
|
||||
authorized_task_writer_provisioning.py
|
||||
aios_authorized_task_writer_api.py
|
||||
owner_authorization_broker.py
|
||||
aios_owner_authorization_broker_api.py
|
||||
```
|
||||
|
||||
The directory and files must not be group/world writable.
|
||||
|
||||
Prepare environment values:
|
||||
|
||||
```yaml
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_BROKER_ENABLED: "true"
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES: <one exact existing scope>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_IDENTITIES: <exact owner account>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_CREDENTIAL_IDS: <exact ID or empty>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_MAX_EXPIRY_SECONDS: "900"
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT:
|
||||
/var/lib/aios-gitops/owner-authorization-broker
|
||||
```
|
||||
|
||||
Use the existing signer, validator and provisioner paths. Do not change their
|
||||
root governance configuration or disable signature checks.
|
||||
|
||||
Start a localhost-only staging instance on a temporary port. Do not replace
|
||||
the production service yet.
|
||||
|
||||
## 4. Authentication fail-closed tests
|
||||
|
||||
The staging endpoint is:
|
||||
|
||||
```text
|
||||
POST /api/governance/owner-authorizations
|
||||
```
|
||||
|
||||
Required tests:
|
||||
|
||||
```yaml
|
||||
unauthenticated:
|
||||
expected: 401
|
||||
|
||||
wrong_role:
|
||||
expected: 403
|
||||
|
||||
wrong_scope:
|
||||
expected: 403
|
||||
|
||||
wildcard_owner:
|
||||
expected: 403
|
||||
error: WILDCARD_OWNER_CREDENTIAL_REJECTED
|
||||
|
||||
unallowlisted_owner:
|
||||
expected: 403
|
||||
error: OWNER_IDENTITY_NOT_ALLOWED
|
||||
|
||||
production_capability_true:
|
||||
expected: 403
|
||||
error: PRODUCTION_CAPABILITY_REJECTED
|
||||
```
|
||||
|
||||
A connection failure, 404 or 500 is not an authorization-policy pass.
|
||||
|
||||
## 5. Formal provisioning request
|
||||
|
||||
Use the selected existing non-wildcard Owner credential through its runtime
|
||||
secret reference. Never place the bearer token in the request body, command
|
||||
line, shell history, output report or Git.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "AUTHORIZE_TASK_WRITER_PROVISIONING",
|
||||
"task_id": "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING",
|
||||
"repository": "aios/aios",
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"target_branch": "agent/v2r1c6-mainline-reconciliation-20260805",
|
||||
"one_time_use": true,
|
||||
"expires_in_seconds": 900,
|
||||
"production_change_authorized": false,
|
||||
"tag_change_authorized": false,
|
||||
"baseline_change_authorized": false,
|
||||
"main_write_authorized": false,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch"
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The Owner authorization reference is generated by the broker. The caller must
|
||||
not submit or invent one.
|
||||
|
||||
Expected result:
|
||||
|
||||
```yaml
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
owner_authorization_event_id: generated
|
||||
owner_authorization_reference: generated
|
||||
owner_authorization_digest_sha256: generated
|
||||
owner_authorization_consumed: true
|
||||
approval_plan_id: generated
|
||||
grant_digest_sha256: generated
|
||||
grant_consumed: true
|
||||
|
||||
branch_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
|
||||
writer_scope:
|
||||
workspace_role: task-scoped-isolated-writer
|
||||
writes_enabled: true
|
||||
allowed_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
allowed_base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
## 6. Independent post-provisioning verification
|
||||
|
||||
Verify the Authority ref:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse \
|
||||
refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
It must equal:
|
||||
|
||||
```text
|
||||
875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
Verify `main` remains unchanged, V2R1C5 tag object and peeled target remain
|
||||
unchanged, and the Production Baseline remains V2R1C5.
|
||||
|
||||
Verify the broker audit event under:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/owner-authorization-broker/events/
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
```yaml
|
||||
status: CONSUMED
|
||||
wildcard_scope_used: false
|
||||
grant_consumed: true
|
||||
permissions:
|
||||
directory: "0700"
|
||||
json_files: "0600"
|
||||
```
|
||||
|
||||
Do not return bearer-token material in the report.
|
||||
|
||||
## 7. Commit the broker through the new writer
|
||||
|
||||
After `READY_FOR_CODE_AUTHORING`, use only the writer's
|
||||
`POST /api/commit-file` route to put this code bundle on:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
Commit the bridge and broker as separate intentional commits when practical:
|
||||
|
||||
1. Owner-authorized task-writer bridge.
|
||||
2. Audited Owner authorization broker and deployment docs.
|
||||
|
||||
Do not merge to `main` directly. Create a protected PR after branch commits
|
||||
and tests are complete.
|
||||
|
||||
## 8. Permanent service deployment
|
||||
|
||||
Only after protected review and merge:
|
||||
|
||||
1. Install the reviewed files from the merge commit.
|
||||
2. Apply a reviewed systemd drop-in based on the provided example.
|
||||
3. Start the wrapper entrypoint:
|
||||
`aios_owner_authorization_broker_api.py`.
|
||||
4. Verify all existing API routes still work.
|
||||
5. Verify the broker remains localhost/reverse-proxy constrained as intended.
|
||||
6. Re-run authentication and policy tests.
|
||||
7. Remove the temporary staging instance.
|
||||
|
||||
This permanent bridge deployment does not authorize a V2R1C6 production tag,
|
||||
Baseline update or application deployment.
|
||||
|
||||
## 9. Fail-closed conditions
|
||||
|
||||
Stop with `BLOCKED` for any of the following:
|
||||
|
||||
- Authority `main` no longer equals the fixed base OID.
|
||||
- Target branch exists at a different OID.
|
||||
- Only a wildcard Owner credential can authenticate.
|
||||
- Exact credential scope cannot be proven.
|
||||
- Owner identity is not explicitly allow-listed.
|
||||
- Signer, validator or provisioner self-test fails.
|
||||
- Grant is not marked one-time and consumed.
|
||||
- Writer allows main, tag, Baseline, force-push or branch-delete operations.
|
||||
- Audit event contains sensitive material.
|
||||
- V2R1C5 tag or Baseline changes.
|
||||
|
||||
## 10. Required Agent result
|
||||
|
||||
```yaml
|
||||
STATUS: READY_FOR_CODE_AUTHORING | BLOCKED | FAILED
|
||||
|
||||
PACKAGE:
|
||||
manifest_verified:
|
||||
unit_tests:
|
||||
smoke_tests:
|
||||
py_compile:
|
||||
|
||||
OWNER_CREDENTIAL:
|
||||
account:
|
||||
credential_id:
|
||||
intake_scope:
|
||||
exact_scope_proven:
|
||||
wildcard_used: false
|
||||
token_logged: false
|
||||
|
||||
BROKER:
|
||||
staging_listener:
|
||||
endpoint_status:
|
||||
event_id:
|
||||
authorization_reference:
|
||||
authorization_digest_sha256:
|
||||
event_consumed:
|
||||
audit_permissions_valid:
|
||||
|
||||
GRANT:
|
||||
approval_plan_id:
|
||||
grant_digest_sha256:
|
||||
signer_identity:
|
||||
signature_valid:
|
||||
one_time_use:
|
||||
consumed:
|
||||
|
||||
BRANCH:
|
||||
name:
|
||||
oid:
|
||||
base_oid:
|
||||
ref_exists:
|
||||
worktree_path:
|
||||
worktree_clean:
|
||||
|
||||
WRITER:
|
||||
status:
|
||||
writes_enabled:
|
||||
allowed_branch:
|
||||
allowed_base_oid:
|
||||
allowed_routes:
|
||||
main_write_denied:
|
||||
tag_write_denied:
|
||||
baseline_write_denied:
|
||||
force_push_denied:
|
||||
|
||||
POSTCHECK:
|
||||
main_unchanged:
|
||||
V2R1C5_tag_unchanged:
|
||||
baseline_unchanged:
|
||||
|
||||
REPOSITORY_INTEGRATION:
|
||||
commit_oids:
|
||||
protected_pr:
|
||||
merged:
|
||||
permanent_bridge_deployed:
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
@@ -1,562 +0,0 @@
|
||||
# Agent execution requirements: V2R1C6 task identity overlay
|
||||
|
||||
## Required result
|
||||
|
||||
The Agent must return one of:
|
||||
|
||||
- `READY_FOR_CODE_AUTHORING`
|
||||
- `BLOCKED`
|
||||
- `FAILED`
|
||||
|
||||
The Agent may not report success until the task branch, isolated worktree and
|
||||
task writer are independently verified.
|
||||
|
||||
This stage is **not** a production deployment. It must not modify `main`,
|
||||
V2R1C5, tags, Registry, Production Baseline or production services.
|
||||
|
||||
## Fixed authority
|
||||
|
||||
```yaml
|
||||
task_id: V2R1C6_TASK_SCOPED_WRITER_PROVISIONING
|
||||
repository: aios/aios
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
owner_identity: aiman
|
||||
one_time_use: true
|
||||
maximum_ttl_seconds: 900
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
## 1. Verify package
|
||||
|
||||
Verify the externally supplied package against the published SHA-256 values and
|
||||
then verify every entry in `MANIFEST.sha256`.
|
||||
|
||||
Do not synthesize a missing archive. Use either the verified ZIP or TAR.GZ.
|
||||
|
||||
Required checks:
|
||||
|
||||
```yaml
|
||||
package_hash_match: true
|
||||
manifest_entries_match: true
|
||||
unit_tests_pass: true
|
||||
smoke_tests_pass: true
|
||||
py_compile_pass: true
|
||||
```
|
||||
|
||||
## 2. Create root-owned staging
|
||||
|
||||
Extract to a fresh directory such as:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/staging/v2r1c6-task-identity-overlay-<nonce>
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
```yaml
|
||||
owner: root
|
||||
group: root
|
||||
directory_mode: "0700"
|
||||
files_not_world_writable: true
|
||||
symlinks_rejected: true
|
||||
```
|
||||
|
||||
Do not overwrite the active runtime tree.
|
||||
|
||||
## 3. Re-run target-host tests
|
||||
|
||||
From the staging root:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_owner_authorization_broker.py
|
||||
python3 scripts/smoke_task_identity_overlay.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/owner_authorization_broker.py \
|
||||
active-runtime/app/task_identity_overlay.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py \
|
||||
active-runtime/app/aios_owner_authorization_broker_api.py \
|
||||
active-runtime/app/aios_task_identity_overlay_api.py \
|
||||
active-runtime/app/aios_task_identity_overlay_cli.py \
|
||||
scripts/call_task_identity_overlay.py
|
||||
```
|
||||
|
||||
Any failure is `BLOCKED`; do not proceed.
|
||||
|
||||
## 4. Prestart Git and production freeze checks
|
||||
|
||||
Read from the authority repository:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse refs/heads/main
|
||||
git --git-dir=/data/aios-gitops/repo.git \
|
||||
show-ref --verify --quiet \
|
||||
refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse refs/tags/V2R1C5
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse 'refs/tags/V2R1C5^{}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```yaml
|
||||
main_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch_exists: false
|
||||
V2R1C5_tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
V2R1C5_peeled_target: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
```
|
||||
|
||||
Read Baseline and require:
|
||||
|
||||
```yaml
|
||||
version: V2R1C5
|
||||
status: PRODUCTION_DEPLOYED
|
||||
target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
evidence_closure_oid: 5e2cf8276f5093f3352b9a3252c701a44c5ab807
|
||||
```
|
||||
|
||||
If `main` has drifted, return:
|
||||
|
||||
```yaml
|
||||
STATUS: MANUAL_OWNER_REVIEW_REQUIRED
|
||||
REASON: AUTHORIZED_BASE_OID_DRIFT
|
||||
```
|
||||
|
||||
If the target branch already exists at another OID, return:
|
||||
|
||||
```yaml
|
||||
STATUS: MANUAL_OWNER_REVIEW_REQUIRED
|
||||
REASON: EXISTING_BRANCH_OID_CONFLICT
|
||||
```
|
||||
|
||||
Do not delete, reset or force-push the branch.
|
||||
|
||||
## 5. Discover active secret references
|
||||
|
||||
The overlay does not read or modify `gitops.tokens`, but its issuer must compare
|
||||
the new random secret against all active bearer secret references.
|
||||
|
||||
Discover references without printing secret contents. At minimum include the
|
||||
currently active ChatGPT/GitOps bearer secret reference and any other bearer
|
||||
files consumed by the active API.
|
||||
|
||||
Pass them as a comma-separated list in:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS
|
||||
```
|
||||
|
||||
Evidence must contain only paths and SHA-256 match counts, not token values.
|
||||
|
||||
If active secret references cannot be enumerated, return:
|
||||
|
||||
```yaml
|
||||
STATUS: BLOCKED
|
||||
REASON: ACTIVE_SECRET_REFERENCE_SET_UNRESOLVED
|
||||
```
|
||||
|
||||
## 6. Install the transient systemd service
|
||||
|
||||
Use the provided template:
|
||||
|
||||
```text
|
||||
deploy/systemd/aios-v2r1c6-task-identity-overlay.service.example
|
||||
```
|
||||
|
||||
Substitute only:
|
||||
|
||||
- verified root-owned staging directory;
|
||||
- verified active secret-ref path list.
|
||||
|
||||
Do not change task ID, repository, base OID, target branch or denied
|
||||
capabilities.
|
||||
|
||||
The service must listen only on:
|
||||
|
||||
```text
|
||||
127.0.0.1:19864
|
||||
```
|
||||
|
||||
Before start, run:
|
||||
|
||||
```bash
|
||||
systemd-analyze verify \
|
||||
/etc/systemd/system/aios-v2r1c6-task-identity-overlay.service
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start aios-v2r1c6-task-identity-overlay.service
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
```bash
|
||||
curl --fail --silent http://127.0.0.1:19864/healthz
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```yaml
|
||||
service: aios-task-identity-overlay
|
||||
status: active
|
||||
global_token_registry_used: false
|
||||
loopback_only: true
|
||||
```
|
||||
|
||||
Do not expose the port externally.
|
||||
|
||||
## 7. Issue the one-time overlay
|
||||
|
||||
Use the root-only CLI. The authorization assertion is an audit label for this
|
||||
already approved Owner task; it must not be described as an external signed
|
||||
event.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
sudo env \
|
||||
PYTHONPATH=<staging>/active-runtime/app \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_STATE_ROOT=/var/lib/aios-gitops/task-identity-overlays \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_SECRET_ROOT=/run/aios-gitops/task-identity-overlays \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_TASK_ID=V2R1C6_TASK_SCOPED_WRITER_PROVISIONING \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_OWNER_IDENTITY=aiman \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_BASE_REF=refs/heads/main \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_BASE_OID=875d41c3101093149414292067fd700dc214d4d3 \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_TARGET_BRANCH=agent/v2r1c6-mainline-reconciliation-20260805 \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS=<verified-secret-ref-list> \
|
||||
python3 <staging>/active-runtime/app/aios_task_identity_overlay_cli.py \
|
||||
issue \
|
||||
--ttl-seconds 900 \
|
||||
--authorization-assertion-ref owner-session:V2R1C6:approved-20260806 \
|
||||
--operator-identity <audited-agent-identity> \
|
||||
--result-json /run/aios-gitops/v2r1c6-overlay-issued.json
|
||||
```
|
||||
|
||||
The result must contain:
|
||||
|
||||
```yaml
|
||||
status: OVERLAY_ACTIVE
|
||||
idempotent: false
|
||||
overlay_id: present
|
||||
secret_ref: under /run/aios-gitops/task-identity-overlays
|
||||
expires_at: within 900 seconds
|
||||
```
|
||||
|
||||
It must not contain a token.
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
secret_file_mode: "0600"
|
||||
metadata_contains_raw_token: false
|
||||
token_hash_unique_across_configured_secret_refs: true
|
||||
```
|
||||
|
||||
## 8. Negative tests
|
||||
|
||||
Before the valid request:
|
||||
|
||||
1. No Authorization header must return HTTP 401.
|
||||
2. A random wrong bearer must return HTTP 401.
|
||||
3. A request with `production_change_authorized=true` must be rejected.
|
||||
4. A request with another base OID or branch must be rejected.
|
||||
5. The valid overlay must remain `ACTIVE` after pre-auth request-validation
|
||||
failures.
|
||||
|
||||
Do not use the global Owner or ChatGPT bearer credential for this endpoint.
|
||||
|
||||
## 9. Send the valid request
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
deploy/v2r1c6-task-writer-authorization-request.json
|
||||
```
|
||||
|
||||
Use the provided client so the bearer is not placed in argv, shell history or
|
||||
logs:
|
||||
|
||||
```bash
|
||||
sudo python3 <staging>/scripts/call_task_identity_overlay.py \
|
||||
--secret-ref <secret_ref_from_issue_result> \
|
||||
--request-json <staging>/deploy/v2r1c6-task-writer-authorization-request.json \
|
||||
--url http://127.0.0.1:19864/api/governance/owner-authorizations \
|
||||
--result-json /var/lib/aios-gitops/v2r1c6-overlay-provision-result.json
|
||||
```
|
||||
|
||||
Required result:
|
||||
|
||||
```yaml
|
||||
http_status: 200
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
owner_authorization_consumed: true
|
||||
grant_consumed: true
|
||||
identity_overlay_consumed: true
|
||||
identity_overlay_secret_removed: true
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
branch_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
The result must not include:
|
||||
|
||||
- bearer token;
|
||||
- private key;
|
||||
- signer secret;
|
||||
- raw signature;
|
||||
- credential value.
|
||||
|
||||
## 10. Independent acceptance
|
||||
|
||||
Verify the branch:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git \
|
||||
rev-parse refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
It must equal:
|
||||
|
||||
```text
|
||||
875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
Verify worktree evidence returned by the provisioner:
|
||||
|
||||
```yaml
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
head_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
detached_head: false
|
||||
worktree_clean: true
|
||||
writer_status: active
|
||||
writes_enabled: true
|
||||
allowed_routes:
|
||||
- POST /api/commit-file
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
Run policy negative tests against the task writer and require explicit policy
|
||||
denials for:
|
||||
|
||||
- main write;
|
||||
- another branch;
|
||||
- tag create/update;
|
||||
- Baseline write;
|
||||
- force push;
|
||||
- branch delete.
|
||||
|
||||
Network errors, 404, 500 and stopped-service failures do not count as policy
|
||||
denials.
|
||||
|
||||
## 11. Stop and clean up the overlay
|
||||
|
||||
After `READY_FOR_CODE_AUTHORING`:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop aios-v2r1c6-task-identity-overlay.service
|
||||
sudo systemctl disable aios-v2r1c6-task-identity-overlay.service || true
|
||||
sudo rm -f /etc/systemd/system/aios-v2r1c6-task-identity-overlay.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
overlay_listener_stopped: true
|
||||
overlay_secret_removed: true
|
||||
global_token_registry_unchanged: true
|
||||
disabled_owner_record_unchanged: true
|
||||
active_chatgpt_bot_record_unchanged: true
|
||||
audit_metadata_retained: true
|
||||
```
|
||||
|
||||
The root-owned staging directory may be retained until the code is committed,
|
||||
then removed after checksum-confirmed repository integration.
|
||||
|
||||
## 12. Commit the developed code through the task writer
|
||||
|
||||
Only after writer acceptance, use `POST /api/commit-file` to commit the
|
||||
verified package contents to:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
Commit in reviewable groups:
|
||||
|
||||
1. authorized task-writer bridge;
|
||||
2. Owner authorization broker;
|
||||
3. task identity overlay;
|
||||
4. tests, docs, OpenAPI and deployment templates.
|
||||
|
||||
Do not include:
|
||||
|
||||
- package archives;
|
||||
- runtime tokens;
|
||||
- `/run` or `/var/lib` state;
|
||||
- test result files containing host paths;
|
||||
- secret references containing values;
|
||||
- generated credentials.
|
||||
|
||||
Run the complete 40-test suite from the branch after commits.
|
||||
|
||||
## 13. Protected PR
|
||||
|
||||
Create a protected PR from:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
main
|
||||
```
|
||||
|
||||
The PR must state:
|
||||
|
||||
- this change closes Owner authorization bootstrap gaps;
|
||||
- the overlay is localhost-only and one-time;
|
||||
- the global token registry is not modified;
|
||||
- no production release is authorized;
|
||||
- V2R1C5 tag and Baseline remain unchanged.
|
||||
|
||||
The Agent must not merge without the repository's normal protected review.
|
||||
|
||||
## 14. Required Agent report
|
||||
|
||||
```yaml
|
||||
STATUS:
|
||||
READY_FOR_CODE_AUTHORING | BLOCKED | FAILED
|
||||
|
||||
PACKAGE:
|
||||
zip_sha256:
|
||||
tar_gz_sha256:
|
||||
manifest_entries:
|
||||
manifest_matched:
|
||||
|
||||
TESTS:
|
||||
unit_total:
|
||||
unit_passed:
|
||||
smoke_bridge:
|
||||
smoke_broker:
|
||||
smoke_overlay:
|
||||
py_compile:
|
||||
|
||||
PRESTART:
|
||||
main_oid:
|
||||
target_branch_previously_absent:
|
||||
V2R1C5_tag_oid:
|
||||
V2R1C5_peeled_target:
|
||||
baseline_unchanged:
|
||||
|
||||
OVERLAY:
|
||||
overlay_id:
|
||||
status:
|
||||
secret_ref:
|
||||
secret_file_mode:
|
||||
token_hash_unique:
|
||||
global_registry_used: false
|
||||
listener:
|
||||
expires_at:
|
||||
consumed:
|
||||
secret_removed:
|
||||
|
||||
NEGATIVE_TESTS:
|
||||
unauthenticated_401:
|
||||
wrong_token_401:
|
||||
production_capability_denied:
|
||||
wrong_base_denied:
|
||||
wrong_branch_denied:
|
||||
|
||||
OWNER_AUTHORIZATION:
|
||||
event_id:
|
||||
reference:
|
||||
consumed:
|
||||
|
||||
GRANT:
|
||||
approval_plan_id:
|
||||
grant_id:
|
||||
grant_digest_sha256:
|
||||
signature_valid:
|
||||
consumed:
|
||||
|
||||
BRANCH:
|
||||
name:
|
||||
oid:
|
||||
base_oid:
|
||||
ref_exists:
|
||||
worktree_path:
|
||||
worktree_clean:
|
||||
|
||||
WRITER:
|
||||
status:
|
||||
writes_enabled:
|
||||
allowed_branch:
|
||||
allowed_base_oid:
|
||||
allowed_routes:
|
||||
main_write_denied:
|
||||
tag_write_denied:
|
||||
baseline_write_denied:
|
||||
force_push_denied:
|
||||
|
||||
REPOSITORY_INTEGRATION:
|
||||
commits:
|
||||
tests_after_commit:
|
||||
protected_pr:
|
||||
merged: false
|
||||
|
||||
POSTCHECK:
|
||||
main_unchanged:
|
||||
V2R1C5_tag_unchanged:
|
||||
baseline_unchanged:
|
||||
global_token_registry_unchanged:
|
||||
overlay_listener_stopped:
|
||||
overlay_secret_removed:
|
||||
|
||||
SECRETS:
|
||||
plaintext_logged: false
|
||||
plaintext_committed: false
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
|
||||
## Fail-closed conditions
|
||||
|
||||
Return `BLOCKED` and stop on any of:
|
||||
|
||||
- authoritative main OID drift;
|
||||
- target branch conflict;
|
||||
- inability to enumerate active secret references;
|
||||
- secret hash collision;
|
||||
- overlay not bound to the exact task/base/branch;
|
||||
- non-loopback listener;
|
||||
- raw secret in result or logs;
|
||||
- Owner Authorization Broker event not created;
|
||||
- signer/validator/provisioner failure;
|
||||
- grant not consumed;
|
||||
- branch OID mismatch;
|
||||
- writer exposes main/tag/Baseline/force-push permissions;
|
||||
- V2R1C5 tag or Baseline changes.
|
||||
|
||||
Never fall back to the shared `gitops-write-bot.token`, a wildcard credential,
|
||||
manual `gitops.tokens` edits or a direct Git ref write.
|
||||
@@ -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,9 +0,0 @@
|
||||
{
|
||||
"schema": "aios.v2r1c7.engineering-build-receipt.v1",
|
||||
"version": "V2R1C7",
|
||||
"base_oid": "b356dec2ac5bd2a5f721aa73f8fd1902f9c65632",
|
||||
"production_deployed": false,
|
||||
"tag_created": false,
|
||||
"baseline_changed": false,
|
||||
"deployable": true
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
bec7da1adb75bf5feaa63c60ec6d03adf15d7508742a486207033d000f72d4ff AGENT_DEPLOYMENT_REQUIREMENTS.md
|
||||
251b46e51ce09aa2fd843bee48ef24a5608f31d24c599841f086ceb25c1a1715 README.md
|
||||
783f68ec4f00a11d43ceed536196bfedccaf6614f6db9805f7b36d24e5fa7bc2 TEST_RESULTS.json
|
||||
9208325e5b2e6f5ba6b64e000bc21d14d9f2f214e3ad33dd79857e85218fd6c2 VERSION
|
||||
c4b24b65195077716983acdc8611f705324413a74453858994ba51541957afd4 active-runtime/app/aios_authorized_task_writer_api.py
|
||||
8a1935fa098d47ac02ce51bbd9fd5fad7cf9ab9374ea3de5f630679b96681d84 active-runtime/app/authorized_task_writer_provisioning.py
|
||||
0997ab8332985c7124f334c4e49735d8833326578acc61a61a389bf792424de3 active-runtime/app/deployment_state.py
|
||||
dd3f8b1cab88e005a291f077807873c381e8ca92fe863dc05817925f0dff9613 active-runtime/app/v2r1c6_privacy_tools.py
|
||||
9e45025c41068cafdea2f360edfc4146a5459b0a6bcfbf1403206fc0161e7bac active-runtime/app/v2r1c7_health_service.py
|
||||
c96ba560d69f6c2eb700b9f2b42a32e394dadd00dffb23bbefdb9fb3e1571c0d deploy/aios-v2/systemd/aios-v2r1c7-production.service
|
||||
a05d4e8038fdb866ce17d4fb8a54798a268a1e8d44ff2cbc559069fa6ab2a667 deploy/systemd/aios-gitops-model-api-authorized-writer-bridge.conf.example
|
||||
a60dac51802bff67c2cfff61acdeb0a3a05d74a4f6cb47e3f78295ceb5c6acd9 docs/gitops/authorized-task-writer-provisioning.md
|
||||
2690f76452bceddda90c728278c600bcebd533db24776e2966423c5a8655e4b5 docs/gitops/openapi-authorized-task-writer-fragment.yaml
|
||||
560e5d9af8a8e2ec60ec31cf8659a7982b93ea09b7adb486b738ea2fba460d70 release-manifests/V2R1C7.json
|
||||
81a423c7814f81cb888b60a538c153e549b2be9099c881c968d55ae84efddc57 scripts/build_v2r1c7_release_artifact.py
|
||||
64c78dabffb05c8851f87b2a27c43bd1f21728dca586397d03a6bc22906a906c scripts/probe_v2r1c7_health.py
|
||||
5419dc7de0f50908ad0ef1dac74172fce811bb7b45f314a6e1c1bb9ce618324d scripts/rollback_v2r1c7_release.sh
|
||||
54e2845d2c3c026323ad45515e082d661e3b461feb3bfefedf5adc52db2d7b78 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
32a860e28d5afc105f413d878a13fb8600fa74874dbd3bc4c031ef3efa17896a scripts/verify_v2r1c7_release.py
|
||||
3cce2b744326fe12b89bcad3cd352c77738950964bf8519e72cb5e9c7a5dfbfc tests/test_authorized_task_writer_provisioning.py
|
||||
8f3153c625241f60b65109eeac045e8aed7661d95f3a10f0b32ba3e0e9ac0a85 versions/V2R1C7.json
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"git_branch_created": false,
|
||||
"git_commit_created": false,
|
||||
"production_mutation": false,
|
||||
"py_compile": {
|
||||
"files": [
|
||||
"active-runtime/app/authorized_task_writer_provisioning.py",
|
||||
"active-runtime/app/aios_authorized_task_writer_api.py"
|
||||
],
|
||||
"status": "PASS"
|
||||
},
|
||||
"schema": "aios.authorized-task-writer-bridge-test-report.v1",
|
||||
"secrets_included": false,
|
||||
"smoke": {
|
||||
"command": "python3 scripts/smoke_authorized_task_writer_provisioning.py",
|
||||
"status": "PASS"
|
||||
},
|
||||
"status": "PASS",
|
||||
"unit_tests": {
|
||||
"command": "python3 -m unittest -v tests/test_authorized_task_writer_provisioning.py",
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"total": 9
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Any
|
||||
from message_handoff import stable_event_id
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -56,19 +55,13 @@ class AgentEventBus:
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event_id = stable_event_id(
|
||||
source=source, target=target, message_type=kind, payload=payload,
|
||||
tenant_id=(payload.get('tenant_id') if isinstance(payload, dict) else None),
|
||||
user_id=(payload.get('user_id') if isinstance(payload, dict) else None),
|
||||
project_id=(payload.get('project_id') if isinstance(payload, dict) else None),
|
||||
conversation_id=(payload.get('conversation_id') if isinstance(payload, dict) else None),
|
||||
task_id=(payload.get('task_id') if isinstance(payload, dict) else None),
|
||||
run_id=(payload.get('run_id') if isinstance(payload, dict) else None),
|
||||
correlation_id=(payload.get('correlation_id') if isinstance(payload, dict) else None),
|
||||
causation_id=(payload.get('causation_id') if isinstance(payload, dict) else None),
|
||||
sequence=(payload.get('sequence') if isinstance(payload, dict) else None),
|
||||
idempotency_key=(payload.get('idempotency_key') if isinstance(payload, dict) else None),
|
||||
)
|
||||
event_id = "evt_" + sha256(
|
||||
json.dumps(
|
||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||
self._write("pending", event.to_dict())
|
||||
self._audit("created", event.to_dict())
|
||||
|
||||
@@ -7,7 +7,7 @@ class AgentEventObservabilityBridge:
|
||||
def __init__(self, hooks: AgentObservabilityHooks | None = None):
|
||||
self.hooks = hooks or AgentObservabilityHooks()
|
||||
|
||||
def consume(self, event: dict, *, actor=None, trace=None):
|
||||
def consume(self, event: dict):
|
||||
kind = event.get("kind")
|
||||
payload = event.get("payload", {})
|
||||
if kind == "agent_started":
|
||||
@@ -16,8 +16,6 @@ class AgentEventObservabilityBridge:
|
||||
visible_scope=payload.get("scope", "self"),
|
||||
entry_agent=payload.get("agent", "unknown"),
|
||||
execution_id=event.get("id"),
|
||||
actor=actor,
|
||||
trace=trace,
|
||||
)
|
||||
if kind == "agent_delegated":
|
||||
return self.hooks.delegate(event["execution_id"], payload.get("agent", "unknown"))
|
||||
|
||||
@@ -31,18 +31,6 @@ class AgentExecutionRecord:
|
||||
status: str = "completed"
|
||||
started_at: str = field(default_factory=utc_now)
|
||||
ended_at: str = field(default_factory=utc_now)
|
||||
trace_id: str | None = None
|
||||
span_id: str | None = None
|
||||
parent_span_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
project_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
task_id: str | None = None
|
||||
claim_id: str | None = None
|
||||
run_id: str | None = None
|
||||
message_id: str | None = None
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
@@ -9,49 +9,25 @@ try:
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
from .trace_tool_credential_governance import GovernanceActor, TraceContext, authorize_scope
|
||||
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
|
||||
def record_agent_execution(payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None):
|
||||
scoped = any(payload.get(name) is not None for name in (
|
||||
"trace_id", "span_id", "tenant_id", "project_id", "conversation_id", "task_id", "run_id"
|
||||
))
|
||||
values = dict(payload)
|
||||
if scoped:
|
||||
if actor is None or trace is None:
|
||||
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
||||
trusted = GovernanceActor.from_value(actor)
|
||||
authorize_scope(trusted, trace.scope)
|
||||
bound = {
|
||||
"trace_id": trace.trace_id, "span_id": trace.span_id,
|
||||
"parent_span_id": trace.parent_span_id, "correlation_id": trace.correlation_id,
|
||||
"causation_id": trace.causation_id, "tenant_id": trace.scope.tenant_id,
|
||||
"user_id": trace.scope.user_id, "project_id": trace.scope.project_id,
|
||||
"conversation_id": trace.scope.conversation_id, "task_id": trace.task_id,
|
||||
"claim_id": trace.claim_id, "run_id": trace.scope.run_id, "message_id": trace.message_id,
|
||||
}
|
||||
for name, authoritative in bound.items():
|
||||
if values.get(name) not in (None, authoritative):
|
||||
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:" + name)
|
||||
values[name] = authoritative
|
||||
record = AgentExecutionRecord(**values)
|
||||
def record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
return {"status": "ok", "record": store.append(record)}
|
||||
|
||||
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user", *, actor: GovernanceActor | dict[str, Any] | None = None):
|
||||
records = store.query(user_id, group_id, role, actor=actor)
|
||||
visible = AgentObservability()
|
||||
visible.records = [AgentExecutionRecord(**record) for record in records]
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
records = store.query(user_id, group_id, role)
|
||||
return {
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": visible.summary(None),
|
||||
"summary": store.load().summary(user_id if role != "owner" else None),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ from uuid import uuid4
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
from .trace_tool_credential_governance import (
|
||||
GovernanceActor,
|
||||
TraceContext,
|
||||
authorize_scope,
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -41,7 +36,6 @@ class AgentRunContext:
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
status: str = "running"
|
||||
trace: TraceContext | None = None
|
||||
|
||||
def delegate(self, agent_name: str) -> None:
|
||||
if agent_name not in self.path:
|
||||
@@ -65,19 +59,7 @@ class AgentObservabilityHooks:
|
||||
visible_scope: str,
|
||||
entry_agent: str,
|
||||
execution_id: str | None = None,
|
||||
*,
|
||||
actor: GovernanceActor | dict[str, Any] | None = None,
|
||||
trace: TraceContext | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if execution_id is not None and execution_id in self.active:
|
||||
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
||||
if trace is not None:
|
||||
if actor is None:
|
||||
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
||||
trusted_actor = GovernanceActor.from_value(actor)
|
||||
authorize_scope(trusted_actor, trace.scope)
|
||||
if trace.scope.user_id is not None and trace.scope.user_id != user_id:
|
||||
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:user_id")
|
||||
run = AgentRunContext(
|
||||
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
|
||||
user_id=user_id,
|
||||
@@ -85,7 +67,6 @@ class AgentObservabilityHooks:
|
||||
entry_agent=entry_agent,
|
||||
path=[entry_agent],
|
||||
used_hermes=entry_agent.lower() == "hermes",
|
||||
trace=trace,
|
||||
)
|
||||
self.active[run.execution_id] = run
|
||||
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
|
||||
@@ -125,18 +106,6 @@ class AgentObservabilityHooks:
|
||||
status=status,
|
||||
started_at=run.started_at,
|
||||
ended_at=ended_at,
|
||||
trace_id=run.trace.trace_id if run.trace else None,
|
||||
span_id=run.trace.span_id if run.trace else None,
|
||||
parent_span_id=run.trace.parent_span_id if run.trace else None,
|
||||
correlation_id=run.trace.correlation_id if run.trace else None,
|
||||
causation_id=run.trace.causation_id if run.trace else None,
|
||||
tenant_id=run.trace.scope.tenant_id if run.trace else None,
|
||||
project_id=run.trace.scope.project_id if run.trace else None,
|
||||
conversation_id=run.trace.scope.conversation_id if run.trace else None,
|
||||
task_id=run.trace.task_id if run.trace else None,
|
||||
claim_id=run.trace.claim_id if run.trace else None,
|
||||
run_id=run.trace.scope.run_id if run.trace else None,
|
||||
message_id=run.trace.message_id if run.trace else None,
|
||||
)
|
||||
return {"status": "stored", "record": self.store.append(record)}
|
||||
|
||||
|
||||
@@ -12,12 +12,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||
from .trace_tool_credential_governance import (
|
||||
GovernanceActor,
|
||||
GovernanceError,
|
||||
GovernanceScope,
|
||||
authorize_scope,
|
||||
)
|
||||
|
||||
|
||||
class AgentObservabilityStore:
|
||||
@@ -29,11 +23,6 @@ class AgentObservabilityStore:
|
||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||
path = self.records_dir / f"{record.execution_id}.json"
|
||||
data = record.to_dict()
|
||||
if path.exists():
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
if existing == data:
|
||||
return existing
|
||||
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return data
|
||||
|
||||
@@ -44,36 +33,8 @@ class AgentObservabilityStore:
|
||||
observability.records.append(AgentExecutionRecord(**raw))
|
||||
return observability
|
||||
|
||||
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user", *, actor: GovernanceActor | dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user") -> list[dict[str, Any]]:
|
||||
records = [record.to_dict() for record in self.load().records]
|
||||
scoped = [record for record in records if record.get("tenant_id") is not None]
|
||||
legacy = [record for record in records if record.get("tenant_id") is None]
|
||||
if actor is not None:
|
||||
trusted = GovernanceActor.from_value(actor)
|
||||
visible: list[dict[str, Any]] = []
|
||||
for record in scoped:
|
||||
try:
|
||||
authorize_scope(
|
||||
trusted,
|
||||
GovernanceScope(
|
||||
"tenant",
|
||||
tenant_id=record.get("tenant_id"),
|
||||
user_id=record.get("user_id"),
|
||||
project_id=record.get("project_id"),
|
||||
conversation_id=record.get("conversation_id"),
|
||||
run_id=record.get("run_id"),
|
||||
),
|
||||
)
|
||||
except GovernanceError:
|
||||
continue
|
||||
visible.append(record)
|
||||
if trusted.privileged:
|
||||
visible.extend(legacy)
|
||||
else:
|
||||
visible.extend(record for record in legacy if record.get("user_id") == trusted.scope.user_id)
|
||||
return visible
|
||||
# Caller-supplied role remains a legacy compatibility path only.
|
||||
records = legacy
|
||||
if role == "owner":
|
||||
return records
|
||||
if role == "group_admin" and group_id:
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
"""Fail-closed Agent Pull Runner primitives for AIOS autonomy v1.
|
||||
|
||||
The runner owns task discovery and reporting only. It never accepts shell
|
||||
strings: executors receive argv, an allowlisted cwd and an allowlisted env.
|
||||
Durable state is intentionally small so restart recovery and duplicate
|
||||
suppression can be verified without coupling the runner to one database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class RunnerError(RuntimeError):
|
||||
"""Raised when a runner contract would be violated."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunnerConfig:
|
||||
worker_id: str
|
||||
capabilities: tuple[str, ...] = ()
|
||||
long_poll_seconds: int = 30
|
||||
fallback_seconds: int = 60
|
||||
jitter_seconds: int = 5
|
||||
max_backoff_seconds: int = 300
|
||||
executable_allowlist: tuple[str, ...] = ()
|
||||
working_directory_allowlist: tuple[str, ...] = ()
|
||||
environment_allowlist: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerState:
|
||||
inflight: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
terminal: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class RunnerTransport(Protocol):
|
||||
def register_worker(self, worker_id: str, capabilities: Sequence[str]) -> Mapping[str, Any]: ...
|
||||
def heartbeat(self, worker_id: str) -> Mapping[str, Any]: ...
|
||||
def long_poll(self, worker_id: str, timeout_seconds: int) -> Mapping[str, Any] | None: ...
|
||||
def poll(self, worker_id: str) -> Mapping[str, Any] | None: ...
|
||||
def claim(self, worker_id: str, task: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def renew(self, worker_id: str, task_id: str) -> Mapping[str, Any]: ...
|
||||
def report_progress(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def report_result(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def report_evidence(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class JsonStateStore:
|
||||
"""Atomic JSON state store for restart recovery."""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def load(self) -> RunnerState:
|
||||
if not self.path.exists():
|
||||
return RunnerState()
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise RunnerError("runner state must be an object")
|
||||
return RunnerState(
|
||||
inflight=dict(value.get("inflight", {})),
|
||||
terminal=dict(value.get("terminal", {})),
|
||||
)
|
||||
|
||||
def save(self, state: RunnerState) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=self.path.name + ".", dir=self.path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump({"inflight": state.inflight, "terminal": state.terminal}, handle, sort_keys=True)
|
||||
handle.write("\n")
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, self.path)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
class AgentPullRunner:
|
||||
"""Pull, claim, execute and report one task at a time."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: RunnerConfig,
|
||||
transport: RunnerTransport,
|
||||
state_store: JsonStateStore | None = None,
|
||||
*,
|
||||
random_fn: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
if not config.worker_id.strip():
|
||||
raise RunnerError("worker_id is required")
|
||||
self.config = config
|
||||
self.transport = transport
|
||||
self.state_store = state_store
|
||||
self.random_fn = random_fn
|
||||
self.state = state_store.load() if state_store else RunnerState()
|
||||
|
||||
def _save(self) -> None:
|
||||
if self.state_store:
|
||||
self.state_store.save(self.state)
|
||||
|
||||
def register(self) -> Mapping[str, Any]:
|
||||
return self.transport.register_worker(self.config.worker_id, self.config.capabilities)
|
||||
|
||||
def heartbeat(self) -> Mapping[str, Any]:
|
||||
return self.transport.heartbeat(self.config.worker_id)
|
||||
|
||||
def discovery_delay(self, failures: int) -> float:
|
||||
failures = max(0, int(failures))
|
||||
base = min(self.config.max_backoff_seconds, self.config.fallback_seconds * (2**failures))
|
||||
jitter = self.random_fn() * max(0, self.config.jitter_seconds)
|
||||
return min(self.config.max_backoff_seconds, base + jitter)
|
||||
|
||||
def discover_once(self) -> Mapping[str, Any] | None:
|
||||
try:
|
||||
task = self.transport.long_poll(self.config.worker_id, self.config.long_poll_seconds)
|
||||
except Exception:
|
||||
task = None
|
||||
if task is None:
|
||||
task = self.transport.poll(self.config.worker_id)
|
||||
if not task:
|
||||
return None
|
||||
task_id = str(task.get("task_id", ""))
|
||||
if not task_id:
|
||||
raise RunnerError("task_id is required")
|
||||
if task_id in self.state.terminal:
|
||||
return {"status": "duplicate_suppressed", "task_id": task_id}
|
||||
claimed = dict(self.transport.claim(self.config.worker_id, task))
|
||||
self.state.inflight[task_id] = claimed
|
||||
self._save()
|
||||
return claimed
|
||||
|
||||
def renew(self, task_id: str) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.renew(self.config.worker_id, task_id)
|
||||
|
||||
def report_progress(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.report_progress(self.config.worker_id, task_id, payload)
|
||||
|
||||
def report_evidence(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.report_evidence(self.config.worker_id, task_id, payload)
|
||||
|
||||
def report_result(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id in self.state.terminal:
|
||||
return {"status": "duplicate_terminal_suppressed", "task_id": task_id}
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
result = dict(self.transport.report_result(self.config.worker_id, task_id, payload))
|
||||
self.state.terminal[task_id] = {"result": result, "reported_at": time.time()}
|
||||
self.state.inflight.pop(task_id, None)
|
||||
self._save()
|
||||
return result
|
||||
|
||||
def recover_after_restart(self) -> list[Mapping[str, Any]]:
|
||||
recovered: list[Mapping[str, Any]] = []
|
||||
for task_id in tuple(self.state.inflight):
|
||||
recovered.append(self.renew(task_id))
|
||||
return recovered
|
||||
|
||||
def validate_execution(self, argv: Sequence[str], cwd: str | os.PathLike[str], env: Mapping[str, str]) -> None:
|
||||
if not argv or any(not isinstance(item, str) or not item for item in argv):
|
||||
raise RunnerError("argv_only_execution_required")
|
||||
if any(ch in argv[0] for ch in (";", "|", "&", "`", "\n")):
|
||||
raise RunnerError("shell_syntax_denied")
|
||||
executable = shutil.which(argv[0])
|
||||
if not executable:
|
||||
raise RunnerError("executable_not_found")
|
||||
if self.config.executable_allowlist and not any(
|
||||
os.path.realpath(executable).startswith(os.path.realpath(prefix).rstrip(os.sep) + os.sep)
|
||||
or os.path.realpath(executable) == os.path.realpath(prefix)
|
||||
for prefix in self.config.executable_allowlist
|
||||
):
|
||||
raise RunnerError("executable_not_allowlisted")
|
||||
real_cwd = os.path.realpath(os.fspath(cwd))
|
||||
if self.config.working_directory_allowlist and not any(
|
||||
real_cwd == os.path.realpath(prefix)
|
||||
or real_cwd.startswith(os.path.realpath(prefix).rstrip(os.sep) + os.sep)
|
||||
for prefix in self.config.working_directory_allowlist
|
||||
):
|
||||
raise RunnerError("working_directory_not_allowlisted")
|
||||
if self.config.environment_allowlist and any(key not in self.config.environment_allowlist for key in env):
|
||||
raise RunnerError("environment_key_not_allowlisted")
|
||||
|
||||
def execute_argv(self, argv: Sequence[str], cwd: str | os.PathLike[str], env: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self.validate_execution(argv, cwd, env)
|
||||
completed = subprocess.run(list(argv), cwd=os.fspath(cwd), env=dict(env), shell=False, text=True, capture_output=True, check=False)
|
||||
return {"returncode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Small operational CLI for the Agent Pull Runner contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from agent_pull_runner import AgentPullRunner, RunnerConfig
|
||||
|
||||
|
||||
class _NoopTransport:
|
||||
def register_worker(self, worker_id, capabilities): return {"status": "registered", "worker_id": worker_id, "capabilities": list(capabilities)}
|
||||
def heartbeat(self, worker_id): return {"status": "alive", "worker_id": worker_id}
|
||||
def long_poll(self, worker_id, timeout_seconds): return None
|
||||
def poll(self, worker_id): return None
|
||||
def claim(self, worker_id, task): return task
|
||||
def renew(self, worker_id, task_id): return {"status": "renewed", "task_id": task_id}
|
||||
def report_progress(self, worker_id, task_id, payload): return {"status": "progress_recorded", "task_id": task_id}
|
||||
def report_result(self, worker_id, task_id, payload): return {"status": "result_recorded", "task_id": task_id}
|
||||
def report_evidence(self, worker_id, task_id, payload): return {"status": "evidence_recorded", "task_id": task_id}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS Agent Pull Runner")
|
||||
parser.add_argument("--worker-id", default="self-test-worker")
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
args = parser.parse_args()
|
||||
runner = AgentPullRunner(RunnerConfig(worker_id=args.worker_id, fallback_seconds=60), _NoopTransport())
|
||||
if args.self_test:
|
||||
print(json.dumps({"status": "PASS", "registration": runner.register(), "fallback_seconds": runner.config.fallback_seconds}, sort_keys=True))
|
||||
return 0
|
||||
print(json.dumps({"status": "READY", "worker_id": args.worker_id}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,722 +0,0 @@
|
||||
"""Owner-controlled CAS-bound execution-envelope rebind for Online Agent Tasks.
|
||||
|
||||
This service exists for a narrow recovery case: a Task was projected/claimed with a read-only
|
||||
source_capture envelope before its already-registered deterministic plan became authoritative.
|
||||
|
||||
It never creates Tasks/Jobs/branches and never accepts arbitrary metadata patches. It only replaces
|
||||
metadata.execution + the derived execution_projection for the same governed binding, after an
|
||||
official NEEDS_REWORK transition has returned the Task to QUEUED with no active lease.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
|
||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
MAX_BODY = 64 * 1024
|
||||
AUTHORIZATION_ID_ENV = "AIOS_AGENT_TASK_EXECUTION_REBIND_AUTHORIZATION_ID"
|
||||
|
||||
|
||||
class ExecutionRebindError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _require_string(
|
||||
value: object,
|
||||
code: str,
|
||||
pattern: re.Pattern[str] | None = None,
|
||||
) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ExecutionRebindError(code, 400)
|
||||
value = value.strip()
|
||||
if pattern is not None and pattern.fullmatch(value) is None:
|
||||
raise ExecutionRebindError(code, 400)
|
||||
return value
|
||||
|
||||
|
||||
def _required_authorization_id() -> str:
|
||||
value = os.environ.get(AUTHORIZATION_ID_ENV, "").strip()
|
||||
if not value:
|
||||
raise RuntimeError("TASK_EXECUTION_REBIND_AUTHORIZATION_ID_MISSING")
|
||||
if KEY_RE.fullmatch(value) is None:
|
||||
raise RuntimeError("TASK_EXECUTION_REBIND_AUTHORIZATION_ID_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionRebindRequest:
|
||||
expected_status: str
|
||||
expected_revision: int
|
||||
expected_attempt: int
|
||||
previous_verification_sha256: str
|
||||
expected_old_execution_digest: str
|
||||
expected_new_execution_digest: str
|
||||
authorization_id: str
|
||||
idempotency_key: str
|
||||
reason: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ExecutionRebindRequest":
|
||||
allowed = {
|
||||
"expected_status",
|
||||
"expected_revision",
|
||||
"expected_attempt",
|
||||
"previous_verification_sha256",
|
||||
"expected_old_execution_digest",
|
||||
"expected_new_execution_digest",
|
||||
"authorization_id",
|
||||
"idempotency_key",
|
||||
"reason",
|
||||
}
|
||||
if set(value) != allowed:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_FIELDS_INVALID", 400)
|
||||
|
||||
status = _require_string(value["expected_status"], "EXPECTED_STATUS_INVALID")
|
||||
if status != "QUEUED":
|
||||
raise ExecutionRebindError("EXPECTED_STATUS_INVALID", 400)
|
||||
|
||||
revision = value["expected_revision"]
|
||||
attempt = value["expected_attempt"]
|
||||
if (
|
||||
not isinstance(revision, int)
|
||||
or isinstance(revision, bool)
|
||||
or revision < 0
|
||||
or not isinstance(attempt, int)
|
||||
or isinstance(attempt, bool)
|
||||
or attempt < 1
|
||||
):
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_INTEGER_INVALID", 400)
|
||||
|
||||
verification = _require_string(
|
||||
value["previous_verification_sha256"], "VERIFICATION_SHA256_INVALID"
|
||||
)
|
||||
old_digest = _require_string(
|
||||
value["expected_old_execution_digest"], "OLD_EXECUTION_DIGEST_INVALID"
|
||||
)
|
||||
new_digest = _require_string(
|
||||
value["expected_new_execution_digest"], "NEW_EXECUTION_DIGEST_INVALID"
|
||||
)
|
||||
for digest, code in (
|
||||
(verification, "VERIFICATION_SHA256_INVALID"),
|
||||
(old_digest, "OLD_EXECUTION_DIGEST_INVALID"),
|
||||
(new_digest, "NEW_EXECUTION_DIGEST_INVALID"),
|
||||
):
|
||||
if SHA_RE.fullmatch(digest) is None:
|
||||
raise ExecutionRebindError(code, 400)
|
||||
if old_digest == new_digest:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 400)
|
||||
|
||||
authorization = _require_string(
|
||||
value["authorization_id"], "AUTHORIZATION_ID_INVALID", KEY_RE
|
||||
)
|
||||
key = _require_string(value["idempotency_key"], "IDEMPOTENCY_KEY_INVALID", KEY_RE)
|
||||
reason = _require_string(value["reason"], "EXECUTION_REBIND_REASON_INVALID")
|
||||
if len(reason) > 512:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_REASON_INVALID", 400)
|
||||
|
||||
return cls(
|
||||
status,
|
||||
revision,
|
||||
attempt,
|
||||
verification,
|
||||
old_digest,
|
||||
new_digest,
|
||||
authorization,
|
||||
key,
|
||||
reason,
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"expected_status": self.expected_status,
|
||||
"expected_revision": self.expected_revision,
|
||||
"expected_attempt": self.expected_attempt,
|
||||
"previous_verification_sha256": self.previous_verification_sha256,
|
||||
"expected_old_execution_digest": self.expected_old_execution_digest,
|
||||
"expected_new_execution_digest": self.expected_new_execution_digest,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
class CanonicalBridgeExecutionResolver:
|
||||
"""Resolve the already-registered plan from the canonical governed Bridge."""
|
||||
|
||||
def __init__(self, base_url: str, control_token: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.control_token = control_token
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
task_id: str,
|
||||
required_capabilities: Sequence[str],
|
||||
) -> dict[str, Any]:
|
||||
body = json.dumps(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"required_capabilities": list(required_capabilities),
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.base_url + "/v2/execution-envelope",
|
||||
method="POST",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self.control_token,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read()
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
status = exc.code
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ExecutionRebindError("CANONICAL_EXECUTION_RESPONSE_INVALID", 502) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise ExecutionRebindError("CANONICAL_EXECUTION_RESPONSE_INVALID", 502)
|
||||
if status != 200:
|
||||
raise ExecutionRebindError(
|
||||
str(value.get("error") or "CANONICAL_EXECUTION_RESOLUTION_FAILED"),
|
||||
502 if status >= 500 else 409,
|
||||
)
|
||||
return dict(value)
|
||||
|
||||
|
||||
class ExecutionRebindStore:
|
||||
"""Official owner-controlled mutation with CAS, plan resolution and durable audit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: Path,
|
||||
resolver: Callable[[str, Sequence[str]], Mapping[str, Any]],
|
||||
) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
self.resolver = resolver
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _columns(
|
||||
connection: sqlite3.Connection,
|
||||
table: str,
|
||||
) -> dict[str, sqlite3.Row]:
|
||||
return {
|
||||
str(row["name"]): row
|
||||
for row in connection.execute(f'PRAGMA table_info("{table}")')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _json_value(raw: object, code: str) -> dict[str, Any]:
|
||||
if isinstance(raw, Mapping):
|
||||
return dict(raw)
|
||||
if not isinstance(raw, str):
|
||||
raise ExecutionRebindError(code, 409)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ExecutionRebindError(code, 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise ExecutionRebindError(code, 409)
|
||||
return dict(value)
|
||||
|
||||
@staticmethod
|
||||
def _verification_hash(raw: object) -> str:
|
||||
if raw in (None, ""):
|
||||
value: object = {}
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ExecutionRebindError("TASK_VERIFICATION_INVALID", 409) from exc
|
||||
else:
|
||||
value = raw
|
||||
return _sha(value)
|
||||
|
||||
@staticmethod
|
||||
def _metadata_column(columns: Mapping[str, sqlite3.Row]) -> str:
|
||||
for name in ("metadata_json", "metadata"):
|
||||
if name in columns:
|
||||
return name
|
||||
raise ExecutionRebindError("TASK_METADATA_SCHEMA_UNSUPPORTED", 503)
|
||||
|
||||
@staticmethod
|
||||
def _required_capabilities(
|
||||
task: sqlite3.Row,
|
||||
columns: Mapping[str, sqlite3.Row],
|
||||
old_execution: Mapping[str, Any],
|
||||
) -> list[str]:
|
||||
for field in ("required_capabilities_json", "required_capabilities"):
|
||||
if field not in columns:
|
||||
continue
|
||||
raw = task[field]
|
||||
if raw in (None, ""):
|
||||
continue
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
value = [item for item in raw.split(",") if item.strip()]
|
||||
else:
|
||||
value = raw
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return [item.strip() for item in value if item.strip()]
|
||||
value = old_execution.get("capability_scope")
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return [item for item in value if item]
|
||||
raise ExecutionRebindError("TASK_CAPABILITIES_UNAVAILABLE", 503)
|
||||
|
||||
@staticmethod
|
||||
def _event_table(
|
||||
connection: sqlite3.Connection,
|
||||
) -> tuple[str, dict[str, sqlite3.Row]]:
|
||||
tables = {
|
||||
row["name"]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
for name in ("task_events", "events"):
|
||||
if name not in tables:
|
||||
continue
|
||||
columns = ExecutionRebindStore._columns(connection, name)
|
||||
if "task_id" in columns and ("event_type" in columns or "type" in columns):
|
||||
return name, columns
|
||||
raise ExecutionRebindError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
||||
|
||||
@staticmethod
|
||||
def _insert_event(
|
||||
connection: sqlite3.Connection,
|
||||
task_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> None:
|
||||
table, columns = ExecutionRebindStore._event_table(connection)
|
||||
values: dict[str, Any] = {"task_id": task_id}
|
||||
if "event_type" in columns:
|
||||
values["event_type"] = "TASK_EXECUTION_REBIND_AUTHORIZED"
|
||||
else:
|
||||
values["type"] = "TASK_EXECUTION_REBIND_AUTHORIZED"
|
||||
payload_text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
if "payload_json" in columns:
|
||||
values["payload_json"] = payload_text
|
||||
elif "payload" in columns:
|
||||
values["payload"] = payload_text
|
||||
else:
|
||||
raise ExecutionRebindError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
||||
if "created_at" in columns:
|
||||
values["created_at"] = _now()
|
||||
if "agent_id" in columns:
|
||||
values["agent_id"] = "owner-control"
|
||||
if "actor_id" in columns:
|
||||
values["actor_id"] = "owner-control"
|
||||
names = list(values)
|
||||
quoted = ",".join('"' + name + '"' for name in names)
|
||||
placeholders = ",".join("?" for _ in names)
|
||||
connection.execute(
|
||||
f'INSERT INTO "{table}" ({quoted}) VALUES ({placeholders})',
|
||||
tuple(values[name] for name in names),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_rebind_table(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_execution_rebinds (
|
||||
task_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL,
|
||||
previous_verification_sha256 TEXT NOT NULL,
|
||||
old_execution_digest TEXT NOT NULL,
|
||||
new_execution_digest TEXT NOT NULL,
|
||||
expected_revision INTEGER NOT NULL,
|
||||
applied_revision INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(task_id,idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _load_and_validate(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
task_id: str,
|
||||
request: ExecutionRebindRequest,
|
||||
) -> tuple[sqlite3.Row, dict[str, sqlite3.Row], str, dict[str, Any], dict[str, Any]]:
|
||||
columns = self._columns(connection, "tasks")
|
||||
required = {
|
||||
"task_id",
|
||||
"status",
|
||||
"attempt",
|
||||
"revision",
|
||||
"claimed_by",
|
||||
"lease_token_hash",
|
||||
"lease_expires_at",
|
||||
"verification_json",
|
||||
}
|
||||
if not required.issubset(columns):
|
||||
raise ExecutionRebindError("TASK_SCHEMA_EXECUTION_REBIND_UNSUPPORTED", 503)
|
||||
metadata_column = self._metadata_column(columns)
|
||||
task = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if task is None:
|
||||
raise ExecutionRebindError("TASK_NOT_FOUND", 404)
|
||||
|
||||
if (
|
||||
str(task["status"]) != request.expected_status
|
||||
or int(task["revision"]) != request.expected_revision
|
||||
or int(task["attempt"]) != request.expected_attempt
|
||||
):
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_CAS_MISMATCH", 409)
|
||||
if task["lease_token_hash"] not in (None, "") or task["lease_expires_at"] not in (None, ""):
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||
|
||||
verification_sha = self._verification_hash(task["verification_json"])
|
||||
if verification_sha != request.previous_verification_sha256:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_VERIFICATION_DRIFT", 409)
|
||||
|
||||
metadata = self._json_value(
|
||||
task[metadata_column],
|
||||
"TASK_METADATA_INVALID",
|
||||
)
|
||||
execution = metadata.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
raise ExecutionRebindError("TASK_EXECUTION_METADATA_MISSING", 409)
|
||||
old_execution = validate_execution_envelope(dict(execution))
|
||||
if old_execution["execution_digest"] != request.expected_old_execution_digest:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_OLD_DIGEST_DRIFT", 409)
|
||||
if old_execution.get("action") != "source_capture":
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_SOURCE_CAPTURE_REQUIRED", 409)
|
||||
|
||||
projection = metadata.get("execution_projection")
|
||||
if isinstance(projection, Mapping):
|
||||
projected_digest = str(projection.get("execution_digest") or "")
|
||||
if projected_digest and projected_digest != request.expected_old_execution_digest:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_PROJECTION_DRIFT", 409)
|
||||
return task, columns, metadata_column, metadata, old_execution
|
||||
|
||||
def rebind(
|
||||
self,
|
||||
task_id: str,
|
||||
request: ExecutionRebindRequest,
|
||||
) -> dict[str, Any]:
|
||||
task_id = _require_string(task_id, "TASK_ID_INVALID", TASK_RE)
|
||||
required_authorization = _required_authorization_id()
|
||||
if request.authorization_id != required_authorization:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_AUTHORIZATION_ID_MISMATCH", 403)
|
||||
|
||||
request_sha = _sha(request.public())
|
||||
|
||||
# Idempotent replay must win before CAS validation because a successful
|
||||
# rebind necessarily increments the Task revision and changes the persisted envelope.
|
||||
with self._connect() as connection:
|
||||
self._ensure_rebind_table(connection)
|
||||
replay = connection.execute(
|
||||
"SELECT * FROM task_execution_rebinds WHERE task_id=? AND idempotency_key=?",
|
||||
(task_id, request.idempotency_key),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["request_sha256"] != request_sha:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "QUEUED",
|
||||
"attempt": int(replay["attempt"]),
|
||||
"revision": int(replay["applied_revision"]),
|
||||
"old_execution_digest": replay["old_execution_digest"],
|
||||
"new_execution_digest": replay["new_execution_digest"],
|
||||
"authorization_id": replay["authorization_id"],
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
# Read/CAS snapshot first. Canonical Bridge resolution happens outside the DB write lock.
|
||||
with self._connect() as connection:
|
||||
task, columns, _, _, old_execution = self._load_and_validate(
|
||||
connection, task_id, request
|
||||
)
|
||||
capabilities = self._required_capabilities(task, columns, old_execution)
|
||||
|
||||
binding = GovernedExecutionBinding.from_mapping(old_execution)
|
||||
resolved = dict(self.resolver(task_id, capabilities))
|
||||
new_execution = validate_execution_envelope(resolved, binding=binding)
|
||||
if new_execution.get("action") != "deterministic":
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_DETERMINISTIC_PLAN_REQUIRED", 409)
|
||||
if new_execution["execution_digest"] != request.expected_new_execution_digest:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_NEW_DIGEST_DRIFT", 409)
|
||||
if new_execution["execution_digest"] == old_execution["execution_digest"]:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 409)
|
||||
|
||||
# Re-read under BEGIN IMMEDIATE and apply only if the exact snapshot is unchanged.
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._ensure_rebind_table(connection)
|
||||
|
||||
replay = connection.execute(
|
||||
"SELECT * FROM task_execution_rebinds WHERE task_id=? AND idempotency_key=?",
|
||||
(task_id, request.idempotency_key),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["request_sha256"] != request_sha:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "QUEUED",
|
||||
"attempt": int(replay["attempt"]),
|
||||
"revision": int(replay["applied_revision"]),
|
||||
"old_execution_digest": replay["old_execution_digest"],
|
||||
"new_execution_digest": replay["new_execution_digest"],
|
||||
"authorization_id": replay["authorization_id"],
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
task, columns, metadata_column, metadata, current_old = self._load_and_validate(
|
||||
connection, task_id, request
|
||||
)
|
||||
if current_old["execution_digest"] != old_execution["execution_digest"]:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_OLD_DIGEST_DRIFT", 409)
|
||||
|
||||
new_metadata = dict(metadata)
|
||||
new_metadata["execution"] = new_execution
|
||||
new_metadata["execution_projection"] = {
|
||||
"schema": "aios.online-agent.task-projection.v1",
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"execution_digest": new_execution["execution_digest"],
|
||||
}
|
||||
applied_revision = request.expected_revision + 1
|
||||
previous_claimed_by = task["claimed_by"]
|
||||
|
||||
cursor = connection.execute(
|
||||
f"""
|
||||
UPDATE tasks
|
||||
SET "{metadata_column}"=?, revision=?, claimed_by=NULL,
|
||||
lease_token_hash=NULL, lease_expires_at=NULL
|
||||
WHERE task_id=? AND status=? AND revision=? AND attempt=?
|
||||
AND claimed_by IS ? AND lease_token_hash IS NULL AND lease_expires_at IS NULL
|
||||
""",
|
||||
(
|
||||
json.dumps(new_metadata, sort_keys=True, separators=(",", ":")),
|
||||
applied_revision,
|
||||
task_id,
|
||||
request.expected_status,
|
||||
request.expected_revision,
|
||||
request.expected_attempt,
|
||||
previous_claimed_by,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_CAS_MISMATCH", 409)
|
||||
|
||||
event_payload = {
|
||||
"schema": "aios.task-execution-rebind.event.v1",
|
||||
"task_id": task_id,
|
||||
"job_id": binding.job_id,
|
||||
"authorization_id": request.authorization_id,
|
||||
"idempotency_key": request.idempotency_key,
|
||||
"reason": request.reason,
|
||||
"previous_verification_sha256": request.previous_verification_sha256,
|
||||
"old_execution_digest": old_execution["execution_digest"],
|
||||
"new_execution_digest": new_execution["execution_digest"],
|
||||
"old_action": old_execution.get("action"),
|
||||
"new_action": new_execution.get("action"),
|
||||
"previous_revision": request.expected_revision,
|
||||
"applied_revision": applied_revision,
|
||||
"attempt": request.expected_attempt,
|
||||
"request_sha256": request_sha,
|
||||
"previous_claimed_by": previous_claimed_by,
|
||||
"stale_claim_normalized": previous_claimed_by not in (None, ""),
|
||||
}
|
||||
self._insert_event(connection, task_id, event_payload)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO task_execution_rebinds(
|
||||
task_id,idempotency_key,request_sha256,authorization_id,
|
||||
previous_verification_sha256,old_execution_digest,new_execution_digest,
|
||||
expected_revision,applied_revision,attempt,reason,created_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
request.idempotency_key,
|
||||
request_sha,
|
||||
request.authorization_id,
|
||||
request.previous_verification_sha256,
|
||||
old_execution["execution_digest"],
|
||||
new_execution["execution_digest"],
|
||||
request.expected_revision,
|
||||
applied_revision,
|
||||
request.expected_attempt,
|
||||
request.reason,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "QUEUED",
|
||||
"attempt": request.expected_attempt,
|
||||
"revision": applied_revision,
|
||||
"old_execution_digest": old_execution["execution_digest"],
|
||||
"new_execution_digest": new_execution["execution_digest"],
|
||||
"authorization_id": request.authorization_id,
|
||||
"idempotent": False,
|
||||
}
|
||||
|
||||
|
||||
class ExecutionRebindHandler(BaseHTTPRequestHandler):
|
||||
store: ExecutionRebindStore | None = None
|
||||
control_token: str = ""
|
||||
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def _json(self, value: Mapping[str, Any], status: int) -> None:
|
||||
data = json.dumps(
|
||||
dict(value), sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
header = self.headers.get("Authorization", "")
|
||||
return (
|
||||
header.startswith("Bearer ")
|
||||
and bool(self.control_token)
|
||||
and hmac.compare_digest(header[7:], self.control_token)
|
||||
)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/health":
|
||||
self._json(
|
||||
{"status": "ok", "service": "aios-agent-task-execution-rebind"},
|
||||
200,
|
||||
)
|
||||
else:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
match = re.fullmatch(r"/v1/tasks/([^/]+)/execution-rebind", self.path)
|
||||
if match is None:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if not self._authorized():
|
||||
self._json({"error": "UNAUTHORIZED"}, 401)
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
self._json({"error": "CONTENT_LENGTH_INVALID"}, 400)
|
||||
return
|
||||
if length < 1 or length > MAX_BODY:
|
||||
self._json({"error": "REQUEST_SIZE_INVALID"}, 413)
|
||||
return
|
||||
try:
|
||||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError
|
||||
request = ExecutionRebindRequest.from_mapping(value)
|
||||
if self.store is None:
|
||||
raise ExecutionRebindError("RUNTIME_UNAVAILABLE", 503)
|
||||
result = self.store.rebind(match.group(1), request)
|
||||
self._json(result, 200 if result["idempotent"] else 201)
|
||||
except ExecutionRebindError as exc:
|
||||
self._json({"error": exc.code}, exc.status)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||
self._json({"error": "REQUEST_INVALID"}, 400)
|
||||
|
||||
|
||||
def load_control_token(path: Path) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RuntimeError("TASK_EXECUTION_REBIND_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
raise RuntimeError("TASK_EXECUTION_REBIND_CONTROL_CREDENTIAL_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
db = Path(os.environ.get("AIOS_AGENT_TASK_DB", "/var/lib/aios-agent-task/state.sqlite3"))
|
||||
_required_authorization_id()
|
||||
token_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
str(
|
||||
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
/ "agent_task_control_token"
|
||||
),
|
||||
)
|
||||
)
|
||||
token = load_control_token(token_path)
|
||||
bridge = os.environ.get(
|
||||
"AIOS_GOVERNED_WRITE_API_URL",
|
||||
"http://127.0.0.1:18892",
|
||||
)
|
||||
host = os.environ.get("AIOS_TASK_EXECUTION_REBIND_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("AIOS_TASK_EXECUTION_REBIND_PORT", "18942"))
|
||||
ExecutionRebindHandler.store = ExecutionRebindStore(
|
||||
db,
|
||||
CanonicalBridgeExecutionResolver(bridge, token),
|
||||
)
|
||||
ExecutionRebindHandler.control_token = token
|
||||
ThreadingHTTPServer((host, port), ExecutionRebindHandler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -1,427 +0,0 @@
|
||||
"""Owner-controlled, CAS-bound retry extension for exhausted Online Agent Tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Mapping
|
||||
|
||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||
MAX_BODY = 64 * 1024
|
||||
AUTHORIZE_EXTENSION_ID_ENV = "AIOS_AGENT_TASK_AUTHORIZATION_ID"
|
||||
|
||||
|
||||
class RetryExtensionError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _sha(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _required_authorization_id() -> str:
|
||||
value = os.environ.get(AUTHORIZE_EXTENSION_ID_ENV, "").strip()
|
||||
if not value:
|
||||
raise RuntimeError("TASK_RETRY_AUTHORIZATION_ID_MISSING")
|
||||
if KEY_RE.fullmatch(value) is None:
|
||||
raise RuntimeError("TASK_RETRY_AUTHORIZATION_ID_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _require_string(value: object, code: str, pattern: re.Pattern[str] | None = None) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RetryExtensionError(code, 400)
|
||||
value = value.strip()
|
||||
if pattern is not None and pattern.fullmatch(value) is None:
|
||||
raise RetryExtensionError(code, 400)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryExtensionRequest:
|
||||
expected_status: str
|
||||
expected_revision: int
|
||||
expected_attempt: int
|
||||
expected_max_attempts: int
|
||||
next_max_attempts: int
|
||||
previous_verification_sha256: str
|
||||
authorization_id: str
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "RetryExtensionRequest":
|
||||
allowed = {
|
||||
"expected_status", "expected_revision", "expected_attempt", "expected_max_attempts",
|
||||
"next_max_attempts", "previous_verification_sha256", "authorization_id", "idempotency_key",
|
||||
}
|
||||
if set(value) != allowed:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_FIELDS_INVALID", 400)
|
||||
status = _require_string(value["expected_status"], "EXPECTED_STATUS_INVALID")
|
||||
if status != "FAILED":
|
||||
raise RetryExtensionError("EXPECTED_STATUS_INVALID", 400)
|
||||
ints: dict[str, int] = {}
|
||||
for field in ("expected_revision", "expected_attempt", "expected_max_attempts", "next_max_attempts"):
|
||||
item = value[field]
|
||||
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_INTEGER_INVALID", 400)
|
||||
ints[field] = item
|
||||
if ints["expected_attempt"] != ints["expected_max_attempts"]:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_NOT_EXHAUSTED", 409)
|
||||
if ints["next_max_attempts"] != ints["expected_max_attempts"] + 1:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_MUST_BE_BOUNDED_ONE_STEP", 400)
|
||||
if ints["next_max_attempts"] > 50:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_LIMIT_EXCEEDED", 400)
|
||||
verification = _require_string(value["previous_verification_sha256"], "VERIFICATION_SHA256_INVALID")
|
||||
if re.fullmatch(r"[0-9a-f]{64}", verification) is None:
|
||||
raise RetryExtensionError("VERIFICATION_SHA256_INVALID", 400)
|
||||
authorization = _require_string(value["authorization_id"], "AUTHORIZATION_ID_INVALID", KEY_RE)
|
||||
key = _require_string(value["idempotency_key"], "IDEMPOTENCY_KEY_INVALID", KEY_RE)
|
||||
return cls(
|
||||
status,
|
||||
ints["expected_revision"],
|
||||
ints["expected_attempt"],
|
||||
ints["expected_max_attempts"],
|
||||
ints["next_max_attempts"],
|
||||
verification,
|
||||
authorization,
|
||||
key,
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"expected_status": self.expected_status,
|
||||
"expected_revision": self.expected_revision,
|
||||
"expected_attempt": self.expected_attempt,
|
||||
"expected_max_attempts": self.expected_max_attempts,
|
||||
"next_max_attempts": self.next_max_attempts,
|
||||
"previous_verification_sha256": self.previous_verification_sha256,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
}
|
||||
|
||||
|
||||
class RetryExtensionStore:
|
||||
"""Owns the official mutation. Direct/manual DB edits remain forbidden."""
|
||||
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _columns(connection: sqlite3.Connection, table: str) -> dict[str, sqlite3.Row]:
|
||||
return {str(row["name"]): row for row in connection.execute(f'PRAGMA table_info("{table}")')}
|
||||
|
||||
@staticmethod
|
||||
def _event_table(connection: sqlite3.Connection) -> tuple[str, dict[str, sqlite3.Row]]:
|
||||
tables = {
|
||||
row["name"]
|
||||
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
for name in ("task_events", "events"):
|
||||
if name not in tables:
|
||||
continue
|
||||
columns = RetryExtensionStore._columns(connection, name)
|
||||
if "task_id" in columns and ("event_type" in columns or "type" in columns):
|
||||
return name, columns
|
||||
raise RetryExtensionError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
||||
|
||||
@staticmethod
|
||||
def _verification_hash(raw: object) -> str:
|
||||
if raw in (None, ""):
|
||||
value: object = {}
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RetryExtensionError("TASK_VERIFICATION_INVALID", 409) from exc
|
||||
else:
|
||||
value = raw
|
||||
return _sha(value)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_extension_table(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_retry_extensions (
|
||||
task_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL,
|
||||
previous_verification_sha256 TEXT NOT NULL,
|
||||
previous_max_attempts INTEGER NOT NULL,
|
||||
next_max_attempts INTEGER NOT NULL,
|
||||
expected_revision INTEGER NOT NULL,
|
||||
applied_revision INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(task_id, idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _insert_event(
|
||||
connection: sqlite3.Connection,
|
||||
task_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> None:
|
||||
table, columns = RetryExtensionStore._event_table(connection)
|
||||
values: dict[str, Any] = {"task_id": task_id}
|
||||
if "event_type" in columns:
|
||||
values["event_type"] = "TASK_RETRY_EXTENSION_AUTHORIZED"
|
||||
else:
|
||||
values["type"] = "TASK_RETRY_EXTENSION_AUTHORIZED"
|
||||
payload_text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
if "payload_json" in columns:
|
||||
values["payload_json"] = payload_text
|
||||
elif "payload" in columns:
|
||||
values["payload"] = payload_text
|
||||
else:
|
||||
raise RetryExtensionError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
||||
if "created_at" in columns:
|
||||
values["created_at"] = _now()
|
||||
if "agent_id" in columns:
|
||||
values["agent_id"] = "owner-control"
|
||||
if "actor_id" in columns:
|
||||
values["actor_id"] = "owner-control"
|
||||
names = list(values)
|
||||
sql = f'INSERT INTO "{table}" ({",".join(f"""\"{name}\"""" for name in names)}) VALUES ({",".join("?" for _ in names)})'
|
||||
connection.execute(sql, tuple(values[name] for name in names))
|
||||
|
||||
def extend(self, task_id: str, request: RetryExtensionRequest) -> dict[str, Any]:
|
||||
task_id = _require_string(task_id, "TASK_ID_INVALID", TASK_RE)
|
||||
required_authorization_id = _required_authorization_id()
|
||||
if request.authorization_id != required_authorization_id:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_AUTHORIZATION_ID_MISMATCH", 403)
|
||||
request_payload = request.public()
|
||||
request_sha = _sha(request_payload)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._ensure_extension_table(connection)
|
||||
replay = connection.execute(
|
||||
"SELECT * FROM task_retry_extensions WHERE task_id=? AND idempotency_key=?",
|
||||
(task_id, request.idempotency_key),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["request_sha256"] != request_sha:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "QUEUED",
|
||||
"attempt": request.expected_attempt,
|
||||
"max_attempts": int(replay["next_max_attempts"]),
|
||||
"revision": int(replay["applied_revision"]),
|
||||
"idempotent": True,
|
||||
"authorization_id": replay["authorization_id"],
|
||||
"previous_verification_sha256": replay["previous_verification_sha256"],
|
||||
}
|
||||
|
||||
columns = self._columns(connection, "tasks")
|
||||
required = {
|
||||
"task_id", "status", "attempt", "max_attempts", "revision", "claimed_by",
|
||||
"lease_token_hash", "lease_expires_at", "verification_json",
|
||||
}
|
||||
if not required.issubset(columns):
|
||||
raise RetryExtensionError("TASK_SCHEMA_RETRY_EXTENSION_UNSUPPORTED", 503)
|
||||
task = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if task is None:
|
||||
raise RetryExtensionError("TASK_NOT_FOUND", 404)
|
||||
actual_verification_sha = self._verification_hash(task["verification_json"])
|
||||
if actual_verification_sha != request.previous_verification_sha256:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_VERIFICATION_DRIFT", 409)
|
||||
if (
|
||||
task["status"] != request.expected_status
|
||||
or int(task["revision"]) != request.expected_revision
|
||||
or int(task["attempt"]) != request.expected_attempt
|
||||
or int(task["max_attempts"]) != request.expected_max_attempts
|
||||
):
|
||||
raise RetryExtensionError("RETRY_EXTENSION_CAS_MISMATCH", 409)
|
||||
for lease_field in ("lease_token_hash", "lease_expires_at"):
|
||||
if lease_field in columns and task[lease_field] not in (None, ""):
|
||||
raise RetryExtensionError("RETRY_EXTENSION_ACTIVE_LEASE_DENY", 409)
|
||||
|
||||
previous_claimed_by = task["claimed_by"]
|
||||
applied_revision = request.expected_revision + 1
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status='QUEUED', max_attempts=?, revision=?, claimed_by=NULL,
|
||||
lease_token_hash=NULL, lease_expires_at=NULL
|
||||
WHERE task_id=? AND status=? AND revision=? AND attempt=? AND max_attempts=?
|
||||
AND claimed_by IS ?
|
||||
AND lease_token_hash IS NULL AND lease_expires_at IS NULL
|
||||
""",
|
||||
(
|
||||
request.next_max_attempts,
|
||||
applied_revision,
|
||||
task_id,
|
||||
request.expected_status,
|
||||
request.expected_revision,
|
||||
request.expected_attempt,
|
||||
request.expected_max_attempts,
|
||||
previous_claimed_by,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise RetryExtensionError("RETRY_EXTENSION_CAS_MISMATCH", 409)
|
||||
|
||||
event_payload = {
|
||||
"schema": "aios.task-retry-extension.event.v1",
|
||||
"task_id": task_id,
|
||||
"authorization_id": request.authorization_id,
|
||||
"idempotency_key": request.idempotency_key,
|
||||
"previous_verification_sha256": request.previous_verification_sha256,
|
||||
"previous_max_attempts": request.expected_max_attempts,
|
||||
"next_max_attempts": request.next_max_attempts,
|
||||
"previous_revision": request.expected_revision,
|
||||
"applied_revision": applied_revision,
|
||||
"request_sha256": request_sha,
|
||||
"previous_claimed_by": previous_claimed_by,
|
||||
"stale_terminal_claim_normalized": previous_claimed_by not in (None, ""),
|
||||
}
|
||||
self._insert_event(connection, task_id, event_payload)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO task_retry_extensions(
|
||||
task_id,idempotency_key,request_sha256,authorization_id,previous_verification_sha256,
|
||||
previous_max_attempts,next_max_attempts,expected_revision,applied_revision,created_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
request.idempotency_key,
|
||||
request_sha,
|
||||
request.authorization_id,
|
||||
request.previous_verification_sha256,
|
||||
request.expected_max_attempts,
|
||||
request.next_max_attempts,
|
||||
request.expected_revision,
|
||||
applied_revision,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "QUEUED",
|
||||
"attempt": request.expected_attempt,
|
||||
"max_attempts": request.next_max_attempts,
|
||||
"revision": applied_revision,
|
||||
"idempotent": False,
|
||||
"authorization_id": request.authorization_id,
|
||||
"previous_verification_sha256": request.previous_verification_sha256,
|
||||
}
|
||||
|
||||
|
||||
class RetryExtensionHandler(BaseHTTPRequestHandler):
|
||||
store: RetryExtensionStore | None = None
|
||||
control_token: str = ""
|
||||
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def _json(self, value: Mapping[str, Any], status: int) -> None:
|
||||
data = json.dumps(dict(value), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
header = self.headers.get("Authorization", "")
|
||||
return (
|
||||
header.startswith("Bearer ")
|
||||
and bool(self.control_token)
|
||||
and hmac.compare_digest(header[7:], self.control_token)
|
||||
)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/health":
|
||||
self._json({"status": "ok", "service": "aios-agent-task-retry-extension"}, 200)
|
||||
else:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
match = re.fullmatch(r"/v1/tasks/([^/]+)/retry-extension", self.path)
|
||||
if match is None:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if not self._authorized():
|
||||
self._json({"error": "UNAUTHORIZED"}, 401)
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
self._json({"error": "CONTENT_LENGTH_INVALID"}, 400)
|
||||
return
|
||||
if length < 1 or length > MAX_BODY:
|
||||
self._json({"error": "REQUEST_SIZE_INVALID"}, 413)
|
||||
return
|
||||
try:
|
||||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError
|
||||
request = RetryExtensionRequest.from_mapping(value)
|
||||
if self.store is None:
|
||||
raise RetryExtensionError("RUNTIME_UNAVAILABLE", 503)
|
||||
result = self.store.extend(match.group(1), request)
|
||||
self._json(result, 200 if result["idempotent"] else 201)
|
||||
except RetryExtensionError as exc:
|
||||
self._json({"error": exc.code}, exc.status)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||
self._json({"error": "REQUEST_INVALID"}, 400)
|
||||
|
||||
|
||||
def load_control_token(path: Path) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RuntimeError("TASK_RETRY_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
raise RuntimeError("TASK_RETRY_CONTROL_CREDENTIAL_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
db = Path(os.environ.get("AIOS_AGENT_TASK_DB", "/var/lib/aios-agent-task/state.sqlite3"))
|
||||
_required_authorization_id()
|
||||
token_path = Path(
|
||||
os.environ.get(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
str(Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials")) / "agent_task_control_token"),
|
||||
)
|
||||
)
|
||||
host = os.environ.get("AIOS_TASK_RETRY_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("AIOS_TASK_RETRY_PORT", "18941"))
|
||||
RetryExtensionHandler.store = RetryExtensionStore(db)
|
||||
RetryExtensionHandler.control_token = load_control_token(token_path)
|
||||
ThreadingHTTPServer((host, port), RetryExtensionHandler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -1,258 +0,0 @@
|
||||
"""Fail-closed Authority Git side-effect boundary.
|
||||
|
||||
Only this module may issue the narrowly scoped ref CAS primitives used by the
|
||||
durable executor. It never accepts force updates, arbitrary refs, tags, or
|
||||
direct production mutations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TASK_BRANCH_RE = re.compile(r"^refs/heads/agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
|
||||
|
||||
class AuthorityGitError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise AuthorityGitError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorityGitExecutor:
|
||||
git_dir: Path
|
||||
worktree_root: Path = Path("/var/lib/aios-gitops/worktrees")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.git_dir.is_absolute() or self.git_dir.is_symlink():
|
||||
raise AuthorityGitError("AUTHORITY_GIT_DIR_INVALID")
|
||||
if not self.worktree_root.is_absolute() or self.worktree_root.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ROOT_INVALID")
|
||||
|
||||
def _git(self, args: Sequence[str], *, check: bool = True) -> str:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise AuthorityGitError("AUTHORITY_GIT_COMMAND_FAILED")
|
||||
return result.stdout.strip()
|
||||
|
||||
def read_ref(self, ref: str) -> str | None:
|
||||
if not ref.startswith("refs/") or ref.startswith(("refs/tags/", "refs/remotes/")):
|
||||
raise AuthorityGitError("REF_READ_SCOPE_DENIED")
|
||||
value = self._git(["rev-parse", "--verify", ref], check=False)
|
||||
return value if value and OID_RE.fullmatch(value) else None
|
||||
|
||||
def commit_exists(self, oid: str) -> bool:
|
||||
_oid(oid, "commit_oid")
|
||||
result = subprocess.run(["git", f"--git-dir={self.git_dir}", "cat-file", "-e", oid + "^{commit}"], capture_output=True, check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def ancestry_check(self, older_oid: str, newer_oid: str) -> bool:
|
||||
_oid(older_oid, "older_oid")
|
||||
_oid(newer_oid, "newer_oid")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "merge-base", "--is-ancestor", older_oid, newer_oid],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode not in (0, 1):
|
||||
raise AuthorityGitError("ANCESTRY_CHECK_FAILED")
|
||||
return result.returncode == 0
|
||||
|
||||
def create_detached_worktree(
|
||||
self,
|
||||
base_oid: str,
|
||||
worktree_name: str,
|
||||
root: Path | None = None,
|
||||
) -> Path:
|
||||
_oid(base_oid, "base_oid")
|
||||
if not worktree_name or "/" in worktree_name or "\\" in worktree_name or worktree_name in {".", ".."}:
|
||||
raise AuthorityGitError("WORKTREE_NAME_INVALID")
|
||||
parent = root or self.worktree_root
|
||||
if not parent.is_absolute() or parent.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ROOT_INVALID")
|
||||
path = parent / worktree_name
|
||||
if path.exists() or path.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ALREADY_EXISTS")
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||
return path
|
||||
|
||||
def recover_failed_detached_worktree_exact(
|
||||
self,
|
||||
*,
|
||||
path: Path,
|
||||
expected_oid: str,
|
||||
stale_oid: str,
|
||||
target_ref: str,
|
||||
) -> str:
|
||||
"""Recreate one exact clean, detached, single-attempt worktree at its durable head."""
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(stale_oid, "stale_oid")
|
||||
if not TASK_BRANCH_RE.fullmatch(target_ref):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
if self.read_ref(target_ref) is not None:
|
||||
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.parent != self.worktree_root
|
||||
or path.name in {"", ".", ".."}
|
||||
or path.is_symlink()
|
||||
or not path.is_dir()
|
||||
):
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_PATH_INVALID")
|
||||
head = subprocess.run(
|
||||
["git", "-C", str(path), "rev-parse", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if head.returncode != 0 or head.stdout.strip() != stale_oid or stale_oid == expected_oid:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_HEAD_INVALID")
|
||||
symbolic = subprocess.run(
|
||||
["git", "-C", str(path), "symbolic-ref", "-q", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if symbolic.returncode != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_DETACHED")
|
||||
status = subprocess.run(
|
||||
["git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if status.returncode != 0 or status.stdout:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_CLEAN")
|
||||
parents = self._git(["rev-list", "--parents", "-n", "1", stale_oid]).split()
|
||||
if parents != [stale_oid, expected_oid]:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_LINEAGE_INVALID")
|
||||
registered = [
|
||||
line.removeprefix("worktree ")
|
||||
for line in self._git(["worktree", "list", "--porcelain"]).splitlines()
|
||||
if line.startswith("worktree ")
|
||||
]
|
||||
if registered.count(str(path)) != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_REGISTRATION_INVALID")
|
||||
self._git(["worktree", "remove", "--force", str(path)])
|
||||
if path.exists() or path.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_REMOVE_FAILED")
|
||||
recreated = self.create_detached_worktree(expected_oid, path.name, path.parent)
|
||||
readback = subprocess.run(
|
||||
["git", "-C", str(recreated), "rev-parse", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
detached = subprocess.run(
|
||||
["git", "-C", str(recreated), "symbolic-ref", "-q", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if readback.returncode != 0 or readback.stdout.strip() != expected_oid or detached.returncode != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_READBACK_MISMATCH")
|
||||
return stale_oid
|
||||
|
||||
def create_task_branch_CAS(self, branch: str, expected_base_oid: str, new_oid: str) -> str:
|
||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
_oid(expected_base_oid, "expected_base_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
current = self.read_ref(branch)
|
||||
if current is not None:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
if not self.ancestry_check(expected_base_oid, new_oid):
|
||||
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
||||
self._git(["update-ref", branch, new_oid, "0" * 40])
|
||||
readback = self.read_ref(branch)
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_CREATE_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
current = self.read_ref(branch)
|
||||
if current != expected_oid:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
||||
if not self.ancestry_check(expected_oid, new_oid):
|
||||
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
||||
self._git(["update-ref", branch, new_oid, expected_oid])
|
||||
readback = self.read_ref(branch)
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def ff_CAS_main_after_verified_protected_merge(self, expected_oid: str, new_oid: str) -> str:
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
if not self.ancestry_check(expected_oid, new_oid):
|
||||
raise AuthorityGitError("MAIN_FF_REQUIRED")
|
||||
current = self.read_ref("refs/heads/main")
|
||||
if current != expected_oid:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_MAIN_EXPECTATION_MISMATCH")
|
||||
self._git(["update-ref", "refs/heads/main", new_oid, expected_oid])
|
||||
readback = self.read_ref("refs/heads/main")
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_MAIN_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def commit_file_in_isolated_worktree(
|
||||
self,
|
||||
*,
|
||||
base_oid: str,
|
||||
files: Mapping[str, bytes],
|
||||
message: str,
|
||||
worktree_name: str,
|
||||
) -> str:
|
||||
_oid(base_oid, "base_oid")
|
||||
if not worktree_name or "/" in worktree_name or "\\" in worktree_name or worktree_name in {".", ".."}:
|
||||
raise AuthorityGitError("WORKTREE_NAME_INVALID")
|
||||
root = self.worktree_root / worktree_name
|
||||
if root.exists() or root.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ALREADY_EXISTS")
|
||||
self.worktree_root.mkdir(parents=True, exist_ok=True)
|
||||
self._git(["worktree", "add", "--detach", str(root), base_oid])
|
||||
try:
|
||||
for relative, content in files.items():
|
||||
path = Path(relative)
|
||||
if path.is_absolute() or ".." in path.parts or not path.parts or path.parts[0] == ".git":
|
||||
raise AuthorityGitError("FILE_PATH_INVALID")
|
||||
target = root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "add", "--", *files.keys()],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthorityGitError("WORKTREE_ADD_FAILED")
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "-c", "user.name=aios-durable-job", "-c", "user.email=aios-durable-job@localhost", "commit", "--no-verify", "-m", message],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthorityGitError("WORKTREE_COMMIT_FAILED")
|
||||
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
|
||||
finally:
|
||||
subprocess.run(["git", f"--git-dir={self.git_dir}", "worktree", "remove", "--force", str(root)], capture_output=True, check=False)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""HTTP adapter for Owner-authorized task writer provisioning.
|
||||
|
||||
Run this module instead of aios_chatgpt_actions_api.py. All existing routes are
|
||||
delegated unchanged; only POST /api/task-writer/provision-authorized is added.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
import aios_chatgpt_actions_api as api
|
||||
from authorized_task_writer_provisioning import (
|
||||
AuthorizedTaskWriterProvisioner,
|
||||
OwnerContext,
|
||||
ProvisioningError,
|
||||
)
|
||||
|
||||
|
||||
_original_do_post = api.H.do_POST
|
||||
_ENDPOINT = "/api/task-writer/provision-authorized"
|
||||
|
||||
|
||||
def _owner_authorization(handler):
|
||||
scope = os.getenv(
|
||||
"AIOS_GITOPS_OWNER_PROVISION_SCOPE", "writer:provision"
|
||||
)
|
||||
auth = handler.need(scope)
|
||||
if not auth:
|
||||
return None
|
||||
if auth.get("role") != "owner":
|
||||
handler.sendj({"error": "OWNER_AUTHORIZATION_REQUIRED"}, 403)
|
||||
return None
|
||||
identity = str(auth.get("account") or "").strip()
|
||||
if not identity:
|
||||
handler.sendj({"error": "OWNER_IDENTITY_REQUIRED"}, 403)
|
||||
return None
|
||||
return auth
|
||||
|
||||
|
||||
def _authorized_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
return _original_do_post(self)
|
||||
auth = _owner_authorization(self)
|
||||
if not auth:
|
||||
return
|
||||
data = self.body()
|
||||
owner_reference = str(data.get("owner_authorization_reference") or "")
|
||||
try:
|
||||
result = AuthorizedTaskWriterProvisioner().provision(
|
||||
data,
|
||||
OwnerContext(
|
||||
identity=str(auth["account"]),
|
||||
role=str(auth["role"]),
|
||||
authorization_reference=owner_reference,
|
||||
),
|
||||
)
|
||||
return self.sendj(result)
|
||||
except ProvisioningError as exc:
|
||||
body = {"error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
return self.sendj(body, exc.status)
|
||||
except Exception:
|
||||
# Do not expose tool output, paths containing credentials, or traceback data.
|
||||
return self.sendj({"error": "AUTHORIZED_WRITER_BRIDGE_INTERNAL_ERROR"}, 500)
|
||||
|
||||
|
||||
api.H.do_POST = _authorized_do_post
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.main()
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Governed API facade for branch-bound repair leases.
|
||||
|
||||
The facade is transport-neutral so the permanent control plane can expose it
|
||||
through its existing authenticated loopback/API seam without creating a second
|
||||
Git writer or a global credential registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from branch_repair_lease import LeaseError, RepairLease, RepairLeaseManager, sanitized_lease_view
|
||||
|
||||
|
||||
class BranchRepairLeaseAPI:
|
||||
"""Validate operation envelopes and return sanitized lease metadata."""
|
||||
|
||||
def __init__(self, manager: RepairLeaseManager) -> None:
|
||||
self.manager = manager
|
||||
|
||||
@staticmethod
|
||||
def _required(request: Mapping[str, Any], name: str) -> Any:
|
||||
value = request.get(name)
|
||||
if value is None or value == "":
|
||||
raise LeaseError(f"missing required field: {name}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _view(lease: RepairLease) -> dict[str, Any]:
|
||||
return {"status": "ok", "lease": sanitized_lease_view(lease)}
|
||||
|
||||
def handle(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
operation = self._required(request, "operation")
|
||||
owner = str(self._required(request, "owner_identity"))
|
||||
if operation == "issue":
|
||||
lease = self.manager.issue(
|
||||
task_id=str(self._required(request, "task_id")),
|
||||
owner_identity=owner,
|
||||
branch=str(self._required(request, "branch")),
|
||||
description=str(self._required(request, "description")),
|
||||
allowed_paths=tuple(self._required(request, "allowed_paths")),
|
||||
ttl_seconds=int(request.get("ttl_seconds", 3600)),
|
||||
)
|
||||
elif operation == "attach_existing_branch":
|
||||
lease = self.manager.attach_existing_branch(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
)
|
||||
elif operation == "authorize_commit":
|
||||
lease = self.manager.authorize_commit(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
paths=tuple(self._required(request, "paths")),
|
||||
content=request.get("content"),
|
||||
)
|
||||
elif operation == "record_commit":
|
||||
lease = self.manager.record_commit(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
new_head_oid=str(self._required(request, "new_head_oid")),
|
||||
paths=tuple(self._required(request, "paths")),
|
||||
)
|
||||
elif operation == "recover":
|
||||
lease = self.manager.recover(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
elif operation == "renew":
|
||||
lease = self.manager.renew(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
ttl_seconds=int(self._required(request, "ttl_seconds")),
|
||||
)
|
||||
elif operation == "complete":
|
||||
lease = self.manager.complete(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
elif operation == "revoke":
|
||||
lease = self.manager.revoke(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
else:
|
||||
raise LeaseError("unsupported lease operation")
|
||||
return self._view(lease)
|
||||
except (LeaseError, TypeError, ValueError):
|
||||
return {"status": "blocked", "error": "lease_request_rejected"}
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Root-only CLI for the branch-bound repair lease API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aios_branch_repair_lease_api import BranchRepairLeaseAPI
|
||||
from branch_repair_lease import GitProbe, LeaseStore
|
||||
from branch_repair_lease import RepairLeaseManager
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS branch repair lease CLI")
|
||||
parser.add_argument("--store", required=True)
|
||||
parser.add_argument("--bare-repo", required=True)
|
||||
parser.add_argument("--request-file")
|
||||
parser.add_argument("--request-json")
|
||||
args = parser.parse_args()
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
print(json.dumps({"status": "blocked", "error": "root_required"}))
|
||||
return 2
|
||||
if bool(args.request_file) == bool(args.request_json):
|
||||
print(json.dumps({"status": "blocked", "error": "one_request_source_required"}))
|
||||
return 2
|
||||
try:
|
||||
raw = (
|
||||
Path(args.request_file).read_text(encoding="utf-8")
|
||||
if args.request_file
|
||||
else str(args.request_json)
|
||||
)
|
||||
request = json.loads(raw)
|
||||
if not isinstance(request, dict):
|
||||
raise ValueError("request must be an object")
|
||||
api = BranchRepairLeaseAPI(RepairLeaseManager(LeaseStore(args.store), GitProbe(args.bare_repo)))
|
||||
response = api.handle(request)
|
||||
print(json.dumps(response, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
||||
return 0 if response.get("status") == "ok" else 1
|
||||
except Exception:
|
||||
print(json.dumps({"status": "blocked", "error": "lease_cli_failed"}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Read-only validation of recovered campaign authorization evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RecoveryEvidenceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _digest(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _secure_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_MISSING")
|
||||
info = path.stat(follow_symlinks=False)
|
||||
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & 0o022:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PERMISSIONS")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
|
||||
def load_recovered_authorization(path: str | Path) -> dict[str, Any]:
|
||||
"""Verify the recovery receipt, sidecar, and all referenced immutable files."""
|
||||
receipt_path = Path(path)
|
||||
receipt = _secure_json(receipt_path)
|
||||
sidecar = receipt_path.with_suffix(receipt_path.suffix + ".sha256")
|
||||
if sidecar.is_symlink() or not sidecar.is_file():
|
||||
raise RecoveryEvidenceError("RECOVERY_DIGEST_SIDECAR_MISSING")
|
||||
expected = sidecar.read_text(encoding="ascii").split()[0]
|
||||
actual = _digest(receipt_path)
|
||||
if expected != actual:
|
||||
raise RecoveryEvidenceError("RECOVERY_DIGEST_MISMATCH")
|
||||
required = {
|
||||
"schema",
|
||||
"campaign_id",
|
||||
"original_event_id",
|
||||
"original_authorization_digest",
|
||||
"signed_grant_id",
|
||||
"signed_grant_digest",
|
||||
"task_id",
|
||||
"repository",
|
||||
"original_base_oid",
|
||||
"branch",
|
||||
"expected_branch_oid",
|
||||
"recovered_from_existing_evidence",
|
||||
"new_owner_interaction",
|
||||
"recovery_state",
|
||||
"production_side_effect_executed",
|
||||
"global_writes_enabled",
|
||||
"grant_consumed",
|
||||
"owner_authorization_consumed",
|
||||
"original_receipts_immutable",
|
||||
"recovered_at",
|
||||
"evidence_files",
|
||||
}
|
||||
if set(receipt) != required or receipt["schema"] != "aios.owner-authorization-recovery.v1":
|
||||
raise RecoveryEvidenceError("RECOVERY_SCHEMA_INVALID")
|
||||
if receipt["recovered_from_existing_evidence"] is not True or receipt["new_owner_interaction"] is not False:
|
||||
raise RecoveryEvidenceError("RECOVERY_NOT_APPEND_ONLY")
|
||||
if receipt["production_side_effect_executed"] is not False or receipt["global_writes_enabled"] is not False:
|
||||
raise RecoveryEvidenceError("RECOVERY_SCOPE_INVALID")
|
||||
if receipt["recovery_state"] != "RECOVERED_PENDING_CONTINUATION_LEASE":
|
||||
raise RecoveryEvidenceError("RECOVERY_STATE_INVALID")
|
||||
evidence = receipt["evidence_files"]
|
||||
if not isinstance(evidence, list) or not evidence:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_LIST_INVALID")
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict) or set(item) != {"path", "sha256"}:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_ENTRY_INVALID")
|
||||
target = Path(item["path"])
|
||||
if _digest(target) != item["sha256"]:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
||||
return {
|
||||
"schema": receipt["schema"],
|
||||
"state": receipt["recovery_state"],
|
||||
"campaign_id": receipt["campaign_id"],
|
||||
"original_event_id": receipt["original_event_id"],
|
||||
"original_authorization_digest": receipt["original_authorization_digest"],
|
||||
"signed_grant_id": receipt["signed_grant_id"],
|
||||
"task_id": receipt["task_id"],
|
||||
"repository": receipt["repository"],
|
||||
"original_base_oid": receipt["original_base_oid"],
|
||||
"branch": receipt["branch"],
|
||||
"expected_branch_oid": receipt["expected_branch_oid"],
|
||||
"digest": actual,
|
||||
"new_owner_interaction": False,
|
||||
}
|
||||
Regular → Executable
+36
-591
@@ -1,68 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
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,
|
||||
)
|
||||
sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os'))
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
CapabilityAuthorizationError,
|
||||
SagaConflict,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
)
|
||||
try:
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
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'))
|
||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
@@ -72,19 +16,6 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
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')))
|
||||
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()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -93,49 +24,7 @@ BASE_MODELS = [
|
||||
{'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_VAL_RE = re.compile(
|
||||
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
|
||||
if (
|
||||
method == 'POST'
|
||||
and path in ('/api/file', '/api/commit-file')
|
||||
and os.getenv('AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED', os.getenv('AIOS_GITOPS_JOB_CONTROLLER_ENABLED', 'false')).lower() in {'1', 'true', 'yes', 'on'}
|
||||
):
|
||||
return False
|
||||
decision = evaluate_git_write(WS)
|
||||
if decision.allowed:
|
||||
return False
|
||||
handler.sendj(decision.payload(), decision.status_code)
|
||||
return True
|
||||
SECRET_VAL_RE = re.compile(r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})')
|
||||
|
||||
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]}'
|
||||
@@ -147,41 +36,6 @@ def sh(args, check=False):
|
||||
def jload(p: Path, default):
|
||||
try: return json.loads(p.read_text(encoding='utf-8'))
|
||||
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:
|
||||
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')
|
||||
@@ -255,13 +109,9 @@ def ensure_git():
|
||||
if not sh(['config','user.name'])['stdout']: sh(['config','user.name','chatgpt-gitops-bot'], True)
|
||||
def checkout(branch, base='main', create=False):
|
||||
ensure_git(); b=branch_name(branch)
|
||||
exists = sh(['rev-parse','--verify',f'refs/heads/{b}'])['code'] == 0
|
||||
if exists:
|
||||
sh(['checkout',b], True)
|
||||
elif create:
|
||||
sh(['checkout',base], True); sh(['checkout','-b',b], True)
|
||||
else:
|
||||
raise ValueError(f'branch does not exist: {b}')
|
||||
if create or sh(['rev-parse','--verify',b])['code'] != 0:
|
||||
sh(['checkout',base], True); sh(['checkout','-B',b], True)
|
||||
else: sh(['checkout',b], True)
|
||||
return b
|
||||
def write_file(path, content):
|
||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||
@@ -271,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/'):
|
||||
return {'status':'skipped','reason':'protected_branch','branch':branch}
|
||||
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}'
|
||||
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}')
|
||||
@@ -293,68 +130,15 @@ def sync_branch(branch: str) -> dict:
|
||||
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
|
||||
def sync_tag(tag: str) -> dict:
|
||||
raise RuntimeError(
|
||||
'unsafe sync_tag disabled: use TagPublisher with expected tag_oid, '
|
||||
'target_oid, Gitea remote verification, and authenticated API verification'
|
||||
)
|
||||
|
||||
def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
||||
oid = str(value or '').strip().lower()
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', oid):
|
||||
raise ValueError(f'{field} must be a full 40-character commit OID')
|
||||
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'),
|
||||
)
|
||||
if not tag: return {'status':'skipped','reason':'empty_tag'}
|
||||
if BARE_REPO.exists():
|
||||
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}')
|
||||
return {'status':'synced','target':'bare','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
p=subprocess.run(['git','push','origin',tag], cwd=str(WS), text=True, capture_output=True)
|
||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
|
||||
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
|
||||
def commit(paths, msg):
|
||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||
@@ -374,16 +158,6 @@ def patches():
|
||||
for p in sorted((WS/'patches').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
|
||||
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):
|
||||
server_version='AIOSChatGPTGitOps/V1R1C0'
|
||||
def sendj(self, data, code=200):
|
||||
@@ -404,282 +178,44 @@ class H(BaseHTTPRequestHandler):
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); 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
|
||||
def bridge_release(self, path, data):
|
||||
route = {
|
||||
'/createTagApprovalRequest': '/v2/tag/approval-request',
|
||||
'/requestOrCreateTag': '/v2/tag/publish',
|
||||
'/getTagPublicationStatus': '/v2/tag/status',
|
||||
'/buildProductionArtifact': '/v2/release/artifact',
|
||||
'/executeApprovedProductionRelease': '/v2/release/execute',
|
||||
'/getReleaseConsistency': '/v2/release/consistency',
|
||||
'/runReleaseAcceptance': '/v2/release/acceptance',
|
||||
}.get(path)
|
||||
if route is None:
|
||||
raise ValueError('governed_release_route_invalid')
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'bridge_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path; q=urllib.parse.parse_qs(u.query)
|
||||
try:
|
||||
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'):
|
||||
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/base','/api/baseline','/base'):
|
||||
data=jload(WS/'baseline'/'current.json', {})
|
||||
data.setdefault('authoritative_repository', repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
))
|
||||
return self.sendj(data)
|
||||
data=jload(WS/'baseline'/'current.json', {}); data.setdefault('git', sh(['rev-parse','--short','HEAD'])['stdout']); return self.sendj(data)
|
||||
if path in ('/api/ver','/api/versions','/ver'):
|
||||
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'],
|
||||
'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'],
|
||||
})
|
||||
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']})
|
||||
if path in ('/api/diff','/diff'):
|
||||
frm=(q.get('from') or [''])[0]
|
||||
to=(q.get('to') or [''])[0]
|
||||
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],
|
||||
})
|
||||
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])})
|
||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'): return self.sendj({'patches':patches(),'branches':sh(['branch','--format','%(refname:short)'])['stdout'].splitlines()})
|
||||
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'):
|
||||
svc=subprocess.run(['systemctl','is-active','aios-gitops-model-api'], text=True, capture_output=True)
|
||||
snapshot = repository_snapshot(
|
||||
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)
|
||||
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']})
|
||||
if path in ('/api/action-monitor','/action-monitor'):
|
||||
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
|
||||
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
|
||||
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
|
||||
return self.ai_evolution_page()
|
||||
if path in ('/admin/ai-evolution/api/data'):
|
||||
return self.ai_evolution_data()
|
||||
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:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_GET', error, {'path':path})
|
||||
return self.sendj({'error':error},500)
|
||||
monitor.record_failure('do_GET', str(e), {'path':path})
|
||||
return self.sendj({'error':str(e)},500)
|
||||
def do_PUT(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if reject_non_read_http(self, 'PUT', path): return
|
||||
data=self.body()
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||
if path == '/api/model-priority': return self.model_priority(data)
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||
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/file','/api/commit-file'): return self.file(data)
|
||||
if path == '/api/commit': return self.commit(data)
|
||||
@@ -690,30 +226,9 @@ class H(BaseHTTPRequestHandler):
|
||||
if path == '/api/model-priority': return self.model_priority(data)
|
||||
if path == '/api/nl': return self.nl(data)
|
||||
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:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
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)
|
||||
monitor.record_failure('do_POST', str(e), {'path':path})
|
||||
return self.sendj({'error':str(e)},500)
|
||||
def branch(self,data):
|
||||
a=self.need('branch:create');
|
||||
if not a: return
|
||||
@@ -722,44 +237,7 @@ class H(BaseHTTPRequestHandler):
|
||||
def file(self,data):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
if os.getenv('AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED', os.getenv('AIOS_GITOPS_JOB_CONTROLLER_ENABLED', 'false')).lower() not in {'1','true','yes','on'}:
|
||||
return self.sendj({'error':'DURABLE_JOB_CONTROLLER_UNAVAILABLE','retryable':True},503)
|
||||
try:
|
||||
path = relpath(str(data.get('path') or ''))
|
||||
branch = str(data.get('branch') or '')
|
||||
expected = str(data.get('expected_branch_oid') or '')
|
||||
campaign_id = str(data.get('campaign_id') or os.getenv('AIOS_GITOPS_CAMPAIGN_ID') or '')
|
||||
supplied_digest = str(data.get('payload_digest') or '')
|
||||
content = data.get('content')
|
||||
payload_bytes = None
|
||||
if content is not None:
|
||||
payload_bytes = content.encode('utf-8') if isinstance(content, str) else json.dumps(content, sort_keys=True, ensure_ascii=False).encode('utf-8')
|
||||
payload_digest = supplied_digest or hashlib.sha256(payload_bytes or b'').hexdigest()
|
||||
if not campaign_id or not branch or not expected:
|
||||
return self.sendj({'error':'JOB_BINDING_REQUIRED','retryable':False},409)
|
||||
request = {
|
||||
'campaign_id': campaign_id,
|
||||
'repository': REPOSITORY_IDENTITY or 'aios/aios',
|
||||
'operation': 'commit-file',
|
||||
'branch': branch,
|
||||
'expected_branch_oid': expected,
|
||||
'payload_digest': payload_digest,
|
||||
# The controller persists only the binding and digest. Raw
|
||||
# content is never copied into durable state or logs.
|
||||
}
|
||||
receipt = GitOpsJobController(
|
||||
JobStore(Path(os.getenv('AIOS_GITOPS_JOB_ROOT', '/var/lib/aios-gitops/jobs')))
|
||||
).submit(request, payload=payload_bytes)
|
||||
return self.sendj({
|
||||
'job_id': receipt.job_id,
|
||||
'state': receipt.state.value,
|
||||
'side_effect_state': receipt.side_effect_state,
|
||||
'retryable': receipt.http_status == 503,
|
||||
}, receipt.http_status)
|
||||
except ServiceUnavailable as exc:
|
||||
return self.sendj({'error':str(exc),'layer':'durable_job','retryable':True,'side_effect_state':'UNKNOWN'},503)
|
||||
except JobError as exc:
|
||||
return self.sendj({'error':str(exc),'retryable':False},409)
|
||||
b=checkout(data.get('branch'), data.get('base','main'), bool(data.get('create_branch'))); r=write_file(data.get('path'), data.get('content','')); c=commit([r], data.get('message') or f'chore(chatgpt): update {r}'); audit(a['account'],'file',r,{'branch':b,'commit':c}); self.sendj({'status':'committed','branch':b,'path':r,'commit':c})
|
||||
def commit(self,data):
|
||||
a=self.need('commit:create');
|
||||
if not a: return
|
||||
@@ -774,41 +252,12 @@ class H(BaseHTTPRequestHandler):
|
||||
if not a: return
|
||||
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 data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
||||
return self.sendj({
|
||||
'error':'production_tag_evidence_required',
|
||||
'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 data.get('confirm') != True:
|
||||
self.sendj({'status':'confirm_required','operation':'tag','hint':'This operation creates a Git tag. Confirm to proceed.','x-openai-isConsequential':True}, 428)
|
||||
return
|
||||
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})
|
||||
request=PublicationRequest(ver,target_oid,evidence_oid,str(data.get('approval_plan_id')))
|
||||
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)
|
||||
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})
|
||||
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)})
|
||||
def rollback(self,data):
|
||||
a=self.need('rollback:request');
|
||||
if not a: return
|
||||
@@ -903,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]
|
||||
bl=jload(WS/'baseline'/'current.json',{})
|
||||
current_ver=bl.get('current_baseline','V1R3C1')
|
||||
try:
|
||||
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)
|
||||
tags=sh(['tag','--list','V*'])['stdout'].splitlines() if WS.exists() else []
|
||||
versions=[]
|
||||
if (WS/'baseline'/'promotions').exists():
|
||||
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
|
||||
@@ -944,10 +390,9 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
|
||||
|
||||
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():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
@@ -1,224 +0,0 @@
|
||||
"""ChatGPT GitOps Bridge v2 contract and governed release operations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
BRIDGE_SCHEMA_VERSION = "aios.chatgpt-gitops-bridge.v2"
|
||||
BRIDGE_OPERATIONS = (
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"createGovernedTask",
|
||||
"acquireWriterLease",
|
||||
"bootstrapWriterLease",
|
||||
"renewWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
"getGitOpsJob",
|
||||
"resumeGitOpsJob",
|
||||
"reconcileGitOpsJob",
|
||||
"createTagApprovalRequest",
|
||||
"requestOrCreateTag",
|
||||
"getTagPublicationStatus",
|
||||
"buildProductionArtifact",
|
||||
"executeApprovedProductionRelease",
|
||||
"getReleaseConsistency",
|
||||
"runReleaseAcceptance",
|
||||
"getControlPlaneSnapshot",
|
||||
)
|
||||
|
||||
OPERATION_REQUIRED_FIELDS = {
|
||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"bootstrapWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"renewWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
"getGitOpsJob": ("job_id",),
|
||||
"resumeGitOpsJob": ("job_id", "expected_revision"),
|
||||
"reconcileGitOpsJob": ("job_id", "expected_revision"),
|
||||
"createTagApprovalRequest": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "owner_approval", "execute"),
|
||||
"requestOrCreateTag": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"),
|
||||
"getTagPublicationStatus": ("version",),
|
||||
"buildProductionArtifact": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"executeApprovedProductionRelease": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"),
|
||||
"getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"getControlPlaneSnapshot": (),
|
||||
}
|
||||
|
||||
BRIDGE_OPERATION_CONTRACTS = {
|
||||
"acquireWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["acquireWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||
),
|
||||
},
|
||||
"bootstrapWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease/bootstrap",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||
"durable_branch_lease_present", "idempotent",
|
||||
),
|
||||
},
|
||||
"renewWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease/renew",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["renewWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||
"durable_branch_lease_present", "idempotent",
|
||||
),
|
||||
},
|
||||
"commitFileGoverned": {
|
||||
"method": "POST", "path": "/v2/commit-file",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["commitFileGoverned"],
|
||||
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||
},
|
||||
"commitFilesGoverned": {
|
||||
"method": "POST", "path": "/v2/commit-files",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["commitFilesGoverned"],
|
||||
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RuntimeDriftError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BridgeV2Error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ControlPlaneReleaseManifest:
|
||||
control_plane_release_version: str
|
||||
control_plane_target_oid: str
|
||||
artifact_sha256: str
|
||||
runtime_path: Path
|
||||
bridge_schema_version: str
|
||||
repository_identity: str
|
||||
authoritative_git_dir: Path
|
||||
deployment_state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"control_plane_release_version": self.control_plane_release_version,
|
||||
"control_plane_target_oid": self.control_plane_target_oid,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"runtime_path": str(self.runtime_path),
|
||||
"bridge_schema_version": self.bridge_schema_version,
|
||||
"repository_identity": self.repository_identity,
|
||||
"authoritative_git_dir": str(self.authoritative_git_dir),
|
||||
"deployment_state": self.deployment_state,
|
||||
}
|
||||
|
||||
def validate(self, artifact: Path, *, observed_oid: str, approved_root: Path = Path("/opt/aios/gitops-control-plane")) -> dict[str, object]:
|
||||
try:
|
||||
runtime = self.runtime_path.resolve(strict=True)
|
||||
root = approved_root.resolve(strict=True)
|
||||
artifact_path = artifact.resolve(strict=True)
|
||||
runtime.relative_to(root)
|
||||
artifact_path.relative_to(runtime)
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT") from None
|
||||
mutable_markers = {"workspace", "workspaces", "worktree", "worktrees"}
|
||||
if mutable_markers.intersection(part.lower() for part in runtime.parts):
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
valid = (
|
||||
len(self.control_plane_target_oid) == 40
|
||||
and observed_oid == self.control_plane_target_oid
|
||||
and len(self.artifact_sha256) == 64
|
||||
and hashlib.sha256(artifact_path.read_bytes()).hexdigest() == self.artifact_sha256
|
||||
and self.bridge_schema_version == BRIDGE_SCHEMA_VERSION
|
||||
and self.repository_identity == "aios/aios"
|
||||
and self.authoritative_git_dir == Path("/data/aios-gitops/repo.git")
|
||||
and self.authoritative_git_dir.is_absolute()
|
||||
and self.deployment_state == "STAGING"
|
||||
)
|
||||
if not valid:
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
return {**self.as_dict(), "drift_status": "PASS"}
|
||||
|
||||
|
||||
class BridgeV2Adapter:
|
||||
"""Thin operation adapter; all mutating handlers are injected seams."""
|
||||
|
||||
def __init__(self, *, runtime_status: Mapping[str, Any], handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None) -> None:
|
||||
self.runtime_status = dict(runtime_status)
|
||||
self.handlers = dict(handlers or {})
|
||||
|
||||
def getRuntimeStatus(self) -> dict[str, Any]:
|
||||
return {"bridge_schema_version": BRIDGE_SCHEMA_VERSION, **self.runtime_status}
|
||||
|
||||
def getGovernedWriteCapabilities(self) -> dict[str, Any]:
|
||||
return {
|
||||
"bridge_schema_version": BRIDGE_SCHEMA_VERSION,
|
||||
"legacy_writes_enabled": False,
|
||||
"governed_writes_enabled": True,
|
||||
"operations": list(BRIDGE_OPERATIONS),
|
||||
"operation_contracts": {
|
||||
operation: {
|
||||
**contract,
|
||||
"required_fields": list(contract["required_fields"]),
|
||||
"response_fields": list(contract["response_fields"]),
|
||||
}
|
||||
for operation, contract in BRIDGE_OPERATION_CONTRACTS.items()
|
||||
},
|
||||
"force_push": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"production_write": False,
|
||||
"approved_release_operations": True,
|
||||
"approved_release_scope": {
|
||||
"version": "V2R2C1",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def legacy_write_disabled() -> dict[str, object]:
|
||||
return {"error": "LEGACY_WRITE_DISABLED", "replacement": "createGovernedTask", "governed_writes_available": True}
|
||||
|
||||
def _call(self, operation: str, request: Mapping[str, Any]) -> Any:
|
||||
required = OPERATION_REQUIRED_FIELDS[operation]
|
||||
if any(key not in request for key in required):
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_MISSING")
|
||||
handler = self.handlers.get(operation)
|
||||
if handler is None:
|
||||
raise BridgeV2Error("GOVERNED_HANDLER_UNAVAILABLE")
|
||||
return handler(dict(request))
|
||||
|
||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||
def bootstrapWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("bootstrapWriterLease", request)
|
||||
def renewWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("renewWriterLease", request)
|
||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
||||
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
||||
def createTagApprovalRequest(self, request: Mapping[str, Any]) -> Any: return self._call("createTagApprovalRequest", request)
|
||||
def requestOrCreateTag(self, request: Mapping[str, Any]) -> Any: return self._call("requestOrCreateTag", request)
|
||||
def getTagPublicationStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTagPublicationStatus", request)
|
||||
def buildProductionArtifact(self, request: Mapping[str, Any]) -> Any: return self._call("buildProductionArtifact", request)
|
||||
def executeApprovedProductionRelease(self, request: Mapping[str, Any]) -> Any: return self._call("executeApprovedProductionRelease", request)
|
||||
def getReleaseConsistency(self, request: Mapping[str, Any]) -> Any: return self._call("getReleaseConsistency", request)
|
||||
def runReleaseAcceptance(self, request: Mapping[str, Any]) -> Any: return self._call("runReleaseAcceptance", request)
|
||||
|
||||
def getControlPlaneSnapshot(self, request: Mapping[str, Any] | None = None) -> Any: return self._call("getControlPlaneSnapshot", request or {})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,692 +0,0 @@
|
||||
"""Governed continuation executor for exact existing task branches.
|
||||
|
||||
The executor composes the existing continuation lease and durable Job store.
|
||||
All Git and Gitea side effects live behind injected narrow interfaces so the
|
||||
state machine can prove Authority, Git-network, API-branch, and commit-object
|
||||
agreement before it reports completion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Callable, Mapping, Protocol
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobState
|
||||
from aios_gitops_reconciliation_core import ReconciliationAction, RemoteObservation, decide_reconciliation
|
||||
from authorized_task_writer_provisioning import validate_writer_lease_request_v2
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(rb"gh[pousr]_[A-Za-z0-9]{20,}"),
|
||||
re.compile(rb"(?i)(?:token|password|secret|api[_-]?key)\s*[:=]\s*[\"'][^\"'\s]{12,}[\"']"),
|
||||
re.compile(rb"-----BEGIN " + rb"(?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
)
|
||||
|
||||
|
||||
class ContinuationExecutionError(RuntimeError):
|
||||
"""Stable fail-closed continuation execution error."""
|
||||
|
||||
|
||||
class DetachedWorkspace(Protocol):
|
||||
def is_detached(self) -> bool: ...
|
||||
def head_oid(self) -> str: ...
|
||||
def commit_files(self, files: Mapping[str, bytes], message: str) -> str: ...
|
||||
|
||||
|
||||
class AuthorityPort(Protocol):
|
||||
def branch_exists(self, branch: str) -> bool: ...
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str: ...
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
class RemotePort(Protocol):
|
||||
def repository_identity(self) -> str: ...
|
||||
def push_new_exact(self, branch: str, new: str) -> None: ...
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None: ...
|
||||
def read_git_ref(self, branch: str) -> str | None: ...
|
||||
def read_api_branch(self, branch: str) -> str | None: ...
|
||||
def commit_visible(self, oid: str) -> bool: ...
|
||||
|
||||
|
||||
class CredentialProjection(Protocol):
|
||||
def project(self) -> None: ...
|
||||
def revoke(self) -> None: ...
|
||||
|
||||
|
||||
def _run(argv: list[str], *, cwd: Path | None = None, environment: Mapping[str, str] | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=None if environment is None else dict(environment),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ContinuationExecutionError("SUBPROCESS_FAILED")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthorityGitAdapter:
|
||||
"""Branch-name adapter over the existing narrow AuthorityGitExecutor."""
|
||||
|
||||
executor: AuthorityGitExecutor
|
||||
|
||||
@staticmethod
|
||||
def _ref(branch: str) -> str:
|
||||
return f"refs/heads/{branch}"
|
||||
|
||||
def branch_exists(self, branch: str) -> bool:
|
||||
return self.executor.read_ref(self._ref(branch)) is not None
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
value = self.executor.read_ref(self._ref(branch))
|
||||
if value is None:
|
||||
raise ContinuationExecutionError("EXISTING_BRANCH_MISSING")
|
||||
return value
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return self.executor.ancestry_check(older, newer)
|
||||
|
||||
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str:
|
||||
return self.executor.create_task_branch_CAS(self._ref(branch), expected_base, new)
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
||||
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DetachedGitWorkspace:
|
||||
path: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.path.is_absolute() or self.path.is_symlink() or not self.path.is_dir():
|
||||
raise ContinuationExecutionError("WORKTREE_PATH_INVALID")
|
||||
top = Path(_run(["git", "rev-parse", "--show-toplevel"], cwd=self.path)).resolve()
|
||||
if top != self.path.resolve():
|
||||
raise ContinuationExecutionError("WORKTREE_PATH_INVALID")
|
||||
|
||||
def is_detached(self) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "symbolic-ref", "-q", "HEAD"],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 1
|
||||
|
||||
def head_oid(self) -> str:
|
||||
return _run(["git", "rev-parse", "HEAD"], cwd=self.path)
|
||||
|
||||
def _dirty_paths(self) -> set[str]:
|
||||
command = subprocess.run(
|
||||
["git", "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
cwd=self.path,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if command.returncode != 0:
|
||||
raise ContinuationExecutionError("SUBPROCESS_FAILED")
|
||||
output = command.stdout
|
||||
result: set[str] = set()
|
||||
for line in output.splitlines():
|
||||
path = line[3:]
|
||||
if " -> " in path:
|
||||
path = path.split(" -> ", 1)[1]
|
||||
result.add(path)
|
||||
return result
|
||||
|
||||
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
||||
dirty = self._dirty_paths()
|
||||
expected_paths = set(files)
|
||||
if dirty and dirty != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
try:
|
||||
target.relative_to(self.path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED") from exc
|
||||
if target.is_symlink():
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
|
||||
def commit_files(self, files: Mapping[str, bytes], message: str) -> str:
|
||||
expected_paths = set(files)
|
||||
if self._dirty_paths() != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
try:
|
||||
target.relative_to(self.path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED") from exc
|
||||
if not target.is_file() or target.is_symlink() or target.read_bytes() != content:
|
||||
raise ContinuationExecutionError("AUTHORING_PAYLOAD_MISMATCH")
|
||||
_run(["git", "add", "--", *sorted(expected_paths)], cwd=self.path)
|
||||
staged = set(_run(["git", "diff", "--cached", "--name-only"], cwd=self.path).splitlines())
|
||||
if staged != expected_paths:
|
||||
raise ContinuationExecutionError("STAGED_SCOPE_MISMATCH")
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"GIT_AUTHOR_NAME": "AIOS Governed Continuation",
|
||||
"GIT_AUTHOR_EMAIL": "aios-governed-continuation@localhost",
|
||||
"GIT_COMMITTER_NAME": "AIOS Governed Continuation",
|
||||
"GIT_COMMITTER_EMAIL": "aios-governed-continuation@localhost",
|
||||
}
|
||||
)
|
||||
_run(["git", "commit", "--no-gpg-sign", "-m", message, "--", *sorted(expected_paths)], cwd=self.path, environment=environment)
|
||||
if not self.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
return self.head_oid()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ServerSideCredentialProjection:
|
||||
ssh_key_path: Path
|
||||
api_token_path: Path
|
||||
active: bool = False
|
||||
|
||||
def project(self) -> None:
|
||||
for path in (self.ssh_key_path, self.api_token_path):
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size < 32:
|
||||
raise ContinuationExecutionError("CREDENTIAL_PROJECTION_MISSING")
|
||||
self.active = True
|
||||
|
||||
def revoke(self) -> None:
|
||||
self.active = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaContinuationRemote:
|
||||
workspace: Path
|
||||
credential: ServerSideCredentialProjection
|
||||
remote_name: str = "gitea"
|
||||
repository: str = "aios/aios"
|
||||
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||
expected_host: str = "g.cnnetmkt.top"
|
||||
|
||||
@staticmethod
|
||||
def validate_remote_url(value: str, expected_host: str = "g.cnnetmkt.top") -> None:
|
||||
if value.startswith(("/", "file:")) or value.endswith("/data/aios-gitops/repo.git"):
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
if "://" in value:
|
||||
parsed = urlsplit(value)
|
||||
host, path = parsed.hostname, parsed.path
|
||||
else:
|
||||
location = value.split("@", 1)[-1]
|
||||
host, separator, path = location.partition(":")
|
||||
if not separator:
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
path = "/" + path
|
||||
if host != expected_host or path.rstrip("/") not in {"/aios/aios.git", "/git/aios/aios.git"}:
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
url = _run(["git", "remote", "get-url", self.remote_name], cwd=self.workspace)
|
||||
self.validate_remote_url(url, self.expected_host)
|
||||
if self.repository != "aios/aios":
|
||||
raise ContinuationExecutionError("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
return self.repository
|
||||
|
||||
def _require_projected(self) -> None:
|
||||
if not self.credential.active:
|
||||
raise ContinuationExecutionError("CREDENTIAL_PROJECTION_MISSING")
|
||||
|
||||
def read_git_ref(self, branch: str) -> str | None:
|
||||
self._require_projected()
|
||||
output = _run(["git", "ls-remote", self.remote_name, f"refs/heads/{branch}"], cwd=self.workspace)
|
||||
if not output:
|
||||
return None
|
||||
oid, ref = output.split(maxsplit=1)
|
||||
if ref != f"refs/heads/{branch}" or not OID_RE.fullmatch(oid):
|
||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||
return oid
|
||||
|
||||
def push_new_exact(self, branch: str, new: str) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
if self.read_git_ref(branch) is not None:
|
||||
raise ContinuationExecutionError("GITEA_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||
if self.read_git_ref(branch) != new:
|
||||
raise ContinuationExecutionError("GITEA_CREATE_READBACK_MISMATCH")
|
||||
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
if self.read_git_ref(branch) != expected:
|
||||
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||
|
||||
def _api(self, path: str) -> object:
|
||||
self._require_projected()
|
||||
token = self.credential.api_token_path.read_text(encoding="utf-8").strip()
|
||||
request = Request(
|
||||
self.api_base.rstrip("/") + path,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise ContinuationExecutionError(f"GITEA_API_HTTP_{exc.code}") from exc
|
||||
except (URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||
raise ContinuationExecutionError("GITEA_API_UNAVAILABLE") from exc
|
||||
|
||||
def read_api_branch(self, branch: str) -> str | None:
|
||||
owner, name = self.repository.split("/", 1)
|
||||
page = 1
|
||||
while page <= 100:
|
||||
value = self._api(f"/repos/{quote(owner)}/{quote(name)}/branches?page={page}&limit=50")
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_COLLECTION_INVALID")
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or item.get("name") != branch:
|
||||
continue
|
||||
oid = item.get("commit", {}).get("id")
|
||||
if not isinstance(oid, str) or not OID_RE.fullmatch(oid):
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_INVALID")
|
||||
return oid
|
||||
if len(value) < 50:
|
||||
return None
|
||||
page += 1
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_PAGINATION_LIMIT")
|
||||
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
owner, name = self.repository.split("/", 1)
|
||||
value = self._api(f"/repos/{quote(owner)}/{quote(name)}/git/commits/{oid}")
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
observed = value.get("sha") or value.get("id")
|
||||
return observed == oid
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationPermissions:
|
||||
create_target_branch: bool = False
|
||||
force_push: bool = False
|
||||
tag_write: bool = False
|
||||
baseline_write: bool = False
|
||||
production_write: bool = False
|
||||
main_write: bool = False
|
||||
|
||||
def validate(self) -> None:
|
||||
if any(asdict(self).values()):
|
||||
raise ContinuationExecutionError("MUTATION_PERMISSION_DENIED")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationRuntimeBinding:
|
||||
request: WriterLeaseRequestV2
|
||||
job_id: str
|
||||
workspace_path: Path
|
||||
lease_not_before: datetime
|
||||
lease_expires_at: datetime
|
||||
original_receipt_valid: bool
|
||||
original_grant_consumed: bool
|
||||
permissions: ContinuationPermissions
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationCommit:
|
||||
idempotency_key: str
|
||||
files: Mapping[str, bytes]
|
||||
message: str
|
||||
|
||||
def payload(self) -> bytes:
|
||||
if not self.idempotency_key or not self.message.strip() or not self.files:
|
||||
raise ContinuationExecutionError("COMMIT_REQUEST_INVALID")
|
||||
normalized: dict[str, str] = {}
|
||||
for path, content in self.files.items():
|
||||
pure = PurePosixPath(path)
|
||||
if (
|
||||
not isinstance(path, str)
|
||||
or pure.is_absolute()
|
||||
or ".." in pure.parts
|
||||
or path.startswith((".git/", "baseline/", "production/"))
|
||||
or not isinstance(content, bytes)
|
||||
):
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED")
|
||||
if any(pattern.search(content) for pattern in SECRET_PATTERNS):
|
||||
raise ContinuationExecutionError("SECRET_LIKE_PAYLOAD_REJECTED")
|
||||
normalized[path] = base64.b64encode(content).decode("ascii")
|
||||
return json.dumps(
|
||||
{"files_base64": normalized, "message": self.message},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
class ContinuationExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
controller: GitOpsJobController,
|
||||
authority: AuthorityPort,
|
||||
workspace: DetachedWorkspace,
|
||||
remote: RemotePort,
|
||||
credential: CredentialProjection,
|
||||
*,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
self.authority = authority
|
||||
self.workspace = workspace
|
||||
self.remote = remote
|
||||
self.credential = credential
|
||||
self.now = now
|
||||
self._observed_generations: dict[str, int] = {}
|
||||
|
||||
def observe_generation(self, branch: str, generation: int) -> None:
|
||||
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
||||
|
||||
def _max_generation(self, branch: str, *, exclude_job_id: str | None = None) -> int:
|
||||
observed = self._observed_generations.get(branch, 0)
|
||||
with self.controller.store._connect() as connection:
|
||||
if exclude_job_id is None:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases "
|
||||
"WHERE branch=? AND job_id!=?",
|
||||
(branch, exclude_job_id),
|
||||
).fetchone()
|
||||
durable = int(row["generation"] or 0)
|
||||
return max(observed, durable)
|
||||
|
||||
@staticmethod
|
||||
def _request_mapping(request: WriterLeaseRequestV2) -> dict[str, object]:
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request.task_id,
|
||||
"job_id": request.job_id,
|
||||
"repository": request.repository,
|
||||
"branch_mode": request.branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
"expected_branch_oid": request.expected_branch_oid,
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"lease_seconds": request.lease_seconds,
|
||||
}
|
||||
|
||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding, *, job_id: str | None = None) -> None:
|
||||
request = binding.request
|
||||
request_mapping = self._request_mapping(request)
|
||||
normalized = validate_writer_lease_request_v2(request_mapping, self.authority)
|
||||
expected_normalized = {
|
||||
**request_mapping,
|
||||
"create_target_branch": request.branch_mode == "CREATE_NEW",
|
||||
"worktree_mode": (
|
||||
"detached_at_base_oid"
|
||||
if request.branch_mode == "CREATE_NEW"
|
||||
else "detached_at_expected_head"
|
||||
),
|
||||
"cas_ref_update_required": True,
|
||||
}
|
||||
if normalized != expected_normalized:
|
||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||
binding.permissions.validate()
|
||||
now = self.now()
|
||||
if now < binding.lease_not_before:
|
||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||
if now >= binding.lease_expires_at:
|
||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
||||
if request.generation <= self._max_generation(request.target_branch, exclude_job_id=job_id):
|
||||
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||
if not self.workspace.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
if self.workspace.head_oid() != request.expected_branch_oid:
|
||||
raise ContinuationExecutionError("WORKTREE_HEAD_MISMATCH")
|
||||
if self.remote.repository_identity() != request.repository:
|
||||
raise ContinuationExecutionError("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
def _job_request(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit, payload: bytes) -> dict[str, object]:
|
||||
request = binding.request
|
||||
return {
|
||||
"idempotency_key": commit.idempotency_key,
|
||||
"campaign_id": request.task_id,
|
||||
"task_id": request.task_id,
|
||||
"repository": request.repository,
|
||||
"operation": "CONTINUATION_COMMIT_FILES",
|
||||
"branch": request.target_branch,
|
||||
"expected_branch_oid": request.expected_branch_oid,
|
||||
"base_oid": request.base_oid,
|
||||
"branch_mode": request.branch_mode,
|
||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
||||
"lineage_anchor_oid": request.base_oid,
|
||||
"generation": request.generation,
|
||||
}
|
||||
|
||||
def execute(
|
||||
self,
|
||||
binding: ContinuationRuntimeBinding,
|
||||
commit: ContinuationCommit,
|
||||
*,
|
||||
before_complete: Callable[[str], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
commit.payload()
|
||||
if not binding.job_id or binding.request.job_id != binding.job_id:
|
||||
raise ContinuationExecutionError("RUNTIME_LINEAGE_MISSING_JOB_ID")
|
||||
current = self.controller.store.read(binding.job_id)
|
||||
current_state = JobState(current["state"])
|
||||
if current_state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
||||
if current_state == JobState.REMOTE_VERIFIED:
|
||||
receipt = json.loads(str(current["receipt_json"] or "{}"))
|
||||
commit_oid = str(receipt.get("authority_oid") or "")
|
||||
if not OID_RE.fullmatch(commit_oid):
|
||||
raise ContinuationExecutionError("GOVERNED_COMMIT_OID_INVALID")
|
||||
if before_complete is not None:
|
||||
before_complete(commit_oid)
|
||||
return self.controller.advance(
|
||||
binding.job_id,
|
||||
JobState.COMPLETED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=receipt,
|
||||
).as_dict()
|
||||
self._validate_preflight(binding, job_id=binding.job_id)
|
||||
request = binding.request
|
||||
projected = False
|
||||
try:
|
||||
self.credential.project()
|
||||
projected = True
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.REQUESTED:
|
||||
self.controller.advance(
|
||||
binding.job_id,
|
||||
JobState.AUTHORIZED,
|
||||
side_effect_state="AUTHORIZED",
|
||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.AUTHORIZED:
|
||||
self.controller.lease_job(
|
||||
binding.job_id,
|
||||
lease_id=f"lease-{binding.job_id}",
|
||||
lineage_anchor_oid=request.base_oid,
|
||||
current_head_oid=request.expected_branch_oid,
|
||||
generation=request.generation,
|
||||
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.LEASED:
|
||||
self.controller.advance(binding.job_id, JobState.WORKTREE_READY,
|
||||
side_effect_state="WORKTREE_READY", expected_revision=int(row["revision"]))
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.WORKTREE_READY:
|
||||
self.controller.advance(binding.job_id, JobState.EXECUTING,
|
||||
side_effect_state="IN_PROGRESS", expected_revision=int(row["revision"]))
|
||||
elif state != JobState.EXECUTING:
|
||||
raise ContinuationExecutionError("JOB_EXECUTION_STATE_INVALID")
|
||||
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
||||
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
||||
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
||||
if request.branch_mode == "CREATE_NEW":
|
||||
authority_oid = self.authority.create_task_branch_cas(request.target_branch, request.base_oid, new_oid)
|
||||
else:
|
||||
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
if authority_oid != new_oid:
|
||||
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
self.controller.advance(
|
||||
binding.job_id,
|
||||
JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="LOCAL_VERIFIED",
|
||||
receipt={"authority_oid": authority_oid, "force_push": False},
|
||||
)
|
||||
try:
|
||||
self.controller.advance(binding.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||
if request.branch_mode == "CREATE_NEW":
|
||||
self.remote.push_new_exact(request.target_branch, new_oid)
|
||||
else:
|
||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
except Exception:
|
||||
return self._remote_pending(binding.job_id, authority_oid, new_oid)
|
||||
return self._finish_remote_verification(
|
||||
binding.job_id,
|
||||
authority_oid,
|
||||
new_oid,
|
||||
before_complete=before_complete,
|
||||
)
|
||||
finally:
|
||||
if projected:
|
||||
self.credential.revoke()
|
||||
|
||||
def _observe_remote(self, branch: str, oid: str) -> tuple[str | None, str | None, bool]:
|
||||
return self.remote.read_git_ref(branch), self.remote.read_api_branch(branch), self.remote.commit_visible(oid)
|
||||
|
||||
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"desired_remote_oid": new_oid,
|
||||
"remote_verified": False,
|
||||
"force_push": False,
|
||||
}
|
||||
result = self.controller.advance(
|
||||
job_id,
|
||||
JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="REMOTE_PUSH_PENDING",
|
||||
receipt=receipt,
|
||||
)
|
||||
return result.as_dict()
|
||||
|
||||
def _finish_remote_verification(
|
||||
self,
|
||||
job_id: str,
|
||||
authority_oid: str,
|
||||
new_oid: str,
|
||||
*,
|
||||
before_complete: Callable[[str], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
branch = row["branch"]
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
git_oid, api_oid, visible = self._observe_remote(branch, new_oid)
|
||||
decision = decide_reconciliation(RemoteObservation("aios/aios", branch, new_oid, authority_oid, git_oid, api_oid, visible, datetime.now(timezone.utc).isoformat(), request.get("expected_branch_oid")))
|
||||
if decision.action is not ReconciliationAction.MARK_REMOTE_VERIFIED:
|
||||
return self._remote_pending(job_id, authority_oid, new_oid)
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"git_remote_oid": git_oid,
|
||||
"api_branch_oid": api_oid,
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
"reconciliation_decision": decision.action.value,
|
||||
"force_push": False,
|
||||
}
|
||||
self.controller.advance(
|
||||
job_id,
|
||||
JobState.REMOTE_VERIFIED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=receipt,
|
||||
)
|
||||
if before_complete is not None:
|
||||
before_complete(new_oid)
|
||||
return self.controller.advance(
|
||||
job_id,
|
||||
JobState.COMPLETED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=receipt,
|
||||
).as_dict()
|
||||
|
||||
def reconcile(self, job_id: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if state != JobState.RECONCILIATION_REQUIRED:
|
||||
raise ContinuationExecutionError("JOB_NOT_RECONCILABLE")
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
receipt = json.loads(row["receipt_json"])
|
||||
new_oid = receipt["desired_remote_oid"]
|
||||
authority_oid = self.authority.branch_head(row["branch"])
|
||||
try:
|
||||
generation = int(request.get("generation") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ContinuationExecutionError("DURABLE_BINDING_GENERATION_INVALID") from exc
|
||||
if generation > 0:
|
||||
with self.controller.store._connect() as connection:
|
||||
earlier = connection.execute(
|
||||
"SELECT 1 FROM jobs WHERE branch=? AND state=? "
|
||||
"AND CAST(json_extract(request_json, '$.binding.generation') AS INTEGER) < ? LIMIT 1",
|
||||
(row["branch"], JobState.RECONCILIATION_REQUIRED.value, generation),
|
||||
).fetchone()
|
||||
if earlier is not None:
|
||||
raise ContinuationExecutionError("EARLIER_RECONCILIATION_REQUIRED")
|
||||
self.credential.project()
|
||||
try:
|
||||
git_oid, api_oid, visible = self._observe_remote(row["branch"], new_oid)
|
||||
if authority_oid == git_oid == api_oid == new_oid and visible:
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST")
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
expected_oid = request["expected_branch_oid"]
|
||||
if (
|
||||
self.authority.is_ancestor(new_oid, authority_oid)
|
||||
and git_oid is not None
|
||||
and git_oid == api_oid
|
||||
and self.authority.is_ancestor(git_oid, new_oid)
|
||||
):
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
||||
self.remote.push_exact(row["branch"], git_oid, new_oid)
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
if authority_oid == new_oid and git_oid == api_oid == expected_oid:
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_RETRY")
|
||||
self.remote.push_exact(row["branch"], expected_oid, new_oid)
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
finally:
|
||||
self.credential.revoke()
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Campaign-aware continuation leases for an existing Git branch.
|
||||
|
||||
This module is deliberately transport-neutral. It validates branch lineage
|
||||
and hands ref mutation to an injected compare-and-swap broker; it does not run
|
||||
Git, inspect credentials, or create a branch itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TARGET_BRANCH = "agent/autonomous-release-campaign-v1-20260807"
|
||||
LINEAGE_ANCHOR = "69b45e81dcd2c3707c367003b08c55fd361faae8"
|
||||
CAMPAIGN_HEAD = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
||||
|
||||
|
||||
class ContinuationError(RuntimeError):
|
||||
"""Stable fail-closed error for lease or branch binding violations."""
|
||||
|
||||
|
||||
class BranchProbe(Protocol):
|
||||
def branch_exists(self, branch: str) -> bool: ...
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
|
||||
|
||||
class RefCAS(Protocol):
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise ContinuationError(f"{field}_invalid")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WriterLeaseRequestV2:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch_mode: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
lease_seconds: int = 900
|
||||
job_id: str = ""
|
||||
|
||||
schema = "aios.task-writer-lease.v2"
|
||||
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
||||
|
||||
def validate(self, probe: BranchProbe) -> None:
|
||||
if not self.task_id or self.repository != "aios/aios":
|
||||
raise ContinuationError("request_identity_invalid")
|
||||
if not self.job_id or not self.job_id.startswith("job-"):
|
||||
raise ContinuationError("job_id_invalid")
|
||||
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
raise ContinuationError("branch_mode_invalid")
|
||||
_oid(self.base_oid, "base_oid")
|
||||
_oid(self.expected_branch_oid, "expected_branch_oid")
|
||||
if self.target_branch in {"main", "master"} or self.target_branch.startswith(("refs/", "tags/")):
|
||||
raise ContinuationError("protected_branch_rejected")
|
||||
if not isinstance(self.generation, int) or isinstance(self.generation, bool) or self.generation < 1:
|
||||
raise ContinuationError("generation_invalid")
|
||||
if not isinstance(self.lease_seconds, int) or not 1 <= self.lease_seconds <= 86_400:
|
||||
raise ContinuationError("lease_duration_invalid")
|
||||
if self.branch_mode == "CONTINUE_EXISTING":
|
||||
if not probe.branch_exists(self.target_branch):
|
||||
raise ContinuationError("EXISTING_BRANCH_MISSING")
|
||||
actual = _oid(probe.branch_head(self.target_branch), "actual_branch_oid")
|
||||
if actual != self.expected_branch_oid:
|
||||
raise ContinuationError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
if not probe.is_ancestor(self.base_oid, actual):
|
||||
raise ContinuationError("BASE_NOT_ANCESTOR_OF_BRANCH")
|
||||
elif hasattr(probe, "commit_exists") and not probe.commit_exists(self.base_oid):
|
||||
raise ContinuationError("BASE_COMMIT_NOT_FOUND")
|
||||
elif self.branch_mode == "CREATE_NEW" and probe.branch_exists(self.target_branch):
|
||||
raise ContinuationError("TARGET_BRANCH_ALREADY_EXISTS")
|
||||
elif self.branch_mode == "CREATE_NEW" and self.base_oid != self.expected_branch_oid:
|
||||
raise ContinuationError("CREATE_NEW_BASE_EXPECTATION_MISMATCH")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWriterLease:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
lineage_anchor_oid: str
|
||||
current_head_oid: str
|
||||
generation: int
|
||||
lease_expires_at: datetime
|
||||
append_only: bool = True
|
||||
renewable: bool = True
|
||||
multi_commit: bool = True
|
||||
recoverable: bool = True
|
||||
state: str = "ACTIVE"
|
||||
branch_bound: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_oid(self.lineage_anchor_oid, "lineage_anchor_oid")
|
||||
_oid(self.current_head_oid, "current_head_oid")
|
||||
if not all((self.append_only, self.renewable, self.multi_commit, self.recoverable, self.branch_bound)):
|
||||
raise ContinuationError("lease_capability_downgrade")
|
||||
|
||||
|
||||
class ContinuationLeaseManager:
|
||||
"""State machine for a multi-commit lease with CAS-only ref updates."""
|
||||
|
||||
def __init__(self, probe: BranchProbe, refs: RefCAS, *, now=datetime.now) -> None:
|
||||
self.probe = probe
|
||||
self.refs = refs
|
||||
self.now = now
|
||||
|
||||
def open(self, request: WriterLeaseRequestV2) -> CampaignWriterLease:
|
||||
request.validate(self.probe)
|
||||
if request.branch_mode != "CONTINUE_EXISTING":
|
||||
raise ContinuationError("CREATE_NEW_REQUIRES_SEPARATE_PROVISIONER")
|
||||
return CampaignWriterLease(
|
||||
task_id=request.task_id,
|
||||
repository=request.repository,
|
||||
branch=request.target_branch,
|
||||
lineage_anchor_oid=request.base_oid,
|
||||
current_head_oid=request.expected_branch_oid,
|
||||
generation=request.generation,
|
||||
lease_expires_at=self.now(timezone.utc) + timedelta(seconds=request.lease_seconds),
|
||||
)
|
||||
|
||||
def renew(self, lease: CampaignWriterLease, seconds: int) -> CampaignWriterLease:
|
||||
if lease.state != "ACTIVE" or not lease.renewable or seconds < 1:
|
||||
raise ContinuationError("lease_not_renewable")
|
||||
return replace(lease, lease_expires_at=self.now(timezone.utc) + timedelta(seconds=seconds))
|
||||
|
||||
def append_commit(self, lease: CampaignWriterLease, new_head_oid: str) -> CampaignWriterLease:
|
||||
if lease.state != "ACTIVE":
|
||||
raise ContinuationError("lease_not_active")
|
||||
_oid(new_head_oid, "new_head_oid")
|
||||
if not self.probe.is_ancestor(lease.current_head_oid, new_head_oid):
|
||||
raise ContinuationError("NON_FAST_FORWARD_COMMIT")
|
||||
try:
|
||||
actual = self.refs.compare_and_swap_branch(
|
||||
lease.branch, lease.current_head_oid, new_head_oid
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ContinuationError("BRANCH_LEASE_STALE") from exc
|
||||
if actual != new_head_oid:
|
||||
raise ContinuationError("BRANCH_LEASE_STALE")
|
||||
return replace(lease, current_head_oid=new_head_oid)
|
||||
|
||||
def supersede(self, lease: CampaignWriterLease) -> CampaignWriterLease:
|
||||
if lease.state not in {"ACTIVE", "RECOVERABLE_FAILURE", "RECONCILIATION_REQUIRED"}:
|
||||
raise ContinuationError("lease_not_supersedable")
|
||||
return replace(lease, generation=lease.generation + 1, state="SUPERSEDED")
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Separate read, write, and release plane readiness evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeStatus:
|
||||
read_plane: Mapping[str, bool]
|
||||
write_plane: Mapping[str, bool]
|
||||
release_plane: Mapping[str, bool]
|
||||
|
||||
@property
|
||||
def release_control_plane_ready(self) -> bool:
|
||||
return all(self.read_plane.values()) and all(self.write_plane.values()) and all(self.release_plane.values())
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"READ_PLANE": dict(self.read_plane),
|
||||
"WRITE_PLANE": dict(self.write_plane),
|
||||
"RELEASE_PLANE": dict(self.release_plane),
|
||||
"release_control_plane_ready": self.release_control_plane_ready,
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Workspace-independent authority/network/Gitea ref sentinel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Callable, Mapping
|
||||
|
||||
|
||||
class SentinelError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SentinelInputs:
|
||||
authority_git_dir: Path
|
||||
gitea_remote: str
|
||||
gitea_api: str
|
||||
ref: str
|
||||
expected_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SentinelResult:
|
||||
authority_oid: str | None
|
||||
network_oid: str | None
|
||||
gitea_oid: str | None
|
||||
equal: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def authority_oid(inputs: SentinelInputs) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "--git-dir", str(inputs.authority_git_dir), "rev-parse", inputs.ref],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SentinelError("AUTHORITY_REF_UNAVAILABLE")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def evaluate(
|
||||
inputs: SentinelInputs,
|
||||
*,
|
||||
network_reader: Callable[[str, str], str | None],
|
||||
gitea_reader: Callable[[str, str], str | None],
|
||||
) -> SentinelResult:
|
||||
try:
|
||||
local = authority_oid(inputs)
|
||||
network = network_reader(inputs.gitea_remote, inputs.ref)
|
||||
gitea = gitea_reader(inputs.gitea_api, inputs.ref)
|
||||
equal = local == network == gitea == inputs.expected_oid
|
||||
return SentinelResult(local, network, gitea, equal, None if equal else "OID_MISMATCH")
|
||||
except SentinelError as exc:
|
||||
return SentinelResult(None, None, None, False, str(exc))
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Non-sensitive control-plane snapshot assembly."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
value = value or {}
|
||||
return {key: value.get(key) for key in keys}
|
||||
|
||||
|
||||
def build_control_plane_snapshot(*, target_version: str, runtime: Mapping[str, Any] | None = None, repository: Mapping[str, Any] | None = None, production: Mapping[str, Any] | None = None, task: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None, credential_broker: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"version": {"target_release": target_version},
|
||||
"runtime": _copy(runtime, ("bridge_schema_version", "control_plane_release_version", "runtime_release_commit", "runtime_path", "artifact_sha256", "drift_status")),
|
||||
"repository": _copy(repository, ("identity", "authority_git_dir", "authority_main_oid")),
|
||||
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
||||
"task": _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_state")),
|
||||
"capabilities": _copy(capabilities, ("legacy_writes_enabled", "governed_writes_enabled", "force_push", "main_write", "tag_write", "baseline_write", "production_write")),
|
||||
"credential_broker": _copy(credential_broker, ("status", "provider")),
|
||||
"secret_material_exposed": False,
|
||||
}
|
||||
|
||||
|
||||
def getControlPlaneSnapshot(**kwargs: Any) -> dict[str, Any]:
|
||||
return build_control_plane_snapshot(**kwargs)
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Metadata-only, server-side credential lease broker."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import re
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialLease:
|
||||
lease_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
scopes: tuple[str, ...]
|
||||
provider: str
|
||||
state: str = "ACTIVE"
|
||||
scope_type: str = "system"
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_id: str | None = None
|
||||
run_id: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state, "scope_type": self.scope_type, "tenant_id": self.tenant_id, "user_id": self.user_id, "project_id": self.project_id, "run_id": self.run_id}
|
||||
|
||||
|
||||
class FileCredentialProvider:
|
||||
def __init__(self, token_path: str | os.PathLike[str], *, actor: str = "aios-autonomy-agent") -> None:
|
||||
self.token_path = Path(token_path)
|
||||
self.actor = actor
|
||||
|
||||
def environment(self, lease: CredentialLease) -> dict[str, str]:
|
||||
if not self.token_path.is_file():
|
||||
raise RuntimeError("CREDENTIAL_PROVIDER_UNAVAILABLE")
|
||||
return {"CREDENTIAL_FILE": str(self.token_path), "GITEA_MACHINE_ACTOR": self.actor}
|
||||
|
||||
|
||||
class CredentialBroker:
|
||||
def __init__(self, provider: Any, *, now: Callable[[], datetime] | None = None) -> None:
|
||||
self.provider = provider
|
||||
self.now = now or (lambda: datetime.now(timezone.utc))
|
||||
self._leases: dict[str, CredentialLease] = {}
|
||||
|
||||
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900, scope_type: str = "system", tenant_id: str | None = None, user_id: str | None = None, project_id: str | None = None, run_id: str | None = None) -> dict[str, Any]:
|
||||
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
||||
raise ValueError("CREDENTIAL_LEASE_BINDING_INVALID")
|
||||
if scope_type == "tenant":
|
||||
if not tenant_id:
|
||||
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||
elif scope_type in {"system", "global"}:
|
||||
if any((tenant_id, user_id, project_id, run_id)):
|
||||
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||
else:
|
||||
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||
now = self.now().astimezone(timezone.utc)
|
||||
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__, "ACTIVE", scope_type, tenant_id, user_id, project_id, run_id)
|
||||
self._leases[lease.lease_id] = lease
|
||||
return lease.as_dict()
|
||||
|
||||
def _get(self, lease_id: str) -> CredentialLease:
|
||||
lease = self._leases.get(lease_id)
|
||||
if lease is None:
|
||||
raise KeyError("CREDENTIAL_LEASE_NOT_FOUND")
|
||||
if lease.state == "ACTIVE" and self.now().astimezone(timezone.utc) >= datetime.fromisoformat(lease.expires_at):
|
||||
lease = CredentialLease(**{**lease.as_dict(), "scopes": tuple(lease.scopes), "state": "EXPIRED"})
|
||||
self._leases[lease_id] = lease
|
||||
return lease
|
||||
|
||||
def environment(self, lease_id: str) -> Mapping[str, str]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state != "ACTIVE":
|
||||
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
||||
environment = self.provider.environment(lease)
|
||||
for key, value in environment.items():
|
||||
upper = str(key).upper()
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
sensitive = any(marker in upper for marker in ("CREDENTIAL", "TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY", "API_KEY"))
|
||||
if re.search(r"(?i)bearer\s+\S+|(?:token|secret|password|api[_-]?key)\s*[:=]", value):
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
if sensitive and upper.endswith("_FILE"):
|
||||
candidate = Path(value)
|
||||
credential_root = Path("/run/credentials")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
except OSError as exc:
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY") from exc
|
||||
if not candidate.is_absolute() or not (resolved == credential_root or credential_root in resolved.parents):
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
elif sensitive and upper.endswith("_REFERENCE"):
|
||||
if re.fullmatch(r"(?:systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}", value) is None:
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
elif sensitive:
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
return environment
|
||||
|
||||
def authorize(self, lease_id: str, *, task_id: str, operation: str, scope_type: str = "system", tenant_id: str | None = None, user_id: str | None = None, project_id: str | None = None, run_id: str | None = None) -> Mapping[str, str]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state != "ACTIVE" or lease.task_id != task_id or operation not in lease.scopes:
|
||||
raise RuntimeError("CREDENTIAL_SCOPE_DENY")
|
||||
requested = (scope_type, tenant_id, user_id, project_id, run_id)
|
||||
actual = (lease.scope_type, lease.tenant_id, lease.user_id, lease.project_id, lease.run_id)
|
||||
if requested != actual:
|
||||
raise RuntimeError("CREDENTIAL_SCOPE_DENY")
|
||||
return self.environment(lease_id)
|
||||
|
||||
def status(self, lease_id: str) -> dict[str, Any]:
|
||||
return self._get(lease_id).as_dict()
|
||||
|
||||
def revoke(self, lease_id: str) -> dict[str, Any]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state == "REVOKED":
|
||||
return lease.as_dict()
|
||||
updated = CredentialLease(**{**lease.as_dict(), "scopes": tuple(lease.scopes), "state": "REVOKED"})
|
||||
self._leases[lease_id] = updated
|
||||
return updated.as_dict()
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Non-Owner Gitea machine capability and branch reconciliation boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
|
||||
class MachineBrokerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class GiteaTransport(Protocol):
|
||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str: ...
|
||||
def create_or_update_pr(self, branch: str, target: str, title: str, body: str) -> Mapping[str, Any]: ...
|
||||
def publish_status(self, branch: str, state: str, description: str) -> Mapping[str, Any]: ...
|
||||
def protected_merge(self, pr_number: int) -> Mapping[str, Any]: ...
|
||||
def read_branch(self, branch: str) -> str | None: ...
|
||||
def whoami(self) -> Mapping[str, Any]: ...
|
||||
def repository(self, owner: str, name: str) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MachineCredentialProjection:
|
||||
"""Opaque systemd credential reference; the credential value is never accepted."""
|
||||
|
||||
reference: str
|
||||
account: str
|
||||
owner_account: str = "aiman"
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "MachineCredentialProjection":
|
||||
if set(value) != {"scheme", "name", "account"}:
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
if value["scheme"] != "systemd-credential" or value["name"] != "gitea-machine-token":
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
return cls(f"{value['scheme']}:{value['name']}", str(value["account"]))
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.reference != "systemd-credential:gitea-machine-token":
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
if not self.account or self.account == self.owner_account:
|
||||
raise MachineBrokerError("OWNER_MACHINE_IDENTITY_REJECTED")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignCapabilityReceipt:
|
||||
campaign_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
expected_oid: str
|
||||
capabilities: frozenset[str]
|
||||
denied: frozenset[str]
|
||||
|
||||
def require(self, capability: str) -> None:
|
||||
if capability in self.denied or capability not in self.capabilities:
|
||||
raise MachineBrokerError("CAPABILITY_DENIED")
|
||||
if self.branch in {"main", "master"}:
|
||||
raise MachineBrokerError("PROTECTED_BRANCH_DENIED")
|
||||
|
||||
|
||||
class GiteaMachineBroker:
|
||||
def __init__(self, transport: GiteaTransport, credential: MachineCredentialProjection, receipt: CampaignCapabilityReceipt) -> None:
|
||||
self.transport = transport
|
||||
self.credential = credential
|
||||
self.receipt = receipt
|
||||
|
||||
def verify_identity(self) -> Mapping[str, Any]:
|
||||
identity = dict(self.transport.whoami())
|
||||
login = identity.get("login") or identity.get("username")
|
||||
if not isinstance(login, str) or not login or login == self.credential.owner_account or login != self.credential.account:
|
||||
raise MachineBrokerError("GITEA_MACHINE_IDENTITY_INVALID")
|
||||
owner, name = self.receipt.repository.split("/", 1)
|
||||
repo = dict(self.transport.repository(owner, name))
|
||||
if repo.get("full_name") != self.receipt.repository:
|
||||
raise MachineBrokerError("GITEA_REPOSITORY_MISMATCH")
|
||||
return {"login": login, "repository": self.receipt.repository, "owner_account": self.credential.owner_account}
|
||||
|
||||
def push_branch(self, new_oid: str) -> str:
|
||||
self.receipt.require("exact_task_branch_push")
|
||||
return self.transport.compare_and_swap_branch(self.receipt.branch, self.receipt.expected_oid, new_oid)
|
||||
|
||||
def create_pr(self, title: str, body: str) -> Mapping[str, Any]:
|
||||
self.receipt.require("pr_create_update")
|
||||
return self.transport.create_or_update_pr(self.receipt.branch, "main", title, body)
|
||||
|
||||
def status(self, state: str, description: str, context: str = "aios/durable-release") -> Mapping[str, Any]:
|
||||
self.receipt.require("status_publish")
|
||||
return self.transport.publish_status(self.receipt.branch, state, description, context)
|
||||
|
||||
def merge(self, pr_number: int) -> Mapping[str, Any]:
|
||||
self.receipt.require("protected_merge")
|
||||
return self.transport.protected_merge(pr_number)
|
||||
|
||||
def readback(self) -> str | None:
|
||||
self.receipt.require("remote_readback")
|
||||
return self.transport.read_branch(self.receipt.branch)
|
||||
|
||||
|
||||
def reconcile_exact_branch(authority_oid: str, gitea_oid: str | None, expected_oid: str) -> str:
|
||||
if authority_oid != expected_oid:
|
||||
return "AUTHORITY_EXPECTATION_MISMATCH"
|
||||
if gitea_oid is None:
|
||||
return "CREATE_REQUIRED"
|
||||
if gitea_oid == expected_oid:
|
||||
return "ALREADY_IN_SYNC"
|
||||
return "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
|
||||
|
||||
def reconcile_fast_forward(
|
||||
remote_oid: str | None,
|
||||
expected_old_oid: str,
|
||||
new_oid: str,
|
||||
*,
|
||||
old_is_ancestor_of_new: bool,
|
||||
) -> str:
|
||||
"""Return a bounded action for an active Job/lease branch scope."""
|
||||
if remote_oid is None:
|
||||
return "CREATE_REQUIRED"
|
||||
if remote_oid == new_oid:
|
||||
return "ALREADY_IN_SYNC"
|
||||
if remote_oid == expected_old_oid and old_is_ancestor_of_new:
|
||||
return "FAST_FORWARD_REQUIRED"
|
||||
return "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Gitea HTTP/Git transport using only a projected systemd credential."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
class GiteaTransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value):
|
||||
raise GiteaTransportError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaMachineTransport:
|
||||
api_base: str
|
||||
repository_id: str
|
||||
remote_url: str
|
||||
authority_git_dir: Path
|
||||
credential_name: str = "gitea-machine-token"
|
||||
actor: str = "aios-autonomy-agent"
|
||||
timeout: int = 20
|
||||
expected_merge_head: str | None = None
|
||||
expected_merge_base: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.credential_name != "gitea-machine-token":
|
||||
raise GiteaTransportError("CREDENTIAL_NAME_INVALID")
|
||||
if "/" not in self.repository_id or self.repository_id.startswith("/"):
|
||||
raise GiteaTransportError("REPOSITORY_INVALID")
|
||||
if not self.api_base.startswith("https://") and not self.api_base.startswith("http://127.0.0.1"):
|
||||
raise GiteaTransportError("GITEA_API_SCOPE_INVALID")
|
||||
if not self.remote_url.startswith("https://"):
|
||||
raise GiteaTransportError("GITEA_REMOTE_SCOPE_INVALID")
|
||||
|
||||
@property
|
||||
def _credential_path(self) -> Path:
|
||||
directory = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
||||
if not directory:
|
||||
raise GiteaTransportError("CREDENTIAL_PROJECTION_MISSING")
|
||||
path = Path(directory) / self.credential_name
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise GiteaTransportError("CREDENTIAL_PROJECTION_MISSING")
|
||||
return path
|
||||
|
||||
def _token(self) -> str:
|
||||
value = self._credential_path.read_text(encoding="utf-8").strip()
|
||||
if not value:
|
||||
raise GiteaTransportError("CREDENTIAL_EMPTY")
|
||||
return value
|
||||
|
||||
def _request(self, method: str, path: str, body: Mapping[str, Any] | None = None) -> tuple[int, Any]:
|
||||
data = None if body is None else json.dumps(dict(body), separators=(",", ":")).encode()
|
||||
headers = {"Accept": "application/json", "Authorization": f"token {self._token()}"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(self.api_base.rstrip("/") + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
raw = response.read()
|
||||
return response.status, json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as exc:
|
||||
# Never include response bodies: Gitea may echo sensitive request context.
|
||||
raise GiteaTransportError(f"GITEA_HTTP_{exc.code}") from exc
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
raise GiteaTransportError("GITEA_NETWORK_ERROR") from exc
|
||||
|
||||
def whoami(self) -> Mapping[str, Any]:
|
||||
return self._request("GET", "/user")[1]
|
||||
|
||||
def repository_info(self) -> Mapping[str, Any]:
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("GET", f"/repos/{quote(owner)}/{quote(name)}")[1]
|
||||
|
||||
def repository(self, owner: str, name: str) -> Mapping[str, Any]:
|
||||
if f"{owner}/{name}" != self.repository_id:
|
||||
raise GiteaTransportError("REPOSITORY_SCOPE_DENIED")
|
||||
return self.repository_info()
|
||||
|
||||
def read_branch(self, branch: str) -> str | None:
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
try:
|
||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/branches/{quote(branch, safe='/')}")[1]
|
||||
except GiteaTransportError as exc:
|
||||
if str(exc) == "GITEA_HTTP_404":
|
||||
return None
|
||||
raise
|
||||
sha = value.get("commit", {}).get("id") if isinstance(value, dict) else None
|
||||
return _safe_oid(sha, "remote_branch_oid")
|
||||
|
||||
def _askpass(self) -> tuple[Path, dict[str, str]]:
|
||||
credential = self._credential_path
|
||||
fd, name = tempfile.mkstemp(prefix="aios-gitea-askpass-", dir="/tmp")
|
||||
path = Path(name)
|
||||
os.fchmod(fd, 0o700)
|
||||
script = "#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$GITEA_MACHINE_ACTOR\" ;; *) cat \"$CREDENTIAL_FILE\" ;; esac\n"
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
stream.write(script)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
return path, {
|
||||
"GIT_ASKPASS": str(path),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GITEA_MACHINE_ACTOR": self.actor,
|
||||
"CREDENTIAL_FILE": str(credential),
|
||||
}
|
||||
|
||||
def _push_branch_fast_forward(self, branch: str, new_oid: str) -> None:
|
||||
askpass, additions = self._askpass()
|
||||
environment = os.environ.copy()
|
||||
environment.update(additions)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.authority_git_dir}", "push", self.remote_url, f"{new_oid}:refs/heads/{branch}"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CREATE_FAILED")
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
_safe_oid(expected_oid, "expected_oid")
|
||||
_safe_oid(new_oid, "new_oid")
|
||||
current = self.read_branch(branch)
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
if current is None:
|
||||
self._push_branch_fast_forward(branch, new_oid)
|
||||
readback = self.read_branch(branch)
|
||||
if readback != new_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CREATE_READBACK_MISMATCH")
|
||||
return readback
|
||||
if current != expected_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||
# The preflight read binds the expected old tip. The Git receive-pack
|
||||
# then enforces the actual fast-forward CAS while transferring the new
|
||||
# objects; no force/lease override or arbitrary ref is permitted.
|
||||
self._push_branch_fast_forward(branch, new_oid)
|
||||
readback = self.read_branch(branch)
|
||||
if readback != new_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def _pulls(self) -> list[Mapping[str, Any]]:
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
query = urlencode({"state": "all", "base_branch": "main", "limit": 100})
|
||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/pulls?{query}")[1]
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
def create_or_update_pr(self, branch: str, target: str, title: str, body: str) -> Mapping[str, Any]:
|
||||
if target != "main":
|
||||
raise GiteaTransportError("PR_BASE_SCOPE_DENIED")
|
||||
head = self.read_branch(branch)
|
||||
if head is None:
|
||||
raise GiteaTransportError("PR_HEAD_MISSING")
|
||||
matches = [p for p in self._pulls() if (p.get("head") or {}).get("ref") == branch and (p.get("base") or {}).get("ref") == target]
|
||||
open_matches = [p for p in matches if p.get("state") == "open"]
|
||||
if len(open_matches) > 1:
|
||||
raise GiteaTransportError("MULTIPLE_MATCHING_PRS")
|
||||
if open_matches:
|
||||
if (open_matches[0].get("head") or {}).get("sha") != head:
|
||||
raise GiteaTransportError("PR_HEAD_CONFLICT")
|
||||
return open_matches[0]
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/pulls", {"head": branch, "base": target, "title": title, "body": body, "allow_maintainer_edit": False})[1]
|
||||
|
||||
def publish_status(self, branch: str, state: str, description: str, context: str = "aios/durable-release") -> Mapping[str, Any]:
|
||||
oid = self.read_branch(branch)
|
||||
if oid is None:
|
||||
raise GiteaTransportError("STATUS_BRANCH_MISSING")
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/statuses/{oid}", {"state": state, "context": context, "description": description})[1]
|
||||
|
||||
def protected_merge(self, pr_number: int) -> Mapping[str, Any]:
|
||||
if not isinstance(pr_number, int) or pr_number < 1:
|
||||
raise GiteaTransportError("PR_NUMBER_INVALID")
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
pr = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/pulls/{pr_number}")[1]
|
||||
if pr.get("state") != "open" or (pr.get("base") or {}).get("ref") != "main":
|
||||
raise GiteaTransportError("PR_MERGE_BINDING_INVALID")
|
||||
if pr.get("mergeable") is False:
|
||||
raise GiteaTransportError("PR_NOT_MERGEABLE")
|
||||
head_oid = (pr.get("head") or {}).get("sha")
|
||||
base_oid = (pr.get("base") or {}).get("sha")
|
||||
if self.expected_merge_head is not None and head_oid != self.expected_merge_head:
|
||||
raise GiteaTransportError("PR_HEAD_EXPECTATION_MISMATCH")
|
||||
if self.expected_merge_base is not None and base_oid != self.expected_merge_base:
|
||||
raise GiteaTransportError("PR_BASE_EXPECTATION_MISMATCH")
|
||||
body = {"do": "fast-forward-only", "force_merge": False, "merge_when_checks_succeed": False, "head_commit_id": head_oid}
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/pulls/{pr_number}/merge", body)[1]
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Canonical Gitea transport and slash-branch verification."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from aios_gitops_reconciliation_core import RemoteObservation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GiteaPublisherConfig:
|
||||
repository: str = "aios/aios"
|
||||
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||
remote_name: str = "gitea-http"
|
||||
|
||||
|
||||
class GiteaPublisher:
|
||||
def __init__(self, workspace: Path, *, config: GiteaPublisherConfig = GiteaPublisherConfig(), credential: Any = None, api_get: Callable[[str], Any] | None = None, git_ref: Callable[[str], Optional[str]] | None = None) -> None:
|
||||
self.workspace = Path(workspace)
|
||||
self.config = config
|
||||
self.credential = credential
|
||||
self._api_get = api_get
|
||||
self._git_ref = git_ref
|
||||
|
||||
@staticmethod
|
||||
def validate_repository(repository: str) -> None:
|
||||
if repository != "aios/aios":
|
||||
raise ValueError("REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
@staticmethod
|
||||
def validate_branch(branch: str) -> None:
|
||||
if not isinstance(branch, str) or not branch.startswith("agent/") or branch in {"main", "master"}:
|
||||
raise ValueError("BRANCH_SCOPE_DENIED")
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
self.validate_repository(self.config.repository)
|
||||
return self.config.repository
|
||||
|
||||
def _environment(self) -> dict[str, str]:
|
||||
if self.credential is not None and hasattr(self.credential, "environment"):
|
||||
return dict(self.credential.environment())
|
||||
return {}
|
||||
|
||||
def read_git_ref(self, branch: str) -> Optional[str]:
|
||||
self.validate_branch(branch)
|
||||
if self._git_ref is not None:
|
||||
return self._git_ref(branch)
|
||||
result = subprocess.run(["git", "-C", str(self.workspace), "ls-remote", self.config.remote_name, f"refs/heads/{branch}"], text=True, capture_output=True, check=False, env={**os.environ, **self._environment(), "GIT_TERMINAL_PROMPT": "0"})
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
return result.stdout.split()[0]
|
||||
|
||||
def _api(self, path: str) -> Any:
|
||||
if self._api_get is not None:
|
||||
return self._api_get(path)
|
||||
headers = {"Accept": "application/json"}
|
||||
env = self._environment()
|
||||
if env.get("AIOS_GITEA_TOKEN"):
|
||||
headers["Authorization"] = "token " + env["AIOS_GITEA_TOKEN"]
|
||||
with urlopen(Request(self.config.api_base.rstrip("/") + path, headers=headers), timeout=20) as response:
|
||||
return json.loads(response.read() or b"{}")
|
||||
|
||||
def read_api_branch(self, branch: str) -> Optional[str]:
|
||||
self.validate_branch(branch)
|
||||
for page in range(1, 101):
|
||||
value = self._api(f"/repos/aios/aios/branches?page={page}&limit=50")
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
for item in value:
|
||||
if isinstance(item, Mapping) and item.get("name") == branch:
|
||||
commit = item.get("commit")
|
||||
return commit.get("id") if isinstance(commit, Mapping) else None
|
||||
if len(value) < 50:
|
||||
return None
|
||||
raise RuntimeError("GITEA_BRANCH_PAGINATION_LIMIT")
|
||||
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
if not isinstance(oid, str) or len(oid) != 40:
|
||||
return False
|
||||
try:
|
||||
value = self._api(f"/repos/aios/aios/git/commits/{quote(oid, safe='')}")
|
||||
return isinstance(value, Mapping) and value.get("sha", oid) == oid
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def observe(self, branch: str, desired_oid: str, authority_oid: Optional[str], *, expected_oid: Optional[str] = None, observed_at: str = "") -> RemoteObservation:
|
||||
return RemoteObservation(self.repository_identity(), branch, desired_oid, authority_oid, self.read_git_ref(branch), self.read_api_branch(branch), self.commit_visible(desired_oid), observed_at, expected_oid)
|
||||
@@ -1,707 +0,0 @@
|
||||
"""SQLite-backed durable GitOps Job Controller.
|
||||
|
||||
The controller owns durable intent, idempotency, lease metadata, state
|
||||
transitions, receipts, and payload references. A worker owns Git side effects
|
||||
behind the campaign capability and branch-CAS boundaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
class JobError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceUnavailable(JobError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LeaseRenewalResult:
|
||||
"""Durable outcome of one writer-lease recovery attempt."""
|
||||
|
||||
renewed: bool
|
||||
idempotent: bool
|
||||
durable: bool
|
||||
lease_expires_at: str | None
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.renewed or self.idempotent
|
||||
|
||||
|
||||
class BindingConflict(JobError):
|
||||
pass
|
||||
|
||||
|
||||
class JobState(str, Enum):
|
||||
REQUESTED = "REQUESTED"
|
||||
AUTHORIZED = "AUTHORIZED"
|
||||
LEASED = "LEASED"
|
||||
WORKTREE_READY = "WORKTREE_READY"
|
||||
EXECUTING = "EXECUTING"
|
||||
LOCAL_VERIFIED = "LOCAL_VERIFIED"
|
||||
REMOTE_SYNCING = "REMOTE_SYNCING"
|
||||
REMOTE_VERIFIED = "REMOTE_VERIFIED"
|
||||
COMPLETED = "COMPLETED"
|
||||
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
|
||||
RECONCILING = "RECONCILING"
|
||||
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
||||
MANUAL_OWNER_REVIEW_REQUIRED = "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
|
||||
|
||||
_ORDER = {
|
||||
JobState.REQUESTED: 0,
|
||||
JobState.AUTHORIZED: 1,
|
||||
JobState.LEASED: 2,
|
||||
JobState.WORKTREE_READY: 3,
|
||||
JobState.EXECUTING: 4,
|
||||
JobState.LOCAL_VERIFIED: 5,
|
||||
JobState.REMOTE_SYNCING: 6,
|
||||
JobState.REMOTE_VERIFIED: 7,
|
||||
JobState.COMPLETED: 8,
|
||||
}
|
||||
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
||||
_TERMINAL = {JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED}
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_utc(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("LEASE_EXPIRY_INVALID")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
class PayloadStore:
|
||||
"""Content-addressed filesystem payloads; raw content never enters SQLite."""
|
||||
|
||||
def __init__(self, root: str | os.PathLike[str]) -> None:
|
||||
self.root = Path(root)
|
||||
if not self.root.is_absolute() or self.root.is_symlink():
|
||||
raise JobError("PAYLOAD_ROOT_INVALID")
|
||||
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(self.root, 0o700)
|
||||
|
||||
def put(self, content: bytes, expected_digest: str) -> tuple[str, int]:
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
if digest != expected_digest:
|
||||
raise JobError("PAYLOAD_DIGEST_MISMATCH")
|
||||
if len(expected_digest) != 64 or any(ch not in "0123456789abcdef" for ch in expected_digest):
|
||||
raise JobError("PAYLOAD_DIGEST_INVALID")
|
||||
target = self.root / expected_digest
|
||||
if target.is_symlink():
|
||||
raise JobError("PAYLOAD_PATH_SYMLINK")
|
||||
if target.is_file():
|
||||
if target.stat().st_size != len(content) or hashlib.sha256(target.read_bytes()).hexdigest() != digest:
|
||||
raise JobError("PAYLOAD_EXISTING_MISMATCH")
|
||||
return f"sha256/{digest}", len(content)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".payload-", dir=self.root)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, target)
|
||||
os.chmod(target, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
return f"sha256/{digest}", len(content)
|
||||
|
||||
def read_verified(self, reference: str, expected_digest: str, expected_size: int) -> bytes:
|
||||
if reference != f"sha256/{expected_digest}":
|
||||
raise JobError("PAYLOAD_REFERENCE_MISMATCH")
|
||||
path = self.root / expected_digest
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise JobError("PAYLOAD_MISSING")
|
||||
content = path.read_bytes()
|
||||
if len(content) != expected_size or hashlib.sha256(content).hexdigest() != expected_digest:
|
||||
raise JobError("PAYLOAD_VERIFY_FAILED")
|
||||
return content
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JobReceipt:
|
||||
job_id: str
|
||||
idempotency_key: str
|
||||
state: JobState
|
||||
side_effect_state: str
|
||||
http_status: int
|
||||
revision: int
|
||||
receipt: Mapping[str, Any] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"state": self.state.value,
|
||||
"side_effect_state": self.side_effect_state,
|
||||
"http_status": self.http_status,
|
||||
"revision": self.revision,
|
||||
"receipt": dict(self.receipt) if self.receipt else None,
|
||||
}
|
||||
|
||||
|
||||
class JobStore:
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
def __init__(self, root: str | os.PathLike[str], *, payload_root: str | os.PathLike[str] | None = None) -> None:
|
||||
self.root = Path(root)
|
||||
if not self.root.is_absolute() or self.root.is_symlink():
|
||||
raise JobError("JOB_ROOT_INVALID")
|
||||
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(self.root, 0o700)
|
||||
self.db_path = self.root / "state.sqlite3"
|
||||
self.payloads = PayloadStore(payload_root or self.root.parent / "payloads" / "sha256")
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
if self.db_path.is_symlink():
|
||||
raise ServiceUnavailable("JOB_DB_SYMLINK")
|
||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id TEXT PRIMARY KEY, idempotency_key TEXT NOT NULL UNIQUE,
|
||||
campaign_id TEXT NOT NULL, repository TEXT NOT NULL, operation TEXT NOT NULL,
|
||||
branch TEXT NOT NULL, expected_branch_oid TEXT NOT NULL,
|
||||
payload_digest TEXT NOT NULL, payload_ref TEXT, payload_size INTEGER,
|
||||
state TEXT NOT NULL, side_effect_state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL, request_json TEXT NOT NULL,
|
||||
receipt_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key TEXT PRIMARY KEY, job_id TEXT NOT NULL UNIQUE,
|
||||
binding_json TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS branch_leases (
|
||||
lease_id TEXT PRIMARY KEY, job_id TEXT NOT NULL UNIQUE,
|
||||
branch TEXT NOT NULL, lineage_anchor_oid TEXT NOT NULL,
|
||||
current_head_oid TEXT NOT NULL, generation INTEGER NOT NULL,
|
||||
lease_expires_at TEXT NOT NULL, state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS state_transitions (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL,
|
||||
from_state TEXT, to_state TEXT NOT NULL, revision INTEGER NOT NULL,
|
||||
side_effect_state TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS receipts (
|
||||
receipt_id TEXT PRIMARY KEY, job_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '1');
|
||||
"""
|
||||
)
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def _mirror(self, job: Mapping[str, Any]) -> None:
|
||||
directory = self.root / str(job["job_id"])
|
||||
directory.mkdir(mode=0o700, exist_ok=True)
|
||||
(directory / "receipts").mkdir(mode=0o700, exist_ok=True)
|
||||
(directory / "logs-digest").mkdir(mode=0o700, exist_ok=True)
|
||||
for name, value in (("request.json", job["request"]), ("state.json", job)):
|
||||
target = directory / name
|
||||
fd, temporary = tempfile.mkstemp(prefix=".mirror-", dir=directory)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(canonical_json(value))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
def read(self, job_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
if row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
return dict(row)
|
||||
|
||||
def _receipt(self, row: Mapping[str, Any], *, duplicate: bool) -> JobReceipt:
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
status, side = 200, "COMPLETED"
|
||||
elif state in _RECOVERABLE:
|
||||
status = 202
|
||||
side = row["side_effect_state"] if row["side_effect_state"] == "REMOTE_PUSH_PENDING" else "RESUME_SAME_JOB"
|
||||
elif row["side_effect_state"] == "UNKNOWN":
|
||||
status, side = 503, "UNKNOWN"
|
||||
else:
|
||||
status, side = 202, "SAME_JOB" if duplicate else "QUEUED"
|
||||
receipt = json.loads(row["receipt_json"]) if row["receipt_json"] else None
|
||||
return JobReceipt(row["job_id"], row["idempotency_key"], state, side, status, int(row["revision"]), receipt)
|
||||
|
||||
|
||||
class GitOpsJobController:
|
||||
def __init__(self, store: JobStore) -> None:
|
||||
self.store = store
|
||||
|
||||
@staticmethod
|
||||
def _binding(request: Mapping[str, Any]) -> dict[str, str]:
|
||||
fields = ("campaign_id", "repository", "operation", "branch", "expected_branch_oid", "payload_digest")
|
||||
if any(not isinstance(request.get(field), str) or not request[field].strip() for field in fields):
|
||||
raise JobError("JOB_BINDING_INVALID")
|
||||
oid = request["expected_branch_oid"]
|
||||
digest = request["payload_digest"]
|
||||
if len(oid) != 40 or any(ch not in "0123456789abcdef" for ch in oid):
|
||||
raise JobError("EXPECTED_BRANCH_OID_INVALID")
|
||||
if len(digest) != 64 or any(ch not in "0123456789abcdef" for ch in digest):
|
||||
raise JobError("PAYLOAD_DIGEST_INVALID")
|
||||
if request["branch"] in {"main", "master"} or request["branch"].startswith(("refs/", "tags/")):
|
||||
raise JobError("PROTECTED_BRANCH_REJECTED")
|
||||
binding = {field: str(request[field]) for field in fields}
|
||||
# Operation-specific, non-secret binding inputs must survive the
|
||||
# durable round trip and participate in idempotency. Arbitrary request
|
||||
# fields are intentionally discarded rather than persisted.
|
||||
optional = (
|
||||
"idempotency_key",
|
||||
"task_id",
|
||||
"new_branch_oid",
|
||||
"authorization_recovery_digest",
|
||||
"lineage_anchor_oid",
|
||||
"base_oid",
|
||||
"branch_mode",
|
||||
"generation",
|
||||
"expected_main_oid",
|
||||
"pr_number",
|
||||
"target_branch",
|
||||
"check_context",
|
||||
"check_state",
|
||||
"check_description",
|
||||
)
|
||||
for field in optional:
|
||||
value = request.get(field)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
raise JobError("JOB_BINDING_OPTION_INVALID")
|
||||
text = str(value).strip()
|
||||
if not text or len(text) > 512:
|
||||
raise JobError("JOB_BINDING_OPTION_INVALID")
|
||||
binding[field] = text
|
||||
return binding
|
||||
|
||||
def submit(self, request: Mapping[str, Any], *, payload: bytes | None = None) -> JobReceipt:
|
||||
binding = self._binding(request)
|
||||
payload_ref = request.get("payload_ref")
|
||||
payload_size = request.get("payload_size")
|
||||
if payload is not None:
|
||||
payload_ref, payload_size = self.store.payloads.put(payload, binding["payload_digest"])
|
||||
if payload_ref is not None and payload_ref != f"sha256/{binding['payload_digest']}":
|
||||
raise JobError("PAYLOAD_REFERENCE_MISMATCH")
|
||||
if payload_ref is not None and (not isinstance(payload_size, int) or payload_size < 0):
|
||||
raise JobError("PAYLOAD_SIZE_INVALID")
|
||||
client_idempotency_key = binding.get("idempotency_key")
|
||||
if client_idempotency_key is not None:
|
||||
if len(client_idempotency_key) > 128 or not all(
|
||||
character.isalnum() or character in "._:-" for character in client_idempotency_key
|
||||
):
|
||||
raise JobError("IDEMPOTENCY_KEY_INVALID")
|
||||
key = sha256_json({"client_idempotency_key": client_idempotency_key})
|
||||
else:
|
||||
key = sha256_json(binding)
|
||||
job_id = "job-" + key[:32]
|
||||
durable_request = dict(binding)
|
||||
if payload_ref is not None:
|
||||
durable_request.update({"payload_ref": str(payload_ref), "payload_size": int(payload_size)})
|
||||
now = utc_now()
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute("SELECT * FROM jobs WHERE idempotency_key=?", (key,)).fetchone()
|
||||
if existing is not None:
|
||||
if json.loads(existing["request_json"]).get("binding") != binding:
|
||||
raise BindingConflict("BINDING_CONFLICT")
|
||||
row = dict(existing)
|
||||
connection.commit()
|
||||
return self.store._receipt(row, duplicate=True)
|
||||
connection.execute(
|
||||
"INSERT INTO jobs(job_id,idempotency_key,campaign_id,repository,operation,branch,expected_branch_oid,payload_digest,payload_ref,payload_size,state,side_effect_state,revision,request_json,receipt_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, key, binding["campaign_id"], binding["repository"], binding["operation"], binding["branch"], binding["expected_branch_oid"], binding["payload_digest"], payload_ref, payload_size, JobState.REQUESTED.value, "NONE", 0, json.dumps({"binding": binding, **durable_request}, sort_keys=True), None, now, now),
|
||||
)
|
||||
connection.execute("INSERT INTO idempotency_keys(key,job_id,binding_json,created_at) VALUES(?,?,?,?)", (key, job_id, json.dumps(binding, sort_keys=True), now))
|
||||
connection.execute("INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)", (job_id, None, JobState.REQUESTED.value, 0, "NONE", now))
|
||||
row = dict(connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone())
|
||||
connection.commit()
|
||||
except BindingConflict:
|
||||
raise
|
||||
except sqlite3.IntegrityError:
|
||||
row = self.store.read(job_id)
|
||||
return self.store._receipt(row, duplicate=True)
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
row["request"] = json.loads(row["request_json"])
|
||||
self.store._mirror(row)
|
||||
return self.store._receipt(row, duplicate=False)
|
||||
|
||||
def lease_job(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
lease_id: str,
|
||||
lineage_anchor_oid: str,
|
||||
current_head_oid: str,
|
||||
generation: int,
|
||||
lease_expires_at: str,
|
||||
expected_revision: int,
|
||||
) -> JobReceipt:
|
||||
"""Atomically create a branch lease and move its Job to LEASED."""
|
||||
if generation < 1 or any(len(oid) != 40 for oid in (lineage_anchor_oid, current_head_oid)):
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
if int(row["revision"]) != expected_revision:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
if row["branch"] in {"main", "master"}:
|
||||
raise JobError("PROTECTED_BRANCH_REJECTED")
|
||||
now = utc_now()
|
||||
connection.execute("INSERT INTO branch_leases(lease_id,job_id,branch,lineage_anchor_oid,current_head_oid,generation,lease_expires_at,state,revision) VALUES(?,?,?,?,?,?,?,?,?)", (lease_id, job_id, row["branch"], lineage_anchor_oid, current_head_oid, generation, lease_expires_at, "ACTIVE", 0))
|
||||
next_revision = expected_revision + 1
|
||||
updated = connection.execute("UPDATE jobs SET state=?,side_effect_state=?,revision=?,updated_at=? WHERE job_id=? AND revision=?", (JobState.LEASED.value, "LEASED", next_revision, now, job_id, expected_revision))
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
connection.execute("INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)", (job_id, row["state"], JobState.LEASED.value, next_revision, "LEASED", now))
|
||||
result = dict(connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone())
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise JobError("LEASE_CONFLICT") from exc
|
||||
except (JobError, BindingConflict):
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
result["request"] = json.loads(result["request_json"])
|
||||
self.store._mirror(result)
|
||||
return self.store._receipt(result, duplicate=True)
|
||||
|
||||
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE", lease_expires_at: str | None = None) -> None:
|
||||
if len(current_head_oid) != 40 or generation < 1:
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
if lease_expires_at is None:
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
||||
else:
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,lease_expires_at=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, lease_expires_at, state, lease_id, expected_revision))
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
connection.commit()
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def renew_lease(
|
||||
self,
|
||||
*,
|
||||
branch: str,
|
||||
lineage_anchor_oid: str,
|
||||
current_head_oid: str,
|
||||
generation: int,
|
||||
lease_expires_at: str,
|
||||
) -> LeaseRenewalResult:
|
||||
if (
|
||||
not branch
|
||||
or len(lineage_anchor_oid) != 40
|
||||
or len(current_head_oid) != 40
|
||||
or generation < 1
|
||||
or not lease_expires_at.strip()
|
||||
):
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT lease_id, revision, generation, state, lease_expires_at "
|
||||
"FROM branch_leases "
|
||||
"WHERE branch=? AND lineage_anchor_oid=? AND current_head_oid=? "
|
||||
"AND generation=? AND state='ACTIVE' ORDER BY revision DESC",
|
||||
(branch, lineage_anchor_oid, current_head_oid, generation),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(False, False, False, None)
|
||||
if len(rows) != 1:
|
||||
raise JobError("LEASE_BINDING_CONFLICT")
|
||||
row = rows[0]
|
||||
try:
|
||||
current_expires_at = parse_utc(str(row["lease_expires_at"]))
|
||||
requested_expires_at = parse_utc(lease_expires_at)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise JobError("LEASE_EXPIRY_INVALID") from exc
|
||||
now = datetime.now(timezone.utc)
|
||||
if requested_expires_at <= now:
|
||||
raise JobError("LEASE_EXPIRY_INVALID")
|
||||
if current_expires_at > now:
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
str(row["lease_expires_at"]),
|
||||
)
|
||||
updated = connection.execute(
|
||||
"UPDATE branch_leases SET lease_expires_at=?,revision=revision+1 "
|
||||
"WHERE lease_id=? AND revision=? AND generation=? "
|
||||
"AND state='ACTIVE' AND lease_expires_at=?",
|
||||
(
|
||||
lease_expires_at,
|
||||
row["lease_id"],
|
||||
int(row["revision"]),
|
||||
generation,
|
||||
row["lease_expires_at"],
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
current = connection.execute(
|
||||
"SELECT lease_expires_at, generation, state FROM branch_leases "
|
||||
"WHERE lease_id=?",
|
||||
(row["lease_id"],),
|
||||
).fetchone()
|
||||
if (
|
||||
current is not None
|
||||
and int(current["generation"]) == generation
|
||||
and current["state"] == "ACTIVE"
|
||||
and parse_utc(str(current["lease_expires_at"])) > now
|
||||
):
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
str(current["lease_expires_at"]),
|
||||
)
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(True, False, True, lease_expires_at)
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def advance(self, job_id: str, state: JobState, *, side_effect_state: str, receipt: Mapping[str, Any] | None = None, expected_revision: int | None = None) -> JobReceipt:
|
||||
return self._advance(
|
||||
job_id,
|
||||
state,
|
||||
side_effect_state=side_effect_state,
|
||||
receipt=receipt,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
def _advance(
|
||||
self,
|
||||
job_id: str,
|
||||
state: JobState,
|
||||
*,
|
||||
side_effect_state: str,
|
||||
receipt: Mapping[str, Any] | None = None,
|
||||
expected_revision: int | None = None,
|
||||
_allow_verified_external_recovery: bool = False,
|
||||
) -> JobReceipt:
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
previous = JobState(row["state"])
|
||||
revision = int(row["revision"])
|
||||
if expected_revision is not None and expected_revision != revision:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
recovery_transition = (
|
||||
_allow_verified_external_recovery
|
||||
and previous == JobState.MANUAL_OWNER_REVIEW_REQUIRED
|
||||
and state == JobState.COMPLETED
|
||||
)
|
||||
if previous in _TERMINAL and state != previous and not recovery_transition:
|
||||
raise JobError("JOB_TERMINAL")
|
||||
if state not in _RECOVERABLE | {JobState.RECONCILING, JobState.MANUAL_OWNER_REVIEW_REQUIRED}:
|
||||
if previous in _RECOVERABLE:
|
||||
raise JobError("RECONCILE_REQUIRED")
|
||||
if previous != JobState.RECONCILING and _ORDER.get(state, -1) < _ORDER.get(previous, -1):
|
||||
raise JobError("JOB_STATE_REGRESSION")
|
||||
next_revision = revision + 1
|
||||
receipt_json = json.dumps(dict(receipt), sort_keys=True) if receipt is not None else row["receipt_json"]
|
||||
now = utc_now()
|
||||
updated = connection.execute("UPDATE jobs SET state=?,side_effect_state=?,revision=?,receipt_json=?,updated_at=? WHERE job_id=? AND revision=?", (state.value, side_effect_state, next_revision, receipt_json, now, job_id, revision))
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
connection.execute("INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)", (job_id, previous.value, state.value, next_revision, side_effect_state, now))
|
||||
if receipt is not None:
|
||||
digest = hashlib.sha256(canonical_json(receipt)).hexdigest()
|
||||
connection.execute("INSERT INTO receipts(receipt_id,job_id,kind,digest,payload_json,created_at) VALUES(?,?,?,?,?,?)", (f"receipt-{job_id}-{next_revision}", job_id, state.value, digest, json.dumps(dict(receipt), sort_keys=True), now))
|
||||
result = dict(connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone())
|
||||
connection.commit()
|
||||
except (JobError, BindingConflict):
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
result["request"] = json.loads(result["request_json"])
|
||||
self.store._mirror(result)
|
||||
return self.store._receipt(result, duplicate=True)
|
||||
|
||||
@staticmethod
|
||||
def _validate_verified_external_merge_proof(
|
||||
job_id: str,
|
||||
binding: Mapping[str, Any],
|
||||
proof: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(proof, Mapping):
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_INVALID")
|
||||
|
||||
def require_oid(field: str) -> str:
|
||||
value = proof.get(field)
|
||||
if not isinstance(value, str) or len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value):
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_INVALID")
|
||||
return value
|
||||
|
||||
if proof.get("proof_schema") != "aios.job-recovery.v1":
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_INVALID")
|
||||
if proof.get("job_id") != job_id:
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_MISMATCH")
|
||||
if proof.get("operation") != "PROTECTED_MERGE" or binding.get("operation") != "PROTECTED_MERGE":
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_OPERATION_REJECTED")
|
||||
if proof.get("pr_number") != 53 or str(binding.get("pr_number")) != "53":
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_MISMATCH")
|
||||
if proof.get("pr_merged") is not True or proof.get("protected_merge") is not True:
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_MISMATCH")
|
||||
if proof.get("force_push") is not False:
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_MISMATCH")
|
||||
if proof.get("machine_actor") != "aios-autonomy-agent":
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_MISMATCH")
|
||||
|
||||
expected_head = str(binding.get("expected_branch_oid", ""))
|
||||
expected_base = str(binding.get("expected_main_oid", ""))
|
||||
head_oid = require_oid("head_oid")
|
||||
base_oid = require_oid("base_oid")
|
||||
merged_main = require_oid("main_oid_after_protected_merge")
|
||||
gitea_merged_main = require_oid("gitea_main_oid_after_protected_merge")
|
||||
authority_merged_main = require_oid("authority_main_oid_after_protected_merge")
|
||||
current_gitea = require_oid("current_gitea_main_oid")
|
||||
current_authority = require_oid("current_authority_main_oid")
|
||||
if head_oid != expected_head or base_oid != expected_base:
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_MISMATCH")
|
||||
if not (merged_main == gitea_merged_main == authority_merged_main == head_oid):
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_MISMATCH")
|
||||
if current_gitea != current_authority or proof.get("current_main_descends_from_protected_merge") is not True:
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_PROOF_MISMATCH")
|
||||
return dict(proof)
|
||||
|
||||
def complete_verified_external_side_effect(
|
||||
self,
|
||||
job_id: str,
|
||||
proof: Mapping[str, Any],
|
||||
*,
|
||||
expected_revision: int | None = None,
|
||||
) -> JobReceipt:
|
||||
"""Close one historical manual job from independently verified proof.
|
||||
|
||||
This is deliberately narrower than advance: it is only valid for the
|
||||
known protected PR53 recovery shape and preserves the old receipt.
|
||||
The actual state change still uses the normal revision-CAS path.
|
||||
"""
|
||||
current = self.store.read(job_id)
|
||||
revision = int(current["revision"])
|
||||
if expected_revision is not None and expected_revision != revision:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
request = json.loads(current["request_json"])
|
||||
binding = request.get("binding", request)
|
||||
normalized_proof = self._validate_verified_external_merge_proof(job_id, binding, proof)
|
||||
existing_receipt = json.loads(current["receipt_json"]) if current["receipt_json"] else None
|
||||
if JobState(current["state"]) == JobState.COMPLETED:
|
||||
if (
|
||||
existing_receipt
|
||||
and existing_receipt.get("recovered_from_historical_manual") is True
|
||||
and existing_receipt.get("proof") == normalized_proof
|
||||
):
|
||||
return self.store._receipt(current, duplicate=True)
|
||||
raise JobError("JOB_TERMINAL")
|
||||
if JobState(current["state"]) != JobState.MANUAL_OWNER_REVIEW_REQUIRED:
|
||||
raise JobError("EXTERNAL_SIDE_EFFECT_RECOVERY_STATE_INVALID")
|
||||
recovery_receipt = {
|
||||
"recovered_from_historical_manual": True,
|
||||
"recovery_kind": "VERIFIED_EXTERNAL_SIDE_EFFECT",
|
||||
"historical_receipt": existing_receipt,
|
||||
"proof": normalized_proof,
|
||||
}
|
||||
return self._advance(
|
||||
job_id,
|
||||
JobState.COMPLETED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=recovery_receipt,
|
||||
expected_revision=revision,
|
||||
_allow_verified_external_recovery=True,
|
||||
)
|
||||
|
||||
def reconcile(self, job_id: str, observer: Callable[[Mapping[str, Any]], Mapping[str, Any]]) -> JobReceipt:
|
||||
current = self.store.read(job_id)
|
||||
if JobState(current["state"]) not in _RECOVERABLE:
|
||||
return self.store._receipt(current, duplicate=True)
|
||||
self.advance(job_id, JobState.RECONCILING, side_effect_state="IN_PROGRESS")
|
||||
try:
|
||||
observed = dict(observer(current))
|
||||
stable = JobState(observed.get("state", JobState.REQUESTED.value))
|
||||
if stable in {JobState.RECOVERABLE_FAILURE, JobState.RECONCILING}:
|
||||
raise JobError("RECONCILIATION_UNSTABLE")
|
||||
return self.advance(job_id, stable, side_effect_state=str(observed.get("side_effect_state", "NONE")), receipt=observed.get("receipt"))
|
||||
except Exception as exc:
|
||||
self.advance(job_id, JobState.RECONCILIATION_REQUIRED, side_effect_state="UNKNOWN", receipt={"error_code": type(exc).__name__})
|
||||
raise JobError("RECONCILIATION_REQUIRED") from exc
|
||||
|
||||
def resume(self, job_id: str) -> JobReceipt:
|
||||
current = self.store.read(job_id)
|
||||
if JobState(current["state"]) in _TERMINAL:
|
||||
raise JobError("JOB_TERMINAL")
|
||||
return self.reconcile(job_id, lambda _current: {"state": JobState.REQUESTED.value, "side_effect_state": "RESUME_SAME_JOB"})
|
||||
@@ -1,279 +0,0 @@
|
||||
"""Durable Job side-effect executor for the AIOS campaign control plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from dataclasses import dataclass, replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor, AuthorityGitError
|
||||
from aios_campaign_recovery import RecoveryEvidenceError, load_recovered_authorization
|
||||
from aios_gitea_machine_broker import CampaignCapabilityReceipt, GiteaMachineBroker, MachineBrokerError
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore
|
||||
|
||||
|
||||
TASK_ID = "AIOS_AUTONOMOUS_RELEASE_CAMPAIGN_V1"
|
||||
CAMPAIGN_ID = "AIOS-AUTONOMOUS-RELEASE-CAMPAIGN-20260807"
|
||||
REPOSITORY = "aios/aios"
|
||||
BRANCH = "agent/autonomous-release-campaign-v1-20260807"
|
||||
EXPECTED_BRANCH_OID = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
||||
AUTHORIZATION_DIGEST = "6b0bf550eb702126e967eeff2d561fa808444b924d8a71f5399accad508ed3b2"
|
||||
LINEAGE_ANCHOR = "69b45e81dcd2c3707c367003b08c55fd361faae8"
|
||||
|
||||
|
||||
class JobExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DurableJobExecutor:
|
||||
controller: GitOpsJobController
|
||||
authority: AuthorityGitExecutor
|
||||
broker: GiteaMachineBroker
|
||||
recovery_path: Path
|
||||
|
||||
def _request(self, job_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
row = self.controller.store.read(job_id)
|
||||
try:
|
||||
request = json.loads(row["request_json"])
|
||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise JobExecutionError("JOB_REQUEST_INVALID") from exc
|
||||
return row, dict(request.get("binding") or {})
|
||||
|
||||
def _validate_campaign(self, request: Mapping[str, Any]) -> str:
|
||||
operation = str(request.get("operation", ""))
|
||||
expected_branch_oid = request.get("expected_branch_oid")
|
||||
required = {
|
||||
"task_id": TASK_ID,
|
||||
"campaign_id": CAMPAIGN_ID,
|
||||
"repository": REPOSITORY,
|
||||
"branch": BRANCH,
|
||||
"authorization_recovery_digest": AUTHORIZATION_DIGEST,
|
||||
}
|
||||
for key, expected in required.items():
|
||||
if request.get(key) != expected:
|
||||
raise JobExecutionError(f"JOB_{key.upper()}_MISMATCH")
|
||||
new_oid = request.get("new_branch_oid")
|
||||
if not isinstance(new_oid, str) or len(new_oid) != 40 or any(ch not in "0123456789abcdef" for ch in new_oid):
|
||||
raise JobExecutionError("NEW_BRANCH_OID_INVALID")
|
||||
if not isinstance(expected_branch_oid, str) or len(expected_branch_oid) != 40 or any(ch not in "0123456789abcdef" for ch in expected_branch_oid):
|
||||
raise JobExecutionError("EXPECTED_BRANCH_OID_INVALID")
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH" and expected_branch_oid == new_oid:
|
||||
raise JobExecutionError("EXPECTED_BRANCH_OID_INVALID")
|
||||
if operation != "ADVANCE_EXISTING_CAMPAIGN_BRANCH" and expected_branch_oid != new_oid:
|
||||
raise JobExecutionError("POST_ADVANCE_HEAD_BINDING_INVALID")
|
||||
try:
|
||||
recovery = load_recovered_authorization(self.recovery_path)
|
||||
except RecoveryEvidenceError as exc:
|
||||
raise JobExecutionError(str(exc)) from exc
|
||||
if recovery["digest"] != AUTHORIZATION_DIGEST:
|
||||
raise JobExecutionError("RECOVERY_DIGEST_MISMATCH")
|
||||
for key, expected in required.items():
|
||||
if key == "campaign_id":
|
||||
actual = recovery.get(key)
|
||||
elif key == "authorization_recovery_digest":
|
||||
continue
|
||||
else:
|
||||
actual = recovery.get({"expected_branch_oid": "expected_branch_oid"}.get(key, key))
|
||||
if actual is not None and actual != expected:
|
||||
raise JobExecutionError("RECOVERY_BINDING_MISMATCH")
|
||||
if recovery.get("expected_branch_oid") != EXPECTED_BRANCH_OID:
|
||||
raise JobExecutionError("RECOVERY_BRANCH_OID_MISMATCH")
|
||||
authority_head = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
if authority_head not in {expected_branch_oid, new_oid}:
|
||||
raise JobExecutionError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
||||
if not self.authority.ancestry_check(expected_branch_oid, new_oid):
|
||||
raise JobExecutionError("NEW_OID_NOT_DESCENDANT")
|
||||
if not self.authority.ancestry_check(EXPECTED_BRANCH_OID, new_oid):
|
||||
raise JobExecutionError("NEW_OID_OUTSIDE_AUTHORIZED_LINEAGE")
|
||||
identity = self.broker.verify_identity()
|
||||
if identity.get("repository") != REPOSITORY or identity.get("login") == "aiman":
|
||||
raise JobExecutionError("MACHINE_IDENTITY_SCOPE_INVALID")
|
||||
return new_oid
|
||||
|
||||
def dry_run(self, job_id: str) -> dict[str, Any]:
|
||||
_row, request = self._request(job_id)
|
||||
new_oid = self._validate_campaign(request)
|
||||
remote = self.broker.readback()
|
||||
if remote is None:
|
||||
action = "CREATE_REQUIRED"
|
||||
elif remote == new_oid:
|
||||
action = "ALREADY_IN_SYNC"
|
||||
elif remote == request["expected_branch_oid"]:
|
||||
action = "FAST_FORWARD_REQUIRED"
|
||||
else:
|
||||
action = "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
if action == "MANUAL_OWNER_REVIEW_REQUIRED":
|
||||
raise JobExecutionError("GITEA_BRANCH_CONFLICT")
|
||||
return {
|
||||
"decision": "SAFE_TO_ADVANCE",
|
||||
"authority_action": "CAS_REQUIRED",
|
||||
"gitea_action": action,
|
||||
"previous_branch_oid": request["expected_branch_oid"],
|
||||
"final_branch_oid": new_oid,
|
||||
"force_push": False,
|
||||
}
|
||||
|
||||
def _recover_requested(self, job_id: str) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) in {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}:
|
||||
self.controller.resume(job_id)
|
||||
row = self.controller.store.read(job_id)
|
||||
return row
|
||||
|
||||
def execute(self, job_id: str, *, dry_run: bool = False) -> dict[str, Any]:
|
||||
try:
|
||||
return self._execute_unprotected(job_id, dry_run=dry_run)
|
||||
except Exception as exc:
|
||||
try:
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state not in {JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED, JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}:
|
||||
self.controller.advance(job_id, JobState.RECOVERABLE_FAILURE, side_effect_state="UNKNOWN", receipt={"error_code": type(exc).__name__})
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _execute_unprotected(self, job_id: str, *, dry_run: bool = False) -> dict[str, Any]:
|
||||
row, request = self._request(job_id)
|
||||
operation = request.get("operation")
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
if dry_run:
|
||||
return self.dry_run(job_id)
|
||||
return self._advance_campaign(job_id)
|
||||
if operation == "COMMIT_FILE":
|
||||
return self._commit_file(job_id, request)
|
||||
if operation == "SYNC_TASK_BRANCH_TO_GITEA":
|
||||
new_oid = self._validate_campaign(request)
|
||||
final = self.broker.push_branch(new_oid)
|
||||
return {"operation": operation, "remote_verified": final == new_oid, "final_network_oid": final}
|
||||
if operation == "CREATE_PR":
|
||||
self._validate_campaign(request)
|
||||
return dict(self.broker.create_pr("AIOS autonomous release control plane", "Durable campaign continuation; exact head and recovery evidence are bound in the Job."))
|
||||
if operation == "PUBLISH_CHECK":
|
||||
self._validate_campaign(request)
|
||||
context = request.get("check_context", "aios/durable-release")
|
||||
state = request.get("check_state", "success")
|
||||
description = request.get("check_description", "Durable campaign control-plane check passed")
|
||||
return dict(self.broker.status(str(state), str(description), str(context)))
|
||||
if operation == "PROTECTED_MERGE":
|
||||
self._validate_campaign(request)
|
||||
number = int(request.get("pr_number", "0"))
|
||||
self.broker.transport.expected_merge_head = request["expected_branch_oid"]
|
||||
expected_main = request.get("expected_main_oid")
|
||||
if expected_main is not None:
|
||||
self.broker.transport.expected_merge_base = expected_main
|
||||
return dict(self.broker.merge(number))
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
self._validate_campaign(request)
|
||||
old = request.get("expected_main_oid")
|
||||
new = request.get("new_branch_oid")
|
||||
if not isinstance(old, str) or not isinstance(new, str):
|
||||
raise JobExecutionError("MAIN_RECONCILIATION_BINDING_INVALID")
|
||||
gitea_main = self.broker.transport.read_branch("main")
|
||||
if gitea_main != new:
|
||||
raise JobExecutionError("GITEA_MAIN_EXPECTATION_MISMATCH")
|
||||
if not self.authority.ancestry_check(old, new):
|
||||
raise JobExecutionError("MAIN_OID_NOT_DESCENDANT")
|
||||
return {"authority_main": self.authority.ff_CAS_main_after_verified_protected_merge(old, new)}
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
|
||||
def _advance_campaign(self, job_id: str) -> dict[str, Any]:
|
||||
self._recover_requested(job_id)
|
||||
row, request = self._request(job_id)
|
||||
new_oid = self._validate_campaign(request)
|
||||
expected_oid = request["expected_branch_oid"]
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.REQUESTED:
|
||||
receipt = self.controller.advance(job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED", receipt={"authorization_digest": AUTHORIZATION_DIGEST, "task_id": TASK_ID})
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||
self.controller.lease_job(job_id, lease_id=f"lease-{job_id}", lineage_anchor_oid=LINEAGE_ANCHOR, current_head_oid=expected_oid, generation=1, lease_expires_at="2099-01-01T00:00:00Z", expected_revision=int(row["revision"]))
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.LEASED:
|
||||
self.controller.advance(job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY", receipt={"branch": BRANCH, "expected_oid": expected_oid})
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.WORKTREE_READY:
|
||||
self.controller.advance(job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||
row = self.controller.store.read(job_id)
|
||||
authority_before = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
if authority_before == expected_oid:
|
||||
authority_oid = self.authority.advance_task_branch_CAS(f"refs/heads/{BRANCH}", expected_oid, new_oid)
|
||||
elif authority_before == new_oid:
|
||||
authority_oid = new_oid
|
||||
else:
|
||||
raise JobExecutionError("AUTHORITY_BRANCH_CONFLICT")
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.EXECUTING:
|
||||
self.controller.advance(job_id, JobState.LOCAL_VERIFIED, side_effect_state="LOCAL_VERIFIED", receipt={"authority": {"branch": BRANCH, "previous_oid": expected_oid, "new_oid": new_oid, "CAS_success": True, "readback_oid": authority_oid}})
|
||||
row = self.controller.store.read(job_id)
|
||||
remote_before = self.broker.readback()
|
||||
if remote_before != new_oid:
|
||||
if remote_before not in {None, expected_oid}:
|
||||
raise JobExecutionError("GITEA_BRANCH_CONFLICT")
|
||||
if JobState(row["state"]) == JobState.LOCAL_VERIFIED:
|
||||
if hasattr(self.broker, "receipt"):
|
||||
self.broker.receipt = replace(self.broker.receipt, expected_oid=expected_oid)
|
||||
self.controller.advance(job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS", receipt={"gitea": {"previous_oid": remote_before, "desired_oid": new_oid, "force_push": False}})
|
||||
final_network = self.broker.push_branch(new_oid)
|
||||
else:
|
||||
final_network = new_oid
|
||||
final_api = self.broker.readback()
|
||||
if final_api != new_oid:
|
||||
raise JobExecutionError("GITEA_BRANCH_READBACK_MISMATCH")
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) in {JobState.LOCAL_VERIFIED, JobState.REMOTE_SYNCING}:
|
||||
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt={"gitea": {"machine_actor": "aios-autonomy-agent", "previous_oid": remote_before, "desired_oid": new_oid, "final_network_oid": final_network, "final_API_oid": final_api, "force_push": False}})
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.REMOTE_VERIFIED:
|
||||
self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt={"authority_oid": authority_oid, "network_oid": final_network, "gitea_api_oid": final_api, "force_push": False})
|
||||
return self.controller.store._receipt(self.controller.store.read(job_id), duplicate=True).as_dict()
|
||||
|
||||
def _commit_file(self, job_id: str, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
payload_ref = request.get("payload_ref")
|
||||
if not payload_ref:
|
||||
raise JobExecutionError("PAYLOAD_REQUIRED")
|
||||
payload = self.controller.store.payloads.read_verified(payload_ref, row["payload_digest"], int(request["payload_size"]))
|
||||
try:
|
||||
spec = json.loads(payload.decode())
|
||||
path = str(spec["path"])
|
||||
content = base64.b64decode(spec["content_base64"], validate=True)
|
||||
except Exception as exc:
|
||||
raise JobExecutionError("COMMIT_FILE_PAYLOAD_INVALID") from exc
|
||||
new_oid = self.authority.commit_file_in_isolated_worktree(base_oid=row["expected_branch_oid"], files={path: content}, message="chore(gitops): durable commit-file", worktree_name=job_id)
|
||||
return {"operation": "COMMIT_FILE", "new_oid": new_oid}
|
||||
|
||||
|
||||
def build_from_environment(job_root: Path) -> DurableJobExecutor:
|
||||
from aios_gitea_machine_transport import GiteaMachineTransport
|
||||
from aios_gitea_machine_broker import MachineCredentialProjection
|
||||
|
||||
account = __import__("os").environ.get("AIOS_GITEA_MACHINE_ACCOUNT", "aios-autonomy-agent")
|
||||
api = __import__("os").environ.get("AIOS_GITEA_API", "https://g.cnnetmkt.top/git/api/v1")
|
||||
remote = __import__("os").environ.get("AIOS_GITEA_REMOTE", "https://g.cnnetmkt.top/git/aios/aios.git")
|
||||
authority_dir = Path(__import__("os").environ.get("AIOS_GITOPS_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git"))
|
||||
worktree_root = Path(__import__("os").environ.get("AIOS_GITOPS_WORKTREE_ROOT", "/var/lib/aios-gitops/worktrees"))
|
||||
recovery = Path(__import__("os").environ.get("AIOS_RELEASE_RECOVERY_RECEIPT", "/var/lib/aios-gitops/release-campaigns/AIOS-AUTONOMOUS-RELEASE-CAMPAIGN-20260807/authorization-recovery.json"))
|
||||
transport = GiteaMachineTransport(api, REPOSITORY, remote, authority_dir)
|
||||
credential = MachineCredentialProjection.from_mapping({"scheme": "systemd-credential", "name": "gitea-machine-token", "account": account})
|
||||
receipt = CampaignCapabilityReceipt(CAMPAIGN_ID, TASK_ID, REPOSITORY, BRANCH, EXPECTED_BRANCH_OID, frozenset({"exact_task_branch_push", "pr_create_update", "status_publish", "protected_merge", "remote_readback"}), frozenset({"direct_main_push", "force_push", "tag_update", "tag_delete", "baseline_write", "production_deploy"}))
|
||||
return DurableJobExecutor(GitOpsJobController(JobStore(job_root)), AuthorityGitExecutor(authority_dir, worktree_root), GiteaMachineBroker(transport, credential, receipt), recovery)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job-root", required=True, type=Path)
|
||||
parser.add_argument("--job-id", required=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
result = build_from_environment(args.job_root).execute(args.job_id, dry_run=args.dry_run)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Pure, observation-first decisions for governed GitOps reconciliation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ReconciliationAction(str, Enum):
|
||||
NOOP = "NOOP"
|
||||
PUSH_EXACT = "PUSH_EXACT"
|
||||
VERIFY_REMOTE = "VERIFY_REMOTE"
|
||||
MARK_REMOTE_VERIFIED = "MARK_REMOTE_VERIFIED"
|
||||
MARK_RECONCILIATION_REQUIRED = "MARK_RECONCILIATION_REQUIRED"
|
||||
FAIL_CONFLICT = "FAIL_CONFLICT"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteObservation:
|
||||
repository_identity: str
|
||||
branch: str
|
||||
desired_oid: str
|
||||
authority_oid: Optional[str]
|
||||
remote_git_oid: Optional[str]
|
||||
remote_api_oid: Optional[str]
|
||||
commit_visible: bool
|
||||
observed_at: str
|
||||
expected_oid: Optional[str] = None
|
||||
|
||||
@property
|
||||
def all_equal(self) -> bool:
|
||||
values = (self.authority_oid, self.remote_git_oid, self.remote_api_oid, self.desired_oid)
|
||||
return all(isinstance(value, str) for value in values) and len(set(values)) == 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationDecision:
|
||||
action: ReconciliationAction
|
||||
state: str
|
||||
reason: str
|
||||
retry_safe: bool = False
|
||||
|
||||
|
||||
def _valid_oid(value: Optional[str]) -> bool:
|
||||
return value is None or bool(OID_RE.fullmatch(value))
|
||||
|
||||
|
||||
def decide_reconciliation(observation: RemoteObservation) -> ReconciliationDecision:
|
||||
"""Decide from a fresh observation; never complete on a missing remote."""
|
||||
if observation.repository_identity != "aios/aios":
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not observation.branch.startswith("agent/"):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "BRANCH_SCOPE_DENIED")
|
||||
if not _valid_oid(observation.desired_oid) or not _valid_oid(observation.expected_oid):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "OID_INVALID")
|
||||
if observation.authority_oid is None:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "AUTHORITY_UNAVAILABLE")
|
||||
if observation.authority_oid != observation.desired_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "AUTHORITY_CONFLICT")
|
||||
present = [oid for oid in (observation.remote_git_oid, observation.remote_api_oid) if oid is not None]
|
||||
if any(oid not in {observation.desired_oid, observation.expected_oid} for oid in present):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "REMOTE_OID_CONFLICT")
|
||||
if observation.all_equal and observation.commit_visible:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_REMOTE_VERIFIED, "REMOTE_VERIFIED", "ALL_OIDS_EQUAL_AND_VISIBLE")
|
||||
if observation.remote_git_oid is None and observation.remote_api_oid is None:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "REMOTE_PUSH_PENDING", "AUTHORITY_DESIRED_REMOTE_MISSING")
|
||||
if observation.remote_git_oid == observation.desired_oid or observation.remote_api_oid == observation.desired_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.VERIFY_REMOTE, "REMOTE_VERIFY_PENDING", "REMOTE_PROOF_PARTIAL")
|
||||
if observation.expected_oid is not None and observation.remote_git_oid == observation.expected_oid and observation.remote_api_oid == observation.expected_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.PUSH_EXACT, "REMOTE_PUSH_PENDING", "SAFE_EXACT_FAST_FORWARD_RETRY", retry_safe=True)
|
||||
if not observation.commit_visible:
|
||||
return ReconciliationDecision(ReconciliationAction.VERIFY_REMOTE, "COMMIT_VISIBILITY_PENDING", "COMMIT_VISIBILITY_LAG")
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "PARTIAL_REMOTE_OBSERVATION")
|
||||
|
||||
|
||||
def reconcile_observation(**kwargs: object) -> ReconciliationDecision:
|
||||
return decide_reconciliation(RemoteObservation(**kwargs))
|
||||
@@ -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))
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Single supervised socket-activated worker for durable GitOps Jobs.
|
||||
|
||||
The worker consumes durable requests and reconciliation queries. Repository
|
||||
mutation is a separate injected executor and remains impossible while the
|
||||
legacy global write gate is false.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore, ServiceUnavailable
|
||||
|
||||
|
||||
def runtime_policy(environment: Mapping[str, str] | None = None) -> dict[str, bool]:
|
||||
env = environment or os.environ
|
||||
legacy = env.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
durable = env.get("AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
# Legacy direct Git mutation remains disabled. Authorized durable Jobs use
|
||||
# their own campaign/task capability and may mutate only through the
|
||||
# worker's lease/CAS path while the legacy gate stays false.
|
||||
return {"legacy_global_write": legacy, "durable_job_execution": durable, "repository_mutation_allowed": durable}
|
||||
|
||||
|
||||
def handle_request(controller: GitOpsJobController, request: Mapping[str, Any], executor: Any | None = None) -> dict[str, Any]:
|
||||
job_id = str(request.get("job_id") or "")
|
||||
if not job_id:
|
||||
raise JobError("JOB_ID_REQUIRED")
|
||||
if request.get("action") == "execute":
|
||||
if executor is None:
|
||||
raise JobError("JOB_EXECUTOR_UNAVAILABLE")
|
||||
result = executor.execute(job_id, dry_run=bool(request.get("dry_run", False)))
|
||||
receipt = controller.store._receipt(controller.store.read(job_id), duplicate=True)
|
||||
return {"job": result, **receipt.as_dict()}
|
||||
if request.get("action") == "resume":
|
||||
receipt = controller.resume(job_id)
|
||||
else:
|
||||
row = controller.store.read(job_id)
|
||||
receipt = controller.store._receipt(row, duplicate=True)
|
||||
return receipt.as_dict()
|
||||
|
||||
|
||||
def serve_socket(sock: socket.socket, controller: GitOpsJobController, stopping: threading.Event, executor: Any | None = None) -> None:
|
||||
sock.listen(32)
|
||||
sock.settimeout(1.0)
|
||||
while not stopping.is_set():
|
||||
try:
|
||||
connection, _ = sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
with connection:
|
||||
try:
|
||||
raw = connection.makefile("rb").readline(1_048_576)
|
||||
request = json.loads(raw.decode("utf-8"))
|
||||
response = {"status": "ok", **handle_request(controller, request, executor)}
|
||||
except ServiceUnavailable as exc:
|
||||
response = {"status": "error", "error": str(exc), "retryable": True, "side_effect_state": "UNKNOWN"}
|
||||
except Exception as exc:
|
||||
response = {"status": "error", "error": type(exc).__name__, "retryable": False}
|
||||
connection.sendall((json.dumps(response, sort_keys=True) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def socket_from_systemd() -> socket.socket:
|
||||
if os.environ.get("LISTEN_FDS") != "1":
|
||||
raise RuntimeError("SOCKET_ACTIVATION_REQUIRED")
|
||||
return socket.fromfd(3, socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job-root", required=True, type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
policy = runtime_policy()
|
||||
if policy["legacy_global_write"]:
|
||||
raise RuntimeError("LEGACY_GLOBAL_WRITES_MUST_REMAIN_FALSE")
|
||||
if not policy["durable_job_execution"]:
|
||||
raise RuntimeError("DURABLE_JOB_EXECUTION_GATE_CLOSED")
|
||||
controller = GitOpsJobController(JobStore(args.job_root))
|
||||
from aios_gitops_job_executor import build_from_environment
|
||||
executor = build_from_environment(args.job_root)
|
||||
stopping = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stopping.set())
|
||||
signal.signal(signal.SIGINT, lambda *_: stopping.set())
|
||||
sock = socket_from_systemd()
|
||||
try:
|
||||
serve_socket(sock, controller, stopping, executor)
|
||||
finally:
|
||||
sock.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,69 +0,0 @@
|
||||
"""CLI client for the governed local writer service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Send one governed-local-writer request")
|
||||
parser.add_argument("--socket", required=True, help="AF_UNIX socket path")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--request", help="Request JSON string")
|
||||
group.add_argument("--request-file", help="Path to JSON request file")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_request_text(args: argparse.Namespace) -> str:
|
||||
if args.request is not None:
|
||||
return args.request
|
||||
request_path = os.fspath(args.request_file)
|
||||
with open(request_path, "r", encoding="utf-8") as request_file:
|
||||
return request_file.read()
|
||||
|
||||
|
||||
def send_request(socket_path: str, request_text: str) -> dict[str, object]:
|
||||
"""Open one Unix socket, send one NDJSON request, and parse one NDJSON response."""
|
||||
payload = json.loads(request_text)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request must be a JSON object")
|
||||
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||
client.connect(socket_path)
|
||||
client.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
response_chunks: list[str] = []
|
||||
while True:
|
||||
chunk = client.recv(1024 * 1024).decode("utf-8")
|
||||
if not chunk:
|
||||
break
|
||||
response_chunks.append(chunk)
|
||||
if "\n" in chunk:
|
||||
break
|
||||
response_text = "".join(response_chunks)
|
||||
|
||||
response_line = response_text.split("\n", 1)[0].strip()
|
||||
if not response_line:
|
||||
raise RuntimeError("empty response")
|
||||
response = json.loads(response_line)
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("invalid response")
|
||||
return response
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
request_text = _load_request_text(args)
|
||||
response = send_request(args.socket, request_text)
|
||||
print(json.dumps(response, separators=(",", ":"), sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # pragma: no cover - CLI entrypoint behavior
|
||||
print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -1,931 +0,0 @@
|
||||
"""Unix-socket service for governed local writer operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from governed_local_writer import (
|
||||
AppendCommitRequest,
|
||||
AuthorizationError,
|
||||
BranchPolicy,
|
||||
CreateBranchRequest,
|
||||
GovernedAuthorization,
|
||||
PeerIdentity,
|
||||
PRINCIPAL,
|
||||
TASK_ID,
|
||||
extract_peer_identity,
|
||||
contains_secret_like_content,
|
||||
load_authorization_file,
|
||||
validate_file_changes,
|
||||
)
|
||||
|
||||
|
||||
class ExistingGitOpsHandler:
|
||||
"""Delegate repository mutations to the installed GitOps handler seam."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace_by_branch: Mapping[str, str | os.PathLike[str]],
|
||||
bare_repo: str | os.PathLike[str],
|
||||
api_source: str | os.PathLike[str],
|
||||
) -> None:
|
||||
self.workspace_by_branch = {str(k): Path(v) for k, v in workspace_by_branch.items()}
|
||||
self.bare_repo = Path(bare_repo)
|
||||
self.api_source = Path(api_source)
|
||||
|
||||
@staticmethod
|
||||
def _restore_env(previous: Mapping[str, str | None]) -> None:
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
def _load_api(self, workspace: Path):
|
||||
if not self.api_source.is_file() or self.api_source.is_symlink():
|
||||
raise RuntimeError("installed GitOps handler source is unavailable")
|
||||
keys = (
|
||||
"AIOS_GITOPS_WORKSPACE",
|
||||
"AIOS_GITOPS_BARE_REPO",
|
||||
"AIOS_GITOPS_WRITES_ENABLED",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_0",
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
"GIT_CONFIG_KEY_1",
|
||||
"GIT_CONFIG_VALUE_1",
|
||||
)
|
||||
previous = {key: os.environ.get(key) for key in keys}
|
||||
os.environ.update(
|
||||
{
|
||||
"AIOS_GITOPS_WORKSPACE": str(workspace),
|
||||
"AIOS_GITOPS_BARE_REPO": str(self.bare_repo),
|
||||
"AIOS_GITOPS_WRITES_ENABLED": "true",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"GIT_CONFIG_COUNT": "2",
|
||||
"GIT_CONFIG_KEY_0": "safe.directory",
|
||||
"GIT_CONFIG_VALUE_0": str(workspace),
|
||||
"GIT_CONFIG_KEY_1": "safe.directory",
|
||||
"GIT_CONFIG_VALUE_1": str(self.bare_repo),
|
||||
}
|
||||
)
|
||||
module_name = f"_governed_gitops_api_{uuid.uuid4().hex}"
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, self.api_source)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("unable to load installed GitOps handler")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
decision = module.evaluate_git_write(module.WS)
|
||||
if not decision.allowed:
|
||||
raise RuntimeError("existing GitOps write gate denied bootstrap")
|
||||
return module, previous, module_name
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
raise
|
||||
|
||||
def _workspace(self, branch: str) -> Path:
|
||||
workspace = self.workspace_by_branch.get(branch)
|
||||
if workspace is None or not workspace.is_dir():
|
||||
raise RuntimeError("branch workspace is not authorized")
|
||||
return workspace
|
||||
|
||||
@staticmethod
|
||||
def _git(workspace: Path, args: list[str]) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=workspace, text=True, capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("governed handler git probe failed")
|
||||
return result.stdout.strip()
|
||||
|
||||
def create_branch(self, request: CreateBranchRequest) -> Mapping[str, object]:
|
||||
workspace = self._workspace(request.branch)
|
||||
# A prior handler call may have completed the fast-forward sync before
|
||||
# the process observed a post-commit error. Reconcile only an exact,
|
||||
# metadata-only result; never overwrite or move an existing ref here.
|
||||
try:
|
||||
existing = self._git(
|
||||
workspace,
|
||||
[
|
||||
"--git-dir",
|
||||
str(self.bare_repo),
|
||||
"rev-parse",
|
||||
f"refs/heads/{request.branch}",
|
||||
],
|
||||
)
|
||||
except RuntimeError:
|
||||
existing = ""
|
||||
if existing:
|
||||
parent = self._git(workspace, ["--git-dir", str(self.bare_repo), "rev-parse", f"{existing}^1"])
|
||||
changed = self._git(
|
||||
workspace,
|
||||
[
|
||||
"--git-dir",
|
||||
str(self.bare_repo),
|
||||
"diff-tree",
|
||||
"--no-commit-id",
|
||||
"--name-only",
|
||||
"-r",
|
||||
existing,
|
||||
],
|
||||
).splitlines()
|
||||
safe_branch = re.sub(r"[^A-Za-z0-9_.-]+", "-", request.branch).strip("-")
|
||||
if parent != request.base_oid or changed != [f"patches/{safe_branch}.branch.json"]:
|
||||
raise RuntimeError("existing branch is not an exact metadata-only bootstrap")
|
||||
return {"head_oid": existing, "parent_oid": parent, "handler_status": "reconciled"}
|
||||
module, previous, module_name = self._load_api(workspace)
|
||||
phase = "loaded"
|
||||
try:
|
||||
phase = "verify_base"
|
||||
parent = self._git(
|
||||
workspace,
|
||||
["--git-dir", str(self.bare_repo), "rev-parse", "refs/heads/main"],
|
||||
)
|
||||
if parent != request.base_oid:
|
||||
raise RuntimeError("authorized base OID drift")
|
||||
phase = "checkout"
|
||||
module.checkout(request.branch, base=request.base_oid, create=True)
|
||||
phase = "write_metadata"
|
||||
safe_branch = re.sub(r"[^A-Za-z0-9_.-]+", "-", request.branch).strip("-")
|
||||
metadata_path = f"patches/{safe_branch}.branch.json"
|
||||
module.write_json(
|
||||
metadata_path,
|
||||
{
|
||||
"schema": "aios.governed-local-writer-bootstrap.v1",
|
||||
"task_id": TASK_ID,
|
||||
"principal": PRINCIPAL,
|
||||
"branch": request.branch,
|
||||
"parent_oid": request.base_oid,
|
||||
"authorization_sha256": request.authorization_digest,
|
||||
},
|
||||
)
|
||||
phase = "commit_and_sync"
|
||||
result = module.commit(
|
||||
[metadata_path], "chore(governance): bootstrap repair lease branch"
|
||||
)
|
||||
head_oid = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
first_parent = self._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if first_parent != request.base_oid or (request.head_oid and head_oid != request.head_oid):
|
||||
raise RuntimeError("bootstrap branch lineage check failed")
|
||||
return {
|
||||
"head_oid": head_oid,
|
||||
"parent_oid": first_parent,
|
||||
"handler_status": str(result.get("status")),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"handler_phase={phase};error={exc.__class__.__name__}") from exc
|
||||
finally:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
|
||||
def append_commit(self, request: AppendCommitRequest) -> Mapping[str, object]:
|
||||
workspace = self._workspace(request.branch)
|
||||
module, previous, module_name = self._load_api(workspace)
|
||||
phase = "loaded"
|
||||
try:
|
||||
phase = "verify_head"
|
||||
current = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
if current != request.expected_head_oid:
|
||||
raise RuntimeError("branch head advanced or is stale")
|
||||
phase = "checkout"
|
||||
module.checkout(request.branch, base=request.expected_head_oid, create=False)
|
||||
phase = "write_files"
|
||||
paths: list[str] = []
|
||||
for change in request.changes:
|
||||
paths.append(module.write_file(change.path, change.content))
|
||||
phase = "commit_and_sync"
|
||||
result = module.commit(paths, request.commit_message)
|
||||
head_oid = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
first_parent = self._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if first_parent != request.expected_head_oid or head_oid == first_parent:
|
||||
raise RuntimeError("append commit lineage check failed")
|
||||
return {
|
||||
"head_oid": head_oid,
|
||||
"parent_oid": first_parent,
|
||||
"handler_status": str(result.get("status")),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"handler_phase={phase};error={exc.__class__.__name__}") from exc
|
||||
finally:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
|
||||
|
||||
class GovernedWriterHandler(Protocol):
|
||||
"""Small adapter interface for real writer backend handlers."""
|
||||
|
||||
def create_branch(self, request: CreateBranchRequest) -> Mapping[str, object]:
|
||||
"""Create a branch using internal repo services."""
|
||||
|
||||
def append_commit(self, request: AppendCommitRequest) -> Mapping[str, object]:
|
||||
"""Append a commit using internal repo services."""
|
||||
|
||||
|
||||
class AuditSink(Protocol):
|
||||
"""Pluggable sink for sanitized audit records."""
|
||||
|
||||
def append(self, record: "AuditRecord") -> None:
|
||||
"""Persist one audit record."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditRecord:
|
||||
"""Sanitized audit log entry."""
|
||||
|
||||
authorization_digest: str
|
||||
peer_pid: int
|
||||
peer_uid: int
|
||||
peer_gid: int
|
||||
peer_executable: str | None
|
||||
timestamp: datetime
|
||||
operation: str
|
||||
branch: str
|
||||
result: str
|
||||
|
||||
|
||||
class InMemoryAuditSink:
|
||||
"""Simple in-memory audit log used by tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.records: list[AuditRecord] = []
|
||||
|
||||
def append(self, record: AuditRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AuthorizationState:
|
||||
"""Tracks one-shot operation consumption."""
|
||||
|
||||
create_done: set[str] = field(default_factory=set)
|
||||
append_done: set[str] = field(default_factory=set)
|
||||
consumed: bool = False
|
||||
|
||||
|
||||
class GovernedLocalWriterService:
|
||||
"""Policy-aware AF_UNIX service that calls an injected handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
socket_path: str | os.PathLike[str],
|
||||
authorization: GovernedAuthorization,
|
||||
handler: GovernedWriterHandler,
|
||||
*,
|
||||
audit_sink: AuditSink | None = None,
|
||||
peer_identity_fn: Callable[[socket.socket], PeerIdentity] | None = None,
|
||||
state_path: str | os.PathLike[str] | None = None,
|
||||
) -> None:
|
||||
self.socket_path = Path(socket_path)
|
||||
self.authorization = authorization
|
||||
self.handler = handler
|
||||
self.peer_identity_fn = peer_identity_fn or extract_peer_identity
|
||||
self.state = _AuthorizationState()
|
||||
self.audit_sink = audit_sink or InMemoryAuditSink()
|
||||
self.state_path = Path(state_path) if state_path is not None else None
|
||||
self._load_state()
|
||||
|
||||
def _load_state(self) -> None:
|
||||
"""Restore one-shot state without restoring request or secret material."""
|
||||
if self.state_path is None or not self.state_path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("state must be an object")
|
||||
if raw.get("authorization_sha256") != self.authorization.authorization_sha256:
|
||||
raise ValueError("state authorization digest mismatch")
|
||||
self.state.create_done = {str(v) for v in raw.get("create_done", [])}
|
||||
self.state.append_done = {str(v) for v in raw.get("append_done", [])}
|
||||
self.state.consumed = bool(raw.get("consumed", False))
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise AuthorizationError("authorization state is invalid") from exc
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
if self.state_path is None:
|
||||
return
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"create_done": sorted(self.state.create_done),
|
||||
"append_done": sorted(self.state.append_done),
|
||||
"consumed": self.state.consumed,
|
||||
}
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".writer-state-", dir=self.state_path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
os.replace(temp_name, self.state_path)
|
||||
os.chmod(self.state_path, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.unlink(temp_name)
|
||||
|
||||
def _new_audit(self, peer: PeerIdentity, operation: str, branch: str, result: str) -> None:
|
||||
self.audit_sink.append(
|
||||
AuditRecord(
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
peer_pid=peer.pid,
|
||||
peer_uid=peer.uid,
|
||||
peer_gid=peer.gid,
|
||||
peer_executable=peer.executable,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
operation=operation,
|
||||
branch=branch,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
|
||||
def _require_request_field(self, request: Mapping[str, Any], field_name: str) -> str:
|
||||
value = request.get(field_name)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise AuthorizationError(f"{field_name} must be a non-empty string")
|
||||
return value
|
||||
|
||||
def _branch_policy(self, branch: str) -> BranchPolicy:
|
||||
policy = self.authorization.branch_operations.get(branch)
|
||||
if policy is None:
|
||||
raise AuthorizationError("branch is not authorized")
|
||||
return policy
|
||||
|
||||
def _ensure_not_consumed(self) -> None:
|
||||
if self.state.consumed:
|
||||
raise AuthorizationError("authorization consumed")
|
||||
if datetime.now(tz=timezone.utc) >= self.authorization.expires_at:
|
||||
raise AuthorizationError("authorization expired")
|
||||
|
||||
def _mark_success(self, op: str, branch: str) -> None:
|
||||
if op == "create_branch":
|
||||
self.state.create_done.add(branch)
|
||||
elif op == "append_commit":
|
||||
self.state.append_done.add(branch)
|
||||
|
||||
if self.state.create_done.issuperset(self.authorization.create_branches) and self.state.append_done.issuperset(
|
||||
self.authorization.append_branches
|
||||
):
|
||||
self.state.consumed = True
|
||||
self._persist_state()
|
||||
|
||||
def _validate_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
task_id = self._require_request_field(request, "task_id")
|
||||
if task_id != TASK_ID:
|
||||
raise AuthorizationError("invalid task_id")
|
||||
|
||||
principal = self._require_request_field(request, "principal")
|
||||
if principal != PRINCIPAL:
|
||||
raise AuthorizationError("invalid principal")
|
||||
|
||||
operation = self._require_request_field(request, "op")
|
||||
if operation not in {"create_branch", "append_commit"}:
|
||||
raise AuthorizationError("unsupported operation")
|
||||
if operation in self.authorization.denied_operations:
|
||||
raise AuthorizationError("operation denied by policy")
|
||||
|
||||
branch = self._require_request_field(request, "branch")
|
||||
self._ensure_not_consumed()
|
||||
policy = self._branch_policy(branch)
|
||||
|
||||
if operation == "create_branch":
|
||||
if policy.create_branch is None:
|
||||
raise AuthorizationError("branch does not allow create_branch")
|
||||
if branch in self.state.create_done:
|
||||
raise AuthorizationError("create_branch already used for this branch")
|
||||
|
||||
base_oid = self._require_request_field(request, "base_oid")
|
||||
raw_head_oid = request.get("head_oid")
|
||||
if raw_head_oid is not None and (not isinstance(raw_head_oid, str) or not raw_head_oid):
|
||||
raise AuthorizationError("head_oid must be a non-empty string when supplied")
|
||||
head_oid = str(raw_head_oid) if raw_head_oid is not None else None
|
||||
if base_oid != policy.create_branch.base_oid:
|
||||
raise AuthorizationError("base_oid mismatch")
|
||||
if policy.create_branch.head_oid is not None and head_oid != policy.create_branch.head_oid:
|
||||
raise AuthorizationError("head_oid mismatch")
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"base_oid": base_oid,
|
||||
"head_oid": head_oid,
|
||||
}
|
||||
|
||||
if policy.append_commit is None:
|
||||
raise AuthorizationError("branch does not allow append_commit")
|
||||
if policy.create_branch is not None and branch not in self.state.create_done:
|
||||
raise AuthorizationError("append_commit requires prior successful create_branch")
|
||||
if branch in self.state.append_done:
|
||||
raise AuthorizationError("append_commit already used for this branch")
|
||||
|
||||
expected_head = policy.append_commit.expected_head_oid
|
||||
if "expected_head_oid" in request:
|
||||
supplied_head = self._require_request_field(request, "expected_head_oid")
|
||||
elif "current_head_oid" in request:
|
||||
supplied_head = self._require_request_field(request, "current_head_oid")
|
||||
else:
|
||||
raise AuthorizationError("expected_head_oid or current_head_oid required")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", supplied_head):
|
||||
raise AuthorizationError("head check must be a full commit OID")
|
||||
if expected_head is not None and supplied_head != expected_head:
|
||||
raise AuthorizationError("head check failed")
|
||||
|
||||
if request.get("force") is True or request.get("non_fast_forward") is True:
|
||||
raise AuthorizationError("non-fast-forward operations are not allowed")
|
||||
if request.get("history_rewrite") is True:
|
||||
raise AuthorizationError("history rewrite is not allowed")
|
||||
parent_oids = request.get("parent_oids")
|
||||
if parent_oids is not None:
|
||||
if not isinstance(parent_oids, list) or parent_oids != [supplied_head]:
|
||||
raise AuthorizationError("parent_oids must contain exactly expected_head_oid")
|
||||
|
||||
changes = request.get("changes")
|
||||
if not isinstance(changes, list):
|
||||
raise AuthorizationError("changes must be a list")
|
||||
validated_changes = validate_file_changes(changes, policy.append_commit.allowed_paths)
|
||||
commit_message = request.get("commit_message", "chore(governance): append governed commit")
|
||||
if not isinstance(commit_message, str) or not commit_message.strip() or len(commit_message) > 200:
|
||||
raise AuthorizationError("commit_message must be a short non-empty string")
|
||||
if "\n" in commit_message or "\r" in commit_message or contains_secret_like_content(commit_message):
|
||||
raise AuthorizationError("commit_message failed policy")
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"expected_head_oid": supplied_head,
|
||||
"changes": validated_changes,
|
||||
"commit_message": commit_message.strip(),
|
||||
}
|
||||
|
||||
def process_request(self, raw_request: Mapping[str, Any], peer: PeerIdentity) -> Mapping[str, Any]:
|
||||
"""Run one request through policy checks and handler adapter."""
|
||||
operation = raw_request.get("op") if isinstance(raw_request.get("op"), str) else "unknown"
|
||||
branch = raw_request.get("branch") if isinstance(raw_request.get("branch"), str) else ""
|
||||
try:
|
||||
details = self._validate_request(raw_request)
|
||||
operation = str(details["operation"])
|
||||
branch = str(details["branch"])
|
||||
|
||||
if operation == "create_branch":
|
||||
self.handler.create_branch(
|
||||
CreateBranchRequest(
|
||||
branch=branch,
|
||||
base_oid=str(details["base_oid"]),
|
||||
head_oid=(
|
||||
str(details["head_oid"])
|
||||
if details.get("head_oid") is not None
|
||||
else None
|
||||
),
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
)
|
||||
)
|
||||
self._mark_success("create_branch", branch)
|
||||
self._new_audit(peer, operation, branch, "ok")
|
||||
return {
|
||||
"status": "ok",
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": "applied",
|
||||
}
|
||||
|
||||
self.handler.append_commit(
|
||||
AppendCommitRequest(
|
||||
branch=branch,
|
||||
expected_head_oid=str(details["expected_head_oid"]),
|
||||
changes=tuple(details["changes"]),
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
commit_message=str(details["commit_message"]),
|
||||
)
|
||||
)
|
||||
self._mark_success("append_commit", branch)
|
||||
self._new_audit(peer, operation, branch, "ok")
|
||||
return {
|
||||
"status": "ok",
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": "applied",
|
||||
}
|
||||
except Exception as error:
|
||||
self._new_audit(peer, operation or "unknown", branch, f"error:{error.__class__.__name__}")
|
||||
if isinstance(error, PermissionError):
|
||||
code = "permission_denied"
|
||||
elif isinstance(error, AuthorizationError):
|
||||
code = "request_denied"
|
||||
else:
|
||||
code = "handler_failed"
|
||||
if code == "handler_failed" and str(error).startswith("handler_phase="):
|
||||
code = str(error)
|
||||
return {
|
||||
"status": "error",
|
||||
"operation": operation or "unknown",
|
||||
"branch": branch,
|
||||
"error": code,
|
||||
}
|
||||
|
||||
def _write_json(self, connection: socket.socket, payload: Mapping[str, Any]) -> None:
|
||||
connection.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
|
||||
def _handle_client(self, connection: socket.socket) -> None:
|
||||
with connection:
|
||||
try:
|
||||
peer = self.peer_identity_fn(connection)
|
||||
except Exception:
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "connection",
|
||||
"branch": "",
|
||||
"error": "peer identity validation failed",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
with connection.makefile("r", encoding="utf-8") as stream:
|
||||
for raw_line in stream:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "request",
|
||||
"branch": "",
|
||||
"error": "invalid json",
|
||||
},
|
||||
)
|
||||
continue
|
||||
if not isinstance(request, Mapping):
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "request",
|
||||
"branch": "",
|
||||
"error": "request must be a JSON object",
|
||||
},
|
||||
)
|
||||
continue
|
||||
self._write_json(connection, self.process_request(request, peer))
|
||||
|
||||
def _bind_socket(self) -> socket.socket:
|
||||
if self.socket_path.exists():
|
||||
raise RuntimeError("governed writer socket already exists")
|
||||
parent = self.socket_path.parent
|
||||
if not parent.is_dir():
|
||||
raise RuntimeError("governed writer socket parent is missing")
|
||||
if parent.is_symlink():
|
||||
raise RuntimeError("governed writer socket parent must not be a symlink")
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(self.socket_path))
|
||||
os.chmod(self.socket_path, 0o600)
|
||||
server.listen(16)
|
||||
return server
|
||||
|
||||
def serve(self) -> None:
|
||||
"""Serve newline-delimited JSON over a filesystem AF_UNIX socket."""
|
||||
server = self._bind_socket()
|
||||
try:
|
||||
while True:
|
||||
connection, _ = server.accept()
|
||||
self._handle_client(connection)
|
||||
finally:
|
||||
server.close()
|
||||
if self.socket_path.exists():
|
||||
self.socket_path.unlink()
|
||||
|
||||
|
||||
class JsonAuditSink:
|
||||
"""Append-only root-owned audit sink containing no request payloads."""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, record: AuditRecord) -> None:
|
||||
payload = {
|
||||
"authorization_sha256": record.authorization_digest,
|
||||
"peer_pid": record.peer_pid,
|
||||
"peer_uid": record.peer_uid,
|
||||
"peer_gid": record.peer_gid,
|
||||
"peer_executable": record.peer_executable,
|
||||
"timestamp": record.timestamp.isoformat(),
|
||||
"operation": record.operation,
|
||||
"branch": record.branch,
|
||||
"result": record.result,
|
||||
}
|
||||
line = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
os.write(fd, line)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
|
||||
# V2R1C6 durable receipt overlay. This subclasses the original root-only
|
||||
# writer in-place so the existing AF_UNIX/SO_PEERCRED and handler policy remain
|
||||
# the only mutation path.
|
||||
_LegacyGovernedLocalWriterService = GovernedLocalWriterService
|
||||
|
||||
class GovernedLocalWriterService(_LegacyGovernedLocalWriterService):
|
||||
"""Existing writer with durable request receipts and safe recovery."""
|
||||
|
||||
def _load_state(self) -> None:
|
||||
super()._load_state()
|
||||
self.state.receipts = {}
|
||||
if self.state_path is None or not self.state_path.exists():
|
||||
return
|
||||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise AuthorizationError("authorization state is invalid")
|
||||
receipts = raw.get("receipts", {})
|
||||
if not isinstance(receipts, dict):
|
||||
raise AuthorizationError("authorization receipts are invalid")
|
||||
self.state.receipts = {
|
||||
str(key): dict(value)
|
||||
for key, value in receipts.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
if self.state_path is None:
|
||||
return
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"create_done": sorted(self.state.create_done),
|
||||
"append_done": sorted(self.state.append_done),
|
||||
"consumed": bool(self.state.consumed),
|
||||
"receipts": self.state.receipts,
|
||||
}
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".writer-state-", dir=self.state_path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
os.replace(temp_name, self.state_path)
|
||||
os.chmod(self.state_path, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.unlink(temp_name)
|
||||
|
||||
def _request_identity(self, request: Mapping[str, Any]) -> dict[str, str]:
|
||||
canonical = json.dumps(dict(request), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
fallback = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
request_id = request.get("request_id")
|
||||
idempotency_key = request.get("idempotency_key")
|
||||
request_id = request_id.strip() if isinstance(request_id, str) and request_id.strip() else "legacy-" + fallback
|
||||
idempotency_key = idempotency_key.strip() if isinstance(idempotency_key, str) and idempotency_key.strip() else "legacy-" + fallback
|
||||
for field_name, value in (("request_id", request_id), ("idempotency_key", idempotency_key)):
|
||||
if len(value) > 200 or "\n" in value or "\r" in value:
|
||||
raise AuthorizationError(field_name + " is invalid")
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"request_fingerprint": fallback,
|
||||
}
|
||||
|
||||
def _find_receipt(self, details: Mapping[str, Any], compare_fingerprint: bool = True):
|
||||
request_id = str(details["request_id"])
|
||||
idempotency_key = str(details["idempotency_key"])
|
||||
fingerprint = str(details.get("request_fingerprint", ""))
|
||||
for receipt in self.state.receipts.values():
|
||||
if receipt.get("request_id") == request_id or receipt.get("idempotency_key") == idempotency_key:
|
||||
if compare_fingerprint and receipt.get("request_fingerprint") != fingerprint:
|
||||
raise AuthorizationError("idempotency key reused with different request")
|
||||
return receipt
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _receipt_response(receipt: Mapping[str, object], status: str = "receipt") -> dict[str, object]:
|
||||
response = {
|
||||
"status": status,
|
||||
"request_id": receipt.get("request_id"),
|
||||
"idempotency_key": receipt.get("idempotency_key"),
|
||||
"operation": receipt.get("operation"),
|
||||
"branch": receipt.get("branch", ""),
|
||||
"receipt_status": receipt.get("status"),
|
||||
}
|
||||
for field_name in ("commit_oid", "final_head", "parent_oid", "audit_sha256", "error_code", "safe_to_retry"):
|
||||
if field_name in receipt:
|
||||
response[field_name] = receipt[field_name]
|
||||
return response
|
||||
|
||||
def _new_receipt(self, details: Mapping[str, Any]) -> dict[str, object]:
|
||||
return {
|
||||
"request_id": str(details["request_id"]),
|
||||
"idempotency_key": str(details["idempotency_key"]),
|
||||
"request_fingerprint": str(details["request_fingerprint"]),
|
||||
"operation": str(details["operation"]),
|
||||
"branch": str(details.get("branch", "")),
|
||||
"expected_head_oid": str(details.get("expected_head_oid", "")),
|
||||
"status": "accepted",
|
||||
"created_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"safe_to_retry": False,
|
||||
}
|
||||
|
||||
def _new_audit(self, peer: PeerIdentity, operation: str, branch: str, result: str) -> str:
|
||||
super()._new_audit(peer, operation, branch, result)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"peer_pid": peer.pid,
|
||||
"peer_uid": peer.uid,
|
||||
"peer_gid": peer.gid,
|
||||
"peer_executable": peer.executable,
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": result,
|
||||
}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
def _mark_success(self, op: str, branch: str) -> None:
|
||||
if op == "create_branch":
|
||||
self.state.create_done.add(branch)
|
||||
elif op in {"append_commit", "append_files", "append_batch"}:
|
||||
self.state.append_done.add(branch)
|
||||
# The provisioning grant is one-shot; the task writer lease itself must
|
||||
# support multiple append receipts for the exact authorized branches.
|
||||
self.state.consumed = False
|
||||
self._persist_state()
|
||||
|
||||
def _validate_durable(self, raw_request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
operation = self._require_request_field(raw_request, "op")
|
||||
identity = self._request_identity(raw_request)
|
||||
if operation == "query_receipt":
|
||||
return {"operation": operation, **identity}
|
||||
if operation == "recover_request":
|
||||
branch = self._require_request_field(raw_request, "branch")
|
||||
policy = self._branch_policy(branch)
|
||||
if policy.append_commit is None:
|
||||
raise AuthorizationError("branch does not allow recovery")
|
||||
expected = raw_request.get("expected_head_oid") or policy.append_commit.expected_head_oid
|
||||
if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", expected):
|
||||
raise AuthorizationError("recovery requires expected_head_oid")
|
||||
return {"operation": operation, "branch": branch, "expected_head_oid": expected.lower(), **identity}
|
||||
if operation not in {"create_branch", "append_commit", "append_files", "append_batch"}:
|
||||
raise AuthorizationError("unsupported operation")
|
||||
request = dict(raw_request)
|
||||
if operation in {"append_files", "append_batch"}:
|
||||
request["op"] = "append_commit"
|
||||
if operation in {"append_commit", "append_files", "append_batch"}:
|
||||
self.state.consumed = False
|
||||
branch = request.get("branch")
|
||||
removed = False
|
||||
dynamic_expected = False
|
||||
if isinstance(branch, str) and branch in self.state.append_done:
|
||||
self.state.append_done.remove(branch)
|
||||
removed = True
|
||||
policy = self.authorization.branch_operations.get(branch)
|
||||
if policy is not None and policy.append_commit is not None and policy.append_commit.expected_head_oid is not None:
|
||||
from governed_local_writer import BranchAppendPolicy, BranchPolicy
|
||||
self.authorization.branch_operations[branch] = BranchPolicy(create_branch=policy.create_branch, append_commit=BranchAppendPolicy(branch=branch, expected_head_oid=None, allowed_paths=policy.append_commit.allowed_paths))
|
||||
dynamic_expected = True
|
||||
try:
|
||||
details = super()._validate_request(request)
|
||||
finally:
|
||||
if dynamic_expected:
|
||||
policy = self.authorization.branch_operations.get(str(branch))
|
||||
if policy is not None and policy.append_commit is not None:
|
||||
from governed_local_writer import BranchAppendPolicy, BranchPolicy
|
||||
self.authorization.branch_operations[str(branch)] = BranchPolicy(create_branch=policy.create_branch, append_commit=BranchAppendPolicy(branch=str(branch), expected_head_oid=str(raw_request.get("expected_head_oid") or ""), allowed_paths=policy.append_commit.allowed_paths))
|
||||
if removed:
|
||||
self.state.append_done.add(str(branch))
|
||||
details.update(identity)
|
||||
details["operation"] = operation
|
||||
return details
|
||||
|
||||
def _recover_backend(self, details: Mapping[str, Any]) -> dict[str, object]:
|
||||
if not hasattr(self.handler, "_workspace") or not hasattr(self.handler, "_git"):
|
||||
raise RuntimeError("recovery backend unavailable")
|
||||
workspace = self.handler._workspace(str(details["branch"]))
|
||||
expected = str(details["expected_head_oid"])
|
||||
current = self.handler._git(workspace, ["rev-parse", "HEAD"])
|
||||
if current == expected:
|
||||
return {"status": "not_started", "final_head": current}
|
||||
parent = self.handler._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if parent != expected:
|
||||
raise RuntimeError("external branch advance during recovery")
|
||||
return {"status": "applied", "commit_oid": current, "final_head": current, "parent_oid": parent}
|
||||
|
||||
def process_request(self, raw_request: Mapping[str, Any], peer: PeerIdentity) -> Mapping[str, Any]:
|
||||
operation = raw_request.get("op") if isinstance(raw_request.get("op"), str) else "unknown"
|
||||
branch = raw_request.get("branch") if isinstance(raw_request.get("branch"), str) else ""
|
||||
try:
|
||||
details = self._validate_durable(raw_request)
|
||||
operation = str(details["operation"])
|
||||
branch = str(details.get("branch", ""))
|
||||
if operation == "query_receipt":
|
||||
receipt = self._find_receipt(details, compare_fingerprint=False)
|
||||
return self._receipt_response(receipt) if receipt is not None else {"status": "not_found", "request_id": details["request_id"], "idempotency_key": details["idempotency_key"]}
|
||||
if operation == "recover_request":
|
||||
receipt = self._find_receipt(details, compare_fingerprint=False)
|
||||
if receipt is None:
|
||||
return {"status": "not_found", "request_id": details["request_id"], "idempotency_key": details["idempotency_key"], "safe_to_retry": True}
|
||||
if receipt.get("status") == "applied":
|
||||
return self._receipt_response(receipt)
|
||||
recovered = self._recover_backend(details)
|
||||
if recovered.get("status") == "applied":
|
||||
receipt.update({"status": "applied", "commit_oid": recovered.get("commit_oid"), "final_head": recovered.get("final_head"), "parent_oid": recovered.get("parent_oid"), "safe_to_retry": False})
|
||||
self._mark_success("append_commit", branch)
|
||||
return self._receipt_response(receipt)
|
||||
receipt["safe_to_retry"] = True
|
||||
self._persist_state()
|
||||
return self._receipt_response(receipt)
|
||||
receipt = self._find_receipt(details)
|
||||
if receipt is not None:
|
||||
return self._receipt_response(receipt)
|
||||
receipt = self._new_receipt(details)
|
||||
self.state.receipts[str(details["request_id"])] = receipt
|
||||
self._persist_state()
|
||||
if operation == "create_branch":
|
||||
result = self.handler.create_branch(CreateBranchRequest(branch=branch, base_oid=str(details["base_oid"]), head_oid=str(details["head_oid"]) if details.get("head_oid") is not None else None, authorization_digest=self.authorization.authorization_sha256))
|
||||
commit_oid = str(result.get("head_oid", ""))
|
||||
parent_oid = str(result.get("parent_oid", ""))
|
||||
audit_sha = self._new_audit(peer, operation, branch, "ok")
|
||||
receipt.update({"status": "applied", "commit_oid": commit_oid, "final_head": commit_oid, "parent_oid": parent_oid, "audit_sha256": audit_sha, "safe_to_retry": False})
|
||||
self._mark_success("create_branch", branch)
|
||||
return self._receipt_response(receipt, status="ok") | {"result": "applied"}
|
||||
result = self.handler.append_commit(AppendCommitRequest(branch=branch, expected_head_oid=str(details["expected_head_oid"]), changes=tuple(details["changes"]), authorization_digest=self.authorization.authorization_sha256, commit_message=str(details["commit_message"])))
|
||||
commit_oid = str(result.get("head_oid", ""))
|
||||
parent_oid = str(result.get("parent_oid", ""))
|
||||
audit_sha = self._new_audit(peer, operation, branch, "ok")
|
||||
receipt.update({"status": "applied", "commit_oid": commit_oid, "final_head": commit_oid, "parent_oid": parent_oid, "audit_sha256": audit_sha, "safe_to_retry": False})
|
||||
self._mark_success("append_commit", branch)
|
||||
return self._receipt_response(receipt, status="ok") | {"result": "applied"}
|
||||
except Exception as error:
|
||||
try:
|
||||
identity = self._request_identity(raw_request)
|
||||
receipt = self.state.receipts.get(identity["request_id"])
|
||||
if receipt is not None and receipt.get("status") == "accepted":
|
||||
receipt.update({"status": "failed", "error_code": error.__class__.__name__, "safe_to_retry": False})
|
||||
self._persist_state()
|
||||
except Exception:
|
||||
pass
|
||||
self._new_audit(peer, operation, branch, "error:" + error.__class__.__name__)
|
||||
return {"status": "error", "operation": operation, "branch": branch, "error": "request_denied" if isinstance(error, AuthorizationError) else "handler_failed"}
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="AIOS governed local writer")
|
||||
parser.add_argument("--socket", required=True)
|
||||
parser.add_argument("--authorization", required=True)
|
||||
parser.add_argument("--state", required=True)
|
||||
parser.add_argument("--audit", required=True)
|
||||
parser.add_argument("--repair-branch", required=True)
|
||||
parser.add_argument("--repair-workspace", required=True)
|
||||
parser.add_argument("--candidate-branch", required=True)
|
||||
parser.add_argument("--candidate-workspace", required=True)
|
||||
parser.add_argument("--bare-repo", required=True)
|
||||
parser.add_argument("--api-source", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
print("governed local writer requires uid 0", file=sys.stderr)
|
||||
return 2
|
||||
args = _parse_args()
|
||||
authorization = load_authorization_file(args.authorization)
|
||||
handler = ExistingGitOpsHandler(
|
||||
workspace_by_branch={
|
||||
args.repair_branch: args.repair_workspace,
|
||||
args.candidate_branch: args.candidate_workspace,
|
||||
},
|
||||
bare_repo=args.bare_repo,
|
||||
api_source=args.api_source,
|
||||
)
|
||||
service = GovernedLocalWriterService(
|
||||
args.socket,
|
||||
authorization,
|
||||
handler,
|
||||
audit_sink=JsonAuditSink(args.audit),
|
||||
state_path=args.state,
|
||||
)
|
||||
service.serve()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - systemd entrypoint
|
||||
raise SystemExit(main())
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Central fail-closed policy seam for governed task and continuation requests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedPolicyInput:
|
||||
identity: str
|
||||
task_id: str
|
||||
repository: str
|
||||
operation: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
risk: str = "engineering"
|
||||
requested_capabilities: tuple[str, ...] = ()
|
||||
previous_generation: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyDecision:
|
||||
decision: str
|
||||
reason: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"decision": self.decision, "reason": self.reason}
|
||||
|
||||
|
||||
def evaluate_policy(value: GovernedPolicyInput | Mapping[str, Any]) -> PolicyDecision:
|
||||
if isinstance(value, Mapping):
|
||||
value = GovernedPolicyInput(**value)
|
||||
if value.repository != "aios/aios":
|
||||
return PolicyDecision("DENY", "REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not OID_RE.fullmatch(value.base_oid) or not OID_RE.fullmatch(value.expected_branch_oid):
|
||||
return PolicyDecision("DENY", "OID_INVALID")
|
||||
if not value.target_branch.startswith("agent/") or value.target_branch in {"main", "master"}:
|
||||
return PolicyDecision("DENY", "BRANCH_SCOPE_DENIED")
|
||||
if "force_push" in value.requested_capabilities or "force" in value.operation.lower():
|
||||
return PolicyDecision("DENY", "FORCE_PUSH_DENIED")
|
||||
if {"main_write", "tag_write", "baseline_write", "production_write", "production_deploy", "tag_mutation", "baseline_mutation"}.intersection(value.requested_capabilities):
|
||||
return PolicyDecision("DENY", "MUTATION_SCOPE_DENIED")
|
||||
if not isinstance(value.generation, int) or isinstance(value.generation, bool) or value.generation < 1:
|
||||
return PolicyDecision("DENY", "GENERATION_INVALID")
|
||||
if value.previous_generation is not None and value.generation <= value.previous_generation:
|
||||
return PolicyDecision("MANUAL_OWNER_REVIEW_REQUIRED", "GENERATION_NOT_MONOTONIC")
|
||||
if not value.task_id:
|
||||
return PolicyDecision("DENY", "TASK_ID_INVALID")
|
||||
return PolicyDecision("ALLOW", "GOVERNED_ENGINEERING_SCOPE")
|
||||
|
||||
|
||||
class GovernedPolicy:
|
||||
def decide(self, request: GovernedPolicyInput | Mapping[str, Any]) -> PolicyDecision:
|
||||
return evaluate_policy(request)
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Durable governed task binding for CREATE_NEW and CONTINUE_EXISTING."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
|
||||
|
||||
class BootstrapError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedTaskBinding:
|
||||
task_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
idempotency_key: str
|
||||
expected_branch_oid: str
|
||||
branch_mode: str
|
||||
generation: int
|
||||
job_id: str | None = None
|
||||
|
||||
def response(self, *, idempotent: bool = False) -> dict[str, Any]:
|
||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "task"
|
||||
|
||||
|
||||
class GovernedTaskBootstrap:
|
||||
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||
root = self.job_root / _safe(task_id); store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||
|
||||
def _metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (_safe(task_id) + ".json")
|
||||
|
||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||
path = self._metadata(task_id)
|
||||
if not path.is_file() or path.is_symlink(): return None
|
||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
||||
path = self._metadata(binding.task_id); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
||||
|
||||
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
||||
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
||||
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||
existing = self._read_existing(values["task_id"])
|
||||
if existing is not None:
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
return existing.response(idempotent=True)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
expected = str(request.get("expected_branch_oid") or values["base_oid"])
|
||||
if current is None:
|
||||
mode, generation = "CREATE_NEW", 1
|
||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||
else:
|
||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||
_, controller = self._store(values["task_id"])
|
||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||
binding = GovernedTaskBinding(**values, expected_branch_oid=expected, branch_mode=mode, generation=generation, job_id=receipt.job_id)
|
||||
self._write(binding)
|
||||
return binding.response()
|
||||
@@ -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,306 +0,0 @@
|
||||
"""Uncredentialed deterministic executor for governed Online Agent changes.
|
||||
|
||||
This process never receives Agent/Owner/Bridge credentials and never writes Git refs.
|
||||
It operates only on an exchange bundle and returns hash-bound changed files + test evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import errno
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
ALLOWED_PROGRAMS = {"/usr/bin/python3", "/usr/bin/git", "/bin/true", "/usr/bin/true"}
|
||||
MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
|
||||
MAX_FILE_BYTES = 4 * 1024 * 1024
|
||||
SECRET_ENV_TOKENS = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "PRIVATE_KEY")
|
||||
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,254}$")
|
||||
SENSITIVE_VALUE_RE = re.compile(
|
||||
r"(?i)(authorization\s*[:=]\s*bearer\s+\S+|authorization\s+bearer\s+\S+|"
|
||||
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
||||
)
|
||||
|
||||
|
||||
class IsolatedExecutorError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def safe_relative(value: object) -> str:
|
||||
if not isinstance(value, str) or not value or value.startswith("/"):
|
||||
raise IsolatedExecutorError("PATH_INVALID")
|
||||
path = Path(value)
|
||||
if ".." in path.parts or "." in path.parts or any(part in {"", ".git"} for part in path.parts):
|
||||
raise IsolatedExecutorError("PATH_INVALID")
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def resolve_under(root: Path, relative: str) -> Path:
|
||||
root = root.resolve()
|
||||
path = (root / safe_relative(relative)).resolve(strict=False)
|
||||
if path == root or root not in path.parents:
|
||||
raise IsolatedExecutorError("PATH_ESCAPE")
|
||||
return path
|
||||
|
||||
|
||||
def _credential_exposure(directory: Path) -> str | None:
|
||||
"""Return an exposure code; EACCES is the intended credential isolation proof."""
|
||||
try:
|
||||
if not directory.exists():
|
||||
return None
|
||||
if directory.is_symlink():
|
||||
return "CREDENTIAL_MOUNT_SYMLINK"
|
||||
if os.access(directory, os.R_OK):
|
||||
return "CREDENTIAL_MOUNT_ACCESSIBLE"
|
||||
if directory.is_dir():
|
||||
for child in directory.iterdir():
|
||||
if child.is_file() and os.access(child, os.R_OK):
|
||||
return "CREDENTIAL_FILE_ACCESSIBLE"
|
||||
except PermissionError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def assert_uncredentialed() -> None:
|
||||
credentials = Path("/run/credentials")
|
||||
candidates = {credentials}
|
||||
credentials_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
candidates.add(credentials_directory)
|
||||
leaked = sorted(
|
||||
key for key, value in os.environ.items()
|
||||
if value
|
||||
and (any(token in key.upper() for token in SECRET_ENV_TOKENS) or SENSITIVE_VALUE_RE.search(str(value)))
|
||||
)
|
||||
if leaked:
|
||||
raise IsolatedExecutorError("CREDENTIAL_ENV_EXPOSED:" + ",".join(leaked))
|
||||
for directory in candidates:
|
||||
exposure = _credential_exposure(directory)
|
||||
if exposure is not None:
|
||||
raise IsolatedExecutorError(exposure)
|
||||
# In the intended PrivateNetwork namespace host loopback services are unreachable.
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EACCES, errno.EPERM, errno.EAFNOSUPPORT}:
|
||||
return
|
||||
raise IsolatedExecutorError("NETWORK_ISOLATION_PROBE_FAILED") from exc
|
||||
probe.settimeout(0.15)
|
||||
try:
|
||||
if probe.connect_ex(("127.0.0.1", 18892)) == 0:
|
||||
raise IsolatedExecutorError("GOVERNED_BRIDGE_REACHABLE")
|
||||
finally:
|
||||
probe.close()
|
||||
|
||||
|
||||
def extract_source(archive: Path, root: Path) -> None:
|
||||
if archive.is_symlink() or not archive.is_file() or archive.stat().st_size > MAX_ARCHIVE_BYTES:
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_INVALID")
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
for member in tf.getmembers():
|
||||
path = Path(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or ".git" in path.parts:
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||
if member.issym() or member.islnk():
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_LINK_DENY")
|
||||
tf.extractall(root, filter="data")
|
||||
|
||||
|
||||
def run_command(item: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
||||
argv = item.get("argv")
|
||||
if not isinstance(argv, Sequence) or isinstance(argv, (str, bytes)) or not argv:
|
||||
raise IsolatedExecutorError("COMMAND_INVALID")
|
||||
argv = [str(part) for part in argv]
|
||||
if argv[0] not in ALLOWED_PROGRAMS:
|
||||
raise IsolatedExecutorError("COMMAND_PROGRAM_DENY")
|
||||
if argv[0] == "/usr/bin/python3" and "-m" in argv:
|
||||
index = argv.index("-m")
|
||||
if index + 1 >= len(argv) or argv[index + 1] not in {"pytest", "py_compile"}:
|
||||
raise IsolatedExecutorError("PYTHON_MODULE_DENY")
|
||||
if argv[0] == "/usr/bin/git" and any(
|
||||
token in {"push", "update-ref", "commit", "merge", "rebase", "reset", "clean", "branch"}
|
||||
for token in argv[1:]
|
||||
):
|
||||
raise IsolatedExecutorError("GIT_MUTATION_DENY")
|
||||
timeout = int(item.get("timeout_seconds") or 300)
|
||||
if not 1 <= timeout <= 1800:
|
||||
raise IsolatedExecutorError("COMMAND_TIMEOUT_INVALID")
|
||||
cwd = root if item.get("cwd", ".") == "." else resolve_under(root, str(item.get("cwd")))
|
||||
started = time.monotonic()
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||
)
|
||||
result = {
|
||||
"label": str(item.get("label") or "command"),
|
||||
"exit_code": completed.returncode,
|
||||
"stdout_sha256": sha256(completed.stdout.encode("utf-8", "replace")),
|
||||
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
}
|
||||
if completed.returncode != 0:
|
||||
raise IsolatedExecutorError(
|
||||
"COMMAND_FAILED:" + result["label"] + ":" + str(completed.returncode)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def apply_changes(request: Mapping[str, Any], root: Path) -> list[str]:
|
||||
changes = request.get("change_set")
|
||||
if not isinstance(changes, list) or not changes:
|
||||
raise IsolatedExecutorError("CHANGE_SET_INVALID")
|
||||
changed: list[str] = []
|
||||
for change in changes:
|
||||
if not isinstance(change, Mapping):
|
||||
raise IsolatedExecutorError("CHANGE_INVALID")
|
||||
relative = safe_relative(change.get("path"))
|
||||
path = resolve_under(root, relative)
|
||||
current = path.read_bytes() if path.is_file() else None
|
||||
expected = change.get("expected_sha256")
|
||||
if expected is not None and (current is None or sha256(current) != expected):
|
||||
raise IsolatedExecutorError("SOURCE_DRIFT:" + relative)
|
||||
operation = change.get("operation")
|
||||
if operation == "write_file":
|
||||
content = change.get("content")
|
||||
if not isinstance(content, str):
|
||||
raise IsolatedExecutorError("WRITE_CONTENT_INVALID")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
elif operation == "replace_text":
|
||||
if current is None:
|
||||
raise IsolatedExecutorError("REPLACE_SOURCE_MISSING")
|
||||
old = str(change.get("old"))
|
||||
new = str(change.get("new"))
|
||||
count = int(change.get("count", 1))
|
||||
text = current.decode("utf-8")
|
||||
if text.count(old) != count:
|
||||
raise IsolatedExecutorError("REPLACE_COUNT_INVALID:" + relative)
|
||||
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||
elif operation == "delete_file":
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
else:
|
||||
raise IsolatedExecutorError("CHANGE_OPERATION_INVALID")
|
||||
changed.append(relative)
|
||||
return sorted(set(changed))
|
||||
|
||||
|
||||
def changed_bundle(root: Path, changed: list[str]) -> list[dict[str, Any]]:
|
||||
files = []
|
||||
for relative in changed:
|
||||
path = resolve_under(root, relative)
|
||||
if path.exists():
|
||||
data = path.read_bytes()
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
raise IsolatedExecutorError("CHANGED_FILE_TOO_LARGE:" + relative)
|
||||
files.append({
|
||||
"path": relative,
|
||||
"deleted": False,
|
||||
"sha256": sha256(data),
|
||||
"content_b64": base64.b64encode(data).decode("ascii"),
|
||||
})
|
||||
else:
|
||||
files.append({"path": relative, "deleted": True, "sha256": None, "content_b64": None})
|
||||
return files
|
||||
|
||||
|
||||
def execute_request(request_path: Path, result_path: Path) -> None:
|
||||
assert_uncredentialed()
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
if request.get("schema") != "aios.isolated-execution.request.v1":
|
||||
raise IsolatedExecutorError("REQUEST_SCHEMA_INVALID")
|
||||
request_id = request.get("request_id")
|
||||
if not isinstance(request_id, str) or not REQUEST_ID_RE.fullmatch(request_id):
|
||||
raise IsolatedExecutorError("REQUEST_ID_INVALID")
|
||||
source_archive = Path(str(request.get("source_archive") or ""))
|
||||
if not source_archive.is_absolute():
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||
expected_archive_sha = str(request.get("source_archive_sha256") or "")
|
||||
if sha256(source_archive.read_bytes()) != expected_archive_sha:
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_SHA_MISMATCH")
|
||||
with tempfile.TemporaryDirectory(prefix="aios-isolated-executor-") as temporary:
|
||||
root = Path(temporary) / "workspace"
|
||||
root.mkdir()
|
||||
extract_source(source_archive, root)
|
||||
changed = apply_changes(request, root)
|
||||
commands = [
|
||||
run_command(item, root)
|
||||
for item in request.get("verification", [])
|
||||
if isinstance(item, Mapping)
|
||||
]
|
||||
result = {
|
||||
"schema": "aios.isolated-execution.result.v1",
|
||||
"request_id": request_id,
|
||||
"request_sha256": sha256(canonical(request)),
|
||||
"changed_files": changed_bundle(root, changed),
|
||||
"verification": commands,
|
||||
"credential_isolation": {
|
||||
"credential_env_absent": True,
|
||||
"credential_mount_inaccessible": True,
|
||||
"governed_bridge_unreachable": True,
|
||||
},
|
||||
}
|
||||
result["result_sha256"] = sha256(canonical(result))
|
||||
temporary_result = result_path.with_suffix(".tmp")
|
||||
temporary_result.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")), encoding="utf-8")
|
||||
os.replace(temporary_result, result_path)
|
||||
|
||||
|
||||
def serve(root: Path) -> None:
|
||||
assert_uncredentialed()
|
||||
requests = root / "requests"
|
||||
results = root / "results"
|
||||
failed = root / "failed"
|
||||
for path in (requests, results, failed):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
while True:
|
||||
for request in sorted(requests.glob("*.json")):
|
||||
if request.is_symlink():
|
||||
continue
|
||||
result = results / request.name
|
||||
try:
|
||||
execute_request(request, result)
|
||||
request.unlink()
|
||||
except Exception as exc:
|
||||
(failed / request.name).write_text(
|
||||
json.dumps({"error": str(exc) or type(exc).__name__}, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
request.unlink(missing_ok=True)
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default="/var/lib/aios-online-agent-executor")
|
||||
parser.add_argument("--request")
|
||||
parser.add_argument("--result")
|
||||
args = parser.parse_args()
|
||||
if args.request and args.result:
|
||||
execute_request(Path(args.request), Path(args.result))
|
||||
else:
|
||||
serve(Path(args.root))
|
||||
@@ -1,316 +0,0 @@
|
||||
"""Task-scoped hardened wrapper around the existing Action API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import urllib.parse
|
||||
|
||||
import aios_chatgpt_actions_api as api
|
||||
import aios_isolated_writer_policy as policy
|
||||
from isolated_writer_authz import AuthorizationError, authorize
|
||||
from isolated_writer_commit_file import AtomicCommitFile, CommitFileError, OperationLedger
|
||||
from isolated_writer_token_registry import TokenRegistry
|
||||
|
||||
_reject = api.reject_non_read_http
|
||||
_write_file = api.write_file
|
||||
_sync_branch = api.sync_branch
|
||||
_original_do_get = api.H.do_GET
|
||||
api.OPENAPI = Path(os.getenv("AIOS_GITOPS_ISOLATED_WRITER_OPENAPI", str(api.OPENAPI)))
|
||||
|
||||
|
||||
def _registry() -> TokenRegistry:
|
||||
return TokenRegistry(Path(os.environ["AIOS_GITOPS_TOKEN_REGISTRY_V2"]))
|
||||
|
||||
|
||||
def _atomic_writer() -> AtomicCommitFile:
|
||||
return AtomicCommitFile(
|
||||
workspace=api.WS,
|
||||
bare_repo=api.BARE_REPO,
|
||||
ledger=OperationLedger(Path(os.environ["AIOS_GITOPS_WRITER_OPERATION_LEDGER"])),
|
||||
remote=os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", ""),
|
||||
audit=policy.append_audit_record,
|
||||
)
|
||||
|
||||
|
||||
def _bearer(handler) -> str:
|
||||
value = handler.headers.get("Authorization", "")
|
||||
return value[7:].strip() if value.startswith("Bearer ") else ""
|
||||
|
||||
|
||||
def _authorize(handler, *, method: str, path: str, branch=None, target_path=None):
|
||||
grant = authorize(
|
||||
_registry(),
|
||||
_bearer(handler),
|
||||
method=method,
|
||||
path=path,
|
||||
source_ip=str(handler.client_address[0]),
|
||||
workspace=api.WS,
|
||||
branch=branch,
|
||||
target_path=target_path,
|
||||
)
|
||||
grant.record["_source_ip"] = str(handler.client_address[0])
|
||||
return grant
|
||||
|
||||
|
||||
def _authorization_error(handler, exc: AuthorizationError):
|
||||
return handler.sendj({"error": exc.code}, exc.status)
|
||||
|
||||
|
||||
def _isolated_runtime_status(handler):
|
||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
||||
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
||||
return handler.sendj({
|
||||
"service": os.getenv(
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
||||
"aios-isolated-writer.service",
|
||||
),
|
||||
"status": "active",
|
||||
"runtime_role": os.getenv("AIOS_GITOPS_RUNTIME_ROLE"),
|
||||
"workspace_role": os.getenv("AIOS_GITOPS_WORKSPACE_ROLE"),
|
||||
"writes_enabled": os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").lower()
|
||||
in {"1", "true", "yes", "on"},
|
||||
"allowed_branch": policy.allowed_branch(),
|
||||
"approved_base_oid": policy.allowed_base_oid(),
|
||||
"workspace_branch": branch,
|
||||
"workspace_head_oid": head,
|
||||
"repository_identity": os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY"),
|
||||
"token_policy_version": 2,
|
||||
"atomic_commit_file": True,
|
||||
"path_policy_sha256": policy.configured_path_policy_sha256(),
|
||||
})
|
||||
|
||||
|
||||
def _isolated_do_get(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path in {"/health", "/api/health"}:
|
||||
return self.sendj({
|
||||
"status": "ok",
|
||||
"service": os.getenv(
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
||||
"aios-isolated-writer.service",
|
||||
),
|
||||
})
|
||||
if path not in {"/api/runtime-status", "/api/baseline", "/api/patches"}:
|
||||
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
||||
try:
|
||||
_authorize(self, method="GET", path=path)
|
||||
except AuthorizationError as exc:
|
||||
return _authorization_error(self, exc)
|
||||
if path == "/api/runtime-status":
|
||||
return _isolated_runtime_status(self)
|
||||
return _original_do_get(self)
|
||||
|
||||
|
||||
def _read_commit_file_body(handler):
|
||||
try:
|
||||
length = int(handler.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
raise CommitFileError("writer_content_length_rejected") from None
|
||||
if length <= 0:
|
||||
raise CommitFileError("writer_request_object_required")
|
||||
if length > 1_100_000:
|
||||
raise CommitFileError("writer_request_too_large", 413)
|
||||
raw = handler.rfile.read(length)
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise CommitFileError("writer_request_json_rejected") from None
|
||||
if not isinstance(value, dict):
|
||||
raise CommitFileError("writer_request_object_required")
|
||||
return value
|
||||
|
||||
|
||||
def _isolated_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/api/commit-file":
|
||||
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
||||
try:
|
||||
data = _read_commit_file_body(self)
|
||||
branch = str(data.get("branch") or "")
|
||||
target_path = str(data.get("path") or "")
|
||||
grant = _authorize(
|
||||
self,
|
||||
method="POST",
|
||||
path=path,
|
||||
branch=branch,
|
||||
target_path=target_path,
|
||||
)
|
||||
if api.content_has_secret(str(data.get("content") or "")):
|
||||
raise CommitFileError("writer_secret_content_rejected", 403)
|
||||
result = _atomic_writer().execute(grant, data)
|
||||
return self.sendj(result)
|
||||
except AuthorizationError as exc:
|
||||
return _authorization_error(self, exc)
|
||||
except CommitFileError as exc:
|
||||
return self.sendj({"error": exc.code}, exc.status)
|
||||
except PermissionError:
|
||||
return self.sendj({"error": "isolated_writer_policy_rejected"}, 403)
|
||||
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
if _reject(handler, method, path):
|
||||
return True
|
||||
try:
|
||||
policy.validate_endpoint(method, path, api.WS, api.BARE_REPO)
|
||||
return False
|
||||
except PermissionError as exc:
|
||||
handler.sendj({"error": str(exc)}, 403)
|
||||
return True
|
||||
|
||||
|
||||
def checkout(branch, base="main", create=False):
|
||||
api.ensure_git()
|
||||
target = api.branch_name(branch)
|
||||
policy.validate_branch_request({"branch": target, "base": base})
|
||||
exists = api.sh(["rev-parse", "--verify", target])["code"] == 0
|
||||
if exists:
|
||||
if api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"] != target:
|
||||
api.sh(["checkout", target], True)
|
||||
elif create:
|
||||
api.sh(["checkout", "-b", target, base], True)
|
||||
else:
|
||||
raise PermissionError("isolated_writer_branch_missing")
|
||||
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
||||
return target
|
||||
|
||||
|
||||
def write_file(path, content):
|
||||
policy.validate_path(path, api.WS)
|
||||
return _write_file(path, content)
|
||||
|
||||
|
||||
def sync_branch(branch: str):
|
||||
policy.validate_sync_branch(branch)
|
||||
local = _sync_branch(branch)
|
||||
remote = os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", "").strip()
|
||||
if remote != "gitea-http":
|
||||
raise PermissionError("isolated_writer_gitea_remote_rejected")
|
||||
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
||||
pushed = subprocess.run(
|
||||
["git", "push", "--porcelain", remote, f"HEAD:refs/heads/{branch}"],
|
||||
cwd=api.WS,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if pushed.returncode != 0:
|
||||
raise RuntimeError("isolated_writer_gitea_push_failed")
|
||||
verified = subprocess.run(
|
||||
["git", "ls-remote", "--heads", remote, f"refs/heads/{branch}"],
|
||||
cwd=api.WS,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if verified.returncode != 0:
|
||||
raise RuntimeError("isolated_writer_gitea_verification_failed")
|
||||
fields = verified.stdout.strip().split()
|
||||
if len(fields) != 2 or fields[0] != head or fields[1] != f"refs/heads/{branch}":
|
||||
raise RuntimeError("isolated_writer_gitea_oid_mismatch")
|
||||
return {
|
||||
"status": "verified",
|
||||
"target": "authoritative-bare+gitea",
|
||||
"branch": branch,
|
||||
"oid": head,
|
||||
"local": local.get("status"),
|
||||
}
|
||||
|
||||
|
||||
def commit(paths, msg):
|
||||
policy.validate_commit_paths(paths, api.WS)
|
||||
api.ensure_git()
|
||||
selected = [api.relpath(p) for p in paths] if paths else ["."]
|
||||
api.sh(["add", *selected], True)
|
||||
policy.validate_staged_paths(api.WS)
|
||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
||||
if not api.sh(["status", "--short"])["stdout"]:
|
||||
return {"status": "no_changes", "branch": branch, "sync": sync_branch(branch)}
|
||||
out = api.sh(["commit", "-m", msg], True)
|
||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
||||
return {
|
||||
"status": "committed",
|
||||
"branch": branch,
|
||||
"rev": api.sh(["rev-parse", "--short", "HEAD"], True)["stdout"],
|
||||
"git": out,
|
||||
"sync": sync_branch(branch),
|
||||
}
|
||||
|
||||
|
||||
def audit(actor, action, target, payload):
|
||||
policy.append_audit_record(
|
||||
{
|
||||
"at": api.now(),
|
||||
"actor": actor,
|
||||
"action": action,
|
||||
"target": target,
|
||||
"payload": api.sanitize(payload),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_request(self, data, validate):
|
||||
try:
|
||||
validate(data)
|
||||
return True
|
||||
except PermissionError as exc:
|
||||
self.sendj({"error": str(exc)}, 403)
|
||||
return False
|
||||
|
||||
|
||||
def _checked_branch(self, data):
|
||||
if not _validate_request(self, data, policy.validate_branch_request):
|
||||
return
|
||||
return _branch(self, data)
|
||||
|
||||
|
||||
def _checked_file(self, data):
|
||||
data = dict(data)
|
||||
data.setdefault("branch", policy.allowed_branch())
|
||||
if not _validate_request(self, data, policy.validate_branch_request):
|
||||
return
|
||||
if not _validate_request(
|
||||
self,
|
||||
{"path": data.get("path", "")},
|
||||
lambda item: policy.validate_path(item["path"], api.WS),
|
||||
):
|
||||
return
|
||||
return _file(self, data)
|
||||
|
||||
|
||||
def _checked_commit(self, data):
|
||||
data = dict(data)
|
||||
data.setdefault("branch", policy.allowed_branch())
|
||||
if not _validate_request(self, data, policy.validate_branch_request):
|
||||
return
|
||||
return _commit(self, data)
|
||||
|
||||
|
||||
def _checked_patch(self, data):
|
||||
data = dict(data)
|
||||
data["branch"] = policy.allowed_branch()
|
||||
if not _validate_request(self, data, policy.validate_branch_request):
|
||||
return
|
||||
return _patch(self, data)
|
||||
|
||||
|
||||
_branch, _file, _commit, _patch = api.H.branch, api.H.file, api.H.commit, api.H.patch
|
||||
api.reject_non_read_http = reject_non_read_http
|
||||
api.checkout, api.write_file, api.sync_branch, api.commit, api.audit = (
|
||||
checkout,
|
||||
write_file,
|
||||
sync_branch,
|
||||
commit,
|
||||
audit,
|
||||
)
|
||||
api.H.branch, api.H.file, api.H.commit, api.H.patch = (
|
||||
_checked_branch,
|
||||
_checked_file,
|
||||
_checked_commit,
|
||||
_checked_patch,
|
||||
)
|
||||
api.H.do_GET = _isolated_do_get
|
||||
api.H.do_POST = _isolated_do_post
|
||||
|
||||
if __name__ == "__main__":
|
||||
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
||||
api.ThreadingHTTPServer((api.HOST, api.PORT), api.H).serve_forever()
|
||||
@@ -1,310 +0,0 @@
|
||||
"""Fail-closed policy used only by the task-scoped Action writer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import unquote
|
||||
|
||||
import isolated_writer_path_policy as path_policy
|
||||
|
||||
WRITE_ENDPOINTS = frozenset({"/api/commit-file"})
|
||||
ALLOWED_ROOTS = (
|
||||
"active-runtime",
|
||||
"docs",
|
||||
"patches",
|
||||
"reports",
|
||||
"scripts",
|
||||
"test",
|
||||
"tests",
|
||||
)
|
||||
DENIED_ROOTS = ("baseline", "config", "deploy", "deployments", "ops", "runtime", "services")
|
||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
||||
_FALSE = frozenset({"0", "false", "no", "off"})
|
||||
_DENY_FLAGS = (
|
||||
"AIOS_GITOPS_ALLOW_MAIN_WRITE",
|
||||
"AIOS_GITOPS_ALLOW_TAG_WRITE",
|
||||
"AIOS_GITOPS_ALLOW_BASELINE_WRITE",
|
||||
"AIOS_GITOPS_ALLOW_REGISTRY_WRITE",
|
||||
"AIOS_GITOPS_ALLOW_ROLLBACK",
|
||||
"AIOS_GITOPS_ALLOW_DEPLOYMENT",
|
||||
)
|
||||
|
||||
|
||||
def enforcement_required() -> bool:
|
||||
return os.getenv("AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT", "") == "required"
|
||||
|
||||
|
||||
def allowed_branch() -> str:
|
||||
value = os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", "").strip()
|
||||
if not value:
|
||||
raise PermissionError("isolated_writer_missing_allowed_branch")
|
||||
return value
|
||||
|
||||
|
||||
def allowed_base_oid() -> str:
|
||||
value = os.getenv("AIOS_GITOPS_ALLOWED_BASE_OID", "").strip().lower()
|
||||
if len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value):
|
||||
raise PermissionError("isolated_writer_invalid_allowed_base_oid")
|
||||
return value
|
||||
|
||||
|
||||
def _git(workspace: Path, *args: str) -> str:
|
||||
result = subprocess.run(["git", *args], cwd=workspace, text=True, capture_output=True)
|
||||
if result.returncode != 0:
|
||||
raise PermissionError("isolated_writer_git_validation_failed")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def require_service_invariants(workspace: Path, bare_repo: Path) -> None:
|
||||
if not enforcement_required():
|
||||
return
|
||||
if os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").strip().lower() not in _TRUE:
|
||||
raise PermissionError("isolated_writer_write_gate_required")
|
||||
if os.getenv("AIOS_GITOPS_RUNTIME_ROLE", "") != "isolated-writer":
|
||||
raise PermissionError("isolated_writer_role_required")
|
||||
if os.getenv("AIOS_GITOPS_WORKSPACE_ROLE", "") != "isolated-writer":
|
||||
raise PermissionError("isolated_writer_workspace_role_required")
|
||||
if os.getenv("AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED", "") != "2":
|
||||
raise PermissionError("isolated_writer_token_policy_v2_required")
|
||||
if not os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", "").strip():
|
||||
raise PermissionError("isolated_writer_service_audience_required")
|
||||
if not os.getenv("AIOS_GITOPS_TOKEN_REGISTRY_V2", "").strip():
|
||||
raise PermissionError("isolated_writer_token_registry_required")
|
||||
if not os.getenv("AIOS_GITOPS_WRITER_OPERATION_LEDGER", "").strip():
|
||||
raise PermissionError("isolated_writer_operation_ledger_required")
|
||||
path_policy.configured_path_policy()
|
||||
for name in _DENY_FLAGS:
|
||||
if os.getenv(name, "").strip().lower() not in _FALSE:
|
||||
raise PermissionError("isolated_writer_forbidden_capability_flag")
|
||||
if os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "") != "aios/aios":
|
||||
raise PermissionError("isolated_writer_repository_identity_rejected")
|
||||
configured_bare_value = os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "").strip()
|
||||
if not configured_bare_value:
|
||||
raise PermissionError("isolated_writer_authoritative_repo_rejected")
|
||||
configured_bare = Path(configured_bare_value)
|
||||
if configured_bare.resolve(strict=True) != bare_repo.resolve(strict=True):
|
||||
raise PermissionError("isolated_writer_authoritative_repo_rejected")
|
||||
writer_root_value = os.getenv("AIOS_GITOPS_APPROVED_WRITER_ROOT", "").strip()
|
||||
if not writer_root_value:
|
||||
raise PermissionError("isolated_writer_workspace_outside_approved_root")
|
||||
writer_root = Path(writer_root_value).resolve(strict=True)
|
||||
lexical_workspace = Path(os.path.abspath(str(workspace)))
|
||||
canonical_workspace = lexical_workspace.resolve(strict=True)
|
||||
if lexical_workspace != canonical_workspace:
|
||||
raise PermissionError("isolated_writer_workspace_symlink_rejected")
|
||||
try:
|
||||
canonical_workspace.relative_to(writer_root)
|
||||
except (ValueError, FileNotFoundError):
|
||||
raise PermissionError("isolated_writer_workspace_outside_approved_root") from None
|
||||
if _git(workspace, "rev-parse", "--abbrev-ref", "HEAD") != allowed_branch():
|
||||
raise PermissionError("isolated_writer_current_branch_rejected")
|
||||
approved_base = allowed_base_oid()
|
||||
try:
|
||||
_git(workspace, "cat-file", "-e", f"{approved_base}^{{commit}}")
|
||||
subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", approved_base, "HEAD"],
|
||||
cwd=workspace,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except (PermissionError, subprocess.CalledProcessError):
|
||||
raise PermissionError("isolated_writer_approved_base_not_ancestor") from None
|
||||
if _git(workspace, "merge-base", approved_base, "HEAD") != approved_base:
|
||||
raise PermissionError("isolated_writer_merge_base_rejected")
|
||||
common = Path(_git(workspace, "rev-parse", "--git-common-dir"))
|
||||
if not common.is_absolute():
|
||||
common = (workspace / common).resolve()
|
||||
if common.resolve() != bare_repo.resolve():
|
||||
raise PermissionError("isolated_writer_common_repo_rejected")
|
||||
|
||||
|
||||
def approved_audit_log_path() -> Path:
|
||||
root_value = os.getenv("AIOS_GITOPS_APPROVED_AUDIT_ROOT", "").strip()
|
||||
log_value = os.getenv("AIOS_GITOPS_AUDIT_LOG", "").strip()
|
||||
if not root_value or not log_value:
|
||||
raise PermissionError("isolated_writer_audit_path_missing")
|
||||
lexical_root = Path(os.path.abspath(root_value))
|
||||
lexical_log = Path(os.path.abspath(log_value))
|
||||
root = lexical_root.resolve(strict=True)
|
||||
if lexical_root != root or root.is_symlink() or not root.is_dir():
|
||||
raise PermissionError("isolated_writer_audit_root_rejected")
|
||||
try:
|
||||
lexical_log.relative_to(root)
|
||||
except ValueError:
|
||||
raise PermissionError("isolated_writer_audit_path_outside_approved_root") from None
|
||||
if lexical_log.parent.resolve(strict=True) != lexical_log.parent:
|
||||
raise PermissionError("isolated_writer_audit_parent_symlink_rejected")
|
||||
if lexical_log.exists() and lexical_log.is_symlink():
|
||||
raise PermissionError("isolated_writer_audit_log_symlink_rejected")
|
||||
os.chmod(root, 0o750)
|
||||
os.chmod(lexical_log.parent, 0o750)
|
||||
return lexical_log
|
||||
|
||||
|
||||
def append_audit_record(record: dict) -> None:
|
||||
path = approved_audit_log_path()
|
||||
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise PermissionError("isolated_writer_audit_log_not_regular")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
line = json.dumps(
|
||||
_audit_sanitize(record),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8") + b"\n"
|
||||
os.write(descriptor, line)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
parent_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
|
||||
|
||||
def _audit_sanitize(value):
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (
|
||||
"[REDACTED]"
|
||||
if any(
|
||||
marker in str(key).lower()
|
||||
for marker in (
|
||||
"authorization",
|
||||
"credential",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"private_key",
|
||||
)
|
||||
)
|
||||
and str(key).lower() != "token_id"
|
||||
else _audit_sanitize(inner)
|
||||
)
|
||||
for key, inner in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_audit_sanitize(item) for item in value]
|
||||
if isinstance(value, str) and re.search(
|
||||
r"(?i)(authorization\s*:\s*bearer|(?:token|password|secret)\s*[:=])",
|
||||
value,
|
||||
):
|
||||
return "[REDACTED]"
|
||||
return value
|
||||
|
||||
|
||||
def validate_endpoint(method: str, path: str, workspace: Path, bare_repo: Path) -> None:
|
||||
if not enforcement_required():
|
||||
return
|
||||
if method != "POST" or path not in WRITE_ENDPOINTS:
|
||||
raise PermissionError("isolated_writer_operation_forbidden")
|
||||
require_service_invariants(workspace, bare_repo)
|
||||
|
||||
|
||||
def validate_branch_request(data: dict) -> None:
|
||||
if not enforcement_required():
|
||||
return
|
||||
branch = allowed_branch() if "branch" not in data else str(data.get("branch") or "").strip()
|
||||
if branch != allowed_branch():
|
||||
raise PermissionError("isolated_writer_branch_rejected")
|
||||
if str(data.get("base", "main")).strip() != "main":
|
||||
raise PermissionError("isolated_writer_base_rejected")
|
||||
if data.get("force") or data.get("force_push"):
|
||||
raise PermissionError("isolated_writer_force_rejected")
|
||||
|
||||
|
||||
def validate_path(path: str, workspace: Path | None = None) -> str:
|
||||
if not isinstance(path, str):
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
value = path
|
||||
if not value or value != value.strip() or "\x00" in value or "\\" in value:
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
try:
|
||||
decoded = unquote(value, errors="strict")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise PermissionError("isolated_writer_path_rejected") from None
|
||||
if decoded != value:
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
windows = PureWindowsPath(value)
|
||||
posix = PurePosixPath(value)
|
||||
parts = value.split("/")
|
||||
if (
|
||||
posix.is_absolute()
|
||||
or windows.is_absolute()
|
||||
or bool(windows.drive)
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
first = parts[0]
|
||||
service_exact_path = False
|
||||
if first in DENIED_ROOTS:
|
||||
try:
|
||||
path_policy.require_service_path("commit_file.atomic", value)
|
||||
service_exact_path = True
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
if value != "README.md" and first not in ALLOWED_ROOTS and not service_exact_path:
|
||||
raise PermissionError("isolated_writer_path_outside_allowed_roots")
|
||||
if workspace is not None:
|
||||
lexical_root = Path(os.path.abspath(str(workspace)))
|
||||
root = lexical_root.resolve(strict=True)
|
||||
if lexical_root != root:
|
||||
raise PermissionError("isolated_writer_workspace_symlink_rejected")
|
||||
current = root
|
||||
for part in parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise PermissionError("isolated_writer_path_symlink_rejected")
|
||||
candidate = (root / value).resolve(strict=False)
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError:
|
||||
raise PermissionError("isolated_writer_path_escapes_workspace") from None
|
||||
return value
|
||||
|
||||
|
||||
def validate_content_for_path(path: str, content: str) -> None:
|
||||
if path.split("/", 1)[0] not in DENIED_ROOTS:
|
||||
return
|
||||
try:
|
||||
path_policy.validate_content("commit_file.atomic", path, content)
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
|
||||
|
||||
def configured_path_policy_sha256() -> str | None:
|
||||
try:
|
||||
return path_policy.configured_path_policy_sha256()
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
|
||||
|
||||
def validate_commit_paths(paths: list[str], workspace: Path | None = None) -> None:
|
||||
if enforcement_required():
|
||||
for path in paths:
|
||||
if str(path).strip() != ".":
|
||||
validate_path(str(path), workspace)
|
||||
|
||||
|
||||
def validate_staged_paths(workspace: Path) -> None:
|
||||
if not enforcement_required():
|
||||
return
|
||||
for path in _git(workspace, "diff", "--cached", "--name-only").splitlines():
|
||||
validate_path(path, workspace)
|
||||
|
||||
|
||||
def validate_sync_branch(branch: str) -> None:
|
||||
if enforcement_required() and branch != allowed_branch():
|
||||
raise PermissionError("isolated_writer_sync_branch_rejected")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,301 +0,0 @@
|
||||
"""Durable checkpoint journal for the generic Online Agent worker.
|
||||
|
||||
The JSON task state remains a fast cache. This SQLite journal is the durable,
|
||||
append-only evidence used for restart/resume/replay decisions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Mapping
|
||||
|
||||
PHASE_ORDER = {
|
||||
"claimed": 10,
|
||||
"workspace_prepared": 20,
|
||||
"verification_passed": 30,
|
||||
"source_capture_done": 40,
|
||||
"commit_done": 40,
|
||||
"reconciliation_required": 45,
|
||||
"result_pending": 50,
|
||||
"result_submitted": 60,
|
||||
}
|
||||
|
||||
|
||||
class CheckpointError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: object) -> str:
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckpointBinding:
|
||||
task_id: str
|
||||
attempt: int
|
||||
execution_digest: str
|
||||
job_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
|
||||
def material(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"attempt": self.attempt,
|
||||
"execution_digest": self.execution_digest,
|
||||
"job_id": self.job_id,
|
||||
"repository": self.repository,
|
||||
"base_oid": self.base_oid,
|
||||
"expected_branch_oid": self.expected_branch_oid,
|
||||
"target_branch": self.target_branch,
|
||||
"generation": self.generation,
|
||||
}
|
||||
|
||||
@property
|
||||
def binding_sha256(self) -> str:
|
||||
return sha256_json(self.material())
|
||||
|
||||
|
||||
class CheckpointStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS checkpoints(
|
||||
task_id TEXT NOT NULL,
|
||||
attempt INTEGER NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
phase_order INTEGER NOT NULL,
|
||||
execution_digest TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
base_oid TEXT NOT NULL,
|
||||
expected_branch_oid TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
binding_sha256 TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(task_id, attempt, sequence)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_checkpoints_task_latest
|
||||
ON checkpoints(task_id, attempt DESC, sequence DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_checkpoints_exact_replay
|
||||
ON checkpoints(task_id, attempt, phase, payload_sha256);
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_heads(
|
||||
task_id TEXT PRIMARY KEY,
|
||||
attempt INTEGER NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
phase_order INTEGER NOT NULL,
|
||||
execution_digest TEXT NOT NULL,
|
||||
binding_sha256 TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _phase_order(phase: str) -> int:
|
||||
try:
|
||||
return PHASE_ORDER[phase]
|
||||
except KeyError as exc:
|
||||
raise CheckpointError("CHECKPOINT_PHASE_INVALID") from exc
|
||||
|
||||
def append(
|
||||
self,
|
||||
binding: CheckpointBinding,
|
||||
phase: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
order = self._phase_order(phase)
|
||||
payload_value = dict(payload)
|
||||
payload_sha = sha256_json(payload_value)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
replay = connection.execute(
|
||||
"""
|
||||
SELECT * FROM checkpoints
|
||||
WHERE task_id=? AND attempt=? AND phase=? AND payload_sha256=?
|
||||
""",
|
||||
(binding.task_id, binding.attempt, phase, payload_sha),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return dict(replay)
|
||||
|
||||
head = connection.execute(
|
||||
"SELECT * FROM checkpoint_heads WHERE task_id=?",
|
||||
(binding.task_id,),
|
||||
).fetchone()
|
||||
if head is not None:
|
||||
head_attempt = int(head["attempt"])
|
||||
head_order = int(head["phase_order"])
|
||||
if binding.attempt < head_attempt:
|
||||
raise CheckpointError("CHECKPOINT_ATTEMPT_REGRESSION")
|
||||
if binding.attempt == head_attempt:
|
||||
if head["execution_digest"] != binding.execution_digest:
|
||||
raise CheckpointError("CHECKPOINT_EXECUTION_DIGEST_CONFLICT")
|
||||
if head["binding_sha256"] != binding.binding_sha256:
|
||||
raise CheckpointError("CHECKPOINT_SCOPE_MISMATCH_DENY")
|
||||
if order < head_order and phase != "reconciliation_required":
|
||||
raise CheckpointError("CHECKPOINT_PHASE_REGRESSION")
|
||||
sequence = int(head["sequence"]) + 1
|
||||
else:
|
||||
sequence = 1
|
||||
else:
|
||||
sequence = 1
|
||||
|
||||
created_at = now()
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO checkpoints(
|
||||
task_id,attempt,sequence,phase,phase_order,execution_digest,job_id,
|
||||
repository,base_oid,expected_branch_oid,target_branch,generation,
|
||||
binding_sha256,payload_json,payload_sha256,created_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
binding.task_id,
|
||||
binding.attempt,
|
||||
sequence,
|
||||
phase,
|
||||
order,
|
||||
binding.execution_digest,
|
||||
binding.job_id,
|
||||
binding.repository,
|
||||
binding.base_oid,
|
||||
binding.expected_branch_oid,
|
||||
binding.target_branch,
|
||||
binding.generation,
|
||||
binding.binding_sha256,
|
||||
canonical(payload_value).decode("utf-8"),
|
||||
payload_sha,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO checkpoint_heads(
|
||||
task_id,attempt,sequence,phase,phase_order,execution_digest,
|
||||
binding_sha256,payload_sha256,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(task_id) DO UPDATE SET
|
||||
attempt=excluded.attempt,
|
||||
sequence=excluded.sequence,
|
||||
phase=excluded.phase,
|
||||
phase_order=excluded.phase_order,
|
||||
execution_digest=excluded.execution_digest,
|
||||
binding_sha256=excluded.binding_sha256,
|
||||
payload_sha256=excluded.payload_sha256,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
binding.task_id,
|
||||
binding.attempt,
|
||||
sequence,
|
||||
phase,
|
||||
order,
|
||||
binding.execution_digest,
|
||||
binding.binding_sha256,
|
||||
payload_sha,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
row = connection.execute(
|
||||
"SELECT * FROM checkpoints WHERE task_id=? AND attempt=? AND sequence=?",
|
||||
(binding.task_id, binding.attempt, sequence),
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
def latest(self, task_id: str) -> dict[str, Any] | None:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT * FROM checkpoints
|
||||
WHERE task_id=?
|
||||
ORDER BY attempt DESC, sequence DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
value = dict(row)
|
||||
value["payload"] = json.loads(value.pop("payload_json"))
|
||||
return value
|
||||
|
||||
def latest_for_attempt(self, task_id: str, attempt: int) -> dict[str, Any] | None:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT * FROM checkpoints
|
||||
WHERE task_id=? AND attempt=?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(task_id, attempt),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
value = dict(row)
|
||||
value["payload"] = json.loads(value.pop("payload_json"))
|
||||
return value
|
||||
|
||||
def verify_latest_binding(self, binding: CheckpointBinding) -> dict[str, Any] | None:
|
||||
value = self.latest(binding.task_id)
|
||||
if value is None:
|
||||
return None
|
||||
if int(value["attempt"]) > binding.attempt:
|
||||
raise CheckpointError("CHECKPOINT_FUTURE_ATTEMPT")
|
||||
# A previous attempt may be reused only when the immutable execution/scope binding matches.
|
||||
if value["execution_digest"] != binding.execution_digest:
|
||||
return None
|
||||
expected = CheckpointBinding(
|
||||
task_id=value["task_id"],
|
||||
attempt=int(value["attempt"]),
|
||||
execution_digest=value["execution_digest"],
|
||||
job_id=value["job_id"],
|
||||
repository=value["repository"],
|
||||
base_oid=value["base_oid"],
|
||||
expected_branch_oid=value["expected_branch_oid"],
|
||||
target_branch=value["target_branch"],
|
||||
generation=int(value["generation"]),
|
||||
)
|
||||
current_scope = binding.material()
|
||||
historical_scope = expected.material()
|
||||
current_scope.pop("attempt", None)
|
||||
historical_scope.pop("attempt", None)
|
||||
if sha256_json(current_scope) != sha256_json(historical_scope):
|
||||
raise CheckpointError("CHECKPOINT_SCOPE_MISMATCH_DENY")
|
||||
return value
|
||||
@@ -1,463 +0,0 @@
|
||||
"""Bounded read-only repository interface map for governed Online Agent analysis.
|
||||
|
||||
The compact map intentionally returns symbols and short source excerpts rather than a
|
||||
source archive. It is designed for control-plane consumers that cannot safely ingest a
|
||||
multi-megabyte repository snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
SCHEMA = "aios.engineering.interface-map.v1"
|
||||
MAX_RESULT_BYTES = 120 * 1024
|
||||
TARGET_RESULT_BYTES = 108 * 1024
|
||||
MAX_MODULES = 40
|
||||
MAX_SYMBOLS = 80
|
||||
MAX_TESTS = 40
|
||||
MAX_EXCERPTS = 25
|
||||
MAX_EXCERPT_CHARS = 1200
|
||||
MAX_FILE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
ROOTS = (
|
||||
"active-runtime/app",
|
||||
"runtime/agent-os",
|
||||
"tests",
|
||||
"db/migrations",
|
||||
"migrations",
|
||||
"deploy/systemd",
|
||||
"docs/task-plane",
|
||||
"docs/superpowers/specs",
|
||||
)
|
||||
SUFFIXES = {".py", ".sql", ".service", ".md", ".json", ".yaml", ".yml"}
|
||||
|
||||
DEFAULT_TERMS = {
|
||||
"agent", "worker", "fleet", "scheduler", "scheduling", "task", "claim", "lease",
|
||||
"heartbeat", "capacity", "concurrency", "priority", "fairness", "queue", "retry",
|
||||
"backoff", "circuit", "health", "liveness", "scope", "tenant", "project", "trace",
|
||||
"message", "checkpoint", "recovery", "idempotency", "dispatch", "assignment",
|
||||
}
|
||||
FLEET_CONTRACT_TERMS: dict[str, tuple[str, ...]] = {
|
||||
"FLEET_AGENT_REGISTRATION": ("agent", "register", "worker", "capability", "version"),
|
||||
"FLEET_CAPACITY_SCHEDULING": ("capacity", "slot", "concurrency", "scheduler", "queue"),
|
||||
"FLEET_FAIRNESS": ("fair", "priority", "queue", "tenant", "project", "starvation"),
|
||||
"FLEET_FAULT_ISOLATION": ("failure", "health", "liveness", "timeout", "circuit", "poison"),
|
||||
"FLEET_WORKER_REASSIGNMENT": ("reassign", "assignment", "claim", "lease", "retry", "worker"),
|
||||
"FLEET_SCOPE_ISOLATION": ("tenant", "project", "scope", "conversation", "task", "run"),
|
||||
"FLEET_IDEMPOTENT_DISPATCH": ("idempot", "dispatch", "claim", "lease", "result", "attempt"),
|
||||
"FLEET_FAILURE_RECOVERY": ("checkpoint", "resume", "recover", "retry", "cleanup", "receipt"),
|
||||
}
|
||||
|
||||
MODEL_GOVERNANCE_CONTRACT_TERMS: dict[str, tuple[str, ...]] = {
|
||||
"MODEL_PROVIDER_REGISTRY": ("model", "provider", "registry", "endpoint", "capability"),
|
||||
"MODEL_SELECTION_POLICY": ("model", "select", "route", "policy", "capability"),
|
||||
"MODEL_VERSION_PINNING": ("model", "version", "pin", "revision", "snapshot"),
|
||||
"MODEL_BUDGET_ACCOUNTING": ("budget", "token", "cost", "quota", "usage"),
|
||||
"MODEL_FALLBACK_POLICY": ("fallback", "retry", "route", "provider", "model"),
|
||||
"MODEL_SCOPE_ISOLATION": ("tenant", "project", "scope", "policy", "model"),
|
||||
"MODEL_CREDENTIAL_BOUNDARY": ("credential", "secret", "provider", "tool", "token"),
|
||||
"MODEL_TRACE_AUDIT": ("trace", "audit", "model", "provider", "request"),
|
||||
"MODEL_EVALUATION_GATE": ("eval", "evaluation", "approval", "quality", "gate"),
|
||||
"MODEL_ROUTING_RESILIENCE": ("route", "retry", "fallback", "timeout", "health"),
|
||||
}
|
||||
|
||||
PROFILE_CLASSIFICATION = {
|
||||
"fleet": "P2_INTERFACE_MAP_CAPTURED",
|
||||
"model_governance": "MODEL_GOVERNANCE_INTERFACE_MAP_CAPTURED",
|
||||
}
|
||||
|
||||
MODEL_PROFILE_MARKERS = {
|
||||
"model", "provider", "llm", "inference", "routing", "router", "budget",
|
||||
"token", "cost", "quota", "fallback", "evaluation", "eval", "approval",
|
||||
"version", "pinning",
|
||||
}
|
||||
SENSITIVE_RE = re.compile(
|
||||
r"(?i)(authorization\s*:\s*bearer\s+\S+|"
|
||||
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['\"]?[^'\"\s,;]+)"
|
||||
)
|
||||
SQL_TABLE_RE = re.compile(
|
||||
r"(?is)\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?[\"`[]?([A-Za-z_][A-Za-z0-9_.-]*)"
|
||||
)
|
||||
|
||||
|
||||
class CompactInterfaceMapError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _bounded_excerpt(text: str, start_line: int, end_line: int) -> str:
|
||||
lines = text.splitlines()
|
||||
start = max(0, start_line - 2)
|
||||
end = min(len(lines), max(end_line + 1, start + 1))
|
||||
excerpt = "\n".join(lines[start:end])
|
||||
excerpt = SENSITIVE_RE.sub("<redacted-sensitive-source-pattern>", excerpt)
|
||||
if len(excerpt) > MAX_EXCERPT_CHARS:
|
||||
excerpt = excerpt[: MAX_EXCERPT_CHARS - 1] + "…"
|
||||
return excerpt
|
||||
|
||||
|
||||
def _signal_terms(signals: Sequence[str]) -> set[str]:
|
||||
terms = set(DEFAULT_TERMS)
|
||||
for signal in signals:
|
||||
if not isinstance(signal, str):
|
||||
continue
|
||||
terms.update(
|
||||
token.lower()
|
||||
for token in re.findall(r"[A-Za-z][A-Za-z0-9_.-]{2,63}", signal)
|
||||
)
|
||||
return set(sorted(terms)[:96])
|
||||
|
||||
|
||||
def _profile(source_signals: Sequence[str]) -> str:
|
||||
tokens: set[str] = set()
|
||||
for signal in source_signals:
|
||||
if not isinstance(signal, str):
|
||||
continue
|
||||
tokens.update(
|
||||
token.lower()
|
||||
for token in re.findall(r"[A-Za-z][A-Za-z0-9_.-]{2,63}", signal)
|
||||
)
|
||||
model_score = len(tokens & MODEL_PROFILE_MARKERS)
|
||||
return "model_governance" if model_score >= 2 else "fleet"
|
||||
|
||||
|
||||
def _contract_terms(profile: str) -> Mapping[str, tuple[str, ...]]:
|
||||
if profile == "model_governance":
|
||||
return MODEL_GOVERNANCE_CONTRACT_TERMS
|
||||
if profile == "fleet":
|
||||
return FLEET_CONTRACT_TERMS
|
||||
raise CompactInterfaceMapError("INTERFACE_MAP_PROFILE_INVALID")
|
||||
|
||||
|
||||
def _candidate_files(root: Path, terms: set[str]) -> list[dict[str, Any]]:
|
||||
root = root.resolve()
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for relative_root in ROOTS:
|
||||
directory = (root / relative_root).resolve(strict=False)
|
||||
if directory == root or root not in directory.parents:
|
||||
raise CompactInterfaceMapError("INTERFACE_MAP_SCOPE_INVALID")
|
||||
if not directory.is_dir() or directory.is_symlink():
|
||||
continue
|
||||
for path in sorted(directory.rglob("*")):
|
||||
if path.is_symlink() or not path.is_file() or path.suffix.lower() not in SUFFIXES:
|
||||
continue
|
||||
resolved = path.resolve(strict=True)
|
||||
if root not in resolved.parents:
|
||||
raise CompactInterfaceMapError("INTERFACE_MAP_PATH_ESCAPE")
|
||||
data = path.read_bytes()
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
continue
|
||||
relative = path.relative_to(root).as_posix()
|
||||
text = data.decode("utf-8", "ignore")
|
||||
searchable = (relative + "\n" + text[:384000]).lower()
|
||||
matched = sorted(term for term in terms if term in searchable)
|
||||
if not matched:
|
||||
continue
|
||||
path_bonus = sum(3 for term in terms if term in relative.lower())
|
||||
test_bonus = 3 if relative.startswith("tests/") else 0
|
||||
score = min(250, len(matched) + path_bonus + test_bonus)
|
||||
candidates.append(
|
||||
{
|
||||
"path": relative,
|
||||
"sha256": _sha(data),
|
||||
"bytes": len(data),
|
||||
"score": score,
|
||||
"text": text,
|
||||
"suffix": path.suffix.lower(),
|
||||
}
|
||||
)
|
||||
candidates.sort(key=lambda item: (-item["score"], item["path"]))
|
||||
return candidates
|
||||
|
||||
|
||||
def _python_symbols(item: Mapping[str, Any], terms: set[str]) -> list[dict[str, Any]]:
|
||||
try:
|
||||
tree = ast.parse(str(item["text"]), filename=str(item["path"]))
|
||||
except SyntaxError:
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
|
||||
class Visitor(ast.NodeVisitor):
|
||||
def __init__(self) -> None:
|
||||
self.stack: list[str] = []
|
||||
|
||||
def _record(self, node: ast.AST, kind: str, name: str) -> None:
|
||||
qualified = ".".join([*self.stack, name]) if self.stack else name
|
||||
haystack = f"{item['path']} {qualified}".lower()
|
||||
score = sum(3 if term in haystack else 0 for term in terms)
|
||||
if name.startswith("test_"):
|
||||
score += 12
|
||||
result.append(
|
||||
{
|
||||
"path": item["path"],
|
||||
"symbol": qualified,
|
||||
"kind": kind,
|
||||
"line": int(getattr(node, "lineno", 1)),
|
||||
"end_line": int(getattr(node, "end_lineno", getattr(node, "lineno", 1))),
|
||||
"score": score + int(item["score"]),
|
||||
}
|
||||
)
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> Any:
|
||||
self._record(node, "class", node.name)
|
||||
self.stack.append(node.name)
|
||||
self.generic_visit(node)
|
||||
self.stack.pop()
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
|
||||
self._record(node, "function", node.name)
|
||||
self.stack.append(node.name)
|
||||
self.generic_visit(node)
|
||||
self.stack.pop()
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
|
||||
self._record(node, "async_function", node.name)
|
||||
self.stack.append(node.name)
|
||||
self.generic_visit(node)
|
||||
self.stack.pop()
|
||||
|
||||
Visitor().visit(tree)
|
||||
return result
|
||||
|
||||
|
||||
def _surface_ref(symbol: Mapping[str, Any]) -> str:
|
||||
return f"{symbol['path']}::{symbol['symbol']}"
|
||||
|
||||
|
||||
def _contract_map(
|
||||
symbols: Sequence[Mapping[str, Any]],
|
||||
modules: Sequence[Mapping[str, Any]],
|
||||
contract_terms: Mapping[str, tuple[str, ...]],
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
module_paths = [str(item["path"]) for item in modules]
|
||||
for contract, terms in contract_terms.items():
|
||||
scored: list[tuple[int, str]] = []
|
||||
for symbol in symbols:
|
||||
haystack = _surface_ref(symbol).lower()
|
||||
score = sum(1 for term in terms if term in haystack)
|
||||
if score:
|
||||
scored.append((score, _surface_ref(symbol)))
|
||||
scored.sort(key=lambda item: (-item[0], item[1]))
|
||||
surfaces = [value for _, value in scored[:6]]
|
||||
if not surfaces:
|
||||
for path in module_paths:
|
||||
score = sum(1 for term in terms if term in path.lower())
|
||||
if score:
|
||||
surfaces.append(path)
|
||||
if len(surfaces) == 6:
|
||||
break
|
||||
result[contract] = {
|
||||
"status": "PARTIAL" if surfaces else "GAP",
|
||||
"surfaces": surfaces,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _gap_evidence(
|
||||
symbols: Sequence[Mapping[str, Any]],
|
||||
profile: str,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
haystack = "\n".join(_surface_ref(item).lower() for item in symbols)
|
||||
|
||||
if profile == "model_governance":
|
||||
invariants = [
|
||||
"Online Task claim/heartbeat remains the authoritative worker ownership boundary.",
|
||||
"Provider/model credentials remain outside uncredentialed planner/executor processes.",
|
||||
"P0-A tenant/project scope and P0-C trace/audit lineage must constrain model routing.",
|
||||
"P1 retry/checkpoint semantics remain authoritative for model-call execution recovery.",
|
||||
]
|
||||
failure_windows: list[str] = []
|
||||
if "provider" not in haystack and "model" not in haystack:
|
||||
failure_windows.append("No first-class model/provider registry surface detected.")
|
||||
if "budget" not in haystack and "cost" not in haystack and "quota" not in haystack:
|
||||
failure_windows.append("No explicit durable model budget/cost/quota accounting surface detected.")
|
||||
if "fallback" not in haystack and "route" not in haystack:
|
||||
failure_windows.append("No explicit model fallback/routing policy surface detected.")
|
||||
if "eval" not in haystack and "approval" not in haystack:
|
||||
failure_windows.append("No explicit evaluation/approval gate surface detected for model changes.")
|
||||
if "version" not in haystack and "pin" not in haystack:
|
||||
failure_windows.append("No explicit model-version pinning surface detected.")
|
||||
desired = (
|
||||
("trace", "P0-C trace/audit lineage"),
|
||||
("scope", "P0-A tenant/project scope isolation"),
|
||||
("credential", "P0-C credential governance"),
|
||||
("checkpoint", "P1 durable execution recovery"),
|
||||
("message", "P0-B MessageEnvelope/handoff"),
|
||||
("retry", "P1 retry/idempotency"),
|
||||
)
|
||||
else:
|
||||
invariants = [
|
||||
"Online Task claim/heartbeat remains the authoritative worker ownership boundary.",
|
||||
"Governed mutation remains separated from read-only analysis and requires WriterLease.",
|
||||
"P1 attempt-aware result idempotency and durable checkpoint recovery are reusable fleet primitives.",
|
||||
"Scope/trace/message lineage must remain attached when work is scheduled or reassigned.",
|
||||
]
|
||||
failure_windows = []
|
||||
if "scheduler" not in haystack:
|
||||
failure_windows.append("No first-class durable fleet scheduler symbol detected; worker-local polling may still be the scheduling mechanism.")
|
||||
if "capacity" not in haystack and "slot" not in haystack:
|
||||
failure_windows.append("No explicit durable worker-capacity/slot primitive detected.")
|
||||
if "fair" not in haystack and "starvation" not in haystack:
|
||||
failure_windows.append("No explicit fairness/starvation policy symbol detected.")
|
||||
if "circuit" not in haystack:
|
||||
failure_windows.append("No explicit circuit-breaker primitive detected for repeated worker/task failures.")
|
||||
if "reassign" not in haystack:
|
||||
failure_windows.append("No explicit worker-reassignment primitive detected beyond lease expiry/retry semantics.")
|
||||
desired = (
|
||||
("checkpoint", "P1 durable checkpoint/resume"),
|
||||
("message", "P0-B MessageEnvelope/handoff"),
|
||||
("trace", "P0-C trace correlation"),
|
||||
("scope", "P0-A scope isolation"),
|
||||
("lease", "claim/WriterLease ownership"),
|
||||
("heartbeat", "worker liveness"),
|
||||
("idempot", "retry-safe dispatch/result"),
|
||||
)
|
||||
|
||||
reuse_points: list[str] = []
|
||||
for token, label in desired:
|
||||
match = next((item for item in symbols if token in _surface_ref(item).lower()), None)
|
||||
if match is not None:
|
||||
reuse_points.append(f"{label}: {_surface_ref(match)}")
|
||||
return invariants, failure_windows, reuse_points
|
||||
|
||||
|
||||
def _trim_to_budget(value: dict[str, Any]) -> dict[str, Any]:
|
||||
def size() -> int:
|
||||
return len(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
||||
|
||||
while size() > TARGET_RESULT_BYTES and value["bounded_excerpts"]:
|
||||
value["bounded_excerpts"].pop()
|
||||
while size() > TARGET_RESULT_BYTES and len(value["implementation_map"]["functions"]) > 20:
|
||||
value["implementation_map"]["functions"].pop()
|
||||
while size() > TARGET_RESULT_BYTES and len(value["implementation_map"]["classes"]) > 15:
|
||||
value["implementation_map"]["classes"].pop()
|
||||
while size() > TARGET_RESULT_BYTES and len(value["implementation_map"]["tests"]) > 15:
|
||||
value["implementation_map"]["tests"].pop()
|
||||
while size() > TARGET_RESULT_BYTES and len(value["implementation_map"]["modules"]) > 20:
|
||||
value["implementation_map"]["modules"].pop()
|
||||
final_size = size()
|
||||
if final_size > MAX_RESULT_BYTES:
|
||||
raise CompactInterfaceMapError("INTERFACE_MAP_RESULT_TOO_LARGE")
|
||||
value["result_bytes"] = final_size
|
||||
return value
|
||||
|
||||
|
||||
def build_compact_interface_map(
|
||||
worktree: Path,
|
||||
source_signals: Sequence[str],
|
||||
*,
|
||||
source_commit: str,
|
||||
) -> dict[str, Any]:
|
||||
root = Path(worktree)
|
||||
if not root.is_dir() or root.is_symlink():
|
||||
raise CompactInterfaceMapError("INTERFACE_MAP_WORKTREE_INVALID")
|
||||
if re.fullmatch(r"[0-9a-f]{40}", source_commit) is None:
|
||||
raise CompactInterfaceMapError("INTERFACE_MAP_SOURCE_COMMIT_INVALID")
|
||||
|
||||
profile = _profile(source_signals)
|
||||
terms = _signal_terms(source_signals)
|
||||
candidates = _candidate_files(root, terms)
|
||||
if not candidates:
|
||||
raise CompactInterfaceMapError("INTERFACE_MAP_EMPTY")
|
||||
|
||||
module_candidates = candidates[:MAX_MODULES]
|
||||
modules = [
|
||||
{
|
||||
"path": item["path"],
|
||||
"sha256": item["sha256"],
|
||||
"bytes": item["bytes"],
|
||||
"score": item["score"],
|
||||
}
|
||||
for item in module_candidates
|
||||
]
|
||||
|
||||
symbols: list[dict[str, Any]] = []
|
||||
tables: list[dict[str, Any]] = []
|
||||
systemd_units: list[dict[str, Any]] = []
|
||||
for item in candidates:
|
||||
if item["suffix"] == ".py":
|
||||
symbols.extend(_python_symbols(item, terms))
|
||||
elif item["suffix"] == ".sql":
|
||||
for table in SQL_TABLE_RE.findall(str(item["text"])):
|
||||
tables.append({"path": item["path"], "table": table, "sha256": item["sha256"]})
|
||||
elif item["suffix"] == ".service":
|
||||
systemd_units.append({"path": item["path"], "unit": Path(str(item["path"])).name, "sha256": item["sha256"]})
|
||||
|
||||
symbols.sort(key=lambda item: (-int(item["score"]), str(item["path"]), str(item["symbol"])))
|
||||
all_symbols = list(symbols)
|
||||
test_symbols = [
|
||||
item for item in all_symbols
|
||||
if str(item["path"]).startswith("tests/") or str(item["symbol"]).split(".")[-1].startswith("test_")
|
||||
][: min(MAX_TESTS, 24)]
|
||||
non_test_symbols = [
|
||||
item for item in all_symbols
|
||||
if item not in test_symbols
|
||||
][: max(0, MAX_SYMBOLS - len(test_symbols))]
|
||||
symbols = [*test_symbols, *non_test_symbols]
|
||||
symbols.sort(key=lambda item: (-int(item["score"]), str(item["path"]), str(item["symbol"])))
|
||||
classes = [
|
||||
{k: item[k] for k in ("path", "symbol", "line", "end_line")}
|
||||
for item in symbols
|
||||
if item["kind"] == "class"
|
||||
]
|
||||
functions = [
|
||||
{k: item[k] for k in ("path", "symbol", "line", "end_line")}
|
||||
for item in symbols
|
||||
if item["kind"] in {"function", "async_function"} and not str(item["symbol"]).split(".")[-1].startswith("test_")
|
||||
]
|
||||
tests = [
|
||||
{k: item[k] for k in ("path", "symbol", "line", "end_line")}
|
||||
for item in symbols
|
||||
if str(item["path"]).startswith("tests/") or str(item["symbol"]).split(".")[-1].startswith("test_")
|
||||
][:MAX_TESTS]
|
||||
|
||||
excerpts: list[dict[str, Any]] = []
|
||||
by_path = {str(item["path"]): item for item in candidates}
|
||||
for symbol in symbols:
|
||||
if len(excerpts) == MAX_EXCERPTS:
|
||||
break
|
||||
item = by_path.get(str(symbol["path"]))
|
||||
if item is None:
|
||||
continue
|
||||
excerpt = _bounded_excerpt(str(item["text"]), int(symbol["line"]), int(symbol["end_line"]))
|
||||
if not excerpt.strip():
|
||||
continue
|
||||
excerpts.append(
|
||||
{
|
||||
"path": symbol["path"],
|
||||
"symbol": symbol["symbol"],
|
||||
"sha256": item["sha256"],
|
||||
"excerpt": excerpt,
|
||||
}
|
||||
)
|
||||
|
||||
invariants, failure_windows, reuse_points = _gap_evidence(all_symbols, profile)
|
||||
result = {
|
||||
"schema": SCHEMA,
|
||||
"profile": profile,
|
||||
"classification": PROFILE_CLASSIFICATION[profile],
|
||||
"source_commit": source_commit,
|
||||
"implementation_map": {
|
||||
"modules": modules,
|
||||
"classes": classes[:MAX_SYMBOLS],
|
||||
"functions": functions[:MAX_SYMBOLS],
|
||||
"tables": tables[:40],
|
||||
"systemd_units": systemd_units[:20],
|
||||
"tests": tests,
|
||||
"current_invariants": invariants,
|
||||
"failure_windows": failure_windows,
|
||||
"reuse_points": reuse_points,
|
||||
},
|
||||
"bounded_excerpts": excerpts,
|
||||
"contracts": _contract_map(all_symbols, modules, _contract_terms(profile)),
|
||||
}
|
||||
return _trim_to_budget(result)
|
||||
@@ -1,397 +0,0 @@
|
||||
"""Canonical governed execution projection for Online Agent Tasks.
|
||||
|
||||
The durable governed binding is authoritative. Natural-language task fields and
|
||||
worker configuration are never allowed to invent repository, ref, generation, or
|
||||
idempotency coordinates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
EXECUTION_SCHEMA = "aios.online-agent.execution.v2"
|
||||
PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||
PROTECTED_PREFIXES = ("main", "master", "refs/", "tags/", "tag/", "release/", "baseline/", "production/")
|
||||
MUTATION_ACTIONS = frozenset({"deterministic"})
|
||||
SUPPORTED_ACTIONS = frozenset({"deterministic", "source_capture"})
|
||||
|
||||
|
||||
class ExecutionProjectionError(RuntimeError):
|
||||
"""A canonical fail-closed projection or validation error."""
|
||||
|
||||
|
||||
class OnlineTaskProjectionClient:
|
||||
"""Official HTTP projection client using only a control credential reference."""
|
||||
|
||||
def __init__(self, api_base: str, control_token_path: Path) -> None:
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.control_token_path = Path(control_token_path)
|
||||
|
||||
def _token(self) -> str:
|
||||
path = self.control_token_path
|
||||
if path.is_symlink() or not path.is_file():
|
||||
_error("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
_error("ONLINE_TASK_PROJECTION_CONTROL_INVALID")
|
||||
return value
|
||||
|
||||
def create(
|
||||
self,
|
||||
projected_task: Mapping[str, Any],
|
||||
*,
|
||||
binding: "GovernedExecutionBinding",
|
||||
) -> dict[str, Any]:
|
||||
body = dict(projected_task)
|
||||
payload = _canonical(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.api_base + "/v1/tasks",
|
||||
method="POST",
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self._token(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(8192).decode("utf-8", "replace")
|
||||
try:
|
||||
error = json.loads(raw).get("error")
|
||||
except (AttributeError, json.JSONDecodeError):
|
||||
error = "HTTP_ERROR"
|
||||
raise ExecutionProjectionError(f"ONLINE_TASK_PROJECTION_FAILED:{exc.code}:{error}") from exc
|
||||
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
||||
raise ExecutionProjectionError("ONLINE_TASK_PROJECTION_UNAVAILABLE") from exc
|
||||
if status not in {200, 201} or not isinstance(result, dict):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
if result.get("task_id") != binding.task_id or result.get("status") not in {"QUEUED", "VERIFYING", "SUCCEEDED"}:
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
metadata = result.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
returned_execution = validate_execution_envelope(
|
||||
_object(metadata.get("execution"), "ONLINE_TASK_PROJECTION_RESPONSE_INVALID"),
|
||||
binding=binding,
|
||||
)
|
||||
submitted_metadata = _object(body.get("metadata"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID")
|
||||
submitted_execution = validate_execution_envelope(
|
||||
_object(submitted_metadata.get("execution"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID"),
|
||||
binding=binding,
|
||||
)
|
||||
if _canonical(returned_execution) != _canonical(submitted_execution):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _error(code: str) -> None:
|
||||
raise ExecutionProjectionError(code)
|
||||
|
||||
|
||||
def _object(value: object, code: str) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
_error(code)
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _string(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
_error(code)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _task_id(value: object) -> str:
|
||||
value = _string(value, "TASK_ID_INVALID")
|
||||
if TASK_RE.fullmatch(value) is None:
|
||||
_error("TASK_ID_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(value: object, code: str) -> str:
|
||||
value = _string(value, code)
|
||||
if OID_RE.fullmatch(value) is None:
|
||||
_error(code)
|
||||
return value
|
||||
|
||||
|
||||
def _branch(value: object) -> str:
|
||||
value = _string(value, "TARGET_BRANCH_INVALID")
|
||||
if BRANCH_RE.fullmatch(value) is None or value.startswith(PROTECTED_PREFIXES):
|
||||
_error("TARGET_BRANCH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _generation(value: object) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
||||
_error("GENERATION_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _source_signals(value: object) -> tuple[str, ...]:
|
||||
"""Normalize untrusted natural-language hints before they enter a signed envelope."""
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
text = " ".join(item.split()).strip()
|
||||
if not text:
|
||||
continue
|
||||
# Projection may accept long task prose, but execution envelopes stay bounded.
|
||||
text = text[:512].rstrip()
|
||||
if text not in result:
|
||||
result.append(text)
|
||||
if len(result) == 16:
|
||||
break
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _capabilities(value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
_error("CAPABILITY_SCOPE_INVALID")
|
||||
result = tuple(sorted({_string(item, "CAPABILITY_SCOPE_INVALID") for item in value}))
|
||||
if any(len(item) > 120 for item in result):
|
||||
_error("CAPABILITY_SCOPE_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedExecutionBinding:
|
||||
task_id: str
|
||||
job_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
branch_mode: str
|
||||
generation: int
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedExecutionBinding":
|
||||
data = dict(value)
|
||||
task_id = _task_id(data.get("task_id"))
|
||||
job_id = _string(data.get("job_id"), "JOB_ID_REQUIRED")
|
||||
if TASK_RE.fullmatch(job_id) is None:
|
||||
_error("JOB_ID_INVALID")
|
||||
repository = _string(data.get("repository"), "REPOSITORY_INVALID")
|
||||
if repository != "aios/aios":
|
||||
_error("WRONG_REPOSITORY_ENVELOPE_DENY")
|
||||
base_oid = _oid(data.get("base_oid"), "BASE_OID_INVALID")
|
||||
expected = _oid(data.get("expected_branch_oid"), "EXPECTED_BRANCH_OID_INVALID")
|
||||
target = _branch(data.get("target_branch"))
|
||||
mode = _string(data.get("branch_mode"), "BRANCH_MODE_MISSING")
|
||||
if mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
_error("BRANCH_MODE_INVALID")
|
||||
generation = _generation(data.get("generation"))
|
||||
if mode == "CREATE_NEW" and expected != base_oid:
|
||||
_error("EXPECTED_BASE_CONFLICT")
|
||||
key = _string(data.get("idempotency_key"), "IDEMPOTENCY_KEY_INVALID")
|
||||
if KEY_RE.fullmatch(key) is None:
|
||||
_error("IDEMPOTENCY_KEY_INVALID")
|
||||
return cls(task_id, job_id, repository, base_oid, expected, target, mode, generation, key)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def binding_from_bootstrap(value: Mapping[str, Any]) -> GovernedExecutionBinding:
|
||||
return GovernedExecutionBinding.from_mapping(value)
|
||||
|
||||
|
||||
def validate_execution_envelope(
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
binding: GovernedExecutionBinding | None = None,
|
||||
worker_capabilities: Sequence[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
envelope = dict(value)
|
||||
if envelope.get("schema") != EXECUTION_SCHEMA:
|
||||
_error("EXECUTION_ENVELOPE_SCHEMA_VALIDATION")
|
||||
candidate = GovernedExecutionBinding.from_mapping(envelope)
|
||||
if binding is not None and candidate != binding:
|
||||
comparisons = (
|
||||
(candidate.task_id, binding.task_id, "TASK_BINDING_CONFLICT"),
|
||||
(candidate.job_id, binding.job_id, "JOB_BINDING_CONFLICT"),
|
||||
(candidate.repository, binding.repository, "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
||||
(candidate.base_oid, binding.base_oid, "WRONG_BASE_ENVELOPE_DENY"),
|
||||
(candidate.expected_branch_oid, binding.expected_branch_oid, "WRONG_EXPECTED_HEAD_ENVELOPE_DENY"),
|
||||
(candidate.target_branch, binding.target_branch, "WRONG_BRANCH_ENVELOPE_DENY"),
|
||||
(candidate.generation, binding.generation, "WRONG_GENERATION_ENVELOPE_DENY"),
|
||||
(candidate.branch_mode, binding.branch_mode, "BRANCH_MODE_BINDING_CONFLICT"),
|
||||
(candidate.idempotency_key, binding.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||
)
|
||||
for actual, expected, code in comparisons:
|
||||
if actual != expected:
|
||||
_error(code)
|
||||
action = _string(envelope.get("action"), "EXECUTION_ACTION_MISSING")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||
capability_scope = _capabilities(envelope.get("capability_scope"))
|
||||
if worker_capabilities is not None and not set(capability_scope).issubset(set(worker_capabilities)):
|
||||
_error("CAPABILITY_MISMATCH_DENY")
|
||||
workspace = _object(envelope.get("workspace"), "WORKSPACE_POLICY_REQUIRED")
|
||||
if workspace.get("authority_git_dir") != "/data/aios-gitops/repo.git" or workspace.get("read_only_repository") is not True:
|
||||
_error("WORKSPACE_POLICY_DENY")
|
||||
governance = _object(envelope.get("governance"), "GOVERNANCE_POLICY_REQUIRED")
|
||||
governed_coordinates = (
|
||||
(governance.get("task_id"), candidate.task_id, "TASK_BINDING_CONFLICT"),
|
||||
(governance.get("job_id"), candidate.job_id, "JOB_BINDING_CONFLICT"),
|
||||
(governance.get("idempotency_key"), candidate.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||
)
|
||||
for actual_coordinate, expected_coordinate, code in governed_coordinates:
|
||||
if actual_coordinate != expected_coordinate:
|
||||
_error(code)
|
||||
commit_key = _string(governance.get("commit_idempotency_key"), "COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||
if KEY_RE.fullmatch(commit_key) is None:
|
||||
_error("COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||
if governance.get("writer_lease") is not (action in MUTATION_ACTIONS):
|
||||
_error("WRITER_LEASE_POLICY_DENY")
|
||||
for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"):
|
||||
if governance.get(field, False) is not False:
|
||||
_error("POLICY_DENY")
|
||||
changes = envelope.get("change_set")
|
||||
verification = envelope.get("verification")
|
||||
if not isinstance(changes, list) or not isinstance(verification, list):
|
||||
_error("EXECUTION_OPERATIONS_INVALID")
|
||||
if action == "source_capture":
|
||||
if changes or verification:
|
||||
_error("SOURCE_CAPTURE_MUTATION_DENY")
|
||||
signals = list(_source_signals(envelope.get("source_signals") or []))
|
||||
if envelope.get("source_signals") != signals:
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
elif not changes:
|
||||
_error("CHANGE_SET_INVALID")
|
||||
declared = str(envelope.get("execution_digest") or "")
|
||||
material = {key: item for key, item in envelope.items() if key != "execution_digest"}
|
||||
actual = _digest(material)
|
||||
if declared != actual:
|
||||
_error("EXECUTION_DIGEST_INVALID")
|
||||
return {**material, "execution_digest": actual}
|
||||
|
||||
|
||||
def project_execution_envelope(
|
||||
binding: GovernedExecutionBinding,
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
required_capabilities: Sequence[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
request = dict(execution_request or {})
|
||||
action = str(request.get("action") or "source_capture")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||
capabilities = _capabilities(request.get("capability_scope", required_capabilities))
|
||||
governance = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"idempotency_key": binding.idempotency_key,
|
||||
"commit_idempotency_key": str(request.get("commit_idempotency_key") or f"{binding.task_id}-commit-g{binding.generation}"),
|
||||
"writer_lease": action in MUTATION_ACTIONS,
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"force_push": False,
|
||||
"protected_ref_bypass": False,
|
||||
}
|
||||
supplied_governance = request.get("governance")
|
||||
if supplied_governance is not None:
|
||||
governance.update(_object(supplied_governance, "GOVERNANCE_POLICY_REQUIRED"))
|
||||
material: dict[str, Any] = {
|
||||
"schema": EXECUTION_SCHEMA,
|
||||
**binding.public(),
|
||||
"action": action,
|
||||
"adapter": str(request.get("adapter") or ("deterministic" if action == "deterministic" else "source_capture")),
|
||||
"capability_scope": list(capabilities),
|
||||
"change_set": list(request.get("change_set") or []),
|
||||
"verification": list(request.get("verification") or []),
|
||||
"source_signals": list(_source_signals(request.get("source_signals") or [])) if action == "source_capture" else [],
|
||||
"governance": governance,
|
||||
"workspace": {
|
||||
"authority_git_dir": "/data/aios-gitops/repo.git",
|
||||
"read_only_repository": True,
|
||||
"cleanup_policy": "always",
|
||||
},
|
||||
}
|
||||
envelope = {**material, "execution_digest": _digest(material)}
|
||||
return validate_execution_envelope(envelope, binding=binding)
|
||||
|
||||
|
||||
def project_online_task(
|
||||
binding: GovernedExecutionBinding,
|
||||
task_request: Mapping[str, Any],
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request = dict(task_request)
|
||||
required = _capabilities(request.get("required_capabilities"))
|
||||
execution = project_execution_envelope(binding, execution_request, required_capabilities=required)
|
||||
metadata = dict(request.get("metadata") or {})
|
||||
if "execution" in metadata:
|
||||
validate_execution_envelope(_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"), binding=binding)
|
||||
if metadata["execution"] != execution:
|
||||
_error("EXECUTION_PROJECTION_CONFLICT")
|
||||
metadata["execution"] = execution
|
||||
metadata["execution_projection"] = {
|
||||
"schema": PROJECTION_SCHEMA,
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"execution_digest": execution["execution_digest"],
|
||||
}
|
||||
return {
|
||||
**request,
|
||||
"task_id": binding.task_id,
|
||||
"metadata": metadata,
|
||||
"required_capabilities": list(required),
|
||||
"idempotency_key": str(request.get("idempotency_key") or f"online-task:{binding.idempotency_key}"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_task_execution(
|
||||
task: Mapping[str, Any],
|
||||
binding: GovernedExecutionBinding,
|
||||
*,
|
||||
worker_capabilities: Sequence[str] | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
metadata = task.get("metadata")
|
||||
if isinstance(metadata, Mapping) and "execution" in metadata:
|
||||
value = validate_execution_envelope(
|
||||
_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"),
|
||||
binding=binding,
|
||||
worker_capabilities=worker_capabilities,
|
||||
)
|
||||
return value, "persisted"
|
||||
projected = project_execution_envelope(
|
||||
binding,
|
||||
{"action": "source_capture", "capability_scope": task.get("required_capabilities") or []},
|
||||
required_capabilities=task.get("required_capabilities") or [],
|
||||
)
|
||||
validate_execution_envelope(projected, binding=binding, worker_capabilities=worker_capabilities)
|
||||
return projected, "recovered_projection"
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Durable, authority-bound deterministic execution plans for Online Agent Tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
project_execution_envelope,
|
||||
)
|
||||
|
||||
|
||||
class ExecutionPlanError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _task_key(task_id: str) -> str:
|
||||
readable = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in task_id).strip("-")[:72] or "task"
|
||||
return readable + "-" + hashlib.sha256(task_id.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPlanRecord:
|
||||
task_id: str
|
||||
job_id: str
|
||||
source_head_oid: str
|
||||
generation: int
|
||||
idempotency_key: str
|
||||
plan_digest: str
|
||||
execution: dict[str, Any]
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "aios.online-agent.plan-record.v1",
|
||||
"task_id": self.task_id,
|
||||
"job_id": self.job_id,
|
||||
"source_head_oid": self.source_head_oid,
|
||||
"generation": self.generation,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"plan_digest": self.plan_digest,
|
||||
"execution": dict(self.execution),
|
||||
}
|
||||
|
||||
|
||||
class ExecutionPlanStore:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path(self, task_id: str) -> Path:
|
||||
return self.root / (_task_key(task_id) + ".json")
|
||||
|
||||
@contextmanager
|
||||
def _lock(self, task_id: str):
|
||||
path = self.root / (_task_key(task_id) + ".lock")
|
||||
with path.open("a+b") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
@staticmethod
|
||||
def _source_head(binding: GovernedExecutionBinding) -> str:
|
||||
return binding.expected_branch_oid if binding.branch_mode == "CONTINUE_EXISTING" else binding.base_oid
|
||||
|
||||
def _read(self, task_id: str) -> ExecutionPlanRecord | None:
|
||||
path = self._path(task_id)
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return None
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping) or value.get("schema") != "aios.online-agent.plan-record.v1":
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||
execution = value.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||
return ExecutionPlanRecord(
|
||||
task_id=str(value.get("task_id") or ""),
|
||||
job_id=str(value.get("job_id") or ""),
|
||||
source_head_oid=str(value.get("source_head_oid") or ""),
|
||||
generation=int(value.get("generation") or 0),
|
||||
idempotency_key=str(value.get("idempotency_key") or ""),
|
||||
plan_digest=str(value.get("plan_digest") or ""),
|
||||
execution=dict(execution),
|
||||
)
|
||||
|
||||
def register(
|
||||
self,
|
||||
binding: GovernedExecutionBinding,
|
||||
request: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
with self._lock(binding.task_id):
|
||||
return self._register_locked(binding, request)
|
||||
|
||||
def _register_locked(
|
||||
self,
|
||||
binding: GovernedExecutionBinding,
|
||||
request: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if request.get("schema") != "aios.online-agent.plan.v1":
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_SCHEMA_INVALID")
|
||||
if request.get("task_id") != binding.task_id or request.get("job_id") != binding.job_id:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||
if int(request.get("generation") or 0) != binding.generation:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_GENERATION_CONFLICT")
|
||||
source_head = str(request.get("source_head_oid") or "")
|
||||
if source_head != self._source_head(binding):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_CONFLICT")
|
||||
key = str(request.get("idempotency_key") or "")
|
||||
if len(key) < 8:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_INVALID")
|
||||
execution_request = request.get("execution")
|
||||
if not isinstance(execution_request, Mapping) or execution_request.get("action") != "deterministic":
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_DETERMINISTIC_REQUIRED")
|
||||
envelope = project_execution_envelope(binding, execution_request)
|
||||
material = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"source_head_oid": source_head,
|
||||
"generation": binding.generation,
|
||||
"idempotency_key": key,
|
||||
"execution": dict(execution_request),
|
||||
}
|
||||
plan_digest = _digest(material)
|
||||
existing = self._read(binding.task_id)
|
||||
expected_previous = request.get("expected_previous_plan_digest")
|
||||
if existing is not None:
|
||||
if existing.idempotency_key == key:
|
||||
if existing.plan_digest != plan_digest:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_CONFLICT")
|
||||
return {**existing.public(), "idempotent": True, "execution_digest": envelope["execution_digest"]}
|
||||
if expected_previous != existing.plan_digest:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||
if existing.source_head_oid != source_head:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_STALE_REPLACEMENT_DENY")
|
||||
elif expected_previous not in (None, ""):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||
|
||||
record = ExecutionPlanRecord(
|
||||
task_id=binding.task_id,
|
||||
job_id=binding.job_id,
|
||||
source_head_oid=source_head,
|
||||
generation=binding.generation,
|
||||
idempotency_key=key,
|
||||
plan_digest=plan_digest,
|
||||
execution=dict(execution_request),
|
||||
)
|
||||
path = self._path(binding.task_id)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(record.public(), sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
return {**record.public(), "idempotent": False, "execution_digest": envelope["execution_digest"]}
|
||||
|
||||
def resolve(self, binding: GovernedExecutionBinding) -> dict[str, Any] | None:
|
||||
record = self._read(binding.task_id)
|
||||
if record is None:
|
||||
return None
|
||||
if (
|
||||
record.task_id != binding.task_id
|
||||
or record.job_id != binding.job_id
|
||||
or record.generation != binding.generation
|
||||
):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||
if record.source_head_oid != self._source_head(binding):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_DRIFT")
|
||||
envelope = project_execution_envelope(binding, record.execution)
|
||||
return {
|
||||
"execution": dict(record.execution),
|
||||
"plan_digest": record.plan_digest,
|
||||
"execution_digest": envelope["execution_digest"],
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {},
|
||||
)
|
||||
@@ -1,144 +0,0 @@
|
||||
"""HTTP adapter for audited Owner authorization and writer provisioning.
|
||||
|
||||
This module layers on top of ``aios_authorized_task_writer_api`` so existing
|
||||
routes and the legacy exact ``writer:provision`` route remain available. The
|
||||
new endpoint does not mint or modify bearer credentials. It authenticates an
|
||||
existing, non-wildcard, explicitly allow-listed Owner credential and then
|
||||
invokes the audited broker.
|
||||
|
||||
Endpoint:
|
||||
POST /api/governance/owner-authorizations
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import Any, Mapping
|
||||
|
||||
import aios_authorized_task_writer_api as bridge_api
|
||||
from authorized_task_writer_provisioning import ProvisioningError
|
||||
from owner_authorization_broker import (
|
||||
OwnerAuthorizationBroker,
|
||||
OwnerAuthorizationError,
|
||||
OwnerPrincipal,
|
||||
)
|
||||
|
||||
|
||||
api = bridge_api.api
|
||||
_original_do_post = api.H.do_POST
|
||||
_ENDPOINT = "/api/governance/owner-authorizations"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_BROKER_ENABLED", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _configured_intake_scope() -> str:
|
||||
scopes = [
|
||||
item.strip()
|
||||
for item in os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES", ""
|
||||
).split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if len(scopes) != 1:
|
||||
raise OwnerAuthorizationError(
|
||||
"EXACTLY_ONE_HTTP_INTAKE_SCOPE_REQUIRED", 503
|
||||
)
|
||||
scope = scopes[0]
|
||||
if scope == "writer:provision" or scope == "*" or scope.endswith(":*"):
|
||||
raise OwnerAuthorizationError("HTTP_INTAKE_SCOPE_REJECTED", 503)
|
||||
return scope
|
||||
|
||||
|
||||
def _scope_set(auth: Mapping[str, Any]) -> frozenset[str]:
|
||||
raw = auth.get("scopes")
|
||||
if raw is None:
|
||||
raw = auth.get("scope")
|
||||
if raw is None:
|
||||
return frozenset()
|
||||
if isinstance(raw, str):
|
||||
candidates = raw.replace(",", " ").split()
|
||||
elif isinstance(raw, (list, tuple, set, frozenset)):
|
||||
candidates = [str(item) for item in raw]
|
||||
else:
|
||||
raise OwnerAuthorizationError("OWNER_SCOPE_EVIDENCE_INVALID", 403)
|
||||
return frozenset(item.strip() for item in candidates if item.strip())
|
||||
|
||||
|
||||
def _credential_id(auth: Mapping[str, Any]) -> str:
|
||||
for name in (
|
||||
"credential_id",
|
||||
"token_id",
|
||||
"key_id",
|
||||
"auth_id",
|
||||
):
|
||||
value = str(auth.get(name) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _authenticate_existing_owner(handler) -> OwnerPrincipal | None:
|
||||
scope = _configured_intake_scope()
|
||||
auth = handler.need(scope)
|
||||
if not auth:
|
||||
return None
|
||||
if auth.get("role") != "owner":
|
||||
handler.sendj({"error": "OWNER_ROLE_REQUIRED"}, 403)
|
||||
return None
|
||||
identity = str(auth.get("account") or auth.get("identity") or "").strip()
|
||||
if not identity:
|
||||
handler.sendj({"error": "OWNER_IDENTITY_REQUIRED"}, 403)
|
||||
return None
|
||||
scopes = _scope_set(auth)
|
||||
if any(item == "*" or item.endswith(":*") for item in scopes):
|
||||
handler.sendj(
|
||||
{"error": "WILDCARD_OWNER_CREDENTIAL_REJECTED"}, 403
|
||||
)
|
||||
return None
|
||||
return OwnerPrincipal(
|
||||
identity=identity,
|
||||
role="owner",
|
||||
authenticated_scope=scope,
|
||||
token_scopes=scopes,
|
||||
credential_id=_credential_id(auth),
|
||||
)
|
||||
|
||||
|
||||
def _broker_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
return _original_do_post(self)
|
||||
if not _enabled():
|
||||
return self.sendj(
|
||||
{"error": "OWNER_AUTHORIZATION_BROKER_DISABLED"}, 503
|
||||
)
|
||||
try:
|
||||
principal = _authenticate_existing_owner(self)
|
||||
if principal is None:
|
||||
return
|
||||
data = self.body()
|
||||
result = OwnerAuthorizationBroker().authorize_and_provision(
|
||||
data, principal
|
||||
)
|
||||
return self.sendj(result)
|
||||
except (OwnerAuthorizationError, ProvisioningError) as exc:
|
||||
body = {"error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
return self.sendj(body, exc.status)
|
||||
except Exception:
|
||||
# Never expose registry data, bearer tokens, signer output or traceback.
|
||||
return self.sendj(
|
||||
{"error": "OWNER_AUTHORIZATION_BROKER_INTERNAL_ERROR"}, 500
|
||||
)
|
||||
|
||||
|
||||
api.H.do_POST = _broker_do_post
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.main()
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loopback-only HTTP API for the task identity overlay.
|
||||
|
||||
Endpoints:
|
||||
GET /healthz
|
||||
POST /api/governance/owner-authorizations
|
||||
|
||||
The API authenticates only the short-lived overlay credential. It does not read
|
||||
or modify the global bearer-token registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
|
||||
from authorized_task_writer_provisioning import ProvisioningError
|
||||
from owner_authorization_broker import OwnerAuthorizationError
|
||||
from task_identity_overlay import (
|
||||
MAX_BODY_BYTES,
|
||||
IdentityOverlayConfig,
|
||||
IdentityOverlayError,
|
||||
TaskIdentityOverlayService,
|
||||
)
|
||||
|
||||
|
||||
_ENDPOINT = "/api/governance/owner-authorizations"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_ENABLED", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _bearer(header: str) -> str:
|
||||
prefix = "Bearer "
|
||||
if not header.startswith(prefix):
|
||||
raise IdentityOverlayError("OVERLAY_AUTHENTICATION_REQUIRED", 401)
|
||||
token = header[len(prefix):].strip()
|
||||
if not token or len(token) > 4096:
|
||||
raise IdentityOverlayError("OVERLAY_AUTHENTICATION_REQUIRED", 401)
|
||||
return token
|
||||
|
||||
|
||||
class OverlayHandler(BaseHTTPRequestHandler):
|
||||
server_version = "aios-task-identity-overlay/1"
|
||||
sys_version = ""
|
||||
|
||||
@property
|
||||
def overlay(self) -> TaskIdentityOverlayService:
|
||||
return self.server.overlay # type: ignore[attr-defined]
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
# Never log Authorization or request bodies.
|
||||
return
|
||||
|
||||
def _send(self, status: int, value: dict[str, Any]) -> None:
|
||||
payload = (
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _body(self) -> dict[str, Any]:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
raise IdentityOverlayError("OVERLAY_CONTENT_LENGTH_INVALID") from None
|
||||
if length <= 0 or length > MAX_BODY_BYTES:
|
||||
raise IdentityOverlayError("OVERLAY_BODY_SIZE_REJECTED", 413)
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise IdentityOverlayError("OVERLAY_BODY_JSON_INVALID") from None
|
||||
if not isinstance(value, dict):
|
||||
raise IdentityOverlayError("OVERLAY_BODY_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/healthz":
|
||||
self._send(404, {"error": "NOT_FOUND"})
|
||||
return
|
||||
self._send(
|
||||
200,
|
||||
{
|
||||
"service": "aios-task-identity-overlay",
|
||||
"status": "active" if _enabled() else "disabled",
|
||||
"global_token_registry_used": False,
|
||||
"loopback_only": True,
|
||||
},
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
self._send(404, {"error": "NOT_FOUND"})
|
||||
return
|
||||
if not _enabled():
|
||||
self._send(503, {"error": "IDENTITY_OVERLAY_DISABLED"})
|
||||
return
|
||||
try:
|
||||
token = _bearer(self.headers.get("Authorization", ""))
|
||||
result = self.overlay.provision(self._body(), token)
|
||||
self._send(200, result)
|
||||
except (IdentityOverlayError, OwnerAuthorizationError, ProvisioningError) as exc:
|
||||
body: dict[str, Any] = {"error": exc.code}
|
||||
if getattr(exc, "details", None):
|
||||
body["details"] = exc.details
|
||||
self._send(exc.status, body)
|
||||
except Exception:
|
||||
self._send(500, {"error": "IDENTITY_OVERLAY_API_INTERNAL_ERROR"})
|
||||
|
||||
|
||||
class OverlayServer(ThreadingHTTPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: tuple[str, int],
|
||||
overlay: TaskIdentityOverlayService,
|
||||
) -> None:
|
||||
super().__init__(address, OverlayHandler)
|
||||
self.overlay = overlay
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.getenv("AIOS_GITOPS_IDENTITY_OVERLAY_HOST", "127.0.0.1")
|
||||
if host not in {"127.0.0.1", "::1", "localhost"}:
|
||||
raise SystemExit("identity overlay must listen on loopback")
|
||||
try:
|
||||
port = int(os.getenv("AIOS_GITOPS_IDENTITY_OVERLAY_PORT", "19864"))
|
||||
except ValueError:
|
||||
raise SystemExit("invalid overlay port") from None
|
||||
if not 1024 <= port <= 65535:
|
||||
raise SystemExit("invalid overlay port")
|
||||
config = IdentityOverlayConfig.from_environment()
|
||||
server = OverlayServer(
|
||||
(host, port),
|
||||
TaskIdentityOverlayService(config),
|
||||
)
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-governed CLI for issuing and revoking task identity overlays.
|
||||
|
||||
The CLI never prints the bearer token. It returns only the root-owned secret
|
||||
reference and non-secret metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from task_identity_overlay import (
|
||||
IdentityOverlayConfig,
|
||||
IdentityOverlayError,
|
||||
TaskIdentityOverlayStore,
|
||||
)
|
||||
|
||||
|
||||
def _write_result(path: str, value: dict) -> None:
|
||||
data = json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n"
|
||||
if path == "-":
|
||||
sys.stdout.write(data)
|
||||
return
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(data, encoding="utf-8")
|
||||
os.chmod(target, 0o600)
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser(
|
||||
description="Issue/status/revoke a task-scoped identity overlay"
|
||||
)
|
||||
sub = value.add_subparsers(dest="command", required=True)
|
||||
|
||||
issue = sub.add_parser("issue")
|
||||
issue.add_argument("--ttl-seconds", type=int, default=900)
|
||||
issue.add_argument("--authorization-assertion-ref", required=True)
|
||||
issue.add_argument(
|
||||
"--operator-identity",
|
||||
default=os.getenv("SUDO_USER") or os.getenv("USER") or "root",
|
||||
)
|
||||
issue.add_argument("--result-json", default="-")
|
||||
|
||||
status = sub.add_parser("status")
|
||||
status.add_argument("--overlay-id", required=True)
|
||||
status.add_argument("--result-json", default="-")
|
||||
|
||||
revoke = sub.add_parser("revoke")
|
||||
revoke.add_argument("--overlay-id", required=True)
|
||||
revoke.add_argument("--reason", required=True)
|
||||
revoke.add_argument("--result-json", default="-")
|
||||
return value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
try:
|
||||
store = TaskIdentityOverlayStore(
|
||||
IdentityOverlayConfig.from_environment()
|
||||
)
|
||||
if args.command == "issue":
|
||||
result = store.issue(
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
authorization_assertion_ref=args.authorization_assertion_ref,
|
||||
operator_identity=args.operator_identity,
|
||||
)
|
||||
elif args.command == "status":
|
||||
result = store.status(args.overlay_id)
|
||||
else:
|
||||
result = store.revoke(args.overlay_id, reason=args.reason)
|
||||
_write_result(args.result_json, result)
|
||||
return 0
|
||||
except IdentityOverlayError as exc:
|
||||
body = {"status": "BLOCKED", "error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
_write_result(getattr(args, "result_json", "-"), body)
|
||||
return 2
|
||||
except Exception:
|
||||
_write_result(
|
||||
getattr(args, "result_json", "-"),
|
||||
{"status": "FAILED", "error": "IDENTITY_OVERLAY_CLI_INTERNAL_ERROR"},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user