Compare commits
62
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 |
@@ -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`.
|
||||
@@ -6,6 +6,7 @@ from .adapter import (
|
||||
HermesNotFound,
|
||||
HermesRuntimeError,
|
||||
InMemoryHermesStore,
|
||||
SQLiteHermesStore,
|
||||
)
|
||||
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
|
||||
from .worker import HermesWorker, WorkerRunResult
|
||||
@@ -21,5 +22,6 @@ __all__ = [
|
||||
"HermesRuntimeError",
|
||||
"HermesWorker",
|
||||
"InMemoryHermesStore",
|
||||
"SQLiteHermesStore",
|
||||
"WorkerRunResult",
|
||||
]
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import replace
|
||||
from dataclasses import asdict, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from .contract import (
|
||||
@@ -41,6 +43,118 @@ class InMemoryHermesStore:
|
||||
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."""
|
||||
@@ -118,6 +232,7 @@ class HermesAdapter:
|
||||
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:
|
||||
@@ -138,7 +253,8 @@ class HermesAdapter:
|
||||
) -> list[HermesJob]:
|
||||
state_filter = {HermesJobState(item) for item in states} if states else None
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
if self._requeue_expired_locked():
|
||||
self.store.persist_locked()
|
||||
jobs = [
|
||||
job
|
||||
for job in self.store.jobs.values()
|
||||
@@ -157,6 +273,7 @@ class HermesAdapter:
|
||||
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:
|
||||
@@ -172,10 +289,12 @@ class HermesAdapter:
|
||||
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,
|
||||
@@ -190,6 +309,7 @@ class HermesAdapter:
|
||||
),
|
||||
)
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
|
||||
self.store.persist_locked()
|
||||
return claimed
|
||||
|
||||
def start(
|
||||
@@ -201,9 +321,12 @@ class HermesAdapter:
|
||||
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)
|
||||
return self._transition_locked(current, HermesJobState.RUNNING)
|
||||
updated = self._transition_locked(current, HermesJobState.RUNNING)
|
||||
self.store.persist_locked()
|
||||
return updated
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
@@ -214,7 +337,10 @@ class HermesAdapter:
|
||||
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")
|
||||
@@ -228,6 +354,7 @@ class HermesAdapter:
|
||||
)
|
||||
self.store.jobs[job_id] = updated
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
|
||||
self.store.persist_locked()
|
||||
return updated
|
||||
|
||||
def complete(
|
||||
@@ -283,6 +410,8 @@ class HermesAdapter:
|
||||
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(
|
||||
@@ -296,6 +425,7 @@ class HermesAdapter:
|
||||
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:
|
||||
@@ -309,9 +439,18 @@ class HermesAdapter:
|
||||
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:
|
||||
self._requeue_expired_locked()
|
||||
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}
|
||||
@@ -354,7 +493,20 @@ class HermesAdapter:
|
||||
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(
|
||||
@@ -371,6 +523,7 @@ class HermesAdapter:
|
||||
)
|
||||
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:
|
||||
@@ -418,8 +571,9 @@ class HermesAdapter:
|
||||
self.store.jobs[current.job_id] = updated
|
||||
return updated
|
||||
|
||||
def _requeue_expired_locked(self) -> None:
|
||||
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 (
|
||||
@@ -435,3 +589,5 @@ class HermesAdapter:
|
||||
lease_expires_at=None,
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
changed.add(current.job_id)
|
||||
return changed
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""AIOS-managed OpenClaw execution adapter."""
|
||||
"""AIOS-managed OpenClaw Ed25519 execution adapter."""
|
||||
|
||||
from .adapter import (
|
||||
AdapterError,
|
||||
@@ -9,7 +9,11 @@ from .adapter import (
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from .contract import (
|
||||
CONTRACT_VERSION,
|
||||
SIGNATURE_ALGORITHM,
|
||||
CommandState,
|
||||
Ed25519CommandSigner,
|
||||
Ed25519CommandVerifier,
|
||||
OpenClawCommand,
|
||||
OpenClawResult,
|
||||
RuntimeHealth,
|
||||
@@ -20,10 +24,13 @@ from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeT
|
||||
|
||||
__all__ = [
|
||||
"AdapterError",
|
||||
"CONTRACT_VERSION",
|
||||
"CommandConflict",
|
||||
"CommandNotFound",
|
||||
"CommandRejected",
|
||||
"CommandState",
|
||||
"Ed25519CommandSigner",
|
||||
"Ed25519CommandVerifier",
|
||||
"HTTPRuntimeTransport",
|
||||
"InMemoryCallbackSink",
|
||||
"InProcessRuntimeTransport",
|
||||
@@ -32,6 +39,7 @@ __all__ = [
|
||||
"OpenClawResult",
|
||||
"RuntimeHealth",
|
||||
"RuntimeTransport",
|
||||
"SIGNATURE_ALGORITHM",
|
||||
"sign_command",
|
||||
"verify_command_signature",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Versioned AIOS to OpenClaw execution contracts."""
|
||||
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -8,9 +9,10 @@ import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
CONTRACT_VERSION = "aios.openclaw.command.v1"
|
||||
CONTRACT_VERSION = "aios.openclaw.command.v2"
|
||||
SIGNATURE_ALGORITHM = "Ed25519"
|
||||
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
@@ -38,7 +40,106 @@ def _required(value: str, label: str) -> str:
|
||||
|
||||
|
||||
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))
|
||||
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):
|
||||
@@ -63,22 +164,43 @@ class OpenClawCommand:
|
||||
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", "key_id",
|
||||
"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))
|
||||
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"))
|
||||
_parse_timestamp(self.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(
|
||||
@@ -118,7 +240,9 @@ class OpenClawCommand:
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +262,9 @@ class OpenClawResult:
|
||||
|
||||
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, 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))
|
||||
@@ -165,8 +291,16 @@ class RuntimeHealth:
|
||||
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))
|
||||
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")
|
||||
@@ -175,32 +309,68 @@ class RuntimeHealth:
|
||||
|
||||
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||
return json.dumps(
|
||||
command.unsigned_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
command.unsigned_dict(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sign_command(command: OpenClawCommand, key_material: bytes | str) -> OpenClawCommand:
|
||||
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if not key:
|
||||
raise ValueError("signing material is required")
|
||||
signature = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
|
||||
return replace(command, signature=signature)
|
||||
def _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,
|
||||
key_material: bytes | str,
|
||||
verification_key: Any,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||
) -> bool:
|
||||
if not command.signature:
|
||||
if (
|
||||
not command.signature
|
||||
or command.contract_version != CONTRACT_VERSION
|
||||
or command.signature_algorithm != SIGNATURE_ALGORITHM
|
||||
):
|
||||
return False
|
||||
key = key_material.encode("utf-8") if isinstance(key_material, str) else bytes(key_material)
|
||||
if not key:
|
||||
try:
|
||||
verifier = _as_verifier(command, verification_key)
|
||||
signature = base64.b64decode(command.signature, validate=True)
|
||||
except Exception:
|
||||
return False
|
||||
expected = hmac.new(key, canonical_command_bytes(command), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, command.signature):
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AIOS V2 control plane and execution orchestration."""
|
||||
"""AIOS V2 控制面、执行编排与可信运行来源证明。"""
|
||||
|
||||
from .control_plane import (
|
||||
AgentRecord,
|
||||
@@ -20,6 +20,41 @@ from .control_plane import (
|
||||
)
|
||||
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",
|
||||
@@ -27,20 +62,49 @@ __all__ = [
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Production-candidate AIOS V2 application entrypoint.
|
||||
|
||||
This process owns the public control-plane API and read-only web console. It
|
||||
loads durable control-plane state, dispatches approved work through runtime
|
||||
adapters, exposes tenant-scoped execution evidence and sanitized GitOps status,
|
||||
and never exposes runtime credentials to the browser.
|
||||
"""
|
||||
"""AIOS V2 production-candidate application entrypoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
@@ -18,17 +12,23 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesResult
|
||||
from aios_openclaw import HTTPRuntimeTransport, OpenClawAdapter
|
||||
from aios_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
|
||||
from .execution_tracking import ExecutionTracker
|
||||
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:
|
||||
@@ -46,27 +46,90 @@ def _json_value(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
class FileSigningMaterialResolver:
|
||||
"""Resolve runtime signing material from a root-owned file reference."""
|
||||
def _enabled(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
|
||||
def __call__(self, tenant_id: str, key_id: str) -> bytes:
|
||||
del tenant_id, key_id
|
||||
metadata = self.path.stat()
|
||||
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("signing material reference is not a regular file")
|
||||
if metadata.st_mode & 0o077:
|
||||
raise RuntimeError("signing material file permissions must be 0600 or stricter")
|
||||
value = self.path.read_bytes().strip()
|
||||
if len(value) < 32:
|
||||
raise RuntimeError("signing material is too short")
|
||||
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 OpenClaw runtime events to the tenant audit stream."""
|
||||
"""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
|
||||
@@ -80,16 +143,20 @@ class ControlPlaneEventPublisher(EventPublisher):
|
||||
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="openclaw-runtime",
|
||||
principal_id=principal_id,
|
||||
trace_id=trace_id,
|
||||
action=event_type,
|
||||
resource_type="openclaw_command",
|
||||
resource_type=f"{runtime}_execution",
|
||||
resource_id=command_id,
|
||||
outcome=outcome,
|
||||
policy_version="runtime-v1",
|
||||
policy_version="runtime-v2",
|
||||
details=dict(event.get("details") or {}),
|
||||
)
|
||||
with self.service.store.lock:
|
||||
@@ -98,8 +165,6 @@ class ControlPlaneEventPublisher(EventPublisher):
|
||||
|
||||
|
||||
class CandidateApplication:
|
||||
"""Compose durable control plane, runtimes, evidence and console routes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gateway: GatewayApplication,
|
||||
@@ -107,16 +172,127 @@ class CandidateApplication:
|
||||
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,
|
||||
@@ -186,25 +362,261 @@ class CandidateApplication:
|
||||
}
|
||||
|
||||
dispatch_match = __import__("re").fullmatch(
|
||||
r"/api/v2/tasks/([^/]+)/dispatch",
|
||||
path,
|
||||
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)}
|
||||
|
||||
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||
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"]),
|
||||
@@ -221,14 +633,26 @@ class CandidateApplication:
|
||||
started_at=body.get("started_at"),
|
||||
finished_at=body.get("finished_at"),
|
||||
)
|
||||
task = self.orchestrator.reconcile_hermes(context, result)
|
||||
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/1.0"
|
||||
server_version = "AIOSV2Candidate/2.0"
|
||||
max_body_bytes = 1_048_576
|
||||
|
||||
@property
|
||||
@@ -258,9 +682,7 @@ class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(
|
||||
_json_value(payload),
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
_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")
|
||||
@@ -291,7 +713,7 @@ class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
{key: value for key, value in self.headers.items()},
|
||||
body,
|
||||
)
|
||||
except BaseException as exc:
|
||||
except Exception as exc:
|
||||
status_code, payload = error_response(exc)
|
||||
self._send_json(status_code, payload)
|
||||
|
||||
@@ -315,16 +737,31 @@ 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)
|
||||
tracker = ExecutionTracker(state_dir / "executions")
|
||||
hermes = HermesAdapter()
|
||||
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()
|
||||
material_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").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 material_file:
|
||||
resolver = FileSigningMaterialResolver(material_file)
|
||||
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,
|
||||
@@ -341,8 +778,21 @@ def build_application() -> CandidateApplication:
|
||||
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")
|
||||
),
|
||||
)
|
||||
return CandidateApplication(gateway, orchestrator, tracker, hermes, openclaw)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -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]
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Git-backed agent execution evidence for AIOS V2.
|
||||
"""AIOS V2 Agent 执行证据。
|
||||
|
||||
Evidence is tenant-scoped, append-friendly, and contains no runtime credentials.
|
||||
It provides a deterministic source for claim, heartbeat, progress, result and
|
||||
verification state across Codex, OpenClaw and Hermes executors.
|
||||
Evidence 按租户隔离并使用原子 JSON 写入。生产模式下,claim、heartbeat
|
||||
和 complete 在落盘前必须通过最小权限 Evidence Grant 授权。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,7 +11,13 @@ import threading
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
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:
|
||||
@@ -49,6 +54,9 @@ class ExecutionEvidence:
|
||||
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 (
|
||||
@@ -70,14 +78,27 @@ class ExecutionEvidence:
|
||||
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:
|
||||
"""Persist one JSON evidence record per tenant and execution."""
|
||||
"""每个租户、每次执行持久保存一份证据记录。"""
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
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
|
||||
@@ -92,16 +113,92 @@ class ExecutionTracker:
|
||||
execution = self._component(execution_id)
|
||||
return self.root / tenant / f"{execution}.json"
|
||||
|
||||
def claim(self, evidence: ExecutionEvidence) -> ExecutionEvidence:
|
||||
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
|
||||
self._write(path, evidence)
|
||||
return evidence
|
||||
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,
|
||||
@@ -113,23 +210,38 @@ class ExecutionTracker:
|
||||
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)
|
||||
updated = replace(
|
||||
current,
|
||||
status=status or current.status,
|
||||
current_step=current_step or current.current_step,
|
||||
progress_completed=(
|
||||
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": (
|
||||
progress_total if progress_total is not None else current.progress_total
|
||||
),
|
||||
last_commit=last_commit or current.last_commit,
|
||||
"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)
|
||||
@@ -145,19 +257,34 @@ class ExecutionTracker:
|
||||
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,
|
||||
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),
|
||||
**changes,
|
||||
last_authorization_id=authorization_id or current.last_authorization_id,
|
||||
heartbeat_at=utc_now(),
|
||||
completed_at=utc_now(),
|
||||
)
|
||||
@@ -192,4 +319,5 @@ class ExecutionTracker:
|
||||
json.dumps(asdict(evidence), ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(temporary, 0o640)
|
||||
os.replace(temporary, path)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""HTTP/JSON gateway for the AIOS V2 control plane.
|
||||
|
||||
The gateway is intentionally runtime-neutral: browsers and external clients
|
||||
call this API, while OpenClaw and Hermes remain behind authenticated adapters.
|
||||
Identity headers are accepted only from a trusted reverse proxy in v1; direct
|
||||
public exposure is prohibited until signed identity middleware is enabled.
|
||||
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
|
||||
|
||||
@@ -17,6 +17,8 @@ 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,
|
||||
@@ -30,7 +32,9 @@ from .control_plane import (
|
||||
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"
|
||||
@@ -72,27 +76,34 @@ def _require_resource_id(value: Any, *, label: str) -> str:
|
||||
|
||||
|
||||
class GatewayApplication:
|
||||
"""Pure request router that can be tested without a listening socket."""
|
||||
"""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:
|
||||
authenticated = str(headers.get(AUTHENTICATED_HEADER, "")).strip().lower()
|
||||
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 str(headers.get(ROLES_HEADER, "")).split(",")
|
||||
for role in header(ROLES_HEADER).split(",")
|
||||
if role.strip()
|
||||
]
|
||||
return RequestContext.create(
|
||||
tenant_id=str(headers.get(TENANT_HEADER, "")),
|
||||
principal_id=str(headers.get(PRINCIPAL_HEADER, "")),
|
||||
trace_id=str(headers.get(TRACE_HEADER, "")).strip() or None,
|
||||
session_id=str(headers.get(SESSION_HEADER, "")).strip() or None,
|
||||
policy_version=str(headers.get(POLICY_HEADER, "v1")).strip() or "v1",
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -128,8 +139,7 @@ class GatewayApplication:
|
||||
return HTTPStatus.CREATED, {"tenant": _json_value(tenant)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/tenants/current":
|
||||
tenant = self.service.get_tenant(context)
|
||||
return HTTPStatus.OK, {"tenant": _json_value(tenant)}
|
||||
return HTTPStatus.OK, {"tenant": _json_value(self.service.get_tenant(context))}
|
||||
|
||||
if method == "POST" and path == "/api/v2/agents":
|
||||
agent = self.service.register_agent(
|
||||
@@ -143,9 +153,7 @@ class GatewayApplication:
|
||||
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))
|
||||
}
|
||||
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:
|
||||
@@ -171,8 +179,7 @@ class GatewayApplication:
|
||||
return HTTPStatus.CREATED, {"task": _json_value(task)}
|
||||
|
||||
if method == "GET" and path == "/api/v2/tasks":
|
||||
requested_states = query.get("state")
|
||||
tasks = self.service.list_tasks(context, states=requested_states)
|
||||
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)
|
||||
@@ -184,38 +191,37 @@ class GatewayApplication:
|
||||
"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|start|complete|fail|cancel)", path
|
||||
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"
|
||||
)
|
||||
transition = transition_match.group(2)
|
||||
target = {
|
||||
"approve": TaskState.APPROVED,
|
||||
"start": TaskState.RUNNING,
|
||||
"complete": TaskState.COMPLETED,
|
||||
"fail": TaskState.FAILED,
|
||||
"cancel": TaskState.CANCELLED,
|
||||
}[transition]
|
||||
target = (
|
||||
TaskState.APPROVED
|
||||
if transition_match.group(2) == "approve"
|
||||
else TaskState.CANCELLED
|
||||
)
|
||||
expected_revision = payload.get("expected_revision")
|
||||
if expected_revision is not None:
|
||||
expected_revision = int(expected_revision)
|
||||
task = self.service.transition_task(
|
||||
context,
|
||||
task_id=task_id,
|
||||
target_state=target,
|
||||
expected_revision=expected_revision,
|
||||
result=(
|
||||
_require_mapping(payload.get("result"), label="result")
|
||||
if payload.get("result") is not None
|
||||
else None
|
||||
),
|
||||
error=(
|
||||
_require_mapping(payload.get("error"), label="error")
|
||||
if payload.get("error") is not None
|
||||
else None
|
||||
expected_revision=(
|
||||
int(expected_revision) if expected_revision is not None else None
|
||||
),
|
||||
)
|
||||
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||
@@ -241,11 +247,18 @@ class GatewayApplication:
|
||||
_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),
|
||||
)
|
||||
@@ -254,10 +267,7 @@ _ERROR_STATUS: tuple[tuple[type[BaseException], HTTPStatus], ...] = (
|
||||
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 status, {"error": error_type.__name__, "message": str(exc)}
|
||||
return HTTPStatus.INTERNAL_SERVER_ERROR, {
|
||||
"error": "InternalServerError",
|
||||
"message": "internal server error",
|
||||
@@ -281,8 +291,7 @@ class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||
if content_type != "application/json":
|
||||
raise ValueError("Content-Type must be application/json")
|
||||
raw = self.rfile.read(length)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
|
||||
def _send_json(self, status: int, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(
|
||||
@@ -305,7 +314,7 @@ class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||
{key: value for key, value in self.headers.items()},
|
||||
body,
|
||||
)
|
||||
except BaseException as exc: # handler boundary; sanitized below
|
||||
except Exception as exc:
|
||||
status, payload = error_response(exc)
|
||||
self._send_json(status, payload)
|
||||
|
||||
@@ -316,8 +325,6 @@ class GatewayRequestHandler(BaseHTTPRequestHandler):
|
||||
self._dispatch("POST")
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
# Production deployment routes structured access logs through the
|
||||
# reverse proxy; do not log identity headers or request bodies here.
|
||||
super().log_message(format, *args)
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""AIOS V2 execution orchestrator.
|
||||
|
||||
The orchestrator is the only component allowed to translate an approved AIOS
|
||||
task into an OpenClaw command or Hermes job. OpenClaw and Hermes remain pure
|
||||
execution runtimes and never own tenant policy or task lifecycle.
|
||||
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 dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from aios_hermes import HermesAdapter, HermesJob, HermesResult
|
||||
from aios_hermes import HermesAdapter, HermesJob, HermesJobState, HermesResult
|
||||
from aios_openclaw import (
|
||||
CommandState,
|
||||
OpenClawAdapter,
|
||||
@@ -25,25 +25,27 @@ from .control_plane import (
|
||||
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."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispatchRecord:
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
runtime: str
|
||||
runtime_execution_id: str
|
||||
state: str
|
||||
trace_id: str
|
||||
|
||||
|
||||
class ExecutionOrchestrator:
|
||||
"""Dispatch approved AIOS tasks to runtime adapters and reconcile results."""
|
||||
"""Dispatch approved AIOS tasks and strictly reconcile Runtime results."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -55,15 +57,76 @@ class ExecutionOrchestrator:
|
||||
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 = openclaw_key_id
|
||||
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._dispatches: dict[tuple[str, str], DispatchRecord] = {}
|
||||
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,
|
||||
@@ -71,36 +134,146 @@ class ExecutionOrchestrator:
|
||||
*,
|
||||
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)
|
||||
if task.state != TaskState.APPROVED:
|
||||
raise OrchestrationError("only APPROVED tasks may be dispatched")
|
||||
agent = self.control_plane.get_agent(context, task.agent_id)
|
||||
existing = self._dispatches.get((task.tenant_id, task.task_id))
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
running = self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.RUNNING,
|
||||
expected_revision=task.revision,
|
||||
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":
|
||||
record = self._dispatch_openclaw(
|
||||
context,
|
||||
agent,
|
||||
running,
|
||||
timeout_seconds=timeout_seconds,
|
||||
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,
|
||||
)
|
||||
)
|
||||
elif runtime == "hermes":
|
||||
record = self._dispatch_hermes(context, agent, running)
|
||||
else:
|
||||
raise OrchestrationError(f"unsupported runtime: {agent.runtime}")
|
||||
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(
|
||||
@@ -112,19 +285,13 @@ class ExecutionOrchestrator:
|
||||
)
|
||||
raise
|
||||
|
||||
self._dispatches[(task.tenant_id, task.task_id)] = record
|
||||
return record
|
||||
|
||||
def _dispatch_openclaw(
|
||||
def _create_openclaw_command(
|
||||
self,
|
||||
context: RequestContext,
|
||||
agent: AgentRecord,
|
||||
task: TaskRecord,
|
||||
*,
|
||||
timeout_seconds: float | None,
|
||||
) -> DispatchRecord:
|
||||
if self.openclaw is None or self.openclaw_signing_material_resolver is None:
|
||||
raise OrchestrationError("OpenClaw runtime is not configured")
|
||||
) -> 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,
|
||||
@@ -138,29 +305,16 @@ class ExecutionOrchestrator:
|
||||
idempotency_key=task.idempotency_key or task.task_id,
|
||||
key_id=self.openclaw_key_id,
|
||||
)
|
||||
command = sign_command(command, key_material)
|
||||
result = self.openclaw.execute(command, timeout_seconds=timeout_seconds)
|
||||
self.reconcile_openclaw(context, result)
|
||||
return DispatchRecord(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
runtime="openclaw",
|
||||
runtime_execution_id=command.command_id,
|
||||
state=result.state.value,
|
||||
trace_id=context.trace_id,
|
||||
)
|
||||
return sign_command(command, key_material)
|
||||
|
||||
def _dispatch_hermes(
|
||||
def _create_hermes_job(
|
||||
self,
|
||||
context: RequestContext,
|
||||
agent: AgentRecord,
|
||||
task: TaskRecord,
|
||||
) -> DispatchRecord:
|
||||
if self.hermes is None:
|
||||
raise OrchestrationError("Hermes runtime is not configured")
|
||||
) -> HermesJob:
|
||||
metadata = dict(agent.metadata)
|
||||
job = HermesJob.create(
|
||||
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),
|
||||
@@ -171,94 +325,275 @@ class ExecutionOrchestrator:
|
||||
idempotency_key=task.idempotency_key or task.task_id,
|
||||
priority=int(metadata.get("priority", 100)),
|
||||
)
|
||||
queued = self.hermes.enqueue(job)
|
||||
return DispatchRecord(
|
||||
tenant_id=task.tenant_id,
|
||||
task_id=task.task_id,
|
||||
agent_id=agent.agent_id,
|
||||
|
||||
@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=queued.job_id,
|
||||
state=queued.state.value,
|
||||
trace_id=context.trace_id,
|
||||
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:
|
||||
task = self.control_plane.get_task(context, result.task_id)
|
||||
if result.tenant_id != task.tenant_id:
|
||||
raise OrchestrationError("OpenClaw result tenant mismatch")
|
||||
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:
|
||||
return task
|
||||
raise OrchestrationError("task is not RUNNING for active dispatch")
|
||||
if result.state == CommandState.COMPLETED:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.COMPLETED,
|
||||
expected_revision=task.revision,
|
||||
result=result.output or {},
|
||||
)
|
||||
if result.state in {
|
||||
target = TaskState.COMPLETED
|
||||
values = {"result": result.output or {}}
|
||||
dispatch_state = DispatchState.COMPLETED
|
||||
elif result.state in {
|
||||
CommandState.FAILED,
|
||||
CommandState.TIMED_OUT,
|
||||
CommandState.REJECTED,
|
||||
}:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.FAILED,
|
||||
expected_revision=task.revision,
|
||||
error=result.error or {"code": result.state.value.lower()},
|
||||
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,
|
||||
)
|
||||
if result.state == CommandState.CANCELLED:
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.CANCELLED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
return task
|
||||
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:
|
||||
task = self.control_plane.get_task(context, result.task_id)
|
||||
if result.tenant_id != task.tenant_id:
|
||||
raise OrchestrationError("Hermes result tenant mismatch")
|
||||
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:
|
||||
return task
|
||||
if result.state.value == "COMPLETED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.COMPLETED,
|
||||
expected_revision=task.revision,
|
||||
result=result.output or {},
|
||||
)
|
||||
if result.state.value == "FAILED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.FAILED,
|
||||
expected_revision=task.revision,
|
||||
error=result.error or {"code": "hermes_failed"},
|
||||
)
|
||||
if result.state.value == "CANCELLED":
|
||||
return self.control_plane.transition_task(
|
||||
context,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.CANCELLED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
return task
|
||||
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 {
|
||||
task_id: record
|
||||
for (record_tenant, task_id), record in self._dispatches.items()
|
||||
if record_tenant == tenant_id
|
||||
item.task_id: item
|
||||
for item in self.dispatch_repository.list(tenant_id=tenant_id)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -9,7 +9,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
RUN groupadd --system --gid 12001 aios \
|
||||
&& useradd --system --uid 12001 --gid aios --home-dir /nonexistent --shell /usr/sbin/nologin aios \
|
||||
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2
|
||||
&& install -d -o aios -g aios -m 0750 /opt/aios/app /var/lib/aios-v2 \
|
||||
&& python3 -m pip install --no-cache-dir 'cryptography>=42'
|
||||
|
||||
WORKDIR /opt/aios/app
|
||||
COPY --chown=aios:aios active-runtime/app/ /opt/aios/app/
|
||||
|
||||
+35
-11
@@ -1,21 +1,45 @@
|
||||
# AIOS V2 candidate runtime configuration.
|
||||
# Copy to /etc/aios-v2/aios-v2.env and keep ownership root:aios, mode 0640.
|
||||
# AIOS V2R1 Candidate 环境模板
|
||||
# 正式环境禁止保存明文 Secret。
|
||||
# Provenance/OpenClaw 私钥只用于签发端,不进入应用容器或 Git。
|
||||
|
||||
# Runtime
|
||||
AIOS_V2_HOST=127.0.0.1
|
||||
AIOS_V2_PORT=18920
|
||||
AIOS_V2_STATE_DIR=/var/lib/aios-v2
|
||||
|
||||
# Optional OpenClaw runtime. Leave blank to run with OpenClaw disabled.
|
||||
# OpenClaw Ed25519 Command v2
|
||||
AIOS_OPENCLAW_URL=
|
||||
AIOS_OPENCLAW_CREDENTIAL_REF=
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=/run/credentials/aios-v2.service/openclaw-command-private-key
|
||||
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||
|
||||
# File path only. The referenced file must be mode 0600 or stricter and contain
|
||||
# at least 32 bytes. Never place signing material in this env file or Git.
|
||||
AIOS_OPENCLAW_SIGNING_MATERIAL_FILE=
|
||||
# Parent Workflow Provenance
|
||||
AIOS_REQUIRE_RUN_PROVENANCE=true
|
||||
AIOS_PROVENANCE_PUBLIC_KEY_FILE=/etc/aios-v2/provenance/public-key.pem
|
||||
AIOS_PROVENANCE_KEY_ID=openclaw-v2r1-candidate-ed25519
|
||||
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2/run-provenance.sqlite3
|
||||
AIOS_PROVENANCE_RECEIPT_DIR=/var/lib/aios-v2/provenance-receipts
|
||||
|
||||
# Reverse proxy must authenticate users and inject X-AIOS-* identity headers.
|
||||
AIOS_TRUST_PROXY_HEADERS=true
|
||||
# Least-privilege child Grants: production requires all seven IDs.
|
||||
AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID=
|
||||
AIOS_PROVENANCE_GRANT_GIT_ID=
|
||||
AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID=
|
||||
AIOS_PROVENANCE_GRANT_RUNTIME_ID=
|
||||
AIOS_PROVENANCE_GRANT_EVIDENCE_ID=
|
||||
AIOS_PROVENANCE_GRANT_ROLLBACK_ID=
|
||||
AIOS_PROVENANCE_GRANT_AUDIT_ID=
|
||||
|
||||
# Candidate deployment metadata exposed through health tooling.
|
||||
AIOS_RELEASE_CHANNEL=candidate
|
||||
AIOS_RELEASE_VERSION=V2R1C0-candidate
|
||||
# Per-operation context. Deployment/rollback wrappers populate these from the
|
||||
# approved task execution context; do not hard-code shared production values.
|
||||
AIOS_PROVENANCE_ID=
|
||||
AIOS_RUN_ID=
|
||||
AIOS_TENANT_ID=
|
||||
AIOS_TASK_ID=
|
||||
AIOS_AGENT_ID=
|
||||
AIOS_PRINCIPAL_ID=
|
||||
|
||||
# 禁止:
|
||||
# AIOS_PROVENANCE_PRIVATE_KEY_FILE
|
||||
# AIOS_OPENCLAW_TOKEN
|
||||
# API_KEY=value
|
||||
# PASSWORD=value
|
||||
|
||||
Regular → Executable
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
OPERATION_PATH="${1:-}"
|
||||
OPERATION_NAME="${2:-}"
|
||||
REQUEST_FILE="${3:-}"
|
||||
|
||||
fail() {
|
||||
echo "PROVENANCE_DENIED: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -n "$OPERATION_PATH" ]] || fail "usage: $0 <deployment|rollback|audit|code_change|runtime_execute|evidence_write> <operation> <request-json-file>"
|
||||
[[ -n "$OPERATION_NAME" ]] || fail "operation name is required"
|
||||
[[ -n "$REQUEST_FILE" && -f "$REQUEST_FILE" ]] || fail "request JSON file is required"
|
||||
|
||||
: "${AIOS_PROVENANCE_PUBLIC_KEY_FILE:?missing AIOS_PROVENANCE_PUBLIC_KEY_FILE}"
|
||||
: "${AIOS_PROVENANCE_KEY_ID:?missing AIOS_PROVENANCE_KEY_ID}"
|
||||
: "${AIOS_PROVENANCE_STORE_FILE:?missing AIOS_PROVENANCE_STORE_FILE}"
|
||||
: "${AIOS_PROVENANCE_RECEIPT_DIR:?missing AIOS_PROVENANCE_RECEIPT_DIR}"
|
||||
: "${AIOS_PROVENANCE_ID:?missing AIOS_PROVENANCE_ID}"
|
||||
: "${AIOS_RUN_ID:?missing AIOS_RUN_ID}"
|
||||
: "${AIOS_TENANT_ID:?missing AIOS_TENANT_ID}"
|
||||
: "${AIOS_TASK_ID:?missing AIOS_TASK_ID}"
|
||||
: "${AIOS_AGENT_ID:?missing AIOS_AGENT_ID}"
|
||||
: "${AIOS_PRINCIPAL_ID:?missing AIOS_PRINCIPAL_ID}"
|
||||
|
||||
PYTHONPATH_VALUE="${AIOS_V2_PYTHONPATH:-/opt/aios/current/active-runtime/app}"
|
||||
|
||||
PYTHONPATH="$PYTHONPATH_VALUE${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
python3 -m aios_v2.provenance_cli authorize \
|
||||
--context-from-env \
|
||||
--store-file "$AIOS_PROVENANCE_STORE_FILE" \
|
||||
--public-key-file "$AIOS_PROVENANCE_PUBLIC_KEY_FILE" \
|
||||
--key-id "$AIOS_PROVENANCE_KEY_ID" \
|
||||
--receipt-dir "$AIOS_PROVENANCE_RECEIPT_DIR" \
|
||||
--path "$OPERATION_PATH" \
|
||||
--operation "$OPERATION_NAME" \
|
||||
--request-file "$REQUEST_FILE"
|
||||
Regular → Executable
+18
-6
@@ -8,12 +8,28 @@ STATE_DIR="${AIOS_V2_STATE_DIR:-/var/lib/aios-v2}"
|
||||
BACKUP_ROOT="${AIOS_V2_BACKUP_ROOT:-/var/backups/aios-v2}"
|
||||
TARGET_RELEASE="${1:-}"
|
||||
RESTORE_STATE="${AIOS_V2_RESTORE_STATE:-0}"
|
||||
HEALTH_RETRIES="${AIOS_V2_ROLLBACK_HEALTH_RETRIES:-30}"
|
||||
HEALTH_RETRY_DELAY="${AIOS_V2_ROLLBACK_HEALTH_RETRY_DELAY:-1}"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_healthcheck() {
|
||||
local release="$1"
|
||||
local attempt
|
||||
for ((attempt = 1; attempt <= HEALTH_RETRIES; attempt++)); do
|
||||
if AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$release/deploy/aios-v2/healthcheck/check.sh"; then
|
||||
return 0
|
||||
fi
|
||||
[[ "$attempt" -lt "$HEALTH_RETRIES" ]] && sleep "$HEALTH_RETRY_DELAY"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
[[ "${EUID}" -eq 0 ]] || fail "rollback must run as root"
|
||||
[[ -n "$TARGET_RELEASE" ]] || fail "usage: $0 <release-name-or-absolute-path>"
|
||||
|
||||
@@ -53,9 +69,7 @@ mv -Tf "${CURRENT_LINK}.next" "$CURRENT_LINK"
|
||||
systemctl daemon-reload
|
||||
systemctl start "$SERVICE"
|
||||
|
||||
if AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$TARGET_PATH/deploy/aios-v2/healthcheck/check.sh"; then
|
||||
if run_healthcheck "$TARGET_PATH"; then
|
||||
echo "ROLLBACK_COMPLETE target=$TARGET_PATH backup=$BACKUP_DIR"
|
||||
exit 0
|
||||
fi
|
||||
@@ -78,9 +92,7 @@ systemctl daemon-reload
|
||||
systemctl start "$SERVICE" || true
|
||||
|
||||
if [[ -n "$PREVIOUS_PATH" && -x "$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" ]]; then
|
||||
AIOS_V2_SERVICE="$SERVICE" \
|
||||
AIOS_V2_STATE_DIR="$STATE_DIR" \
|
||||
"$PREVIOUS_PATH/deploy/aios-v2/healthcheck/check.sh" || true
|
||||
run_healthcheck "$PREVIOUS_PATH" || true
|
||||
fi
|
||||
|
||||
fail "rollback target failed; previous release restored; evidence=$BACKUP_DIR"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# AIOS V2 用户需求规格
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 状态:候选版本需求基线
|
||||
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、审计与部署
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:加固候选版本需求基线
|
||||
- 适用范围:AIOS Control Plane、Agent Router、OpenClaw Adapter、Hermes Runtime、Web Console、GitOps、Run Provenance、Dispatch、审计、部署与回滚
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现有系统已经具备 OpenClaw、Hermes、Agent Router、GitOps、局部 WebUI、审计和多租户候选能力。AIOS V2 的目标不是推倒重写,而是将这些分散能力收敛到统一、可审计、可 Web 化、可多租户隔离的控制面。
|
||||
|
||||
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录和执行证据。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
|
||||
AIOS V2 必须成为唯一控制面,负责租户、身份、Agent、策略、任务生命周期、审批、审计、Runtime 目录、执行证据、Run Provenance 和 Dispatch。OpenClaw 与 Hermes 只承担执行职责,不得持有第二套控制面状态。
|
||||
|
||||
## 2. 用户角色
|
||||
|
||||
@@ -95,6 +95,7 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
- 禁止越级变化,例如 `CREATED -> COMPLETED`。
|
||||
- 每次变化包含 revision、principal、trace、时间和审计事件。
|
||||
- 公共 API 不允许直接调用 `start`、`complete` 或 `fail` 推进权威状态。
|
||||
|
||||
### UR-007 策略与审批
|
||||
|
||||
@@ -138,12 +139,12 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
### UR-011 持久化与恢复
|
||||
|
||||
租户、Agent、Task、Audit、Idempotency 和 Execution Evidence 必须在服务重启后恢复。
|
||||
租户、Agent、Task、Audit、Idempotency、Dispatch、Run Provenance、Grant 使用记录和 Execution Evidence 必须在服务重启后恢复。
|
||||
|
||||
验收:
|
||||
|
||||
- 候选版支持 SQLite WAL 原子快照。
|
||||
- 有 restart recovery 测试。
|
||||
- 候选版支持 SQLite WAL 或等价原子持久化。
|
||||
- 有 Control Plane 与 Dispatch restart recovery 测试。
|
||||
- 生产版需进一步提供迁移、备份、恢复、HA 和容量证据。
|
||||
|
||||
### UR-012 审计与合规
|
||||
@@ -155,6 +156,7 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
- tenant、principal、trace、action、resource、outcome、policy version、timestamp。
|
||||
- OpenClaw/Hermes Runtime 生命周期。
|
||||
- GitOps、部署和回滚动作。
|
||||
- Provenance Grant、Scope、Authorization Receipt 和 Dispatch 结果摘要。
|
||||
|
||||
### UR-013 密钥安全
|
||||
|
||||
@@ -165,6 +167,7 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
- Git 只存 `secret_ref`、`credential_ref`、`key_id` 或受保护文件路径。
|
||||
- Secret 文件权限为 `0600` 或更严格。
|
||||
- Secret Scanner 默认拒绝可疑提交。
|
||||
- OpenClaw Command 和 Run Provenance 使用 Ed25519;生产应用只读取公钥。
|
||||
|
||||
### UR-014 部署与回滚
|
||||
|
||||
@@ -187,15 +190,102 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
- Traceability Matrix 必须指向当前代码和测试。
|
||||
- 新架构决策必须增加或更新 ADR。
|
||||
|
||||
### UR-016 Workflow Run Provenance
|
||||
|
||||
每次生产 Run 必须具有一个可验证、可撤销、可过期的 Workflow Provenance,用于绑定 Run、Tenant、Task、Agent 与 Principal,但父 Workflow 本身不得授予操作权限。
|
||||
|
||||
验收:
|
||||
|
||||
- Workflow Provenance 使用 Ed25519 签名。
|
||||
- `allowed_paths` 与 `allowed_git_operations` 为空。
|
||||
- 上下文、签名、TTL、Nonce、Key ID 不合法时默认拒绝。
|
||||
- 撤销父 Workflow 后全部子 Grant 失效。
|
||||
|
||||
### UR-017 最小权限子 Grant
|
||||
|
||||
所有生产副作用必须通过与单一 ExecutionPath 对应的子 Grant 授权。
|
||||
|
||||
验收:
|
||||
|
||||
- Runtime、Evidence、Git、Deployment、Rollback、Audit 和 Code Change 使用独立 Grant。
|
||||
- 一个 Grant 只能包含一个路径。
|
||||
- Git Grant 必须限定具体 GitOperation。
|
||||
- 子 Grant 具有独立 TTL、撤销、状态和重放防护。
|
||||
|
||||
### UR-018 Runtime Result 信任绑定
|
||||
|
||||
OpenClaw/Hermes 结果只有在与持久 Dispatch 的身份和执行上下文完全匹配时,才能更新权威 Task。
|
||||
|
||||
验收:
|
||||
|
||||
- 至少校验 tenant、task、agent、principal、trace、runtime、runtime execution、provenance 和 run。
|
||||
- 不同终态结果摘要不得覆盖已有终态。
|
||||
- Hermes 终态结果必须来自持有 Job Lease 的 Worker。
|
||||
- Hermes Worker 生命周期验收入口必须是默认关闭的内部接口,仅接受可信
|
||||
`runtime` 或 `platform_admin` 身份;普通 Operator、浏览器和公共 API 不得直接调用。
|
||||
- 每次 Claim/Start/Heartbeat/Complete/Fail/Cancel 必须绑定
|
||||
tenant、task、trace、principal、provenance、run、dispatch、job、cell 和 profile。
|
||||
- Complete/Fail/Cancel 必须通过 `ExecutionOrchestrator.reconcile_hermes` 更新权威
|
||||
Task/Dispatch,并同步生成 Audit/Event 与 Execution Evidence。
|
||||
- Lease 到期必须持久化清除旧 Owner、递增 revision 并允许安全重领;旧 Worker
|
||||
后续 Result 必须拒绝。
|
||||
- Runtime callback 审计不得绕过 Orchestrator Reconcile。
|
||||
|
||||
### UR-019 Dispatch 持久化与恢复
|
||||
|
||||
Runtime 副作用之前必须持久化唯一 Dispatch,重启后能够恢复并继续严格 Reconcile。
|
||||
|
||||
验收:
|
||||
|
||||
- 同租户 Task 和 Runtime Execution 各自唯一绑定一个 Dispatch。
|
||||
- Dispatch 支持按 ID、Task 与 Runtime Execution 查询。
|
||||
- 终态 Dispatch 不得迁移到不同终态。
|
||||
- Hermes Job、Result、Lease、幂等键和 Worker heartbeat 必须与 Dispatch 一样在
|
||||
服务重启后恢复;不得以只存在于进程内的 Map 作为在线验收依据。
|
||||
- 有 SQLite Repository 重启恢复测试。
|
||||
|
||||
### UR-020 OpenClaw Ed25519 命令合同
|
||||
|
||||
OpenClaw 生产命令必须使用版本化 Ed25519 合同,不得继续使用 HMAC 作为新命令信任根。
|
||||
|
||||
验收:
|
||||
|
||||
- 合同版本为 `aios.openclaw.command.v2`。
|
||||
- 签名覆盖 Key ID、算法、公钥 Fingerprint、Tenant、Task、Trace、Payload、Idempotency 与时间字段。
|
||||
- Key ID/Fingerprint/签名/过期或时钟偏差不合法时拒绝。
|
||||
- Fingerprint 使用恒定时间比较。
|
||||
|
||||
### UR-021 服务级权限隔离
|
||||
|
||||
每个生产服务或 Worker 只能加载其职责所需的 Grant,不得加载全部 Grant 备用。
|
||||
|
||||
验收:
|
||||
|
||||
- 默认 AIOS Application 仅加载 Runtime、Evidence 与 Audit Grant。
|
||||
- Git、Deployment、Rollback 与 Code Change 由独立 Scope 加载。
|
||||
- 空 Scope、多余 Grant 映射和未声明路径调用全部拒绝。
|
||||
- Authorization Receipt 记录 Grant ID、Writer ID 和 Scope。
|
||||
|
||||
### UR-022 CLI 单 Grant 授权
|
||||
|
||||
运维 CLI 的一次授权操作只能加载一个与请求路径一致的 Grant。
|
||||
|
||||
验收:
|
||||
|
||||
- `authorize` 使用一个 `--path` 和一个 `--grant-id`。
|
||||
- Grant 类型与 Path 不一致时拒绝。
|
||||
- CLI 不提供一次加载七个生产 Grant 的授权入口。
|
||||
- 旧全权限 `issue` 仅允许候选兼容,不得进入生产流程。
|
||||
|
||||
## 4. 非功能需求
|
||||
|
||||
### NFR-001 安全
|
||||
|
||||
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计。
|
||||
- 默认拒绝、最小权限、租户隔离、身份可信链、SecretRef、审计、可撤销 Provenance 和 Runtime 结果绑定。
|
||||
|
||||
### NFR-002 可靠性
|
||||
|
||||
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、健康检查和回滚。
|
||||
- 幂等、revision、worker lease、heartbeat、重试边界、原子持久化、Dispatch 恢复、健康检查和回滚。
|
||||
|
||||
### NFR-003 可维护性
|
||||
|
||||
@@ -203,28 +293,36 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
|
||||
### NFR-004 可扩展性
|
||||
|
||||
- Store、Transport、Policy、EventPublisher、Runtime Adapter 均通过接口扩展。
|
||||
- Store、Transport、Policy、EventPublisher、Runtime Adapter、Provenance Verifier 与 Writer 均通过接口扩展。
|
||||
|
||||
### NFR-005 可运维性
|
||||
|
||||
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告。
|
||||
- Web/API 状态、systemd/Docker、健康检查、日志、GitOps 状态、候选报告、Grant 状态和 Receipt。
|
||||
|
||||
### NFR-006 性能候选指标
|
||||
|
||||
候选版上线前至少验证:
|
||||
|
||||
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间。
|
||||
- API P95、任务调度延迟、队列等待、Worker 心跳、SQLite 锁等待、审计写入时间、Grant 授权延迟和 Dispatch 恢复时间。
|
||||
- 具体 SLO 在预发布基准测试后固化,禁止无证据承诺。
|
||||
|
||||
## 5. 本候选版本范围
|
||||
|
||||
### 已纳入代码
|
||||
|
||||
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、部署和回滚。
|
||||
- Control Plane、持久化、Gateway、Orchestrator、OpenClaw Adapter、Hermes Boundary、Execution Tracking、Web Console、GitOps Status、Run Provenance、Least-Privilege Grant、Scoped Writer、Dispatch Repository、部署和回滚资产。
|
||||
|
||||
### 已纳入测试代码
|
||||
|
||||
- Control Plane/Runtime 候选流程。
|
||||
- OpenClaw Ed25519 签名与验证。
|
||||
- Hermes Worker Lease 与严格 Reconcile。
|
||||
- Dispatch SQLite 重启恢复。
|
||||
- Workflow/Grant 撤销、过期、重放和服务 Scope 隔离。
|
||||
|
||||
### 尚需环境验证
|
||||
|
||||
- 精确测试执行证据。
|
||||
- 完整 Acceptance 实际执行证据。
|
||||
- OpenClaw 真实 Runtime 联调。
|
||||
- 生产数据库/队列替换或容量验证。
|
||||
- 预发布性能、安全、重启和回滚演练。
|
||||
@@ -234,10 +332,11 @@ Git、日志、Web、任务 payload 和审计记录中不得出现明文 API Key
|
||||
|
||||
1. 平台管理员创建租户并注册 OpenClaw/Hermes Agent。
|
||||
2. 操作员创建任务,审批人批准。
|
||||
3. AIOS Orchestrator 选择 Runtime,并创建签名命令或租户分区 Job。
|
||||
4. Runtime 执行并返回结果。
|
||||
5. AIOS 更新 Task、Audit 和 Execution Evidence。
|
||||
6. 租户用户在 Web Console 查看完整链路。
|
||||
7. 跨租户访问被拒绝。
|
||||
8. 服务重启后状态恢复。
|
||||
9. 候选部署健康检查通过,回滚演练成功。
|
||||
3. Owner 或受控签发端签发 Workflow Provenance 与所需最小权限 Grant。
|
||||
4. AIOS Orchestrator 持久化 Dispatch,并创建 Ed25519 命令或租户分区 Job。
|
||||
5. Runtime 执行并返回与 Dispatch 完全匹配的结果。
|
||||
6. AIOS 更新 Task、Audit、Authorization Receipt 和 Execution Evidence。
|
||||
7. 租户用户在 Web Console 查看完整链路。
|
||||
8. 跨租户、跨 Principal、跨 Grant Path 和重放请求被拒绝。
|
||||
9. 服务重启后 Control Plane、Dispatch 和 Provenance 状态恢复。
|
||||
10. 候选部署健康检查、Acceptance 和回滚演练实际执行并保存证据。
|
||||
|
||||
+286
-180
@@ -1,29 +1,37 @@
|
||||
# AIOS V2 模块契约
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 状态:候选实现契约基线
|
||||
- 原则:Control Plane 权威、Runtime 执行、全链路租户隔离、所有状态可审计
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:加固候选契约基线
|
||||
- 原则:Control Plane 权威、Runtime 仅执行、默认拒绝、服务级最小权限、全链路租户隔离、所有权威状态可审计和恢复
|
||||
|
||||
## 1. 通用上下文契约
|
||||
核心模型名称与实现保持逐字同步:`RequestContext` 承载可信租户、主体、角色、trace、
|
||||
session 与 policy version;`TaskState` 是 AIOS 权威 Task 生命周期枚举,Runtime Worker
|
||||
不得直接写入或复制该状态机。
|
||||
|
||||
## 1. 通用可信上下文契约
|
||||
|
||||
所有控制与执行请求必须携带以下上下文:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---:|---|
|
||||
| `tenant_id` | 是 | 权威租户标识 |
|
||||
| `principal_id` | 控制请求是 | 已认证主体 |
|
||||
| `principal_id` | 控制和 Reconcile 是 | 已认证主体 |
|
||||
| `trace_id` | 是 | 全链路追踪 |
|
||||
| `task_id` | 执行阶段是 | AIOS 权威任务 |
|
||||
| `session_id` | Web/交互请求建议 | 用户会话 |
|
||||
| `agent_id` | 执行和 Provenance 是 | 任务绑定 Agent |
|
||||
| `provenance_id` | 生产副作用操作是 | 父 Workflow Provenance |
|
||||
| `run_id` | 生产副作用操作是 | 本次执行 Run |
|
||||
| `dispatch_id` | Runtime Result 是 | 持久化 Dispatch 记录 |
|
||||
| `policy_version` | 控制请求是 | 策略版本 |
|
||||
| `idempotency_key` | 创建/执行是 | 幂等键 |
|
||||
|
||||
规则:
|
||||
|
||||
- 缺少必填字段时 fail closed。
|
||||
- 客户端不得覆盖由可信代理或 AIOS 生成的 `tenant_id`。
|
||||
- Runtime 返回的 tenant/task/trace 必须与请求完全匹配。
|
||||
- 所有持久化键、缓存键和队列分区必须包含租户命名空间。
|
||||
- 客户端不得覆盖可信代理或 AIOS 生成的租户、身份和追踪字段。
|
||||
- Runtime 返回的 tenant/task/trace/runtime execution 必须与持久 Dispatch 完全匹配。
|
||||
- Runtime Result 还必须匹配 Principal、Parent Provenance 和 Run。
|
||||
- 所有持久化键、缓存键、队列分区、Receipt 和 Evidence 路径必须包含租户命名空间。
|
||||
|
||||
## 2. Tenant Contract
|
||||
|
||||
@@ -41,9 +49,9 @@ updated_at
|
||||
约束:
|
||||
|
||||
- `tenant_id` 不可为空且不可变。
|
||||
- `status` 至少支持 `active` 和受控停用状态。
|
||||
- Tenant 只由 AIOS Control Plane 创建和维护。
|
||||
- Runtime 不得创建租户。
|
||||
- Runtime 不得创建或修改租户。
|
||||
- 跨租户读取默认返回拒绝或资源不存在,不得泄露存在性。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/control_plane.py`
|
||||
|
||||
@@ -68,8 +76,7 @@ updated_at
|
||||
- Agent 属于唯一租户。
|
||||
- `runtime` 由 Registry 决定,可取 `openclaw`、`hermes` 或后续注册 Runtime。
|
||||
- `capabilities` 是允许调度的动作集合。
|
||||
- `metadata` 可存 `cell_id`、`profile_id`、priority 等非敏感引用。
|
||||
- 不得在 Agent metadata 中保存明文凭据。
|
||||
- `metadata` 仅保存非敏感引用,不得保存明文凭据。
|
||||
|
||||
## 4. Task Contract
|
||||
|
||||
@@ -93,7 +100,7 @@ created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
### 4.2 状态机
|
||||
### 4.2 权威状态机
|
||||
|
||||
```text
|
||||
CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
@@ -104,22 +111,22 @@ CREATED -> APPROVED -> RUNNING -> COMPLETED
|
||||
|
||||
允许转换:
|
||||
|
||||
| 当前 | 允许目标 |
|
||||
|---|---|
|
||||
| CREATED | APPROVED, CANCELLED |
|
||||
| APPROVED | RUNNING, CANCELLED |
|
||||
| RUNNING | COMPLETED, FAILED, CANCELLED |
|
||||
| COMPLETED | 无 |
|
||||
| FAILED | 无 |
|
||||
| CANCELLED | 无 |
|
||||
| 当前 | 允许目标 | 权威入口 |
|
||||
|---|---|---|
|
||||
| CREATED | APPROVED, CANCELLED | Control Plane Approval/Cancel |
|
||||
| APPROVED | RUNNING, CANCELLED | Orchestrator Dispatch / Control Plane Cancel |
|
||||
| RUNNING | COMPLETED, FAILED, CANCELLED | Orchestrator Reconcile / Control Plane Cancel |
|
||||
| COMPLETED | 无 | 终态 |
|
||||
| FAILED | 无 | 终态 |
|
||||
| CANCELLED | 无 | 终态 |
|
||||
|
||||
约束:
|
||||
|
||||
- 每次转换必须校验 `revision`。
|
||||
- COMPLETED 必须带 `result`。
|
||||
- FAILED 必须带 `error`。
|
||||
- COMPLETED 必须带 `result`;FAILED 必须带 `error`。
|
||||
- 每次转换生成 AuditEvent。
|
||||
- Runtime 不得直接写 Task Store,只能通过 Orchestrator reconcile。
|
||||
- Runtime 不得直接写 Task Store。
|
||||
- 公共 API 不得直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`。
|
||||
|
||||
## 5. Policy Contract
|
||||
|
||||
@@ -158,17 +165,166 @@ created_at
|
||||
|
||||
约束:
|
||||
|
||||
- 按租户查询。
|
||||
- `details` 不得包含明文凭据。
|
||||
- OpenClaw/Hermes 生命周期事件必须映射到统一 AuditEvent。
|
||||
- 按租户写入和查询。
|
||||
- `details` 不得包含明文凭据、私钥或完整敏感 Payload。
|
||||
- Runtime callback 可以生成非权威审计事件,但不得单独触发 Task 状态变化。
|
||||
- Task 状态变化必须经过 Orchestrator 严格 Reconcile。
|
||||
- 生产版需增加不可变外部固化和完整性验证。
|
||||
|
||||
## 7. OpenClaw Contract
|
||||
## 7. Workflow Provenance Contract
|
||||
|
||||
### 7.1 OpenClawCommand
|
||||
### 7.1 RunProvenance
|
||||
|
||||
父 Workflow Provenance 只绑定身份与 Run,不授予操作权限:
|
||||
|
||||
```text
|
||||
contract_version
|
||||
provenance_id
|
||||
run_id
|
||||
issuer
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
allowed_paths = []
|
||||
allowed_git_operations = []
|
||||
issued_at
|
||||
expires_at
|
||||
policy_version
|
||||
key_id
|
||||
nonce
|
||||
signature
|
||||
schema_version
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 使用 Ed25519 签名。
|
||||
- `allowed_paths` 和 `allowed_git_operations` 必须为空。
|
||||
- 撤销或过期父 Workflow 后,全部子 Grant 自动失效。
|
||||
- 私钥只存在签发端,生产应用只读取公钥。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/provenance.py`
|
||||
|
||||
## 8. Provenance Grant Contract
|
||||
|
||||
### 8.1 ProvenanceGrant
|
||||
|
||||
```text
|
||||
grant_id
|
||||
parent_provenance_id
|
||||
grant_type
|
||||
execution_path
|
||||
run_id
|
||||
issuer
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
allowed_git_operations
|
||||
issued_at
|
||||
expires_at
|
||||
policy_version
|
||||
key_id
|
||||
nonce
|
||||
signature
|
||||
schema_version
|
||||
```
|
||||
|
||||
Grant 类型与唯一允许路径:
|
||||
|
||||
| GrantType | ExecutionPath |
|
||||
|---|---|
|
||||
| `code_change` | `code_change` |
|
||||
| `git` | `git_operation` |
|
||||
| `deployment` | `deployment` |
|
||||
| `runtime` | `runtime_execute` |
|
||||
| `evidence` | `evidence_write` |
|
||||
| `rollback` | `rollback` |
|
||||
| `audit` | `audit` |
|
||||
|
||||
约束:
|
||||
|
||||
- 一个 Grant 只允许一个路径。
|
||||
- Git Grant 必须显式声明具体 GitOperation;非 Git Grant 不得包含 Git 操作。
|
||||
- 子 Grant 身份字段必须与父 Workflow 完全一致。
|
||||
- 子 Grant TTL 不得超过父 Workflow。
|
||||
- `(grant_id, path, git_operation, request_digest)` 重复使用时拒绝。
|
||||
- Grant 支持独立 `issued/verified/used/expired/revoked` 生命周期。
|
||||
|
||||
实现:
|
||||
|
||||
- `active-runtime/app/aios_v2/provenance_grants.py`
|
||||
- `active-runtime/app/aios_v2/provenance_scoped.py`
|
||||
|
||||
## 9. Service Scoped Writer Contract
|
||||
|
||||
生产进程只加载职责所需 Grant:
|
||||
|
||||
- AIOS Application:`runtime_execute`、`evidence_write`、`audit`。
|
||||
- Git Worker:`git_operation`,代码提交场景另需独立 `code_change` Grant。
|
||||
- Deployment Worker:`deployment`。
|
||||
- Rollback Worker:`rollback`。
|
||||
- CLI 单次授权:一个 `--path` + 一个 `--grant-id`。
|
||||
|
||||
约束:
|
||||
|
||||
- 空 Scope 拒绝。
|
||||
- 多余 Grant 映射拒绝。
|
||||
- 未配置路径的方法调用拒绝。
|
||||
- Receipt 必须记录 `grant_id`、`writer_id` 和服务 Scope。
|
||||
|
||||
## 10. Dispatch Contract
|
||||
|
||||
### 10.1 DispatchRecord
|
||||
|
||||
```text
|
||||
dispatch_id
|
||||
tenant_id
|
||||
task_id
|
||||
agent_id
|
||||
principal_id
|
||||
runtime
|
||||
runtime_execution_id
|
||||
trace_id
|
||||
provenance_id
|
||||
run_id
|
||||
authorization_id
|
||||
state
|
||||
expected_worker_id
|
||||
result_digest
|
||||
created_at
|
||||
updated_at
|
||||
completed_at
|
||||
```
|
||||
|
||||
状态:
|
||||
|
||||
```text
|
||||
PREPARED
|
||||
RUNNING
|
||||
QUEUED
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- Dispatch 必须在 Runtime 副作用之前持久化。
|
||||
- 同一租户的 Task 和 Runtime Execution 各只能绑定一个 Dispatch。
|
||||
- 终态 Dispatch 不得跳转到不同终态。
|
||||
- 相同终态结果允许幂等读取,不同结果摘要拒绝。
|
||||
- SQLite Repository 必须支持按 Task 和 Runtime Execution 恢复。
|
||||
|
||||
实现:`active-runtime/app/aios_v2/dispatch_store.py`
|
||||
|
||||
## 11. OpenClaw Contract
|
||||
|
||||
### 11.1 OpenClawCommand v2
|
||||
|
||||
```text
|
||||
contract_version = aios.openclaw.command.v2
|
||||
signature_algorithm = Ed25519
|
||||
command_id
|
||||
tenant_id
|
||||
task_id
|
||||
@@ -179,29 +335,24 @@ idempotency_key
|
||||
created_at
|
||||
expires_at
|
||||
key_id
|
||||
public_key_fingerprint
|
||||
signature
|
||||
```
|
||||
|
||||
签名覆盖:
|
||||
|
||||
- command_id
|
||||
- tenant_id
|
||||
- task_id
|
||||
- trace_id
|
||||
- action
|
||||
- payload
|
||||
- idempotency_key
|
||||
- created_at / expires_at
|
||||
- contract_version / key_id
|
||||
签名覆盖全部未签名字段,包括算法、Key ID 和公钥 Fingerprint。
|
||||
|
||||
约束:
|
||||
|
||||
- 使用规范化 JSON 和 HMAC-SHA256。
|
||||
- 只接受已批准任务生成的命令。
|
||||
- 过期、未来时间超差、签名错误命令必须拒绝。
|
||||
- `key_id` 只引用运行时材料,不包含材料本身。
|
||||
- 使用规范化 JSON 与 Ed25519。
|
||||
- HMAC-SHA256 不再是生产命令合同。
|
||||
- 只接受由已批准任务和已授权 Dispatch 生成的命令。
|
||||
- 过期、未来时间超差、签名错误、Key ID 或 Fingerprint 不匹配时拒绝。
|
||||
- Fingerprint 使用恒定时间比较。
|
||||
- `key_id` 只引用密钥,不包含密钥材料。
|
||||
- Candidate 组合层必须把 `AIOS_OPENCLAW_SIGNING_KEY_ID` 原样传入 Orchestrator,
|
||||
不得在已有批准 Key ID 时回落为默认值。
|
||||
|
||||
### 7.2 OpenClawResult
|
||||
### 11.2 OpenClawResult
|
||||
|
||||
```text
|
||||
command_id
|
||||
@@ -217,49 +368,30 @@ runtime_id
|
||||
attempts
|
||||
```
|
||||
|
||||
状态:
|
||||
|
||||
```text
|
||||
ACCEPTED
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
TIMED_OUT
|
||||
REJECTED
|
||||
```
|
||||
终态:`COMPLETED`、`FAILED`、`CANCELLED`、`TIMED_OUT`、`REJECTED`。
|
||||
|
||||
约束:
|
||||
|
||||
- Adapter 对外只接受终态结果用于 reconcile。
|
||||
- 身份字段不匹配时拒绝回写。
|
||||
- 结果通过 Orchestrator 更新 AIOS Task。
|
||||
|
||||
### 7.3 RuntimeHealth
|
||||
|
||||
```text
|
||||
runtime
|
||||
runtime_id
|
||||
status
|
||||
version
|
||||
heartbeat_at
|
||||
active_tasks
|
||||
workspace_isolated
|
||||
credential_ref_configured
|
||||
details
|
||||
```
|
||||
|
||||
健康策略:
|
||||
|
||||
- heartbeat 不得超时。
|
||||
- workspace isolation 与 credential reference 必须符合策略。
|
||||
- 不安全 Runtime 应 quarantine,不得继续接收新任务。
|
||||
- Result 必须匹配持久 Dispatch 的 command、tenant、task、trace、principal、provenance 和 run。
|
||||
- 只有 Orchestrator 可以更新 AIOS Task。
|
||||
- callback 审计不是权威 Reconcile。
|
||||
|
||||
实现:`active-runtime/app/aios_openclaw/`
|
||||
|
||||
## 8. Hermes Contract
|
||||
## 12. Hermes Contract
|
||||
|
||||
### 8.1 HermesJob
|
||||
### 12.0 内部生命周期入口
|
||||
|
||||
- 路径:`/api/v2/internal/runtime/hermes/lifecycle/{claim,start,heartbeat,complete,fail,cancel,status}`。
|
||||
- `AIOS_HERMES_LIFECYCLE_API_ENABLED` 默认关闭;关闭时按不存在的路由 fail closed。
|
||||
- 仅可信反向代理注入且具备 `runtime` 或 `platform_admin` 角色的身份可调用。
|
||||
- 所有写请求必须携带并严格匹配 tenant、task、trace、principal、provenance、run、
|
||||
dispatch、job、cell、profile;Claim 还必须指定精确 job,不能从分区中误领其他 Job。
|
||||
- Worker 不得直接修改 Task Store。Complete、Fail 和 Cancel 产生的 HermesResult
|
||||
必须经持久 Dispatch 校验并由 `ExecutionOrchestrator.reconcile_hermes` 终态化。
|
||||
- 每次成功生命周期写入同时落盘 Hermes 状态、Audit/Event 和 Execution Evidence。
|
||||
|
||||
### 12.1 HermesJob
|
||||
|
||||
```text
|
||||
job_id
|
||||
@@ -280,24 +412,18 @@ created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
状态机:
|
||||
|
||||
```text
|
||||
QUEUED -> CLAIMED -> RUNNING -> COMPLETED
|
||||
| | |
|
||||
+---------+----------+-> CANCELLED
|
||||
+-> FAILED
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `tenant_id + cell_id + profile_id` 形成执行分区。
|
||||
- claim 只能领取同分区 Job。
|
||||
- Worker ID 不得跨分区复用。
|
||||
- Lease 到期 Job 回到 QUEUED。
|
||||
- Hermes Store 使用 `AIOS_V2_STATE_DIR/hermes.sqlite3` 保存 Job、Result、Lease、
|
||||
Idempotency 和 Worker heartbeat;Adapter 仍是唯一 Queue/Lease 状态机。
|
||||
- Lease 到期重排必须清空 `assigned_worker_id`、清空 `lease_expires_at`、递增 revision
|
||||
并持久化。重启后由显式 lifecycle status/claim 或健康检查安全触发重排。
|
||||
- revision 冲突不得覆盖。
|
||||
|
||||
### 8.2 HermesResult
|
||||
### 12.2 HermesResult
|
||||
|
||||
```text
|
||||
job_id
|
||||
@@ -314,30 +440,19 @@ finished_at
|
||||
|
||||
约束:
|
||||
|
||||
- COMPLETED 必须有 output。
|
||||
- FAILED 必须有 error。
|
||||
- 只允许 AIOS Orchestrator reconcile。
|
||||
|
||||
### 8.3 HermesHealth
|
||||
|
||||
```text
|
||||
runtime_id
|
||||
status
|
||||
version
|
||||
heartbeat_at
|
||||
active_workers
|
||||
active_jobs
|
||||
queued_jobs
|
||||
tenant_partitions
|
||||
profile_isolation_enforced
|
||||
details
|
||||
```
|
||||
- 终态 Result 必须携带 Worker ID。
|
||||
- Worker 必须持有 Job Lease。
|
||||
- Result 必须匹配 Dispatch、Principal、Parent Provenance 和 Run。
|
||||
- 相同 Worker、相同终态和相同 output/error 的重复提交返回原 Result;不同终态或
|
||||
不同结果内容返回 Conflict,不得覆盖已持久化终态。
|
||||
- 只允许内部入口 `POST /api/v2/internal/runtime/hermes/reconcile` 处理。
|
||||
- 旧入口 `POST /api/v2/hermes/results/reconcile` 返回 `410 Gone`。
|
||||
|
||||
实现:`active-runtime/app/aios_hermes/`
|
||||
|
||||
## 9. Execution Evidence Contract
|
||||
## 13. Execution Evidence Contract
|
||||
|
||||
### 9.1 ExecutionEvidence
|
||||
### 13.1 ExecutionEvidence
|
||||
|
||||
```text
|
||||
execution_id
|
||||
@@ -357,53 +472,37 @@ result_summary
|
||||
claimed_at
|
||||
heartbeat_at
|
||||
completed_at
|
||||
```
|
||||
|
||||
状态建议:
|
||||
|
||||
```text
|
||||
claimed
|
||||
running
|
||||
blocked
|
||||
complete
|
||||
failed
|
||||
cancelled
|
||||
provenance_id
|
||||
run_id
|
||||
last_authorization_id
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 按租户存储和查询。
|
||||
- 每个阻塞必须记录原因、责任方和解除条件。
|
||||
- 每次结果必须记录 branch、commit、测试命令和状态。
|
||||
- 不得包含 Secret 或完整敏感 payload。
|
||||
- 生产 `claim/heartbeat/complete` 落盘前必须通过 Evidence Grant。
|
||||
- Evidence ID 不得被不同 Task、Agent 或 Provenance Context 复用。
|
||||
- 不得包含 Secret 或完整敏感 Payload。
|
||||
|
||||
## 10. GitOps Status Contract
|
||||
实现:`active-runtime/app/aios_v2/execution_tracking.py`
|
||||
|
||||
```text
|
||||
available
|
||||
service_status
|
||||
workspace_mode
|
||||
workspace_branch
|
||||
workspace_ref
|
||||
workspace_dirty
|
||||
candidate_branch
|
||||
release_channel
|
||||
release_version
|
||||
sync_status
|
||||
details
|
||||
```
|
||||
## 14. Git Writer Contract
|
||||
|
||||
允许操作:`READ_REF`、`FETCH`、`CREATE_BRANCH`、`COMMIT`、`MERGE`、`PUSH`、`UPDATE_EVIDENCE`,具体能力由 Git Grant 限定。
|
||||
|
||||
约束:
|
||||
|
||||
- Web 响应不得包含 remote URL、凭据或 stderr。
|
||||
- Controller 必须 `detached + clean`。
|
||||
- `sync_status` 必须区分 local 和 Gitea 镜像状态。
|
||||
- 禁止直接操作 `main` 或 `master`。
|
||||
- 所有命令使用参数数组,禁止 Shell 字符串拼接。
|
||||
- Commit 和 Evidence Commit 必须先计算文件内容摘要并完成 Grant 授权,再执行 `git add`。
|
||||
- `UPDATE_EVIDENCE` 只允许 `.agent/` 路径。
|
||||
- 输出必须脱敏并截断。
|
||||
|
||||
## 11. Web/API Contract
|
||||
实现:`active-runtime/app/aios_v2/provenance_git.py`
|
||||
|
||||
### 11.1 身份头
|
||||
## 15. Web/API Contract
|
||||
|
||||
可信代理注入:
|
||||
### 15.1 可信身份头
|
||||
|
||||
```text
|
||||
X-AIOS-Authenticated
|
||||
@@ -413,14 +512,17 @@ X-AIOS-Roles
|
||||
X-AIOS-Trace-ID
|
||||
X-AIOS-Session-ID
|
||||
X-AIOS-Policy-Version
|
||||
X-AIOS-Provenance-ID
|
||||
X-AIOS-Run-ID
|
||||
```
|
||||
|
||||
生产约束:
|
||||
|
||||
- 代理必须删除客户端同名头后重新注入。
|
||||
- 可信代理必须删除客户端同名头后重新注入。
|
||||
- 应用不得直接暴露公网端口。
|
||||
- Header 与 Body 中重复的 Provenance/Run 值必须一致。
|
||||
|
||||
### 11.2 核心 API
|
||||
### 15.2 核心 API
|
||||
|
||||
```text
|
||||
GET /api/v2/health
|
||||
@@ -432,71 +534,75 @@ POST /api/v2/tasks
|
||||
GET /api/v2/tasks
|
||||
GET /api/v2/tasks/{id}
|
||||
POST /api/v2/tasks/{id}/approve
|
||||
POST /api/v2/tasks/{id}/start
|
||||
POST /api/v2/tasks/{id}/complete
|
||||
POST /api/v2/tasks/{id}/fail
|
||||
POST /api/v2/tasks/{id}/cancel
|
||||
POST /api/v2/tasks/{id}/dispatch
|
||||
GET /api/v2/audit
|
||||
GET /api/v2/runtime
|
||||
GET /api/v2/executions
|
||||
GET /api/v2/gitops
|
||||
POST /api/v2/hermes/results/reconcile
|
||||
POST /api/v2/internal/runtime/hermes/reconcile
|
||||
```
|
||||
|
||||
## 12. Persistence Contract
|
||||
公共生命周期旁路:
|
||||
|
||||
```text
|
||||
POST /api/v2/tasks/{id}/start -> 403
|
||||
POST /api/v2/tasks/{id}/complete -> 403
|
||||
POST /api/v2/tasks/{id}/fail -> 403
|
||||
```
|
||||
|
||||
## 16. Persistence Contract
|
||||
|
||||
候选版:
|
||||
|
||||
- SQLite WAL
|
||||
- `BEGIN IMMEDIATE`
|
||||
- 原子 snapshot
|
||||
- schema version
|
||||
- restart restore
|
||||
|
||||
持久化范围:
|
||||
|
||||
- tenants
|
||||
- agents
|
||||
- tasks
|
||||
- audit
|
||||
- idempotency
|
||||
- Control Plane:SQLite Snapshot。
|
||||
- Dispatch:独立 SQLite Repository。
|
||||
- Provenance/Grant/Usage/Event:SQLite WAL。
|
||||
- Evidence/Receipt:租户隔离原子文件写入。
|
||||
- 写事务使用原子替换或 `BEGIN IMMEDIATE`。
|
||||
|
||||
生产替换要求:
|
||||
|
||||
- 不改变 Domain/API Contract。
|
||||
- 提供 migration、backup、restore、HA、RLS/隔离和容量测试。
|
||||
- 提供 migration、backup、restore、HA、隔离和容量测试。
|
||||
- 恢复时保留已使用 Grant 与终态 Dispatch,禁止旧授权重放。
|
||||
|
||||
## 13. Deployment Contract
|
||||
## 17. Deployment Contract
|
||||
|
||||
### systemd
|
||||
|
||||
- 非 root `aios` 用户。
|
||||
- 状态目录 `/var/lib/aios-v2`。
|
||||
- 最小权限和文件系统保护。
|
||||
- 私钥通过受保护 credential 注入,不写入 Git 或普通环境文件。
|
||||
- 私钥文件必须是 regular file、禁止 symlink、权限 `0600` 或更严格。
|
||||
|
||||
### Docker
|
||||
|
||||
- 非 root UID/GID。
|
||||
- read-only root filesystem。
|
||||
- drop all capabilities。
|
||||
- 独立状态 volume。
|
||||
- 独立状态 volume 和 Secret mount。
|
||||
|
||||
### Rollback
|
||||
|
||||
- 回滚前备份状态。
|
||||
- 默认只回滚代码。
|
||||
- 状态恢复需显式授权。
|
||||
- 回滚后必须执行 healthcheck。
|
||||
- 状态恢复需独立 Rollback Grant 和显式批准。
|
||||
- 回滚后必须执行 Healthcheck 与恢复验证。
|
||||
- Release Archive 必须保留 `rollback.sh` 与 `healthcheck/check.sh` 的 executable bit;
|
||||
验收测试必须阻止非执行模式进入候选包。
|
||||
- `systemctl start` 后的回滚健康检查必须执行有界 readiness 重试,默认最多 30 次、
|
||||
每次间隔 1 秒;不得因服务尚未监听的一次瞬时拒绝误判目标 Release 失败。
|
||||
|
||||
## 14. 契约变更规则
|
||||
## 18. 契约变更规则
|
||||
|
||||
任何以下变化必须在同一提交或同一分支更新本文档:
|
||||
以下变化必须在同一分支更新本文档、追踪矩阵和相关 ADR:
|
||||
|
||||
- 字段、状态、Header、API、错误语义;
|
||||
- 租户或安全约束;
|
||||
- 租户、安全和 Provenance 约束;
|
||||
- OpenClaw/Hermes 边界;
|
||||
- Persistence 或 Deployment Contract;
|
||||
- Evidence/Audit 格式。
|
||||
- Dispatch、Persistence、Deployment、Rollback Contract;
|
||||
- Evidence、Audit、Receipt 格式;
|
||||
- 服务 Scope 或 Grant 类型。
|
||||
|
||||
不兼容变化必须新增 ADR 和版本号,不得静默修改。
|
||||
不兼容变化必须新增 ADR 和版本号,不得静默修改。正式 Gate A 结论必须基于实际执行 Acceptance 和保存的证据,不得仅凭测试文件存在宣布通过。
|
||||
|
||||
@@ -1,113 +1,79 @@
|
||||
# AIOS V2 需求—架构—代码—测试—部署追踪矩阵
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 目的:确保每项用户需求都能追踪到架构设计、代码实现、自动测试、部署资产和验收命令
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 状态:实现与测试代码已提交;测试执行和 Acceptance 证据待完成
|
||||
- 基线需求:`UR-001`~`UR-022`
|
||||
- 判定规则:测试文件存在不等于测试通过;只有实际执行并保存证据后,状态才能改为“已验证”
|
||||
|
||||
## 1. 追踪规则
|
||||
## 1. 状态定义
|
||||
|
||||
每条需求至少具备:
|
||||
| 状态 | 含义 |
|
||||
|---|---|
|
||||
| 已实现 | 代码、配置或文档已经提交到当前加固分支 |
|
||||
| 测试已提交 | 自动测试代码已经提交,但本报告没有实际执行结果 |
|
||||
| 待验收 | 需要运行 `deploy/aios-v2/acceptance/run-all.sh` 或对应预发布检查并保存证据 |
|
||||
| 未批准 | 尚未获得 Owner 的 Merge、Tag 或生产部署批准 |
|
||||
|
||||
1. 需求编号;
|
||||
2. 架构章节;
|
||||
3. 实现路径;
|
||||
4. 测试路径;
|
||||
5. 部署/运维资产;
|
||||
6. 验收状态。
|
||||
## 2. 用户需求追踪
|
||||
|
||||
状态定义:
|
||||
|
||||
- `Implemented`:代码已进入 Candidate 分支;
|
||||
- `Test-defined`:测试代码已进入 Candidate 分支;
|
||||
- `Test-verified`:精确 commit 的测试已运行并保存证据;
|
||||
- `Pre-production verified`:预发布部署、健康检查和回滚演练已通过;
|
||||
- `Production released`:Owner 批准并完成 merge/tag/deploy。
|
||||
|
||||
## 2. 核心追踪矩阵
|
||||
|
||||
| 需求 | 设计依据 | 代码实现 | 测试 | 部署/运维 | 当前状态 |
|
||||
| 需求 | 架构/决策 | 主要实现 | 测试代码 | 部署/验收入口 | 当前状态 |
|
||||
|---|---|---|---|---|---|
|
||||
| UR-001 AIOS 唯一控制面 | `02-TARGET-ARCHITECTURE.md` §3.2/§3.3;ADR-001 | `aios_v2/control_plane.py`、`aios_v2/orchestrator.py`、`aios_v2/app.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/systemd/aios-v2.service` | Implemented / Test-defined |
|
||||
| UR-002 复用现有能力 | `02-TARGET-ARCHITECTURE.md` §2;`03-MIGRATION-AND-DELIVERY.md` §2 | `aios_openclaw/`、`aios_hermes/` | OpenClaw/Hermes flow tests | Candidate branch + adapter boundaries | Implemented / Test-defined |
|
||||
| UR-003 OpenClaw/Hermes 解耦 | `02-TARGET-ARCHITECTURE.md` §3.4/§3.5;ADR-002 | `aios_openclaw/adapter.py`、`aios_hermes/adapter.py` | runtime boundary tests in candidate flow suite | systemd/Docker runtime configuration | Implemented / Test-defined |
|
||||
| UR-004 全面 Web 化 | `02-TARGET-ARCHITECTURE.md` §3.1/§8 | `aios_v2/console.py`、`aios_v2/gateway.py`、`aios_v2/app.py` | API/console smoke test pending | Docker/systemd + reverse proxy requirement | Implemented / Test pending |
|
||||
| UR-005 多租户隔离 | `02-TARGET-ARCHITECTURE.md` §4;ADR-004 | `control_plane.py`、`orchestrator.py`、`aios_openclaw/*`、`aios_hermes/*`、`execution_tracking.py` | `test_cross_tenant_task_read_is_denied` | tenant-scoped state/evidence directories | Implemented / Test-defined |
|
||||
| UR-006 权威任务生命周期 | `04-MODULE-CONTRACTS.md` §4 | `control_plane.py` | OpenClaw/Hermes full-flow tests | persisted state + audit | Implemented / Test-defined |
|
||||
| UR-007 策略与审批 | `04-MODULE-CONTRACTS.md` §5 | `control_plane.py`、`gateway.py` | role/policy negative tests需补充 | reverse proxy identity + audit | Implemented / Partial test |
|
||||
| UR-008 幂等与并发安全 | `02-TARGET-ARCHITECTURE.md` §7 | `control_plane.py`、`aios_openclaw/adapter.py`、`aios_hermes/adapter.py`、`worker.py` | flow + restart tests;并发专项测试需补充 | GitOps detached guard + SQLite WAL | Implemented / Partial test |
|
||||
| UR-009 执行证据 | `04-MODULE-CONTRACTS.md` §9 | `execution_tracking.py` | `test_execution_tracking_persists_claim_heartbeat_and_result` | `/var/lib/aios-v2/executions` | Implemented / Test-defined |
|
||||
| UR-010 GitOps 唯一变更流程 | `03-MIGRATION-AND-DELIVERY.md` §4/§5;ADR-003 | GitOps Runtime、`gitops_status.py` | healthcheck 的 detached/clean 检查 | `deploy/aios-v2/healthcheck/check.sh` | Implemented / Runtime verified partially |
|
||||
| UR-011 持久化与恢复 | `02-TARGET-ARCHITECTURE.md` §3.6 | `persistence.py` | `test_durable_service_recovers_state_after_restart` | state dir + backup/rollback | Implemented / Test-defined |
|
||||
| UR-012 审计与合规 | `04-MODULE-CONTRACTS.md` §6 | `control_plane.py`、`aios_openclaw/callback.py`、`app.py` | flow test + audit assertions需补充 | Console audit view | Implemented / Partial test |
|
||||
| UR-013 密钥安全 | `02-TARGET-ARCHITECTURE.md` §6;ADR-006 | `FileSigningMaterialResolver`、credential refs、Secret Gate | permission/secret-scan test需补充 | env template + external protected file | Implemented / Partial test |
|
||||
| UR-014 部署与回滚 | `03-MIGRATION-AND-DELIVERY.md` §7 | deployment scripts/assets | acceptance runner + healthcheck | `deploy/aios-v2/` | Implemented / Pre-prod pending |
|
||||
| UR-015 文档与代码同步 | `03-MIGRATION-AND-DELIVERY.md` §9;ADR-005 | `tools/aios_v2_docs_code_check.py` | checker self-test / acceptance runner | CI/Gitea Actions 可调用 | Implemented after checker commit |
|
||||
| UR-001 唯一控制面 | ADR-001;模块契约第 2~6 节 | `active-runtime/app/aios_v2/control_plane.py`、`gateway.py`、`orchestrator.py` | `tests/aios_v2_candidate/test_candidate_flow.py` | `deploy/aios-v2/acceptance/run-all.sh`;公共生命周期旁路负向检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-002 复用现有能力 | ADR-002 | `active-runtime/app/aios_openclaw/`、`active-runtime/app/aios_hermes/`、`active-runtime/app/aios_v2/orchestrator.py` | `test_candidate_flow.py` | OpenClaw/Hermes 联调 | 已实现;真实 Runtime 联调待验收 |
|
||||
| UR-003 OpenClaw/Hermes 解耦 | ADR-002、ADR-010;模块契约第 11、12 节 | OpenClaw Adapter、Hermes Adapter/Worker、内部 Lifecycle/Reconcile | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | 默认关闭的内部 Runtime API;Runtime audit/evidence 检查 | 已实现;Hermes 专项 17/17 |
|
||||
| UR-004 全面 Web 化 | 目标架构;模块契约第 15 节 | `active-runtime/app/aios_v2/app.py`、`console.py`、`gateway.py` | `test_candidate_flow.py` | Web/API 冒烟、Healthcheck | 已实现;UI/浏览器验收待执行 |
|
||||
| UR-005 多租户隔离 | ADR-004 | `control_plane.py`、`persistence.py`、`execution_tracking.py`、`dispatch_store.py` | `test_candidate_flow.py::test_cross_tenant_task_read_is_denied` | 跨租户负向 Acceptance | 已实现;测试已提交;待验收 |
|
||||
| UR-006 权威任务生命周期 | ADR-001;模块契约第 4 节 | `control_plane.py`、`gateway.py`、`orchestrator.py` | `test_candidate_flow.py` | `/start`、`/complete`、`/fail` 应返回拒绝;Runtime 严格 Reconcile | 已实现;测试/接口验收待执行 |
|
||||
| UR-007 策略与审批 | ADR-001 | `control_plane.py`、`gateway.py` | `test_candidate_flow.py` | Approval、deny audit、policy version 检查 | 已实现;策略场景验收待执行 |
|
||||
| UR-008 幂等与并发安全 | 模块契约第 4、10、12 节 | Task idempotency/revision、Hermes lease/revision、Dispatch 唯一约束 | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | Lease 到期重排、换主、相同终态幂等、不同终态冲突 | 已实现;专项测试通过 |
|
||||
| UR-009 可观测与执行证据 | 模块契约第 6、13 节 | `execution_tracking.py`、`gitops_status.py`、`console.py` | `test_candidate_flow.py::test_execution_tracking_persists_claim_heartbeat_and_result` | `/api/v2/executions`、Evidence Receipt 检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-010 GitOps 唯一变更流程 | ADR-003 | `active-runtime/app/aios_v2/provenance_git.py`、GitOps 工具与任务资产 | `test_provenance.py`、Grant 隔离测试 | Branch/Commit/Push 门禁、Controller clean、OID 同步 | 已实现候选控制;环境 GitOps 验收待执行 |
|
||||
| UR-011 持久化与恢复 | ADR-006、ADR-010;模块契约第 10、12、16 节 | `persistence.py`、`dispatch_store.py`、`aios_hermes/adapter.py::SQLiteHermesStore`、Provenance/Grant Store | `test_candidate_flow.py`、`test_hermes_live_lifecycle.py` | 运行中 Job 重启恢复、重启后 Reconcile、SQLite quick_check | Hermes/Dispatch/Task 恢复测试通过;生产 HA/容量待验收 |
|
||||
| UR-012 审计与合规 | ADR-001、ADR-004、ADR-009 | `control_plane.py` AuditEvent、`app.py` callback audit、Provenance/Grant Event/Receipt | `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` | `/api/v2/audit`、Receipt/Event 完整性检查 | 已实现候选审计;不可变外部固化待预发布 |
|
||||
| UR-013 密钥安全 | ADR-009;模块契约第 6、11、17 节 | Ed25519 Signer/Verifier、protected file reader、SecretRef 配置 | `test_provenance.py`、`test_candidate_flow.py` | Secret scanner、权限检查、容器/systemd credential 检查 | 已实现;环境密钥注入验收待执行 |
|
||||
| UR-014 部署与回滚 | 迁移与交付文档;模块契约第 17 节 | `deploy/aios-v2/systemd/`、`docker/`、`healthcheck/`、`rollback/` | Acceptance 脚本与候选测试 | systemd/Docker 安装、Healthcheck、备份与回滚演练 | 资产已提交;实际部署/回滚未执行 |
|
||||
| UR-015 文档与代码同步 | ADR-005 | `tools/aios_v2_docs_code_check.py`、`docs/aios-v2/` | 文档门禁脚本 | Acceptance Runner 调用文档门禁 | 已实现;门禁尚未实际执行 |
|
||||
| UR-016 Workflow Run Provenance | ADR-009;模块契约第 7 节 | `active-runtime/app/aios_v2/provenance.py`、`provenance_grants.py::WorkflowProvenanceIssuer` | `test_provenance.py`、`test_provenance_grant_isolation.py::test_parent_workflow_has_no_operational_rights` | CLI `issue-workflow`、状态/撤销检查 | 已实现;测试已提交;待验收 |
|
||||
| UR-017 最小权限子 Grant | ADR-009;模块契约第 8 节 | `provenance_grants.py`、`provenance_scoped.py` | `test_provenance_grant_isolation.py` | `issue-grant`、Grant status/revoke、Receipt 检查 | 已实现;专项测试已提交;待验收 |
|
||||
| UR-018 Runtime Result 信任绑定 | ADR-009、ADR-010;模块契约第 10~12 节 | `orchestrator.py`、`dispatch_store.py`、内部 Hermes Lifecycle/Reconcile | `test_hermes_live_lifecycle.py` | tenant/task/trace/principal/provenance/run/dispatch/job/cell/profile/worker 负向矩阵 | 已实现;专项 17/17 |
|
||||
| UR-019 Dispatch 持久化与恢复 | ADR-009、ADR-010;模块契约第 10、12 节 | `dispatch_store.py`、`orchestrator.py`、`app.py`、`SQLiteHermesStore` | Dispatch recovery 与 Hermes restart-reconcile 测试 | 进程重启后读取 Dispatch/Job 并完成 Reconcile | 已实现;测试通过 |
|
||||
| UR-020 OpenClaw Ed25519 命令合同 | ADR-009;模块契约第 11 节 | `active-runtime/app/aios_openclaw/contract.py`、`adapter.py`、`app.py` | `test_candidate_flow.py`、`test_provenance.py` | 真实 OpenClaw v2 合同联调、Key rotation、clock skew/expiry 负向验收 | 已实现;测试代码已升级;真实联调待执行 |
|
||||
| UR-021 服务级权限隔离 | ADR-009;模块契约第 9 节 | `provenance_scoped.py`、`provenance_operations.py`、`app.py` | `test_provenance_grant_isolation.py` | Application 仅加载 Runtime/Evidence/Audit;Git/Deploy/Rollback 单独 Scope | 已实现;专项测试已提交;配置验收待执行 |
|
||||
| UR-022 CLI 单 Grant 授权 | ADR-009;模块契约第 9 节 | `provenance_cli.py` | `test_provenance_grant_isolation.py` 提供 Writer/Grant 基础验证;CLI 端到端待补执行证据 | `authorize --path ... --grant-id ...`;旧批量 Grant 参数应不存在 | 已实现;CLI 端到端验收待执行 |
|
||||
|
||||
## 3. API 追踪
|
||||
## 3. P0 加固项追踪
|
||||
|
||||
| API | 需求 | 实现 | 测试状态 |
|
||||
|---|---|---|---|
|
||||
| `GET /api/v2/health` | UR-004, UR-014 | `gateway.py` | healthcheck 定义完成 |
|
||||
| `POST /api/v2/tenants` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | 需增加 HTTP 集成测试 |
|
||||
| `POST /api/v2/agents` | UR-001, UR-005 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks` | UR-006, UR-008 | `gateway.py` / `control_plane.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks/{id}/approve` | UR-006, UR-007 | `gateway.py` | Domain flow 已覆盖 |
|
||||
| `POST /api/v2/tasks/{id}/dispatch` | UR-001, UR-003 | `app.py` / `orchestrator.py` | OpenClaw/Hermes flow 已覆盖 |
|
||||
| `GET /api/v2/runtime` | UR-004, UR-009 | `app.py` | HTTP smoke test待执行 |
|
||||
| `GET /api/v2/executions` | UR-009 | `app.py` / `execution_tracking.py` | Evidence persistence test已定义 |
|
||||
| `GET /api/v2/gitops` | UR-010 | `app.py` / `gitops_status.py` | Runtime/healthcheck验证待执行 |
|
||||
| `POST /api/v2/hermes/results/reconcile` | UR-003, UR-006 | `app.py` / `orchestrator.py` | Hermes flow已覆盖 Domain 路径 |
|
||||
| P0 | 风险 | 实现 | 测试/证据 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| P0-1 公共生命周期旁路 | 客户端直接推进 RUNNING/COMPLETED/FAILED | `gateway.py` 公共入口拒绝;`orchestrator.py` 权威推进 | API 负向 Acceptance | 代码完成;待执行 |
|
||||
| P0-2 Dispatch 持久化 | 重启丢失 Runtime 绑定 | `dispatch_store.py` SQLite Repository;`app.py` 接线 | Dispatch restart recovery 测试 | 代码/测试已提交;待执行 |
|
||||
| P0-3 Result 严格绑定 | 伪造或错配 Runtime Result 更新 Task | `orchestrator.py` 校验 Runtime Execution、Tenant、Task、Trace、Principal、Provenance、Run | Runtime 正向恢复测试;负向 Acceptance | 代码完成;完整验收待执行 |
|
||||
| P0-4 OpenClaw Ed25519 | HMAC 共享密钥和合同不一致 | `aios_openclaw/contract.py` v2 Ed25519;安全文件读取 | 候选 Flow 测试;真实 Runtime 联调 | 代码/测试已提交;联调待执行 |
|
||||
| P0-5 最小权限 Grant | 单凭证拥有全部生产路径 | Workflow + Child Grant + Scoped Writer + 单 Grant CLI | Grant isolation 专项测试 | 代码/测试已提交;待执行 |
|
||||
|
||||
## 4. 运行时契约追踪
|
||||
## 4. Acceptance 证据要求
|
||||
|
||||
| 契约 | 设计 | 代码 | 测试 |
|
||||
|---|---|---|---|
|
||||
| OpenClawCommand | `04-MODULE-CONTRACTS.md` §7.1 | `aios_openclaw/contract.py` | OpenClaw full-flow;签名负向测试需补充 |
|
||||
| OpenClawResult | §7.2 | `aios_openclaw/contract.py` | OpenClaw full-flow |
|
||||
| RuntimeHealth | §7.3 | `aios_openclaw/contract.py`、`health.py` | health policy测试需补充 |
|
||||
| HermesJob | §8.1 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||
| HermesResult | §8.2 | `aios_hermes/contract.py` | Hermes full-flow |
|
||||
| HermesHealth | §8.3 | `aios_hermes/contract.py`、`adapter.py` | healthcheck/worker test待执行 |
|
||||
| ExecutionEvidence | §9 | `execution_tracking.py` | evidence persistence test |
|
||||
| GitOpsStatus | §10 | `gitops_status.py` | healthcheck detached/clean检查 |
|
||||
完整 Acceptance 至少应保存:
|
||||
|
||||
## 5. 部署追踪
|
||||
1. 测试命令、开始/结束时间、退出码和完整日志。
|
||||
2. `test_candidate_flow.py`、`test_provenance.py`、`test_provenance_grant_isolation.py` 的实际结果。
|
||||
3. 文档—代码同步门禁实际结果。
|
||||
4. Secret scanner、依赖和容器/systemd 安全检查结果。
|
||||
5. OpenClaw Ed25519 v2 真实联调结果。
|
||||
6. Hermes 内部 Reconcile 正向与伪造上下文负向结果。
|
||||
7. Control Plane、Dispatch、Provenance/Grant 的重启恢复结果。
|
||||
8. 部署、Healthcheck、备份和回滚演练结果。
|
||||
9. 分支、Commit、Controller clean、远端 OID 同步证据。
|
||||
10. Owner 对 Merge、Tag 和生产部署的独立批准。
|
||||
|
||||
| 能力 | 文件 | 验收命令 |
|
||||
## 5. 当前发布门禁
|
||||
|
||||
| Gate | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| systemd | `deploy/aios-v2/systemd/aios-v2.service` | `systemctl status aios-v2` |
|
||||
| 环境模板 | `deploy/aios-v2/env/aios-v2.env.example` | `systemd-analyze verify` + 文件权限检查 |
|
||||
| Docker | `deploy/aios-v2/docker/Dockerfile` | `docker build` |
|
||||
| Compose | `deploy/aios-v2/docker/compose.yaml` | `docker compose config` / `up` |
|
||||
| 健康检查 | `deploy/aios-v2/healthcheck/check.sh` | 直接执行 |
|
||||
| 回滚 | `deploy/aios-v2/rollback/rollback.sh` | 预发布回滚演练 |
|
||||
| 全量验收 | `deploy/aios-v2/acceptance/run-all.sh` | 直接执行 |
|
||||
|
||||
## 6. 文档覆盖追踪
|
||||
|
||||
| 文档 | 覆盖内容 | 代码/变更触发条件 |
|
||||
|---|---|---|
|
||||
| `README.md` | 事实源索引、状态语言、实现地图 | 新模块/目录/版本 |
|
||||
| `01-USER-REQUIREMENTS.md` | 用户需求、NFR、验收场景 | 用户行为、功能范围、验收变化 |
|
||||
| `02-TARGET-ARCHITECTURE.md` | 分层、边界、数据流、安全、租户 | 架构边界、数据流、运行时变化 |
|
||||
| `03-MIGRATION-AND-DELIVERY.md` | 迁移、GitOps、Gate、回滚 | 发布流程、迁移阶段、门禁变化 |
|
||||
| `04-MODULE-CONTRACTS.md` | 字段、API、状态机、错误语义 | Contract/API/模型变化 |
|
||||
| `05-TRACEABILITY-MATRIX.md` | 需求到代码测试部署映射 | 代码、测试、部署路径变化 |
|
||||
| `adr/*` | 关键不可逆或跨模块决策 | 新架构决策或旧决策替换 |
|
||||
|
||||
## 7. 当前未完成验证项
|
||||
|
||||
以下项目代码或测试定义已存在,但尚不能标记为 `Test-verified` 或 `Pre-production verified`:
|
||||
|
||||
1. 精确 Candidate commit 的测试执行结果;
|
||||
2. HTTP API 与 Web Console 冒烟测试;
|
||||
3. 真实 OpenClaw Runtime 签名、取消、超时和健康联调;
|
||||
4. Hermes 长任务心跳、Worker 崩溃和 Lease 恢复演练;
|
||||
5. 多实例 SQLite/并发容量边界;
|
||||
6. systemd、Docker、Compose、restart 和 rollback 演练;
|
||||
7. local/Gitea Candidate OID 一致证明;
|
||||
8. 生产身份代理、防伪 Header、CSRF/CORS/Rate Limit 验证;
|
||||
9. 文档—代码同步检查器在 CI/Acceptance 中执行通过。
|
||||
|
||||
## 8. 更新要求
|
||||
|
||||
任何代码、API、部署或测试路径变化,必须同步更新本矩阵。验收脚本会检查本文档引用的关键路径存在,并检查架构/功能代码变化是否同时包含 `docs/aios-v2/` 变化。
|
||||
| 实现 | 完成 | 五项 P0 代码与配置已提交当前加固分支 |
|
||||
| 测试代码 | 完成 | 回归、Dispatch recovery、Grant isolation 测试已提交 |
|
||||
| 测试执行 | 未完成 | 当前没有可验证的实际运行日志和退出码 |
|
||||
| 文档同步 | 已提交 | 需求、契约、ADR、追踪矩阵已更新;门禁尚未执行 |
|
||||
| Acceptance | 未完成 | `run-all.sh` 尚无本轮实际执行证据 |
|
||||
| Gate A | 未批准 | 只能在 Acceptance 通过并完成独立审查后批准 |
|
||||
| Merge/Tag/生产部署 | 未批准 | 需要 AIOS Owner 明确批准 |
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# AIOS V2R1 Hermes 生命周期最终验证报告
|
||||
|
||||
- 报告版本:`V2R1-hermes-lifecycle-20260721.3`
|
||||
- 日期:`2026-07-21`
|
||||
- 分支:`patch/chatgpt/AIOS-V2R1-HERMES-LIFECYCLE-20260721`
|
||||
- 固定验证 OID:`95d2e52cb55c7b913e89bc6bed2bcdacf67512d4`
|
||||
- 总体状态:`PARTIAL / BLOCKED_GLOBAL_WORKTREE_CLEANLINESS`
|
||||
- 发布决策:`STOP_PREPRODUCTION`
|
||||
- 生产发布:`NOT_APPROVED`
|
||||
|
||||
## 1. 执行结论
|
||||
|
||||
`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING` 的 Hermes 功能与证据子条件已在固定 OID 上全部通过。候选实现复用现有 Hermes Adapter、Queue、Lease、Task 与 Orchestrator 状态机,新增持久 SQLite Store 和默认关闭的受保护内部生命周期接口。Hermes 完整生命周期 `17/17`、候选测试 `49/49`、Gate A、Gate B `10/10`、离线 Acceptance `8/0/1`、真实 OpenClaw HTTP、重启恢复、双向回滚和最终健康检查均通过。
|
||||
|
||||
但 Owner 指令规定只有全部 Acceptance 条件同时满足时才允许正式解除该 Blocker。由于“所有 Worktree clean”失败,本报告不得把它标记为正式 `RESOLVED`;准确状态是 `EVIDENCE_COMPLETE / FORMAL_CLOSURE_BLOCKED`。
|
||||
|
||||
最终发布门禁仍失败:权威 Bare Repo 登记的 88 个 worktree 中有 10 个脏 worktree。候选 worktree 和活动 Controller worktree 均 clean,且无 locked/prunable;但 Owner 的验收条件要求所有 worktree clean,因此不得准备 RC、不得发起 Tag Approval Request、不得进入预生产评审。
|
||||
|
||||
## 2. 关键实现
|
||||
|
||||
- `SQLiteHermesStore` 持久保存 Job、Result、Idempotency 与 Worker Heartbeat。
|
||||
- 现有 `HermesAdapter` 保持唯一 Queue/Lease 状态机,支持指定 Job claim、租约到期 requeue、重启恢复与严格终态冲突。
|
||||
- 生命周期身份绑定覆盖 tenant、task、trace、principal、provenance、run、dispatch、job、cell、profile。
|
||||
- start/heartbeat/complete/fail 强制 Lease Owner;complete/fail/cancel 通过 `ExecutionOrchestrator.reconcile_hermes` 更新 Task 与 Dispatch。
|
||||
- cancel 仅允许同时具备 `runtime` 与 `platform_admin` 的 AIOS Control Plane,要求 `cancellation_source=aios-control-plane`,并拒绝携带 `worker_id`,Worker 只能观察取消。
|
||||
- 所有成功生命周期操作写入 Audit/Event/Execution Evidence;接口由 `AIOS_HERMES_LIFECYCLE_API_ENABLED` 控制,默认关闭。
|
||||
|
||||
## 3. 固定 OID 验证
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| Python syntax/import | PASS |
|
||||
| 全部 candidate tests | `49/49` |
|
||||
| Grant isolation | `11/11` |
|
||||
| Dispatch recovery | `1/1` |
|
||||
| Hermes lifecycle | `17/17` |
|
||||
| Docs code gate / Gate A | `0 errors / 0 warnings` |
|
||||
| Secret scan | `547 tracked files / 0 violations` |
|
||||
| SQLite quick_check | `2/2 ok` |
|
||||
| Gate B | `10/10` |
|
||||
| Offline Acceptance | `8 passed / 0 failed / 1 expected warning` |
|
||||
| OpenClaw real HTTP | `COMPLETED` |
|
||||
| Hermes full live rerun | `17/17` |
|
||||
| Restart recovery | `2/2` |
|
||||
| Previous → Current rollback/forward | PASS |
|
||||
| State preservation | `STATE_PRESERVED_EXACTLY=PASS` |
|
||||
| Corrected final health (`16R`) | PASS,三服务 active |
|
||||
|
||||
原 `16.log` 由非服务用户执行健康脚本,打印 state-dir 权限失败后被外层脚本错误覆盖为退出码 0,因此不作为通过证据。纠错后的 `16R.log` 使用受控 `sudo env`,子 shell 保留失败码,所有健康项实际 PASS 且 `EXIT_CODE=0`。原文件保留用于审计。
|
||||
|
||||
回滚日志中的短暂 connection-refused 发生于 systemd 重启窗口内,受 `AIOS_V2_ROLLBACK_HEALTH_RETRIES=30` 与 1 秒间隔限制;前后两次均在界限内达到健康、输出 `ROLLBACK_COMPLETE`,并完成状态文件精确哈希对账。
|
||||
|
||||
## 4. Evidence
|
||||
|
||||
- 根目录:`/var/lib/aios-gitops/acceptance/aios-v2r1-hermes-lifecycle-20260721T054205Z`
|
||||
- `steps.tsv` SHA256:`b2ad20585ba7f576e85657827a0b1db6f252887bbd73bcd718df38623e9d95cf`
|
||||
- `final-live-gate.log` SHA256:`82040c674759dacf587658c5e936c313a1f50e8e2425be48ad891927befdf4fa`
|
||||
- `16R.log` SHA256:`641246927a372ebaa9e6dde8b018b88275b2ba15a442473759b23fb1f215d9cf`
|
||||
- `15.log` SHA256:`b0a474e2a74164f560c4f7572091911f32011c75faccd37a3c7315046275011e`
|
||||
- `13.log` SHA256:`b9f637cca6e97ed893bf38530e05cd33b78e324c5e8865d4c602dfeac22a7710`
|
||||
- `12R2.log` SHA256:`0c431f5f5f1c1b17acda160c917b08c7f4036485016c23e1236b9b6f4a78de67`
|
||||
|
||||
证据只记录身份、状态、命令、时间、退出码与 Hash,不记录 Secret 值。
|
||||
|
||||
## 5. Git 与运行态门禁
|
||||
|
||||
| 门禁 | 当前值 | 结果 |
|
||||
|---|---|---|
|
||||
| Bare/Gitea/Candidate OID | 全部 `95d2e52cb55c7b913e89bc6bed2bcdacf67512d4` | PASS |
|
||||
| Candidate worktree | clean | PASS |
|
||||
| Active Controller | `6188da26453505dacde9d6d298191437164c8d27`,detached、clean | PASS |
|
||||
| Locked worktree | `0` | PASS |
|
||||
| Prunable worktree | `0` | PASS |
|
||||
| 全局 worktree clean | `10 dirty / 88 total` | FAIL |
|
||||
| 当前隔离 Release | `b158041b8633e658c34ce182f3c8aa97b4bf5f9a` | 已恢复 |
|
||||
| AIOS/OpenClaw/Controller services | active | PASS |
|
||||
|
||||
10 个脏 worktree 均不属于本任务允许清理的范围:
|
||||
|
||||
1. `chatgpt/TASK-V2R1-PERSONAL-WEIXIN-GOLDEN-CONTRACT-R2-20260715`(36)
|
||||
2. `chatgpt/TASK-V2R1-PERSONAL-WEIXIN-PORTAL-ADAPTER-20260715`(2)
|
||||
3. `chatgpt/TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8`(2)
|
||||
4. `codex`(56)
|
||||
5. `codex/TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8`(6)
|
||||
6. `controller`(11)
|
||||
7. `openclaw`(5)
|
||||
8. `openclaw/TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8`(3)
|
||||
9. `openclaw/TASK-V2R1-OPENCLAW-WEIXIN-WEB-LOGIN-CONTRACT-20260715`(20)
|
||||
10. `openclaw/TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8`(3)
|
||||
|
||||
## 6. Reviewer
|
||||
|
||||
独立 Spark Reviewer 首先发现 cancel 权限边界不够明确;该问题已在 `95d2e52` 修复并新增三条 HTTP 断言。纠错后 Reviewer 确认代码安全边界、16 步证据与有界回滚均 PASS。对发布门禁的最终布尔复核为:`DIRTY_WORKTREE_COUNT=10` 且 `GLOBAL_WORKTREE_CLEAN=false`,所以全局门禁 FAIL,不能进入预生产评审。
|
||||
|
||||
## 7. Blocker 与发布决策
|
||||
|
||||
- 功能与证据子条件通过、正式关闭受阻:`BLK-LIVE-HERMES-FULL-LIFECYCLE-EVIDENCE-MISSING`
|
||||
- 直接阻断条件:`BLK-GLOBAL-WORKTREE-CLEANLINESS`
|
||||
- 状态:`BLOCKED_EVIDENCE_REQUIRED`
|
||||
- 决策:`STOP_PREPRODUCTION`
|
||||
|
||||
本轮未修改 `main`、未创建 Tag、未 Force Push、未切换生产流量、未部署新 RC、未输出 Secret。
|
||||
|
||||
下一步唯一允许动作:由各 worktree Owner 审查并提交、归档或可信清理上述 10 个脏 worktree,然后重新执行全局 Git 门禁。只有全局 dirty 数量降为 0 后,才允许准备 RC 与 Tag Approval Request。
|
||||
@@ -0,0 +1,107 @@
|
||||
# ADR-007:AIOS V2 Run Provenance 可信执行来源控制
|
||||
|
||||
- 状态:已采纳
|
||||
- 日期:2026-07-20
|
||||
- 影响范围:Runtime 执行、Evidence、GitOps、部署、回滚、审计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
AIOS V2 在进入预生产阶段前,需要证明每一次高权限动作来源可信。仅依靠进程内 runId、sender 或任务字段无法证明:
|
||||
|
||||
- 谁授权了执行;
|
||||
- 执行是否属于当前任务;
|
||||
- Git、部署、回滚和 Evidence 写入是否经过批准;
|
||||
- 是否存在重放或伪造运行记录。
|
||||
|
||||
## 2. 决策
|
||||
|
||||
引入 Run Provenance:
|
||||
|
||||
```text
|
||||
Owner / Control Plane
|
||||
|
|
||||
v
|
||||
Ed25519 Signed Provenance
|
||||
|
|
||||
v
|
||||
Persistent Provenance Store
|
||||
|
|
||||
v
|
||||
Controlled Writer
|
||||
|
|
||||
v
|
||||
Runtime / GitOps / Deploy / Evidence / Rollback
|
||||
```
|
||||
|
||||
## 3. 七类受保护路径
|
||||
|
||||
生产 Provenance 必须覆盖:
|
||||
|
||||
1. code_change
|
||||
2. git_operation
|
||||
3. deployment
|
||||
4. runtime_execute
|
||||
5. evidence_write
|
||||
6. rollback
|
||||
7. audit
|
||||
|
||||
任何未授权路径默认拒绝。
|
||||
|
||||
## 4. 安全规则
|
||||
|
||||
- 私钥只用于签发端。
|
||||
- Runtime 只持有公钥验证能力。
|
||||
- Provenance 使用 Ed25519 签名。
|
||||
- 状态持久保存 issued、verified、used、expired、revoked。
|
||||
- 相同 provenance/path/request_digest 不允许重复授权。
|
||||
- Secret 不进入 Git、日志和 Evidence。
|
||||
|
||||
## 5. 实现映射
|
||||
|
||||
代码:
|
||||
|
||||
```text
|
||||
active-runtime/app/aios_v2/provenance.py
|
||||
active-runtime/app/aios_v2/provenance_git.py
|
||||
active-runtime/app/aios_v2/provenance_operations.py
|
||||
active-runtime/app/aios_v2/provenance_cli.py
|
||||
```
|
||||
|
||||
接入:
|
||||
|
||||
```text
|
||||
orchestrator.py
|
||||
execution_tracking.py
|
||||
app.py
|
||||
```
|
||||
|
||||
## 6. 验收要求
|
||||
|
||||
必须验证:
|
||||
|
||||
- 签名正确执行;
|
||||
- 篡改拒绝;
|
||||
- 过期拒绝;
|
||||
- revoke 拒绝;
|
||||
- replay 拒绝;
|
||||
- 无 Provenance Runtime Dispatch 拒绝;
|
||||
- 无 Evidence 授权写入拒绝;
|
||||
- 无 Git 授权操作拒绝;
|
||||
- 无 Deployment/Rollback 授权拒绝。
|
||||
|
||||
## 7. 回滚
|
||||
|
||||
若 Provenance 服务异常:
|
||||
|
||||
- 不允许降级绕过安全检查进入生产;
|
||||
- 候选环境可以在明确测试配置中关闭强制模式;
|
||||
- 生产模式必须保持 AIOS_REQUIRE_RUN_PROVENANCE=true。
|
||||
|
||||
## 8. 后续演进
|
||||
|
||||
生产阶段可进一步接入:
|
||||
|
||||
- HSM/KMS;
|
||||
- 外部不可变审计;
|
||||
- 多签审批;
|
||||
- 硬件密钥认证。
|
||||
@@ -0,0 +1,156 @@
|
||||
# ADR-009:Workflow Provenance 与最小权限子 Grant
|
||||
|
||||
- 状态:已采纳
|
||||
- 日期:2026-07-21
|
||||
- 决策范围:AIOS V2R1 生产授权、Runtime 调度、Evidence、Git、部署、回滚与审计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
早期候选实现使用一个 Run Provenance 同时承载身份绑定和七类生产操作权限。该模型可以证明一次执行来自哪个租户、任务、Agent 与 Principal,但同一凭证能够授权 Runtime、Evidence、Git、部署、回滚、审计和代码变更,违反最小权限原则。任何持有该凭证的进程都可能获得超出职责的能力。
|
||||
|
||||
同时,Runtime Result 回写、Git 写入和部署包装器需要可撤销、可过期、可重放防护且可持久恢复的独立授权。仅依赖进程角色或环境变量不足以形成可审计的生产信任链。
|
||||
|
||||
## 2. 决策
|
||||
|
||||
采用两层 Provenance 模型:
|
||||
|
||||
1. **Workflow Provenance** 只绑定 `run_id`、`tenant_id`、`task_id`、`agent_id`、`principal_id`、签发者、TTL、Key ID、Nonce 和签名,不授予任何操作路径。
|
||||
2. **Provenance Grant** 由 Workflow Provenance 派生,每个 Grant 只绑定一个 `ExecutionPath`,拥有独立的 TTL、Nonce、状态、撤销和重放记录。
|
||||
3. Git Grant 还必须显式限定允许的 `GitOperation`,例如只允许 `READ_REF` 而不允许 `PUSH`。
|
||||
4. 子 Grant 的身份字段必须与父 Workflow 完全一致,且子 Grant 的有效期不得超过父 Workflow。
|
||||
5. 父 Workflow 被撤销或过期后,其全部子 Grant 自动失效。
|
||||
6. 授权使用 `(grant_id, execution_path, git_operation, request_digest)` 建立唯一使用记录,重复请求默认拒绝。
|
||||
|
||||
## 3. Grant 类型
|
||||
|
||||
| GrantType | 唯一允许路径 |
|
||||
|---|---|
|
||||
| `code_change` | `code_change` |
|
||||
| `git` | `git_operation` |
|
||||
| `deployment` | `deployment` |
|
||||
| `runtime` | `runtime_execute` |
|
||||
| `evidence` | `evidence_write` |
|
||||
| `rollback` | `rollback` |
|
||||
| `audit` | `audit` |
|
||||
|
||||
Grant 类型与路径不一致时,构造阶段即拒绝。非 Git Grant 不得包含 Git 操作;Git Grant 未声明具体操作时不得签发。
|
||||
|
||||
## 4. 服务级 Scope
|
||||
|
||||
生产进程不得加载全部 Grant。`ScopedLeastPrivilegeProvenanceWriter` 只接受当前服务声明的路径和对应 Grant ID,多余 Grant 映射会导致启动失败。
|
||||
|
||||
默认 AIOS Application Scope 仅包含:
|
||||
|
||||
- `runtime_execute`
|
||||
- `evidence_write`
|
||||
- `audit`
|
||||
|
||||
Git、部署、回滚和代码变更必须由独立进程或包装器显式声明自己的单一路径 Scope。运维 CLI 的一次授权命令只接受一个 `--grant-id`,并要求它与 `--path` 一致。
|
||||
|
||||
## 5. Runtime 与 Dispatch 绑定
|
||||
|
||||
Runtime 执行还必须经过持久化 `DispatchRecord`。记录至少绑定:
|
||||
|
||||
- `dispatch_id`
|
||||
- `tenant_id`
|
||||
- `task_id`
|
||||
- `agent_id`
|
||||
- `principal_id`
|
||||
- `runtime`
|
||||
- `runtime_execution_id`
|
||||
- `trace_id`
|
||||
- `provenance_id`
|
||||
- `run_id`
|
||||
- `authorization_id`
|
||||
|
||||
OpenClaw/Hermes 结果只有在上述上下文完全匹配时才能由 Orchestrator 更新 Task。公开 Task API 不允许直接把任务推进到 `RUNNING`、`COMPLETED` 或 `FAILED`;Hermes 结果只通过内部 Runtime Reconcile 入口处理。
|
||||
|
||||
## 6. 密钥与签名
|
||||
|
||||
Workflow Provenance、子 Grant 和 OpenClaw Command 均采用 Ed25519。生产应用只读取公钥;私钥仅存在于受保护的签发端或 systemd credential。Git、日志、环境模板和审计中不得出现私钥内容。
|
||||
|
||||
Key ID 用于选择受信任密钥,不包含密钥材料。公钥 Fingerprint 使用恒定时间比较。OpenClaw Command 合同版本为 `aios.openclaw.command.v2`。
|
||||
|
||||
## 7. 备选方案
|
||||
|
||||
### 7.1 单一全权限 Provenance
|
||||
|
||||
拒绝。实现简单,但任何服务泄漏都可能获得全部生产能力,无法独立撤销 Runtime、Git 或部署权限。
|
||||
|
||||
### 7.2 仅依赖 RBAC
|
||||
|
||||
拒绝。RBAC 可以限制 API 调用者,但不能把具体 Run、Task、Agent、请求摘要和 Runtime Result 绑定到一次可验证授权。
|
||||
|
||||
### 7.3 每个操作重新签发完整 Workflow
|
||||
|
||||
拒绝。会复制身份数据和生命周期状态,难以统一撤销整次工作流,也增加签发和审计复杂度。
|
||||
|
||||
## 8. 正面影响
|
||||
|
||||
- 服务与操作权限按职责隔离。
|
||||
- Runtime、Evidence、Git、部署、回滚和审计可以独立撤销。
|
||||
- 父 Workflow 可作为整次 Run 的紧急停止开关。
|
||||
- SQLite 持久化支持重启后的授权与 Dispatch 恢复。
|
||||
- Receipt 明确记录 Grant、Scope、请求摘要和授权时间。
|
||||
- Git Index 只有在 Grant 授权完成后才允许修改。
|
||||
|
||||
## 9. 负面影响与风险
|
||||
|
||||
- 签发和部署配置比单凭证模式复杂。
|
||||
- 每个生产服务必须配置正确的 Grant ID。
|
||||
- Grant 状态与 Dispatch 状态需要备份、迁移和容量治理。
|
||||
- 当前候选实现使用 SQLite;高可用生产部署仍需外部持久化和并发容量证据。
|
||||
- OpenClaw callback 目前只作为非权威审计事件;状态变化仍必须经过 Orchestrator 严格 Reconcile。
|
||||
|
||||
## 10. 迁移与回滚
|
||||
|
||||
迁移步骤:
|
||||
|
||||
1. 签发无操作权限的 Workflow Provenance。
|
||||
2. 按所需路径签发独立子 Grant。
|
||||
3. 为每个服务配置其最小 Grant ID。
|
||||
4. 启用 `AIOS_REQUIRE_RUN_PROVENANCE=true`。
|
||||
5. 验证 Scope 拒绝、撤销、过期、重放和重启恢复测试。
|
||||
6. 完成预发布 Acceptance 后,禁止旧全权限 `issue` 命令进入生产流程。
|
||||
|
||||
回滚仅允许回到前一个已验证候选版本,不得回滚为生产全权限 Provenance。状态恢复必须保留父 Workflow、Grant 使用记录、Dispatch 和 Receipt,防止旧授权被再次使用。
|
||||
|
||||
## 11. 合规检查
|
||||
|
||||
必须满足:
|
||||
|
||||
- 父 Workflow 无操作路径。
|
||||
- 子 Grant 只包含一个路径。
|
||||
- 生产服务只加载职责所需 Grant。
|
||||
- 上下文不匹配、签名错误、过期、撤销和重放全部 fail closed。
|
||||
- Runtime Result 必须匹配持久 Dispatch。
|
||||
- 测试与文档在同一分支提交。
|
||||
- 正式 Gate A 结论必须基于实际执行 Acceptance,不得仅凭测试文件存在声明通过。
|
||||
|
||||
## 12. 受影响资产
|
||||
|
||||
代码:
|
||||
|
||||
- `active-runtime/app/aios_v2/provenance.py`
|
||||
- `active-runtime/app/aios_v2/provenance_grants.py`
|
||||
- `active-runtime/app/aios_v2/provenance_scoped.py`
|
||||
- `active-runtime/app/aios_v2/provenance_operations.py`
|
||||
- `active-runtime/app/aios_v2/provenance_cli.py`
|
||||
- `active-runtime/app/aios_v2/dispatch_store.py`
|
||||
- `active-runtime/app/aios_v2/orchestrator.py`
|
||||
- `active-runtime/app/aios_v2/execution_tracking.py`
|
||||
- `active-runtime/app/aios_v2/provenance_git.py`
|
||||
- `active-runtime/app/aios_openclaw/contract.py`
|
||||
|
||||
测试:
|
||||
|
||||
- `tests/aios_v2_candidate/test_provenance.py`
|
||||
- `tests/aios_v2_candidate/test_provenance_grant_isolation.py`
|
||||
- `tests/aios_v2_candidate/test_candidate_flow.py`
|
||||
|
||||
部署与文档:
|
||||
|
||||
- `deploy/aios-v2/env/aios-v2.env.example`
|
||||
- `docs/aios-v2/04-MODULE-CONTRACTS.md`
|
||||
- `docs/aios-v2/05-TRACEABILITY-MATRIX.md`
|
||||
- `docs/aios-v2/AIOS-V2R1-HARDENING-GATE-A-REPORT.md`
|
||||
@@ -0,0 +1,80 @@
|
||||
# ADR-010:Hermes 在线生命周期使用默认关闭的内部 Runtime API
|
||||
|
||||
- 状态:Accepted for Release Candidate
|
||||
- 日期:2026-07-21
|
||||
- 范围:AIOS V2R1 Hermes Worker Lifecycle Acceptance
|
||||
|
||||
## 决策
|
||||
|
||||
使用现有 `HermesAdapter` Queue/Lease 状态机,增加 SQLite 持久 Store,并通过默认关闭的
|
||||
内部 Runtime API 执行 Claim、Start、Heartbeat、Complete、Fail、Cancel 与 Status。
|
||||
不建立第二套 Queue、Lease 或 Task 状态机。
|
||||
|
||||
内部 API 只接受可信代理注入且具有 `runtime`/`platform_admin` 角色的身份。请求必须绑定
|
||||
tenant、task、trace、principal、provenance、run、dispatch、job、cell 和 profile。
|
||||
Complete、Fail、Cancel 只能经 `ExecutionOrchestrator.reconcile_hermes` 更新权威 Task。
|
||||
|
||||
## 背景
|
||||
|
||||
原 Candidate 已具有进程内 Hermes Claim/Lease/Worker 能力及持久 Dispatch,但在线服务
|
||||
只暴露 enqueue/reconcile,且 Hermes Job/Lease 本身在重启后丢失,不能形成完整在线证据。
|
||||
|
||||
## 原因
|
||||
|
||||
直接验收驱动会形成 Adapter 之外的额外变更入口,难以证明 HTTP 身份、Dispatch 与
|
||||
Orchestrator 边界。受保护的内部 API 可以在真实 localhost 服务、真实 SQLite Store 和
|
||||
真实 HTTP 解析路径上验证完整生命周期,同时在生产通过配置保持关闭。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- 受信验收驱动直接导入 Adapter:改动较小,但不能证明内部 HTTP 身份与请求解析边界,拒绝。
|
||||
- 新建独立 Queue/Lease 服务:会产生第二状态机和双写一致性问题,禁止。
|
||||
- 开放公共 Worker API:扩大攻击面并允许浏览器/Operator 触发 Runtime 状态,禁止。
|
||||
|
||||
## 安全不变量
|
||||
|
||||
1. 只有 APPROVED Task 可由 Orchestrator 创建 Hermes Job。
|
||||
2. Dispatch 在 Runtime 副作用前持久化。
|
||||
3. Worker 只能操作精确绑定且由其持有 Lease 的 Job。
|
||||
4. Runtime 结果必须通过完整身份元组与持久 Dispatch 校验。
|
||||
5. 只有 Orchestrator 能把 Task/Dispatch 推进到 Runtime 终态。
|
||||
6. 生命周期写入同步生成 Audit/Event 与 Execution Evidence。
|
||||
7. 相同终态重放幂等,不同终态或不同内容 fail closed。
|
||||
|
||||
## 恢复语义
|
||||
|
||||
`SQLiteHermesStore` 持久保存 Job、Result、Lease、幂等键与 Worker heartbeat。服务重启
|
||||
加载同一状态;过期 Lease 被持久重排为 QUEUED,清理旧 Owner 并递增 revision。新 Worker
|
||||
可重领,旧 Worker 的 Start/Heartbeat/Result 被拒绝。
|
||||
|
||||
## 影响与风险
|
||||
|
||||
- 正面:同一 Adapter 状态机获得持久恢复、真实 HTTP 验收、严格身份绑定和可审计证据。
|
||||
- 负面:SQLite Snapshot 每次生命周期变更都执行同步事务,需在预生产观察锁等待与吞吐。
|
||||
- 风险:可信代理头是身份边界;因此接口必须保持 localhost/内部路由、默认关闭,并由部署
|
||||
配置保证外部请求不能自行注入身份头。
|
||||
|
||||
## 迁移与回滚
|
||||
|
||||
新 Store 文件为新增的 `hermes.sqlite3`,不修改既有 Control Plane/Dispatch schema。回滚到
|
||||
Previous OID 时保留该文件但旧版本不读取;不得用旧备份覆盖新业务状态。重新升级时可继续
|
||||
加载。接口回滚只需关闭环境开关并恢复 Previous Release OID。
|
||||
|
||||
## 合规检查
|
||||
|
||||
- 不修改 main,不创建 Tag,不绕过 Provenance/Grant。
|
||||
- 生产默认关闭生命周期验收入口。
|
||||
- Secret 不进入请求、Audit、Evidence 或测试数据。
|
||||
- Result 由 Orchestrator 与持久 Dispatch 校验,负向场景 fail closed。
|
||||
|
||||
## 受影响资产
|
||||
|
||||
- 代码:`aios_hermes/adapter.py`、`aios_v2/app.py`、`orchestrator.py`、`gateway.py`。
|
||||
- 测试:`test_hermes_live_lifecycle.py` 及 Candidate/Provenance 回归。
|
||||
- 文档:用户需求、模块合同、追踪矩阵、Gate A 报告与本 ADR。
|
||||
|
||||
## 验证
|
||||
|
||||
`tests/aios_v2_candidate/test_hermes_live_lifecycle.py` 通过真实 localhost HTTP Server 与
|
||||
临时 SQLite 状态目录覆盖 17 项正向、负向、Lease、幂等和重启场景。最终发布结论仍必须
|
||||
以提交后的 Gate、在线回归、Secret Audit、SQLite 检查、回滚与独立 Reviewer 证据为准。
|
||||
@@ -1,6 +1,6 @@
|
||||
# AIOS V2 架构决策记录索引
|
||||
|
||||
- 文档版本:`V2R1C0-candidate`
|
||||
- 文档版本:`V2R1-hardening-candidate`
|
||||
- 适用范围:影响多个模块、长期演进、兼容性、安全、数据、部署或交付流程的关键架构决策
|
||||
|
||||
## 1. 架构决策记录列表
|
||||
@@ -13,15 +13,27 @@
|
||||
| [ADR-004](ADR-004-multi-tenant-isolation.md) | 多租户隔离采用默认拒绝模型 | 已采纳 |
|
||||
| [ADR-005](ADR-005-document-code-sync-gate.md) | 文档与代码同步门禁 | 已采纳 |
|
||||
| [ADR-006](ADR-006-candidate-persistence-strategy.md) | 候选持久化采用可替换 SQLite Snapshot | 已采纳 |
|
||||
| ADR-007 | 当前仓库未分配;禁止补写不存在的历史决策 | 未分配 |
|
||||
| ADR-008 | 当前仓库未分配;禁止补写不存在的历史决策 | 未分配 |
|
||||
| [ADR-009](ADR-009-provenance-least-privilege.md) | Workflow Provenance、最小权限子 Grant 与服务级 Scope | 已采纳 |
|
||||
| [ADR-010](ADR-010-hermes-live-lifecycle.md) | Hermes 在线生命周期使用默认关闭的内部 Runtime API | RC 已采纳;待 Owner 发布批准 |
|
||||
|
||||
## 2. 状态定义
|
||||
## 2. 编号规则
|
||||
|
||||
- ADR 编号一经发布不得重用。
|
||||
- 编号缺口可以保留,不能通过伪造历史文件填补。
|
||||
- 新决策使用下一个未占用编号,并在同一分支更新本索引、模块契约和追踪矩阵。
|
||||
- ADR-007 与 ADR-008 在当前仓库没有可验证历史文件,因此明确标记为未分配。
|
||||
|
||||
## 3. 状态定义
|
||||
|
||||
- **已提出**:决策已提出,但尚未被正式采纳。
|
||||
- **已采纳**:当前有效,所有新实现必须遵守。
|
||||
- **已替代**:已被新的 ADR 替代,保留用于历史追踪。
|
||||
- **已废弃**:保留历史,但禁止新实现继续采用。
|
||||
- **未分配**:该编号当前没有 ADR 文件,不代表存在隐含历史决策。
|
||||
|
||||
## 3. 必须新增或更新 ADR 的场景
|
||||
## 4. 必须新增或更新 ADR 的场景
|
||||
|
||||
以下变化必须新增或更新 ADR:
|
||||
|
||||
@@ -32,9 +44,10 @@
|
||||
5. GitOps、发布、回滚或文档门禁变化;
|
||||
6. 替换 OpenClaw、Hermes、Agent Router 或核心基础设施;
|
||||
7. 引入新的权威事实源、任务生命周期或租户状态;
|
||||
8. 生产安全模型、身份代理或 Secret 管理方式变化。
|
||||
8. 生产安全模型、身份代理、Run Provenance、Grant 或 Secret 管理方式变化;
|
||||
9. Runtime Result、Dispatch 或服务 Scope 信任边界变化。
|
||||
|
||||
## 4. ADR 内容要求
|
||||
## 5. ADR 内容要求
|
||||
|
||||
每份 ADR 必须包含:
|
||||
|
||||
@@ -48,20 +61,22 @@
|
||||
- 合规检查;
|
||||
- 受影响的代码、测试和文档。
|
||||
|
||||
## 5. ADR 变更联动
|
||||
## 6. ADR 变更联动
|
||||
|
||||
ADR 新增、替代或废弃时,必须同步检查并更新:
|
||||
|
||||
- `01-USER-REQUIREMENTS.md`;
|
||||
- `02-TARGET-ARCHITECTURE.md`;
|
||||
- `03-MIGRATION-AND-DELIVERY.md`;
|
||||
- `04-MODULE-CONTRACTS.md`;
|
||||
- `05-TRACEABILITY-MATRIX.md`;
|
||||
- 相关代码、测试和部署资产;
|
||||
- `AIOS-V2R1-PRODUCTION-CANDIDATE.md` 中的风险与发布结论。
|
||||
- 候选或 Gate 报告中的风险与发布结论。
|
||||
|
||||
## 6. 审查规则
|
||||
## 7. 审查规则
|
||||
|
||||
- OpenClaw 不得自行采纳改变控制面边界的 ADR;发现需求后必须升级给 Codex。
|
||||
- Codex 负责审查 ADR 与代码、测试和文档是否一致。
|
||||
- 涉及合并、Tag、生产部署或重大风险接受的 ADR,必须由 AIOS Owner 最终批准。
|
||||
- ADR 未完成审查前,相关变更不得标记为 `Test-verified` 或 `Production released`。
|
||||
- ADR、测试文件或 Acceptance 脚本存在,不等于 Gate 已通过;必须有实际执行日志、退出码和证据。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Adoption Plan
|
||||
|
||||
1. Keep all candidates on the future incubator branch.
|
||||
2. Require a named product requirement and threat model for each candidate.
|
||||
3. Validate API stability, tenant isolation, secret handling and rollback independently.
|
||||
4. Adopt one module at a time through a future release branch.
|
||||
5. Never merge this branch into `V2R1C1`.
|
||||
|
||||
Current status for every module is `INCUBATING`; none is approved for production.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Incubator Architecture
|
||||
|
||||
The incubator is a sibling capability area under `active-runtime/app/aios_incubator`. It has no import edge from `aios_v2`, `aios_openclaw` or `aios_hermes`.
|
||||
|
||||
| Module | Boundary |
|
||||
|---|---|
|
||||
| `contracts` | Versioning, required/optional/forbidden fields, compatibility and redaction |
|
||||
| `adapters` | Channel-neutral health, capability, session and result protocols |
|
||||
| `worktree_governance` | Registry facts, state classification and release blockers |
|
||||
| `runners` | Provider-neutral provision/health/execute/cancel/evidence/deprovision contract |
|
||||
| `evidence` | Artifact hashes, reviewer state, supersession and log replacement audit |
|
||||
| `auth_session` | Generic user-mediated authentication lifecycle |
|
||||
|
||||
No module owns a production queue, lease, task state machine, credential store or deployment controller.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Feature Matrix
|
||||
|
||||
| Capability | Source category | Adopted | Explicit exclusions | Status |
|
||||
|---|---|---|---|---|
|
||||
| Contract SDK | Golden Contract | schema semantics, compatibility, redaction, tests | Weixin fields and flows | INCUBATING |
|
||||
| Adapter protocols | Portal Adapter | generic health/capability/session/result | Weixin implementation | INCUBATING |
|
||||
| Worktree governance | Concurrency tasks | owner/task/OID/heartbeat/dirty/lock/prune/release blocking | task-specific ACL probes | INCUBATING |
|
||||
| Runner provider | Spark provisioning | generic lifecycle methods | Spark credentials and fixed host paths | INCUBATING |
|
||||
| Evidence framework | B3 hardening | manifest/hash/reviewer/supersession/replacement audit | Personal-Weixin evidence facts | INCUBATING |
|
||||
| Auth session | Web login contract | generic states and transitions | QR, cookies and account identifiers | INCUBATING |
|
||||
@@ -0,0 +1,14 @@
|
||||
# AIOS Future Incubator
|
||||
|
||||
This tree contains channel-neutral capabilities distilled from archived AIOS task worktrees. It is intentionally excluded from `V2R1C1` and must not be imported by production AIOS modules without a separate adoption review.
|
||||
|
||||
Current candidates:
|
||||
|
||||
- versioned contract SDK;
|
||||
- channel/portal adapter protocols;
|
||||
- worktree ownership and release-blocking registry;
|
||||
- provider-neutral runner lifecycle;
|
||||
- evidence integrity and replacement audit;
|
||||
- authentication-session state machine.
|
||||
|
||||
Platform implementations, credentials, cookies, QR payloads, account fields, runtime logs and fixed deployment paths are excluded.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Worktree Archive Manifest
|
||||
|
||||
Status: `CLEANUP_REVIEWED_RELEASE_STOPPED`
|
||||
|
||||
Release gate: `STOP_PREPRODUCTION`
|
||||
|
||||
The machine-readable authority is `.agent/results/incubator/WORKTREE-ARCHIVE-MANIFEST.json`. Ten of ten named worktrees were reviewed: seven task worktrees were archived, pushed, verified and removed; three active long-term roots were retained and made clean.
|
||||
|
||||
The incubator is not a release source and was not merged into V2R1C1. The archived Portal Adapter is explicitly rejected for adoption because its portal-surface contract tests fail; its Django test is also blocked by a missing host dependency.
|
||||
|
||||
| # | Worktree | Decision | Archive / retained OID | Validation |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `TASK-V2R1-PERSONAL-WEIXIN-GOLDEN-CONTRACT-R2-20260715` | removed | `fe86e35257113853ba08b1438bc820bb4554e6e8` | PASS: contract harness |
|
||||
| 2 | `TASK-V2R1-PERSONAL-WEIXIN-PORTAL-ADAPTER-20260715` | removed | `79f529a91f8bf7cf3237feb01e98807ab5ad10b3` | REJECTED_FOR_ADOPTION: official-login tests pass; portal-surface tests fail; Django test blocked by missing dependency |
|
||||
| 3 | `TASK-WORKTREE-CONCURRENCY-CHATGPT-20260715T055106UTC8` | removed | `f0feed397dc67edab7daa16f0cdb5b553eca5668` | PASS: evidence integrity and zero-secret scan |
|
||||
| 4 | `TASK-CODEX-AIOS-SPARK-RUNNER-PROVISIONING-20260717T103500UTC8` | removed | `d97cd3642528bd3373fe4cf5161a5d99c0b9285f` | PASS: 27 pytest tests |
|
||||
| 5 | `TASK-OPENCLAW-V2R1-PERSONAL-WEIXIN-B3-EVIDENCE-HARDEN-20260716T133000UTC8` | removed | `2ddbc456124c02707dc23e8d6c241826d7026575` | PASS: compile, canonical template JSON and CLI help |
|
||||
| 6 | `TASK-V2R1-OPENCLAW-WEIXIN-WEB-LOGIN-CONTRACT-20260715` | removed | `d03b4ee34debe6e30ce2af233e2226b02e6dd029` | PASS: Node contract tests |
|
||||
| 7 | `TASK-WORKTREE-CONCURRENCY-OPENCLAW-20260715T055106UTC8` | removed | `d9a85bb86cc09c117d63de803a404ee5a864e7c1` | PASS: evidence integrity and zero-secret scan |
|
||||
| 8 | `codex-long-term-root` | retained-active | `0193a12b57c4974888b25753fd2bfb1f2028909b` | PASS: 55 untracked entries exactly matched 55 registered child worktrees; final clean |
|
||||
| 9 | `controller-long-term-root` | retained-active | `102d2306f05397374fc91c711767021c5d9f41f9` | PASS: watcher, model API and autonomous controller active; final clean |
|
||||
| 10 | `openclaw-long-term-root` | retained-active | `633c8308f53b0ad04ce054ceafb624cb0904cbf4` | PASS: 2 untracked entries exactly matched 2 registered child worktrees; final clean |
|
||||
|
||||
## Independent review
|
||||
|
||||
Reviewer: `gpt-5.3-codex-spark-validator`
|
||||
|
||||
Cleanup review: `PASS`; release decision: `STOP_PREPRODUCTION`. The reviewer matched ten Manifest records, seven archive/removal OID pairs and three retained roots, then preserved the stop decision because `portal-surface` and `seed-ucc` returned non-zero.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Worktree Cleanup Authorization
|
||||
|
||||
Owner authorization source: `AIOS V2R1C1 最终归档、清理、验证与上线执行命令`, received `2026-07-21`.
|
||||
|
||||
The authorization is limited to the ten named dirty worktrees. Before removal, each task worktree requires status/diff evidence, zero secret violations, a pushed archive OID, local/remote OID equality and a clean workspace. Active long-term worktrees must be retained when a service depends on them and must be cleaned without deleting the service path.
|
||||
|
||||
Forbidden cleanup methods:
|
||||
|
||||
- `git reset --hard`;
|
||||
- `git clean -fdx`;
|
||||
- deleting active Controller or OpenClaw roots;
|
||||
- deleting content before its archive OID and artifact hashes are verified.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/chatgpt/AIOS-V2R1-FUTURE-INCUBATOR-20260721",
|
||||
"base": "fb739f6e8ff9c031d319221ace68dce157694086",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-21T06:30:22Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/chatgpt/AIOS-V2R1-HARDENING-20260721",
|
||||
"base": "patch/codex/AIOS-V2R1-RUN-PROVENANCE-CONTROL-20260720",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-21T02:46:59Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/codex/AIOS-V2R1-RUN-PROVENANCE-CONTROL-20260720",
|
||||
"base": "integration/aios-v2r1-cto-production-candidate-20260720",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-20T12:11:59Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Incubator-only test suite."""
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_incubator.adapters import AdapterCapability, AdapterResult, AdapterSession
|
||||
from aios_incubator.runners import RunnerExecution, RunnerStatus
|
||||
|
||||
|
||||
class AdapterAndRunnerContractTests(unittest.TestCase):
|
||||
def test_adapter_values_are_channel_neutral(self) -> None:
|
||||
session = AdapterSession("session-1", "tenant-1", "waiting")
|
||||
capability = AdapterCapability("messages.read")
|
||||
result = AdapterResult(True, "completed", {"count": 1})
|
||||
self.assertEqual(session.tenant_id, "tenant-1")
|
||||
self.assertEqual(capability.version, "1")
|
||||
self.assertTrue(result.ok)
|
||||
|
||||
def test_runner_execution_has_provider_neutral_status(self) -> None:
|
||||
execution = RunnerExecution("exec-1", "runner-1", RunnerStatus.RUNNING)
|
||||
self.assertEqual(execution.status, RunnerStatus.RUNNING)
|
||||
self.assertIsNone(execution.exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_incubator.auth_session import (
|
||||
AuthSession,
|
||||
AuthSessionState,
|
||||
InvalidAuthSessionTransition,
|
||||
)
|
||||
|
||||
|
||||
class AuthSessionTests(unittest.TestCase):
|
||||
def test_successful_authentication_flow(self) -> None:
|
||||
session = AuthSession("session-1", "tenant-1")
|
||||
session = session.transition(AuthSessionState.WAITING_FOR_USER)
|
||||
session = session.transition(AuthSessionState.USER_CONFIRMED)
|
||||
session = session.transition(AuthSessionState.AUTHENTICATED)
|
||||
self.assertTrue(session.terminal)
|
||||
|
||||
def test_expiry_cancel_failure_and_invalid_transition(self) -> None:
|
||||
waiting = AuthSession("s", "t").transition(AuthSessionState.WAITING_FOR_USER)
|
||||
for target in (AuthSessionState.EXPIRED, AuthSessionState.CANCELLED, AuthSessionState.FAILED):
|
||||
self.assertTrue(waiting.transition(target).terminal)
|
||||
with self.assertRaises(InvalidAuthSessionTransition):
|
||||
waiting.transition(AuthSessionState.AUTHENTICATED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_incubator.contracts import (
|
||||
Compatibility,
|
||||
ContractVersion,
|
||||
ContractViolation,
|
||||
VersionedContract,
|
||||
)
|
||||
|
||||
|
||||
class ContractTests(unittest.TestCase):
|
||||
def test_validation_redaction_and_descriptor(self) -> None:
|
||||
contract = VersionedContract(
|
||||
name="channel.request",
|
||||
version=ContractVersion.parse("1.2"),
|
||||
required_fields=("request_id",),
|
||||
optional_fields=("credential_ref",),
|
||||
forbidden_fields=("credential_plain",),
|
||||
secret_fields=("credential_ref",),
|
||||
)
|
||||
value = contract.validate(
|
||||
{"request_id": "request-1", "credential_ref": "secretref:channel/test"}
|
||||
)
|
||||
self.assertEqual(contract.redact(value)["credential_ref"], "***")
|
||||
self.assertEqual(len(contract.descriptor()["descriptor_digest"]), 64)
|
||||
|
||||
def test_missing_forbidden_and_unknown_fields_fail(self) -> None:
|
||||
contract = VersionedContract(
|
||||
name="channel.request",
|
||||
version=ContractVersion(1, 0),
|
||||
required_fields=("request_id",),
|
||||
forbidden_fields=("credential_plain",),
|
||||
)
|
||||
for payload in ({}, {"request_id": "x", "credential_plain": "x"}, {"request_id": "x", "extra": 1}):
|
||||
with self.subTest(payload=payload), self.assertRaises(ContractViolation):
|
||||
contract.validate(payload)
|
||||
|
||||
def test_compatibility_is_explicit(self) -> None:
|
||||
previous = VersionedContract("event", ContractVersion(1, 0), ("id",), ("kind",))
|
||||
compatible = VersionedContract("event", ContractVersion(1, 1), ("id",), ("kind", "trace"))
|
||||
breaking = VersionedContract("event", ContractVersion(2, 0), ("id",))
|
||||
self.assertEqual(compatible.compatibility_with(previous), Compatibility.BACKWARD_COMPATIBLE)
|
||||
self.assertEqual(breaking.compatibility_with(previous), Compatibility.INCOMPATIBLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_incubator.evidence import EvidenceIntegrityError, EvidenceManifest, sha256_file
|
||||
|
||||
|
||||
class EvidenceManifestTests(unittest.TestCase):
|
||||
def test_hash_validation_and_replacement_audit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
log = root / "step.log"
|
||||
log.write_text("first\n", encoding="utf-8")
|
||||
manifest = EvidenceManifest(
|
||||
command=("validate",),
|
||||
start_time="2026-07-21T00:00:00Z",
|
||||
end_time="2026-07-21T00:00:01Z",
|
||||
exit_code=0,
|
||||
artifacts={"step.log": sha256_file(log)},
|
||||
)
|
||||
manifest.validate(root)
|
||||
log.write_text("replacement\n", encoding="utf-8")
|
||||
with self.assertRaises(EvidenceIntegrityError):
|
||||
manifest.validate(root)
|
||||
replacement = manifest.replace_artifact(
|
||||
artifact="step.log",
|
||||
replacement_sha256=sha256_file(log),
|
||||
reason="superseded invalid harness output",
|
||||
replaced_at="2026-07-21T00:01:00Z",
|
||||
)
|
||||
replacement.validate(root)
|
||||
self.assertEqual(len(replacement.replacement_audit), 1)
|
||||
|
||||
def test_missing_replacement_reason_is_rejected(self) -> None:
|
||||
manifest = EvidenceManifest(("x",), "s", "e", 0, {"a": "0" * 64})
|
||||
with self.assertRaises(EvidenceIntegrityError):
|
||||
manifest.replace_artifact(
|
||||
artifact="a", replacement_sha256="1" * 64, reason="", replaced_at="now"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_incubator.worktree_governance import (
|
||||
WorktreeRecord,
|
||||
WorktreeRegistry,
|
||||
WorktreeState,
|
||||
)
|
||||
|
||||
|
||||
def record(**values) -> WorktreeRecord:
|
||||
defaults = dict(
|
||||
path="/data/worktrees/task-1",
|
||||
owner="codex",
|
||||
task_id="task-1",
|
||||
branch="task/one",
|
||||
base_oid="a" * 40,
|
||||
current_oid="a" * 40,
|
||||
)
|
||||
defaults.update(values)
|
||||
return WorktreeRecord(**defaults)
|
||||
|
||||
|
||||
class WorktreeGovernanceTests(unittest.TestCase):
|
||||
def test_dirty_and_runtime_dirty_are_release_blocking(self) -> None:
|
||||
dirty = record(dirty_count=2)
|
||||
runtime = replace(dirty, active_runtime=True)
|
||||
self.assertEqual(dirty.state, WorktreeState.OWNER_REVIEW_REQUIRED)
|
||||
self.assertEqual(runtime.state, WorktreeState.TRUSTED_RUNTIME_DIRTY)
|
||||
self.assertTrue(dirty.release_blocking)
|
||||
self.assertTrue(runtime.release_blocking)
|
||||
|
||||
def test_locked_or_prunable_requires_cleanup(self) -> None:
|
||||
self.assertEqual(record(locked=True).state, WorktreeState.STALE_CLEANUP_REQUIRED)
|
||||
self.assertEqual(record(prunable=True).state, WorktreeState.STALE_CLEANUP_REQUIRED)
|
||||
|
||||
def test_registry_refuses_dirty_archive_and_lists_blockers(self) -> None:
|
||||
registry = WorktreeRegistry((record(dirty_count=1),))
|
||||
self.assertEqual(len(registry.release_blockers()), 1)
|
||||
with self.assertRaises(ValueError):
|
||||
registry.mark_archived("/data/worktrees/task-1", "b" * 40)
|
||||
|
||||
def test_clean_record_can_be_archived(self) -> None:
|
||||
registry = WorktreeRegistry((record(),))
|
||||
archived = registry.mark_archived("/data/worktrees/task-1", "b" * 40)
|
||||
self.assertEqual(archived.state, WorktreeState.ARCHIVED)
|
||||
self.assertFalse(archived.release_blocking)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -13,6 +15,7 @@ if str(APP_ROOT) not in sys.path:
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesWorker
|
||||
from aios_openclaw import (
|
||||
CommandState,
|
||||
Ed25519CommandSigner,
|
||||
InProcessRuntimeTransport,
|
||||
OpenClawAdapter,
|
||||
)
|
||||
@@ -25,10 +28,14 @@ from aios_v2 import (
|
||||
TaskState,
|
||||
TenantIsolationError,
|
||||
)
|
||||
from aios_v2.dispatch_store import DispatchState, SQLiteDispatchRepository
|
||||
from aios_v2.gateway import GatewayApplication
|
||||
from aios_v2.app import build_application
|
||||
from aios_v2.persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||
|
||||
|
||||
SIGNING_MATERIAL = b"candidate-test-signing-material-0001"
|
||||
OPENCLAW_KEY_ID = "candidate-test-ed25519"
|
||||
OPENCLAW_PRIVATE_SEED = bytes(range(32))
|
||||
|
||||
|
||||
def context(
|
||||
@@ -64,6 +71,64 @@ def bootstrap(service: ControlPlaneService, tenant_id: str = "tenant-a"):
|
||||
|
||||
|
||||
class CandidateFlowTests(unittest.TestCase):
|
||||
def test_rollback_entrypoints_are_executable_in_release_archive(self) -> None:
|
||||
for relative in (
|
||||
"deploy/aios-v2/rollback/rollback.sh",
|
||||
"deploy/aios-v2/healthcheck/check.sh",
|
||||
):
|
||||
mode = (REPO_ROOT / relative).stat().st_mode
|
||||
self.assertTrue(mode & stat.S_IXUSR, relative)
|
||||
|
||||
def test_rollback_waits_for_bounded_service_readiness(self) -> None:
|
||||
script = (REPO_ROOT / "deploy/aios-v2/rollback/rollback.sh").read_text()
|
||||
self.assertIn("AIOS_V2_ROLLBACK_HEALTH_RETRIES", script)
|
||||
self.assertIn("run_healthcheck", script)
|
||||
self.assertIn("HEALTH_RETRY_DELAY", script)
|
||||
|
||||
def test_build_application_propagates_openclaw_signing_key_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
material_path = Path(temp_dir) / "signing-material.bin"
|
||||
material_path.write_bytes(OPENCLAW_PRIVATE_SEED)
|
||||
material_path.chmod(0o600)
|
||||
values = {
|
||||
"AIOS_V2_STATE_DIR": temp_dir,
|
||||
"AIOS_OPENCLAW_URL": "http://127.0.0.1:18789",
|
||||
"AIOS_OPENCLAW_SIGNING_MATERIAL_FILE": str(material_path),
|
||||
"AIOS_OPENCLAW_SIGNING_KEY_ID": "openclaw-v2r1-candidate-ed25519",
|
||||
"AIOS_OPENCLAW_CREDENTIAL_REF": "secretref:test",
|
||||
}
|
||||
previous = {key: os.environ.get(key) for key in values}
|
||||
try:
|
||||
os.environ.update(values)
|
||||
application = build_application()
|
||||
finally:
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
self.assertEqual(
|
||||
application.orchestrator.openclaw_key_id,
|
||||
"openclaw-v2r1-candidate-ed25519",
|
||||
)
|
||||
|
||||
def test_http_identity_headers_are_case_insensitive(self) -> None:
|
||||
request_context = GatewayApplication.context_from_headers(
|
||||
{
|
||||
"X-Aios-Authenticated": "true",
|
||||
"x-aios-tenant-id": "tenant-a",
|
||||
"X-AIOS-PRINCIPAL-ID": "operator-a",
|
||||
"X-Aios-Roles": "operator,auditor",
|
||||
"x-aios-trace-id": "trace-http-header-case",
|
||||
}
|
||||
)
|
||||
self.assertEqual(request_context.tenant_id, "tenant-a")
|
||||
self.assertEqual(request_context.principal_id, "operator-a")
|
||||
self.assertEqual(request_context.trace_id, "trace-http-header-case")
|
||||
self.assertEqual(
|
||||
request_context.roles, frozenset({"operator", "auditor"})
|
||||
)
|
||||
|
||||
def test_openclaw_full_flow_reconciles_completed_task(self) -> None:
|
||||
service = ControlPlaneService()
|
||||
_, operator = bootstrap(service)
|
||||
@@ -88,25 +153,35 @@ class CandidateFlowTests(unittest.TestCase):
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
|
||||
signer = Ed25519CommandSigner.from_bytes(
|
||||
OPENCLAW_PRIVATE_SEED,
|
||||
key_id=OPENCLAW_KEY_ID,
|
||||
)
|
||||
transport = InProcessRuntimeTransport(
|
||||
lambda command: {
|
||||
"state": CommandState.COMPLETED.value,
|
||||
"output": {"echo": command.payload["message"]},
|
||||
}
|
||||
)
|
||||
resolver = lambda tenant_id, key_id: SIGNING_MATERIAL
|
||||
adapter = OpenClawAdapter(transport, resolver)
|
||||
adapter = OpenClawAdapter(
|
||||
transport,
|
||||
lambda tenant_id, key_id: signer.verifier(),
|
||||
)
|
||||
orchestrator = ExecutionOrchestrator(
|
||||
service,
|
||||
openclaw=adapter,
|
||||
openclaw_signing_material_resolver=resolver,
|
||||
openclaw_signing_material_resolver=(
|
||||
lambda tenant_id, key_id: signer
|
||||
),
|
||||
openclaw_key_id=OPENCLAW_KEY_ID,
|
||||
)
|
||||
|
||||
dispatch = orchestrator.dispatch(operator, task_id=approved.task_id)
|
||||
completed = service.get_task(operator, task.task_id)
|
||||
|
||||
self.assertEqual(dispatch.runtime, "openclaw")
|
||||
self.assertEqual(dispatch.state, CommandState.COMPLETED.value)
|
||||
self.assertEqual(dispatch.state, DispatchState.COMPLETED)
|
||||
self.assertEqual(dispatch.principal_id, operator.principal_id)
|
||||
self.assertEqual(completed.state, TaskState.COMPLETED)
|
||||
self.assertEqual(completed.result, {"echo": "hello"})
|
||||
|
||||
@@ -139,7 +214,7 @@ class CandidateFlowTests(unittest.TestCase):
|
||||
orchestrator = ExecutionOrchestrator(service, hermes=hermes)
|
||||
dispatch = orchestrator.dispatch(operator, task_id=approved.task_id)
|
||||
self.assertEqual(dispatch.runtime, "hermes")
|
||||
self.assertEqual(dispatch.state, HermesJobState.QUEUED.value)
|
||||
self.assertEqual(dispatch.state, DispatchState.QUEUED)
|
||||
|
||||
worker = HermesWorker(
|
||||
hermes,
|
||||
@@ -155,10 +230,98 @@ class CandidateFlowTests(unittest.TestCase):
|
||||
self.assertEqual(run.state, HermesJobState.COMPLETED.value)
|
||||
|
||||
result = hermes.result(dispatch.runtime_execution_id, tenant_id="tenant-a")
|
||||
completed = orchestrator.reconcile_hermes(operator, result)
|
||||
completed = orchestrator.reconcile_hermes(
|
||||
operator,
|
||||
result,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=dispatch.provenance_id,
|
||||
run_id=dispatch.run_id,
|
||||
)
|
||||
self.assertEqual(completed.state, TaskState.COMPLETED)
|
||||
self.assertEqual(completed.result, {"transformed": 14})
|
||||
|
||||
def test_dispatch_and_control_plane_recover_before_hermes_reconcile(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
control_path = root / "control-plane.sqlite3"
|
||||
dispatch_path = root / "dispatches.sqlite3"
|
||||
service = DurableControlPlaneService(SQLiteSnapshotRepository(control_path))
|
||||
_, operator = bootstrap(service)
|
||||
agent = service.register_agent(
|
||||
operator,
|
||||
agent_id="recoverable-hermes-agent",
|
||||
display_name="Recoverable Hermes",
|
||||
runtime="hermes",
|
||||
capabilities=("transform",),
|
||||
metadata={"cell_id": "cell-a", "profile_id": "profile-a"},
|
||||
)
|
||||
task = service.create_task(
|
||||
operator,
|
||||
agent_id=agent.agent_id,
|
||||
action="transform",
|
||||
payload={"value": 11},
|
||||
idempotency_key="dispatch-recovery-1",
|
||||
)
|
||||
approved = service.transition_task(
|
||||
operator,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.APPROVED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
hermes = HermesAdapter(default_lease_seconds=10)
|
||||
first = ExecutionOrchestrator(
|
||||
service,
|
||||
hermes=hermes,
|
||||
dispatch_repository=SQLiteDispatchRepository(dispatch_path),
|
||||
)
|
||||
dispatch = first.dispatch(operator, task_id=approved.task_id)
|
||||
|
||||
worker = HermesWorker(
|
||||
hermes,
|
||||
lambda job: {"transformed": int(job.payload["value"]) * 3},
|
||||
worker_id="worker-recovery",
|
||||
tenant_id="tenant-a",
|
||||
cell_id="cell-a",
|
||||
profile_id="profile-a",
|
||||
heartbeat_seconds=0.05,
|
||||
)
|
||||
run = worker.run_once()
|
||||
self.assertTrue(run.claimed)
|
||||
|
||||
recovered_service = DurableControlPlaneService(
|
||||
SQLiteSnapshotRepository(control_path)
|
||||
)
|
||||
recovered_repository = SQLiteDispatchRepository(dispatch_path)
|
||||
restored_dispatch = recovered_repository.get(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
self.assertEqual(restored_dispatch.runtime_execution_id, dispatch.runtime_execution_id)
|
||||
self.assertEqual(restored_dispatch.principal_id, operator.principal_id)
|
||||
|
||||
recovered = ExecutionOrchestrator(
|
||||
recovered_service,
|
||||
hermes=hermes,
|
||||
dispatch_repository=recovered_repository,
|
||||
)
|
||||
result = hermes.result(dispatch.runtime_execution_id, tenant_id="tenant-a")
|
||||
completed = recovered.reconcile_hermes(
|
||||
operator,
|
||||
result,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=dispatch.provenance_id,
|
||||
run_id=dispatch.run_id,
|
||||
)
|
||||
self.assertEqual(completed.state, TaskState.COMPLETED)
|
||||
self.assertEqual(completed.result, {"transformed": 33})
|
||||
self.assertEqual(
|
||||
recovered_repository.get(
|
||||
dispatch.dispatch_id,
|
||||
tenant_id="tenant-a",
|
||||
).state,
|
||||
DispatchState.COMPLETED,
|
||||
)
|
||||
|
||||
def test_cross_tenant_task_read_is_denied(self) -> None:
|
||||
service = ControlPlaneService()
|
||||
_, tenant_a = bootstrap(service, "tenant-a")
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
APP_ROOT = REPO_ROOT / "active-runtime" / "app"
|
||||
if str(APP_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(APP_ROOT))
|
||||
|
||||
from aios_v2.app import CandidateHTTPServer, build_application
|
||||
|
||||
|
||||
class HermesLiveLifecycleTests(unittest.TestCase):
|
||||
"""Exercise the real HTTP, SQLite dispatch and Hermes lifecycle boundary."""
|
||||
|
||||
tenant_id = "tenant-hermes-live"
|
||||
principal_id = "operator-hermes-live"
|
||||
trace_id = "trace-hermes-live"
|
||||
provenance_id = "provenance-hermes-live"
|
||||
run_id = "run-hermes-live"
|
||||
cell_id = "cell-hermes-live"
|
||||
profile_id = "profile-hermes-live"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self._saved_environment = {
|
||||
name: os.environ.get(name)
|
||||
for name in (
|
||||
"AIOS_V2_STATE_DIR",
|
||||
"AIOS_HERMES_LIFECYCLE_API_ENABLED",
|
||||
"AIOS_REQUIRE_RUN_PROVENANCE",
|
||||
"AIOS_OPENCLAW_URL",
|
||||
"AIOS_OPENCLAW_SIGNING_MATERIAL_FILE",
|
||||
)
|
||||
}
|
||||
os.environ["AIOS_V2_STATE_DIR"] = self.temporary.name
|
||||
os.environ["AIOS_HERMES_LIFECYCLE_API_ENABLED"] = "1"
|
||||
os.environ["AIOS_REQUIRE_RUN_PROVENANCE"] = "0"
|
||||
os.environ.pop("AIOS_OPENCLAW_URL", None)
|
||||
os.environ.pop("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", None)
|
||||
self._start_server()
|
||||
self._bootstrap()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._stop_server()
|
||||
for name, value in self._saved_environment.items():
|
||||
if value is None:
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
self.temporary.cleanup()
|
||||
|
||||
def _start_server(self) -> None:
|
||||
self.application = build_application()
|
||||
self.server = CandidateHTTPServer(("127.0.0.1", 0), self.application)
|
||||
self.port = int(self.server.server_address[1])
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _stop_server(self) -> None:
|
||||
if getattr(self, "server", None) is None:
|
||||
return
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
self.server = None
|
||||
|
||||
def _restart_server(self) -> None:
|
||||
self._stop_server()
|
||||
self._start_server()
|
||||
|
||||
def _headers(self, **overrides: str) -> dict[str, str]:
|
||||
values = {
|
||||
"X-AIOS-Authenticated": "true",
|
||||
"X-AIOS-Tenant-ID": self.tenant_id,
|
||||
"X-AIOS-Principal-ID": self.principal_id,
|
||||
"X-AIOS-Trace-ID": self.trace_id,
|
||||
"X-AIOS-Roles": (
|
||||
"platform_admin,tenant_admin,operator,approver,auditor,runtime"
|
||||
),
|
||||
"X-AIOS-Provenance-ID": self.provenance_id,
|
||||
"X-AIOS-Run-ID": self.run_id,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
values.update(overrides)
|
||||
return values
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, object] | None = None,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
encoded = json.dumps(body or {}, separators=(",", ":")).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{self.port}{path}",
|
||||
data=encoded if method == "POST" else None,
|
||||
method=method,
|
||||
headers=headers or self._headers(),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, json.loads(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read())
|
||||
|
||||
def _bootstrap(self) -> None:
|
||||
status, _ = self._request(
|
||||
"POST",
|
||||
"/api/v2/tenants",
|
||||
{"tenant_id": self.tenant_id, "display_name": "Hermes Live"},
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
status, _ = self._request(
|
||||
"POST",
|
||||
"/api/v2/agents",
|
||||
{
|
||||
"agent_id": "hermes-live-agent",
|
||||
"display_name": "Hermes Live",
|
||||
"runtime": "hermes",
|
||||
"capabilities": ["transform"],
|
||||
"metadata": {
|
||||
"cell_id": self.cell_id,
|
||||
"profile_id": self.profile_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
def _dispatch(self, suffix: str) -> dict[str, object]:
|
||||
status, created = self._request(
|
||||
"POST",
|
||||
"/api/v2/tasks",
|
||||
{
|
||||
"agent_id": "hermes-live-agent",
|
||||
"action": "transform",
|
||||
"payload": {"value": suffix},
|
||||
"idempotency_key": f"hermes-live-{suffix}",
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
task = created["task"]
|
||||
assert isinstance(task, dict)
|
||||
task_id = str(task["task_id"])
|
||||
status, approved = self._request(
|
||||
"POST",
|
||||
f"/api/v2/tasks/{task_id}/approve",
|
||||
{"expected_revision": task["revision"]},
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
approved_task = approved["task"]
|
||||
assert isinstance(approved_task, dict)
|
||||
status, response = self._request(
|
||||
"POST",
|
||||
f"/api/v2/tasks/{task_id}/dispatch",
|
||||
{
|
||||
"provenance_id": self.provenance_id,
|
||||
"run_id": self.run_id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 202)
|
||||
dispatch = response["dispatch"]
|
||||
assert isinstance(dispatch, dict)
|
||||
return dispatch
|
||||
|
||||
def _identity(self, dispatch: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"dispatch_id": dispatch["dispatch_id"],
|
||||
"job_id": dispatch["runtime_execution_id"],
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": dispatch["task_id"],
|
||||
"trace_id": self.trace_id,
|
||||
"principal_id": self.principal_id,
|
||||
"provenance_id": self.provenance_id,
|
||||
"run_id": self.run_id,
|
||||
"cell_id": self.cell_id,
|
||||
"profile_id": self.profile_id,
|
||||
}
|
||||
|
||||
def _lifecycle(
|
||||
self,
|
||||
action: str,
|
||||
identity: dict[str, object],
|
||||
**values: object,
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
body = dict(identity)
|
||||
body.update(values)
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/api/v2/internal/runtime/hermes/lifecycle/{action}",
|
||||
body,
|
||||
)
|
||||
|
||||
def _claim_start(self, suffix: str, worker_id: str = "worker-a"):
|
||||
dispatch = self._dispatch(suffix)
|
||||
identity = self._identity(dispatch)
|
||||
status, claimed = self._lifecycle(
|
||||
"claim", identity, worker_id=worker_id, lease_seconds=5
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
job = claimed["job"]
|
||||
assert isinstance(job, dict)
|
||||
status, started = self._lifecycle(
|
||||
"start",
|
||||
identity,
|
||||
worker_id=worker_id,
|
||||
expected_revision=job["revision"],
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
return dispatch, identity, started["job"]
|
||||
|
||||
def test_01_claim(self) -> None:
|
||||
dispatch = self._dispatch("claim")
|
||||
status, payload = self._lifecycle(
|
||||
"claim", self._identity(dispatch), worker_id="worker-claim"
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["state"], "CLAIMED")
|
||||
self.assertEqual(payload["dispatch"]["expected_worker_id"], "worker-claim")
|
||||
|
||||
def test_02_start(self) -> None:
|
||||
_, _, job = self._claim_start("start")
|
||||
self.assertEqual(job["state"], "RUNNING")
|
||||
|
||||
def test_03_heartbeat(self) -> None:
|
||||
_, identity, job = self._claim_start("heartbeat")
|
||||
status, payload = self._lifecycle(
|
||||
"heartbeat", identity, worker_id="worker-a", lease_seconds=5
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertGreater(payload["job"]["revision"], job["revision"])
|
||||
|
||||
def test_04_complete(self) -> None:
|
||||
_, identity, _ = self._claim_start("complete")
|
||||
status, payload = self._lifecycle(
|
||||
"complete", identity, worker_id="worker-a", output={"ok": True}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["task"]["state"], "COMPLETED")
|
||||
self.assertEqual(payload["dispatch"]["state"], "COMPLETED")
|
||||
|
||||
def test_05_fail(self) -> None:
|
||||
_, identity, _ = self._claim_start("fail")
|
||||
status, payload = self._lifecycle(
|
||||
"fail", identity, worker_id="worker-a", error={"code": "expected"}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["task"]["state"], "FAILED")
|
||||
|
||||
def test_06_cancel(self) -> None:
|
||||
dispatch = self._dispatch("cancel")
|
||||
identity = self._identity(dispatch)
|
||||
runtime_only = self._headers(**{"X-AIOS-Roles": "runtime"})
|
||||
status, _ = self._request(
|
||||
"POST",
|
||||
"/api/v2/internal/runtime/hermes/lifecycle/cancel",
|
||||
{**identity, "cancellation_source": "aios-control-plane"},
|
||||
headers=runtime_only,
|
||||
)
|
||||
self.assertEqual(status, 401)
|
||||
status, _ = self._lifecycle(
|
||||
"cancel",
|
||||
identity,
|
||||
worker_id="worker-must-not-cancel",
|
||||
cancellation_source="aios-control-plane",
|
||||
)
|
||||
self.assertEqual(status, 401)
|
||||
status, payload = self._lifecycle(
|
||||
"cancel", identity, cancellation_source="aios-control-plane"
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["task"]["state"], "CANCELLED")
|
||||
|
||||
def test_07_lease_expiry(self) -> None:
|
||||
dispatch = self._dispatch("expiry")
|
||||
identity = self._identity(dispatch)
|
||||
status, _ = self._lifecycle(
|
||||
"claim", identity, worker_id="worker-expired", lease_seconds=5
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
time.sleep(5.1)
|
||||
status, payload = self._lifecycle("status", identity)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["state"], "QUEUED")
|
||||
self.assertIsNone(payload["job"]["assigned_worker_id"])
|
||||
|
||||
def test_08_requeue_to_new_worker(self) -> None:
|
||||
dispatch = self._dispatch("requeue")
|
||||
identity = self._identity(dispatch)
|
||||
self.assertEqual(
|
||||
self._lifecycle(
|
||||
"claim", identity, worker_id="worker-old", lease_seconds=5
|
||||
)[0],
|
||||
200,
|
||||
)
|
||||
time.sleep(5.1)
|
||||
status, payload = self._lifecycle(
|
||||
"claim", identity, worker_id="worker-new", lease_seconds=5
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["assigned_worker_id"], "worker-new")
|
||||
self.assertEqual(payload["dispatch"]["expected_worker_id"], "worker-new")
|
||||
|
||||
def test_09_crash_recovery_preserves_running_job(self) -> None:
|
||||
_, identity, _ = self._claim_start("crash")
|
||||
self._restart_server()
|
||||
status, payload = self._lifecycle("status", identity)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["state"], "RUNNING")
|
||||
self.assertEqual(payload["dispatch"]["state"], "QUEUED")
|
||||
self.assertEqual(payload["task"]["state"], "RUNNING")
|
||||
|
||||
def test_10_cell_and_profile_isolation(self) -> None:
|
||||
dispatch = self._dispatch("isolation")
|
||||
identity = self._identity(dispatch)
|
||||
for field in ("cell_id", "profile_id"):
|
||||
bad = dict(identity)
|
||||
bad[field] = f"wrong-{field}"
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(
|
||||
self._lifecycle("claim", bad, worker_id="worker-a")[0], 400
|
||||
)
|
||||
self.assertEqual(self._lifecycle("status", identity)[1]["job"]["state"], "QUEUED")
|
||||
|
||||
def test_11_wrong_lease_owner(self) -> None:
|
||||
dispatch = self._dispatch("wrong-owner")
|
||||
identity = self._identity(dispatch)
|
||||
self.assertEqual(
|
||||
self._lifecycle("claim", identity, worker_id="worker-owner")[0], 200
|
||||
)
|
||||
status, _ = self._lifecycle("start", identity, worker_id="worker-other")
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
def test_12_mismatched_identity_tuple(self) -> None:
|
||||
dispatch = self._dispatch("identity")
|
||||
identity = self._identity(dispatch)
|
||||
for field in (
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"trace_id",
|
||||
"principal_id",
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"dispatch_id",
|
||||
"job_id",
|
||||
):
|
||||
bad = dict(identity)
|
||||
bad[field] = f"wrong-{field}"
|
||||
with self.subTest(field=field):
|
||||
status, _ = self._lifecycle("status", bad)
|
||||
self.assertIn(status, {400, 404, 409})
|
||||
|
||||
def test_13_same_terminal_result_is_idempotent(self) -> None:
|
||||
_, identity, _ = self._claim_start("same-terminal")
|
||||
values = {"worker_id": "worker-a", "output": {"stable": True}}
|
||||
first = self._lifecycle("complete", identity, **values)
|
||||
second = self._lifecycle("complete", identity, **values)
|
||||
self.assertEqual(first[0], 200)
|
||||
self.assertEqual(second[0], 200)
|
||||
self.assertEqual(first[1]["result"], second[1]["result"])
|
||||
|
||||
def test_14_different_terminal_result_conflicts(self) -> None:
|
||||
_, identity, _ = self._claim_start("different-terminal")
|
||||
self.assertEqual(
|
||||
self._lifecycle(
|
||||
"complete", identity, worker_id="worker-a", output={"v": 1}
|
||||
)[0],
|
||||
200,
|
||||
)
|
||||
status, _ = self._lifecycle(
|
||||
"fail", identity, worker_id="worker-a", error={"code": "different"}
|
||||
)
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
def test_15_full_state_consistency(self) -> None:
|
||||
_, identity, _ = self._claim_start("consistency")
|
||||
self.assertEqual(
|
||||
self._lifecycle(
|
||||
"complete", identity, worker_id="worker-a", output={"ok": True}
|
||||
)[0],
|
||||
200,
|
||||
)
|
||||
status, payload = self._lifecycle("status", identity)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["job"]["state"], "COMPLETED")
|
||||
self.assertEqual(payload["dispatch"]["state"], "COMPLETED")
|
||||
self.assertEqual(payload["task"]["state"], "COMPLETED")
|
||||
self.assertEqual(payload["result"]["state"], "COMPLETED")
|
||||
status, executions = self._request("GET", "/api/v2/executions")
|
||||
self.assertEqual(status, 200)
|
||||
matching = [
|
||||
item
|
||||
for item in executions["executions"]
|
||||
if item["execution_id"] == identity["job_id"]
|
||||
]
|
||||
self.assertEqual(len(matching), 1)
|
||||
self.assertEqual(matching[0]["status"], "completed")
|
||||
self.assertEqual(matching[0]["provenance_id"], self.provenance_id)
|
||||
self.assertEqual(matching[0]["run_id"], self.run_id)
|
||||
status, audit = self._request("GET", "/api/v2/audit")
|
||||
self.assertEqual(status, 200)
|
||||
actions = {
|
||||
item["action"]
|
||||
for item in audit["events"]
|
||||
if item["resource_id"] == identity["job_id"]
|
||||
}
|
||||
self.assertTrue({"HERMES_CLAIM", "HERMES_START", "HERMES_COMPLETE"} <= actions)
|
||||
|
||||
def test_16_restart_then_reconcile(self) -> None:
|
||||
_, identity, _ = self._claim_start("restart-reconcile")
|
||||
self._restart_server()
|
||||
status, payload = self._lifecycle(
|
||||
"complete", identity, worker_id="worker-a", output={"recovered": True}
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["task"]["state"], "COMPLETED")
|
||||
|
||||
def test_17_lifecycle_api_disabled_fail_closed(self) -> None:
|
||||
dispatch = self._dispatch("disabled")
|
||||
identity = self._identity(dispatch)
|
||||
self._stop_server()
|
||||
os.environ["AIOS_HERMES_LIFECYCLE_API_ENABLED"] = "0"
|
||||
self._start_server()
|
||||
status, _ = self._lifecycle("status", identity)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,383 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
if str(APP_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(APP_ROOT))
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_hermes import HermesAdapter
|
||||
from aios_v2 import (
|
||||
ControlPlaneService,
|
||||
Ed25519PrivateKeySigner,
|
||||
ExecutionEvidence,
|
||||
ExecutionOrchestrator,
|
||||
ExecutionPath,
|
||||
ExecutionTracker,
|
||||
GitExecutionContext,
|
||||
GitOperation,
|
||||
OperationContext,
|
||||
OrchestrationError,
|
||||
PRODUCTION_POLICY_VERSION,
|
||||
ProvenanceControlledWriter,
|
||||
ProvenanceDenied,
|
||||
ProvenanceExpired,
|
||||
ProvenanceGitError,
|
||||
ProvenanceGitWriter,
|
||||
ProvenanceIssuer,
|
||||
ProvenanceOperationGate,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceSignatureError,
|
||||
ProvenanceStatus,
|
||||
ProvenanceVerifier,
|
||||
RequestContext,
|
||||
RunProvenance,
|
||||
SQLiteProvenanceStore,
|
||||
TaskState,
|
||||
digest_payload,
|
||||
)
|
||||
|
||||
|
||||
class ProvenanceTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.store_path = self.root / "provenance.sqlite3"
|
||||
self.store = SQLiteProvenanceStore(self.store_path)
|
||||
self.signer = Ed25519PrivateKeySigner(
|
||||
Ed25519PrivateKey.generate(),
|
||||
key_id="owner-ed25519-test",
|
||||
)
|
||||
self.verifier = self.signer.public_verifier()
|
||||
self.issuer = ProvenanceIssuer(self.signer, self.store)
|
||||
self.writer = ProvenanceControlledWriter(
|
||||
ProvenanceVerifier(self.store, {self.verifier.key_id: self.verifier}),
|
||||
self.root / "receipts",
|
||||
)
|
||||
self.context = OperationContext(
|
||||
provenance_id="placeholder",
|
||||
run_id="run-001",
|
||||
tenant_id="tenant-a",
|
||||
task_id="task-001",
|
||||
agent_id="agent-001",
|
||||
principal_id="owner-001",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
try:
|
||||
self.store.close()
|
||||
finally:
|
||||
self.temp.cleanup()
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
ttl_seconds: int = 3600,
|
||||
now: datetime | None = None,
|
||||
allowed_git_operations: tuple[str, ...] | None = None,
|
||||
) -> RunProvenance:
|
||||
provenance = self.issuer.issue(
|
||||
run_id=self.context.run_id,
|
||||
issuer="aios-owner",
|
||||
tenant_id=self.context.tenant_id,
|
||||
task_id=self.context.task_id,
|
||||
agent_id=self.context.agent_id,
|
||||
principal_id=self.context.principal_id,
|
||||
allowed_git_operations=(
|
||||
allowed_git_operations
|
||||
if allowed_git_operations is not None
|
||||
else tuple(item.value for item in GitOperation)
|
||||
),
|
||||
ttl_seconds=ttl_seconds,
|
||||
now=now,
|
||||
)
|
||||
self.context = replace(self.context, provenance_id=provenance.provenance_id)
|
||||
return provenance
|
||||
|
||||
def authorization_values(self, request: dict[str, object]) -> dict[str, str]:
|
||||
return {
|
||||
"provenance_id": self.context.provenance_id,
|
||||
"run_id": self.context.run_id,
|
||||
"tenant_id": self.context.tenant_id,
|
||||
"task_id": self.context.task_id,
|
||||
"agent_id": self.context.agent_id,
|
||||
"principal_id": self.context.principal_id,
|
||||
"request_digest": digest_payload(request),
|
||||
}
|
||||
|
||||
def test_issue_verify_use_and_restart_recovery(self) -> None:
|
||||
provenance = self.issue()
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.ISSUED
|
||||
)
|
||||
receipt = ProvenanceOperationGate(self.writer).authorize(
|
||||
self.context,
|
||||
execution_path=ExecutionPath.DEPLOYMENT.value,
|
||||
operation="candidate-deploy",
|
||||
request={"release": "v2r1-candidate", "target": "staging"},
|
||||
)
|
||||
self.assertTrue(receipt.authorization_id.startswith("pauth_"))
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.USED
|
||||
)
|
||||
self.assertEqual(self.store.authorization_count(provenance.provenance_id), 1)
|
||||
self.store.close()
|
||||
self.store = SQLiteProvenanceStore(self.store_path)
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.USED
|
||||
)
|
||||
self.assertEqual(self.store.authorization_count(provenance.provenance_id), 1)
|
||||
event_types = [
|
||||
item["event_type"] for item in self.store.events(provenance.provenance_id)
|
||||
]
|
||||
self.assertIn("issued", event_types)
|
||||
self.assertIn("verified", event_types)
|
||||
self.assertIn("used", event_types)
|
||||
|
||||
def test_tampered_payload_is_rejected(self) -> None:
|
||||
provenance = self.issue()
|
||||
tampered = replace(
|
||||
provenance,
|
||||
provenance_id="prov_tampered",
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
self.store.save_issued(tampered)
|
||||
with self.assertRaises(ProvenanceSignatureError):
|
||||
self.writer.authorize_deployment(
|
||||
provenance_id=tampered.provenance_id,
|
||||
run_id=tampered.run_id,
|
||||
tenant_id=tampered.tenant_id,
|
||||
task_id=tampered.task_id,
|
||||
agent_id=tampered.agent_id,
|
||||
principal_id=tampered.principal_id,
|
||||
request_digest=digest_payload({"operation": "tamper-test"}),
|
||||
)
|
||||
|
||||
def test_expired_provenance_is_marked_and_rejected(self) -> None:
|
||||
issued_at = datetime(2026, 7, 20, tzinfo=timezone.utc)
|
||||
provenance = self.issue(ttl_seconds=1, now=issued_at)
|
||||
verifier = ProvenanceVerifier(
|
||||
self.store, {self.verifier.key_id: self.verifier}
|
||||
)
|
||||
with self.assertRaises(ProvenanceExpired):
|
||||
verifier.authorize(
|
||||
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=ExecutionPath.DEPLOYMENT.value,
|
||||
request_digest=digest_payload({"operation": "expired"}),
|
||||
now=issued_at + timedelta(seconds=2),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.EXPIRED
|
||||
)
|
||||
|
||||
def test_revoked_provenance_is_rejected(self) -> None:
|
||||
provenance = self.issue()
|
||||
self.store.revoke(
|
||||
provenance.provenance_id,
|
||||
reason="owner emergency stop",
|
||||
principal_id=self.context.principal_id,
|
||||
)
|
||||
with self.assertRaises(ProvenanceRevoked):
|
||||
self.writer.authorize_rollback(
|
||||
**self.authorization_values({"operation": "rollback"})
|
||||
)
|
||||
self.assertEqual(
|
||||
self.store.status(provenance.provenance_id), ProvenanceStatus.REVOKED
|
||||
)
|
||||
|
||||
def test_identical_authorization_replay_is_rejected(self) -> None:
|
||||
self.issue()
|
||||
values = self.authorization_values({"operation": "deploy", "release": "r1"})
|
||||
self.writer.authorize_deployment(**values)
|
||||
with self.assertRaises(ProvenanceReplay):
|
||||
self.writer.authorize_deployment(**values)
|
||||
|
||||
def test_production_policy_requires_all_seven_paths(self) -> None:
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
self.issuer.issue(
|
||||
run_id=self.context.run_id,
|
||||
issuer="aios-owner",
|
||||
tenant_id=self.context.tenant_id,
|
||||
task_id=self.context.task_id,
|
||||
agent_id=self.context.agent_id,
|
||||
principal_id=self.context.principal_id,
|
||||
allowed_paths=(ExecutionPath.RUNTIME_EXECUTE.value,),
|
||||
allowed_git_operations=(),
|
||||
policy_version=PRODUCTION_POLICY_VERSION,
|
||||
)
|
||||
|
||||
def test_context_mismatch_is_rejected(self) -> None:
|
||||
self.issue()
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
self.writer.authorize_runtime_execute(
|
||||
provenance_id=self.context.provenance_id,
|
||||
run_id=self.context.run_id,
|
||||
tenant_id="tenant-b",
|
||||
task_id=self.context.task_id,
|
||||
agent_id=self.context.agent_id,
|
||||
principal_id=self.context.principal_id,
|
||||
request_digest=digest_payload({"operation": "runtime"}),
|
||||
)
|
||||
|
||||
def test_evidence_requires_provenance_and_persists_receipt(self) -> None:
|
||||
self.issue()
|
||||
tracker = ExecutionTracker(
|
||||
self.root / "evidence",
|
||||
provenance_writer=self.writer,
|
||||
require_provenance=True,
|
||||
)
|
||||
evidence = ExecutionEvidence(
|
||||
execution_id="exec-001",
|
||||
tenant_id=self.context.tenant_id,
|
||||
task_id=self.context.task_id,
|
||||
agent_id=self.context.agent_id,
|
||||
executor="openclaw",
|
||||
branch="task/openclaw/provenance-test",
|
||||
provenance_id=self.context.provenance_id,
|
||||
run_id=self.context.run_id,
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
tracker.claim(evidence)
|
||||
claimed = tracker.claim(
|
||||
evidence,
|
||||
principal_id=self.context.principal_id,
|
||||
)
|
||||
self.assertIsNotNone(claimed.last_authorization_id)
|
||||
heartbeat = tracker.heartbeat(
|
||||
tenant_id=self.context.tenant_id,
|
||||
execution_id=evidence.execution_id,
|
||||
status="running",
|
||||
current_step="test",
|
||||
progress_completed=1,
|
||||
progress_total=2,
|
||||
principal_id=self.context.principal_id,
|
||||
)
|
||||
self.assertEqual(heartbeat.status, "running")
|
||||
completed = tracker.complete(
|
||||
tenant_id=self.context.tenant_id,
|
||||
execution_id=evidence.execution_id,
|
||||
status="complete",
|
||||
test_status="passed",
|
||||
test_summary={"passed": 1, "failed": 0},
|
||||
result_summary={"result": "ok"},
|
||||
principal_id=self.context.principal_id,
|
||||
)
|
||||
self.assertEqual(completed.status, "complete")
|
||||
self.assertEqual(completed.progress_completed, completed.progress_total)
|
||||
|
||||
def test_runtime_dispatch_without_provenance_is_denied_before_running(self) -> None:
|
||||
self.issue()
|
||||
service = ControlPlaneService()
|
||||
request = RequestContext(
|
||||
tenant_id=self.context.tenant_id,
|
||||
principal_id=self.context.principal_id,
|
||||
roles=frozenset({"platform_admin", "tenant_admin", "operator", "approver"}),
|
||||
trace_id="trace-prov-runtime",
|
||||
session_id="session-prov-runtime",
|
||||
policy_version="policy-v1",
|
||||
)
|
||||
service.register_tenant(
|
||||
request,
|
||||
tenant_id=self.context.tenant_id,
|
||||
display_name="Tenant A",
|
||||
)
|
||||
agent = service.register_agent(
|
||||
request,
|
||||
agent_id=self.context.agent_id,
|
||||
display_name="Hermes Agent",
|
||||
runtime="hermes",
|
||||
capabilities=("build",),
|
||||
metadata={"cell_id": "cell-a", "profile_id": "profile-a"},
|
||||
)
|
||||
task = service.create_task(
|
||||
request,
|
||||
agent_id=agent.agent_id,
|
||||
action="build",
|
||||
payload={"target": "candidate"},
|
||||
idempotency_key="prov-runtime-001",
|
||||
)
|
||||
task = service.transition_task(
|
||||
request,
|
||||
task_id=task.task_id,
|
||||
target_state=TaskState.APPROVED,
|
||||
expected_revision=task.revision,
|
||||
)
|
||||
orchestrator = ExecutionOrchestrator(
|
||||
service,
|
||||
hermes=HermesAdapter(),
|
||||
provenance_writer=self.writer,
|
||||
require_provenance=True,
|
||||
)
|
||||
with self.assertRaises(OrchestrationError):
|
||||
orchestrator.dispatch(request, task_id=task.task_id)
|
||||
self.assertEqual(service.get_task(request, task.task_id).state.value, "APPROVED")
|
||||
|
||||
def test_git_writer_rejects_main_fetch_to_local_branch_and_unallowed_operation(self) -> None:
|
||||
self.issue(allowed_git_operations=(GitOperation.READ_REF.value,))
|
||||
repo = self.root / "repo"
|
||||
repo.mkdir()
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["git", "init", str(repo)], check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "config", "user.name", "AIOS Test"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "config", "user.email", "aios@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
(repo / "README.md").write_text("test\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "commit", "-m", "initial"], check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
writer = ProvenanceGitWriter(repo, self.writer)
|
||||
git_context = GitExecutionContext(**self.context.as_dict())
|
||||
with self.assertRaises(ProvenanceGitError):
|
||||
writer.push(git_context, remote="origin", branch="main")
|
||||
with self.assertRaises(ProvenanceGitError):
|
||||
writer.fetch(
|
||||
git_context,
|
||||
remote="origin",
|
||||
refspecs=("+refs/heads/x:refs/heads/x",),
|
||||
)
|
||||
with self.assertRaises(ProvenanceGitError):
|
||||
writer.create_branch(git_context, branch="patch/test", start_point="HEAD")
|
||||
|
||||
def test_deployment_and_rollback_are_separate_authorizations(self) -> None:
|
||||
self.issue()
|
||||
gate = ProvenanceOperationGate(self.writer)
|
||||
deployment = gate.authorize(
|
||||
self.context,
|
||||
execution_path=ExecutionPath.DEPLOYMENT.value,
|
||||
operation="systemd-restart",
|
||||
request={"service": "aios-v2"},
|
||||
)
|
||||
rollback = gate.authorize(
|
||||
self.context,
|
||||
execution_path=ExecutionPath.ROLLBACK.value,
|
||||
operation="release-rollback",
|
||||
request={"target": "previous"},
|
||||
)
|
||||
self.assertNotEqual(deployment.authorization_id, rollback.authorization_id)
|
||||
self.assertEqual(deployment.execution_path, ExecutionPath.DEPLOYMENT.value)
|
||||
self.assertEqual(rollback.execution_path, ExecutionPath.ROLLBACK.value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
if str(APP_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(APP_ROOT))
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_v2.provenance import (
|
||||
Ed25519PrivateKeySigner,
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
ProvenanceDenied,
|
||||
ProvenanceExpired,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceVerifier,
|
||||
SQLiteProvenanceStore,
|
||||
digest_payload,
|
||||
)
|
||||
from aios_v2.provenance_grants import (
|
||||
GrantIssuer,
|
||||
GrantType,
|
||||
GrantVerifier,
|
||||
SQLiteGrantStore,
|
||||
WorkflowProvenanceIssuer,
|
||||
)
|
||||
from aios_v2.provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
|
||||
|
||||
|
||||
class ProvenanceGrantIsolationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.store_file = self.root / "provenance.sqlite3"
|
||||
self.parent_store = SQLiteProvenanceStore(self.store_file)
|
||||
self.grant_store = SQLiteGrantStore(self.store_file)
|
||||
self.signer = Ed25519PrivateKeySigner(
|
||||
Ed25519PrivateKey.generate(),
|
||||
key_id="grant-isolation-key",
|
||||
)
|
||||
self.public_verifier = self.signer.public_verifier()
|
||||
self.workflow = WorkflowProvenanceIssuer(
|
||||
self.signer,
|
||||
self.parent_store,
|
||||
).issue(
|
||||
run_id="run-grant-001",
|
||||
issuer="aios-owner",
|
||||
tenant_id="tenant-a",
|
||||
task_id="task-a",
|
||||
agent_id="agent-a",
|
||||
principal_id="owner-a",
|
||||
ttl_seconds=3600,
|
||||
)
|
||||
self.grant_issuer = GrantIssuer(
|
||||
self.signer,
|
||||
self.parent_store,
|
||||
self.grant_store,
|
||||
)
|
||||
self.grant_verifier = GrantVerifier(
|
||||
self.parent_store,
|
||||
self.grant_store,
|
||||
{self.public_verifier.key_id: self.public_verifier},
|
||||
)
|
||||
self.values = {
|
||||
"provenance_id": self.workflow.provenance_id,
|
||||
"run_id": self.workflow.run_id,
|
||||
"tenant_id": self.workflow.tenant_id,
|
||||
"task_id": self.workflow.task_id,
|
||||
"agent_id": self.workflow.agent_id,
|
||||
"principal_id": self.workflow.principal_id,
|
||||
}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
try:
|
||||
self.grant_store.close()
|
||||
finally:
|
||||
try:
|
||||
self.parent_store.close()
|
||||
finally:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def issue_grant(
|
||||
self,
|
||||
grant_type: GrantType,
|
||||
*,
|
||||
ttl_seconds: int = 900,
|
||||
allowed_git_operations: tuple[str, ...] = (),
|
||||
now: datetime | None = None,
|
||||
):
|
||||
return self.grant_issuer.issue(
|
||||
parent_provenance_id=self.workflow.provenance_id,
|
||||
grant_type=grant_type,
|
||||
ttl_seconds=ttl_seconds,
|
||||
allowed_git_operations=allowed_git_operations,
|
||||
now=now,
|
||||
)
|
||||
|
||||
def writer(self, path: ExecutionPath, grant_id: str):
|
||||
return ScopedLeastPrivilegeProvenanceWriter(
|
||||
self.grant_verifier,
|
||||
self.root / "receipts",
|
||||
{path.value: grant_id},
|
||||
required_paths=(path.value,),
|
||||
writer_id=f"test-{path.value}-writer",
|
||||
)
|
||||
|
||||
def authorization_values(self, label: str) -> dict[str, str]:
|
||||
return {
|
||||
**self.values,
|
||||
"request_digest": digest_payload({"operation": label}),
|
||||
}
|
||||
|
||||
def test_parent_workflow_has_no_operational_rights(self) -> None:
|
||||
verifier = ProvenanceVerifier(
|
||||
self.parent_store,
|
||||
{self.public_verifier.key_id: self.public_verifier},
|
||||
)
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
verifier.authorize(
|
||||
provenance_id=self.workflow.provenance_id,
|
||||
run_id=self.workflow.run_id,
|
||||
tenant_id=self.workflow.tenant_id,
|
||||
task_id=self.workflow.task_id,
|
||||
agent_id=self.workflow.agent_id,
|
||||
principal_id=self.workflow.principal_id,
|
||||
execution_path=ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
request_digest=digest_payload({"operation": "parent-runtime"}),
|
||||
)
|
||||
|
||||
def test_runtime_scope_allows_runtime_and_denies_deployment(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
writer = self.writer(ExecutionPath.RUNTIME_EXECUTE, grant.grant_id)
|
||||
|
||||
authorization = writer.authorize_runtime_execute(
|
||||
**self.authorization_values("runtime-dispatch")
|
||||
)
|
||||
self.assertEqual(authorization.grant_id, grant.grant_id)
|
||||
self.assertEqual(
|
||||
authorization.execution_path,
|
||||
ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
)
|
||||
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
writer.authorize_deployment(
|
||||
**self.authorization_values("forbidden-deploy")
|
||||
)
|
||||
|
||||
def test_evidence_scope_cannot_authorize_runtime(self) -> None:
|
||||
grant = self.issue_grant(GrantType.EVIDENCE)
|
||||
writer = self.writer(ExecutionPath.EVIDENCE_WRITE, grant.grant_id)
|
||||
writer.authorize_evidence_write(
|
||||
**self.authorization_values("evidence-write")
|
||||
)
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
writer.authorize_runtime_execute(
|
||||
**self.authorization_values("forbidden-runtime")
|
||||
)
|
||||
|
||||
def test_deployment_scope_cannot_authorize_rollback(self) -> None:
|
||||
grant = self.issue_grant(GrantType.DEPLOYMENT)
|
||||
writer = self.writer(ExecutionPath.DEPLOYMENT, grant.grant_id)
|
||||
writer.authorize_deployment(
|
||||
**self.authorization_values("deploy-release")
|
||||
)
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
writer.authorize_rollback(
|
||||
**self.authorization_values("forbidden-rollback")
|
||||
)
|
||||
|
||||
def test_git_grant_limits_individual_git_operations(self) -> None:
|
||||
grant = self.issue_grant(
|
||||
GrantType.GIT,
|
||||
allowed_git_operations=(GitOperation.READ_REF.value,),
|
||||
)
|
||||
writer = self.writer(ExecutionPath.GIT_OPERATION, grant.grant_id)
|
||||
authorization = writer.authorize_git_operation(
|
||||
git_operation=GitOperation.READ_REF.value,
|
||||
**self.authorization_values("git-read-ref"),
|
||||
)
|
||||
self.assertEqual(authorization.git_operation, GitOperation.READ_REF.value)
|
||||
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
writer.authorize_git_operation(
|
||||
git_operation=GitOperation.PUSH.value,
|
||||
**self.authorization_values("forbidden-git-push"),
|
||||
)
|
||||
|
||||
def test_context_mismatch_is_rejected(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
with self.assertRaises(ProvenanceDenied):
|
||||
self.grant_verifier.authorize(
|
||||
grant_id=grant.grant_id,
|
||||
parent_provenance_id=self.workflow.provenance_id,
|
||||
run_id=self.workflow.run_id,
|
||||
tenant_id="tenant-b",
|
||||
task_id=self.workflow.task_id,
|
||||
agent_id=self.workflow.agent_id,
|
||||
principal_id=self.workflow.principal_id,
|
||||
execution_path=ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
request_digest=digest_payload({"operation": "tenant-mismatch"}),
|
||||
)
|
||||
|
||||
def test_identical_grant_authorization_is_replay_protected(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
writer = self.writer(ExecutionPath.RUNTIME_EXECUTE, grant.grant_id)
|
||||
values = self.authorization_values("same-runtime-request")
|
||||
writer.authorize_runtime_execute(**values)
|
||||
with self.assertRaises(ProvenanceReplay):
|
||||
writer.authorize_runtime_execute(**values)
|
||||
|
||||
def test_revoked_child_grant_is_rejected(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
self.grant_store.revoke(
|
||||
grant.grant_id,
|
||||
reason="runtime emergency stop",
|
||||
principal_id=self.workflow.principal_id,
|
||||
)
|
||||
writer = self.writer(ExecutionPath.RUNTIME_EXECUTE, grant.grant_id)
|
||||
with self.assertRaises(ProvenanceRevoked):
|
||||
writer.authorize_runtime_execute(
|
||||
**self.authorization_values("revoked-runtime")
|
||||
)
|
||||
|
||||
def test_revoked_parent_blocks_all_child_grants(self) -> None:
|
||||
grant = self.issue_grant(GrantType.RUNTIME)
|
||||
self.parent_store.revoke(
|
||||
self.workflow.provenance_id,
|
||||
reason="workflow cancelled",
|
||||
principal_id=self.workflow.principal_id,
|
||||
)
|
||||
writer = self.writer(ExecutionPath.RUNTIME_EXECUTE, grant.grant_id)
|
||||
with self.assertRaises(ProvenanceRevoked):
|
||||
writer.authorize_runtime_execute(
|
||||
**self.authorization_values("parent-revoked")
|
||||
)
|
||||
|
||||
def test_expired_child_grant_is_rejected(self) -> None:
|
||||
issued = datetime.now(timezone.utc)
|
||||
grant = self.issue_grant(
|
||||
GrantType.RUNTIME,
|
||||
ttl_seconds=1,
|
||||
now=issued,
|
||||
)
|
||||
with self.assertRaises(ProvenanceExpired):
|
||||
self.grant_verifier.authorize(
|
||||
grant_id=grant.grant_id,
|
||||
parent_provenance_id=self.workflow.provenance_id,
|
||||
run_id=self.workflow.run_id,
|
||||
tenant_id=self.workflow.tenant_id,
|
||||
task_id=self.workflow.task_id,
|
||||
agent_id=self.workflow.agent_id,
|
||||
principal_id=self.workflow.principal_id,
|
||||
execution_path=ExecutionPath.RUNTIME_EXECUTE.value,
|
||||
request_digest=digest_payload({"operation": "expired-runtime"}),
|
||||
now=issued + timedelta(seconds=2),
|
||||
)
|
||||
|
||||
def test_scoped_writer_rejects_unnecessary_grant_mapping(self) -> None:
|
||||
runtime = self.issue_grant(GrantType.RUNTIME)
|
||||
deployment = self.issue_grant(GrantType.DEPLOYMENT)
|
||||
with self.assertRaises(ValueError):
|
||||
ScopedLeastPrivilegeProvenanceWriter(
|
||||
self.grant_verifier,
|
||||
self.root / "receipts-extra",
|
||||
{
|
||||
ExecutionPath.RUNTIME_EXECUTE.value: runtime.grant_id,
|
||||
ExecutionPath.DEPLOYMENT.value: deployment.grant_id,
|
||||
},
|
||||
required_paths=(ExecutionPath.RUNTIME_EXECUTE.value,),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user