Compare commits

...
Author SHA1 Message Date
Codex a90023e8d1 docs(incubator): record independent cleanup review 2026-07-21 15:33:37 +08:00
Codex db72e3184f docs(incubator): record V2R1C1 worktree cleanup 2026-07-21 15:18:31 +08:00
Codex 4487a774fd docs(incubator): define adoption and archive boundaries 2026-07-21 14:57:41 +08:00
Codex a8e7a97f2d test(incubator): cover future capability contracts 2026-07-21 14:57:41 +08:00
Codex 2f1d63c147 feat(incubator): add auth session and evidence integrity 2026-07-21 14:57:41 +08:00
Codex 662ccf36f8 feat(incubator): add adapter runner and worktree contracts 2026-07-21 14:57:41 +08:00
Codex 102d2306f0 feat(incubator): add channel-neutral versioned contract SDK 2026-07-21 14:34:29 +08:00
Codex 28eca15d75 chore(incubator): create future capability integration branch from validated Hermes lifecycle snapshot 2026-07-21 14:30:22 +08:00
Codex fb739f6e8f docs(hermes): preserve formal acceptance blocker 2026-07-21 13:59:01 +08:00
Codex e6da39a3cd docs(hermes): record final evidence and worktree blocker 2026-07-21 13:55:08 +08:00
Codex 95d2e52cb5 fix(hermes): reserve cancellation for control plane 2026-07-21 13:41:34 +08:00
Codex 283dec58ab fix(hermes): enforce minimum heartbeat lease duration 2026-07-21 13:21:09 +08:00
Codex 7de1fe80ac docs(hermes): retain key-id and rollback contracts 2026-07-21 13:19:21 +08:00
Codex 89a502b39b test(hermes): preserve validated ten-case release gate 2026-07-21 13:19:21 +08:00
Codex 61bec280e2 deploy(hermes): preserve bounded rollback readiness 2026-07-21 13:19:21 +08:00
Codex 1791c91c5b docs(hermes): define lifecycle contract and validation state 2026-07-21 13:13:15 +08:00
Codex e8c0f0250b test(hermes): cover live lifecycle and recovery matrix 2026-07-21 13:09:06 +08:00
Codex 94718308fc fix(hermes): normalize trusted runtime headers and conflicts 2026-07-21 13:09:06 +08:00
Codex a4cbed0f5f feat(hermes): persist and protect full runtime lifecycle 2026-07-21 13:09:06 +08:00
Codex 25c7740739 docs(hardening): refresh Gate A report with verified execution results 2026-07-21 12:39:53 +08:00
Codex 8bbb049c39 docs(hardening): index ADR-009 without inventing missing history 2026-07-21 12:36:22 +08:00
Codex 1707cc4953 docs(hardening): rebuild complete UR-001 to UR-022 traceability 2026-07-21 12:34:50 +08:00
Codex e397594200 docs(hardening): add Run Provenance and scoped Grant requirements 2026-07-21 12:31:58 +08:00
Codex 68b7f2c1d2 docs(hardening): align module contracts with hardened runtime and Grant model 2026-07-21 12:28:25 +08:00
Codex fda98e61f5 docs(hardening): record least-privilege Provenance decision 2026-07-21 12:16:22 +08:00
Codex ea9f599522 fix(hardening): scope CLI authorization to one Grant 2026-07-21 12:13:21 +08:00
Codex 1aabcd8b62 test(hardening): add least-privilege Grant isolation coverage 2026-07-21 12:04:02 +08:00
Codex 4d68af9524 test(hardening): upgrade runtime flows and add dispatch recovery coverage 2026-07-21 12:01:17 +08:00
Codex 467b127804 fix(hardening): give application a safe default Grant scope 2026-07-21 11:57:32 +08:00
Codex 50a73b211b fix(hardening): bind dispatch and runtime results to principal 2026-07-21 11:55:07 +08:00
Codex f14d4b6e14 feat(hardening): persist principal and runtime lookup in dispatch store 2026-07-21 11:51:01 +08:00
Codex fc8bd8c8d4 feat(hardening): build scoped Grant writers per service 2026-07-21 11:48:11 +08:00
Codex 382fe662a3 feat(hardening): add service-scoped least-privilege writer 2026-07-21 11:45:46 +08:00
Codex 4754e5762a fix(hardening): use constant-time fingerprint comparison 2026-07-21 11:42:08 +08:00
Codex ce7d35a811 config(hardening): add seven least-privilege Grant references 2026-07-21 11:37:58 +08:00
Codex 60ac7d9d0b refactor(hardening): make evidence tracking use least-privilege authorization interface 2026-07-21 11:34:43 +08:00
Codex 2a10d1cdd9 feat(hardening): add workflow and least-privilege Grant CLI 2026-07-21 11:31:51 +08:00
Codex 677ea738e7 fix(hardening): authorize Git content before modifying index 2026-07-21 11:27:28 +08:00
Codex 7154d62cca fix(hardening): export OpenClaw Ed25519 contract types 2026-07-21 11:24:35 +08:00
Codex 4e154c8baf feat(hardening): wire durable dispatch least-privilege grants and internal reconcile 2026-07-21 11:20:39 +08:00
Codex b848361079 feat(hardening): require least-privilege grants in production writer 2026-07-21 11:17:39 +08:00
Codex 74224cfbd5 feat(hardening): add least-privilege provenance child grants 2026-07-21 11:14:42 +08:00
Codex a7c09ba646 feat(hardening): upgrade OpenClaw command contract to Ed25519 v2 2026-07-21 11:10:21 +08:00
Codex f055cb74bb feat(hardening): persist dispatches and strictly bind runtime results 2026-07-21 11:04:59 +08:00
Codex 071a378de6 fix(hardening): close public runtime lifecycle bypass 2026-07-21 11:00:28 +08:00
Codex a492b15df6 feat(hardening): add durable dispatch repository 2026-07-21 10:53:32 +08:00
Codex a9f0a4359b patch(chatgpt): start full Gate A hardening 2026-07-21 10:46:59 +08:00
Codex 6a9fab894a docs(traceability): add run provenance implementation matrix 2026-07-21 10:30:54 +08:00
Codex e9111dea47 deploy(provenance): add mandatory operation authorization wrapper 2026-07-21 10:30:40 +08:00
Codex d25fbfa61e test(provenance): add signature lifecycle replay and seven-path gate coverage 2026-07-21 10:21:01 +08:00
Codex 6b03c47625 build(provenance): install Ed25519 runtime dependency 2026-07-21 10:16:28 +08:00
Codex 770fe1ef66 docs(aios-v2): add ADR-007 run provenance control 2026-07-21 10:12:03 +08:00
Codex 7a025430f9 config(provenance): add production environment references 2026-07-21 10:11:56 +08:00
Codex d079da4029 feat(provenance): export trusted provenance APIs 2026-07-21 10:11:49 +08:00
Codex b8f7dc4112 feat(provenance): wire production gate into API application 2026-07-21 10:07:19 +08:00
Codex 947cdaff42 feat(provenance): add issuer authorization revoke and status CLI 2026-07-21 10:00:02 +08:00
Codex 83b0c8bd74 feat(provenance): add deployment and rollback operation gate factory 2026-07-21 09:56:50 +08:00
Codex fbd880cf8c feat(provenance): add controlled Git writer with operation policy 2026-07-21 09:46:40 +08:00
Codex 819ac94c9f feat(provenance): enforce authorization before evidence writes 2026-07-21 09:42:25 +08:00
Codex b02401e60d feat(provenance): enforce signed provenance before runtime dispatch 2026-07-21 09:39:33 +08:00
Codex cbb2e232f5 feat(provenance): implement Ed25519 issuer persistent store and seven-path writer 2026-07-21 09:33:07 +08:00
Codex 9e46ac0504 patch(codex): start trusted run provenance issuer store and writer blocker resolution 2026-07-20 20:11:59 +08:00
61 changed files with 8342 additions and 614 deletions
@@ -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",
]
+161 -5
View File
@@ -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))
+9 -1
View File
@@ -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",
]
+194 -24
View File
@@ -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)
+65 -1
View File
@@ -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",
]
+495 -45
View File
@@ -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]
+152 -24
View File
@@ -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)
+57 -50
View File
@@ -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)
+466 -131
View File
@@ -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)
}
+831
View File
@@ -0,0 +1,831 @@
"""AIOS V2 可信 Run Provenance 签发、持久化与受控写入。
生产策略要求 Provenance 同时覆盖七类路径:代码变更、Git 操作、部署、
Runtime 执行、Evidence 写入、回滚和审计。任何缺失、篡改、过期、撤销、
重放或上下文不匹配都默认拒绝。
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import sqlite3
import stat
import threading
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
PROVENANCE_VERSION = "aios.v2.run-provenance.v1"
PRODUCTION_POLICY_VERSION = "aios.v2.production.v1"
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def parse_timestamp(value: str) -> datetime:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("timestamp is required")
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
raise ValueError("timestamp must include timezone")
return parsed.astimezone(timezone.utc)
def required(value: str, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{label} is required")
return normalized
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
return json.dumps(
dict(value),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
def digest_payload(value: Mapping[str, Any]) -> str:
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
def _validate_digest(value: str) -> str:
normalized = required(value, "request_digest").lower()
if len(normalized) != 64 or any(ch not in "0123456789abcdef" for ch in normalized):
raise ValueError("request_digest must be a SHA-256 hexadecimal digest")
return normalized
class ExecutionPath(str, Enum):
CODE_CHANGE = "code_change"
GIT_OPERATION = "git_operation"
DEPLOYMENT = "deployment"
RUNTIME_EXECUTE = "runtime_execute"
EVIDENCE_WRITE = "evidence_write"
ROLLBACK = "rollback"
AUDIT = "audit"
REQUIRED_PRODUCTION_PATHS = frozenset(item.value for item in ExecutionPath)
class GitOperation(str, Enum):
READ_REF = "read_ref"
FETCH = "fetch"
CREATE_BRANCH = "create_branch"
COMMIT = "commit"
MERGE = "merge"
PUSH = "push"
UPDATE_EVIDENCE = "update_evidence"
class ProvenanceStatus(str, Enum):
ISSUED = "issued"
VERIFIED = "verified"
USED = "used"
EXPIRED = "expired"
REVOKED = "revoked"
class ProvenanceError(RuntimeError):
"""Run Provenance 基础错误。"""
class ProvenanceDenied(ProvenanceError):
"""策略拒绝当前操作。"""
class ProvenanceNotFound(ProvenanceDenied):
"""未找到 Provenance。"""
class ProvenanceSignatureError(ProvenanceDenied):
"""签名无效。"""
class ProvenanceExpired(ProvenanceDenied):
"""Provenance 已过期。"""
class ProvenanceRevoked(ProvenanceDenied):
"""Provenance 已撤销。"""
class ProvenanceReplay(ProvenanceDenied):
"""检测到相同授权摘要重放。"""
class Signer(Protocol):
key_id: str
def sign(self, payload: bytes) -> str: ...
class SignatureVerifier(Protocol):
key_id: str
fingerprint: str
def verify(self, payload: bytes, signature: str) -> bool: ...
def _load_crypto() -> tuple[Any, Any, Any]:
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
except ImportError as exc: # pragma: no cover - depends on deployment image
raise RuntimeError(
"cryptography package with Ed25519 support is required"
) from exc
return serialization, Ed25519PrivateKey, Ed25519PublicKey
def _check_private_key_file(path: Path) -> bytes:
resolved = path.resolve()
metadata = resolved.stat()
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("private key reference is not a regular file")
if metadata.st_mode & 0o077:
raise RuntimeError("private key file permissions must be 0600 or stricter")
return resolved.read_bytes()
def _check_public_key_file(path: Path) -> bytes:
resolved = path.resolve()
metadata = resolved.stat()
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("public key reference is not a regular file")
if metadata.st_mode & 0o022:
raise RuntimeError("public key file must not be group/world writable")
return resolved.read_bytes()
class Ed25519PrivateKeySigner:
"""Ed25519 Owner/Control Plane 签发器。"""
def __init__(self, private_key: Any, *, key_id: str) -> None:
self.private_key = private_key
self.key_id = required(key_id, "key_id")
@classmethod
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PrivateKeySigner":
serialization, private_cls, _ = _load_crypto()
value = _check_private_key_file(Path(path))
if value.startswith(b"-----BEGIN"):
key = serialization.load_pem_private_key(value, password=None)
elif len(value) == 32:
key = private_cls.from_private_bytes(value)
else:
raise RuntimeError("private key must be PEM or a raw 32-byte Ed25519 seed")
if not isinstance(key, private_cls):
raise RuntimeError("private key is not Ed25519")
return cls(key, key_id=key_id)
def sign(self, payload: bytes) -> str:
return base64.b64encode(self.private_key.sign(bytes(payload))).decode("ascii")
def public_verifier(self) -> "Ed25519PublicKeyVerifier":
return Ed25519PublicKeyVerifier(self.private_key.public_key(), key_id=self.key_id)
class Ed25519PublicKeyVerifier:
"""Ed25519 签名验证器。"""
def __init__(self, public_key: Any, *, key_id: str) -> None:
serialization, _, public_cls = _load_crypto()
if not isinstance(public_key, public_cls):
raise RuntimeError("public key is not Ed25519")
self.public_key = public_key
self.key_id = required(key_id, "key_id")
raw = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
self.fingerprint = hashlib.sha256(raw).hexdigest()
@classmethod
def from_file(cls, path: str | Path, *, key_id: str) -> "Ed25519PublicKeyVerifier":
serialization, _, public_cls = _load_crypto()
value = _check_public_key_file(Path(path))
if value.startswith(b"-----BEGIN"):
key = serialization.load_pem_public_key(value)
elif len(value) == 32:
key = public_cls.from_public_bytes(value)
else:
raise RuntimeError("public key must be PEM or raw 32-byte Ed25519 key")
if not isinstance(key, public_cls):
raise RuntimeError("public key is not Ed25519")
return cls(key, key_id=key_id)
def verify(self, payload: bytes, signature: str) -> bool:
try:
decoded = base64.b64decode(required(signature, "signature"), validate=True)
self.public_key.verify(decoded, bytes(payload))
return True
except Exception:
return False
@dataclass(frozen=True)
class RunProvenance:
provenance_id: str
run_id: str
issuer: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
allowed_paths: Sequence[str]
allowed_git_operations: Sequence[str]
issued_at: str
expires_at: str
policy_version: str
key_id: str
nonce: str
signature: str = ""
schema_version: str = PROVENANCE_VERSION
def __post_init__(self) -> None:
for name in (
"provenance_id",
"run_id",
"issuer",
"tenant_id",
"task_id",
"agent_id",
"principal_id",
"issued_at",
"expires_at",
"policy_version",
"key_id",
"nonce",
"schema_version",
):
object.__setattr__(self, name, required(getattr(self, name), name))
if self.schema_version != PROVENANCE_VERSION:
raise ValueError(f"unsupported schema_version: {self.schema_version}")
issued = parse_timestamp(self.issued_at)
expires = parse_timestamp(self.expires_at)
if expires <= issued:
raise ValueError("expires_at must be later than issued_at")
paths = tuple(sorted({ExecutionPath(item).value for item in self.allowed_paths}))
operations = tuple(
sorted({GitOperation(item).value for item in self.allowed_git_operations})
)
object.__setattr__(self, "allowed_paths", paths)
object.__setattr__(self, "allowed_git_operations", operations)
object.__setattr__(self, "signature", str(self.signature or "").strip())
def unsigned_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"provenance_id": self.provenance_id,
"run_id": self.run_id,
"issuer": self.issuer,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"agent_id": self.agent_id,
"principal_id": self.principal_id,
"allowed_paths": list(self.allowed_paths),
"allowed_git_operations": list(self.allowed_git_operations),
"issued_at": self.issued_at,
"expires_at": self.expires_at,
"policy_version": self.policy_version,
"key_id": self.key_id,
"nonce": self.nonce,
}
def canonical_bytes(self) -> bytes:
return canonical_json_bytes(self.unsigned_dict())
def as_dict(self) -> dict[str, Any]:
value = self.unsigned_dict()
value["signature"] = self.signature
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "RunProvenance":
return cls(**dict(value))
class ProvenanceIssuer:
"""只由 Owner 或 AIOS Control Plane 持有的签发服务。"""
def __init__(self, signer: Signer, store: "SQLiteProvenanceStore") -> None:
self.signer = signer
self.store = store
def issue(
self,
*,
run_id: str,
issuer: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
allowed_paths: Sequence[str] = tuple(REQUIRED_PRODUCTION_PATHS),
allowed_git_operations: Sequence[str] = tuple(item.value for item in GitOperation),
ttl_seconds: int = 3600,
policy_version: str = PRODUCTION_POLICY_VERSION,
provenance_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> RunProvenance:
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be positive")
normalized_paths = frozenset(ExecutionPath(item).value for item in allowed_paths)
if policy_version == PRODUCTION_POLICY_VERSION and normalized_paths != REQUIRED_PRODUCTION_PATHS:
missing = sorted(REQUIRED_PRODUCTION_PATHS - normalized_paths)
extra = sorted(normalized_paths - REQUIRED_PRODUCTION_PATHS)
raise ProvenanceDenied(
f"production provenance must cover exactly seven paths; missing={missing}, extra={extra}"
)
normalized_operations = tuple(
sorted({GitOperation(item).value for item in allowed_git_operations})
)
if ExecutionPath.GIT_OPERATION.value in normalized_paths and not normalized_operations:
raise ProvenanceDenied("git_operation path requires explicit allowed Git operations")
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
unsigned = RunProvenance(
provenance_id=provenance_id or f"prov_{uuid.uuid4().hex}",
run_id=run_id,
issuer=issuer,
tenant_id=tenant_id,
task_id=task_id,
agent_id=agent_id,
principal_id=principal_id,
allowed_paths=tuple(normalized_paths),
allowed_git_operations=normalized_operations,
issued_at=issued.isoformat().replace("+00:00", "Z"),
expires_at=(issued + timedelta(seconds=ttl_seconds))
.isoformat()
.replace("+00:00", "Z"),
policy_version=policy_version,
key_id=self.signer.key_id,
nonce=nonce or uuid.uuid4().hex,
)
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
self.store.save_issued(signed)
return signed
@dataclass(frozen=True)
class ProvenanceAuthorization:
authorization_id: str
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
git_operation: str | None
request_digest: str
policy_version: str
key_id: str
public_key_fingerprint: str
authorized_at: str = field(default_factory=utc_now)
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class SQLiteProvenanceStore:
"""持久化 Provenance、状态、授权使用和不可变事件。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._connection = sqlite3.connect(
str(self.path), timeout=30.0, check_same_thread=False
)
self._connection.row_factory = sqlite3.Row
with self._lock:
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA synchronous=FULL")
self._connection.execute("PRAGMA foreign_keys=ON")
self._connection.executescript(
"""
CREATE TABLE IF NOT EXISTS provenance_records (
provenance_id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
signature_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS provenance_usage (
authorization_id TEXT PRIMARY KEY,
provenance_id TEXT NOT NULL,
execution_path TEXT NOT NULL,
git_operation TEXT NOT NULL DEFAULT '',
request_digest TEXT NOT NULL,
authorized_at TEXT NOT NULL,
receipt_json TEXT NOT NULL,
UNIQUE(provenance_id, execution_path, git_operation, request_digest),
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
);
CREATE TABLE IF NOT EXISTS provenance_events (
event_id TEXT PRIMARY KEY,
provenance_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(provenance_id) REFERENCES provenance_records(provenance_id)
);
CREATE INDEX IF NOT EXISTS idx_provenance_usage_id
ON provenance_usage(provenance_id, authorized_at);
CREATE INDEX IF NOT EXISTS idx_provenance_events_id
ON provenance_events(provenance_id, created_at);
"""
)
self._connection.commit()
def close(self) -> None:
with self._lock:
self._connection.close()
def _event(
self,
provenance_id: str,
event_type: str,
details: Mapping[str, Any] | None = None,
) -> None:
self._connection.execute(
"INSERT INTO provenance_events VALUES (?, ?, ?, ?, ?)",
(
f"pevt_{uuid.uuid4().hex}",
provenance_id,
event_type,
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
utc_now(),
),
)
def save_issued(self, provenance: RunProvenance) -> None:
payload = json.dumps(
provenance.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
now = utc_now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
prior = self._connection.execute(
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
(provenance.provenance_id,),
).fetchone()
if prior is not None:
if prior["payload_json"] != payload:
raise ProvenanceDenied("provenance_id reused with different payload")
self._connection.rollback()
return
self._connection.execute(
"INSERT INTO provenance_records VALUES (?, ?, ?, 0, ?, ?)",
(
provenance.provenance_id,
payload,
ProvenanceStatus.ISSUED.value,
now,
now,
),
)
self._event(provenance.provenance_id, "issued")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def get(self, provenance_id: str) -> RunProvenance:
with self._lock:
row = self._connection.execute(
"SELECT payload_json FROM provenance_records WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance not found")
value = json.loads(row["payload_json"])
if not isinstance(value, Mapping):
raise ProvenanceError("stored provenance payload is invalid")
return RunProvenance.from_dict(value)
def status(self, provenance_id: str) -> ProvenanceStatus:
with self._lock:
row = self._connection.execute(
"SELECT status FROM provenance_records WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance not found")
return ProvenanceStatus(row["status"])
def mark_verified(self, provenance_id: str, *, fingerprint: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(provenance_id)
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
raise ProvenanceDenied(f"cannot verify provenance in state {current.value}")
next_status = (
ProvenanceStatus.USED.value
if current == ProvenanceStatus.USED
else ProvenanceStatus.VERIFIED.value
)
self._connection.execute(
"UPDATE provenance_records SET status = ?, signature_verified = 1, updated_at = ? WHERE provenance_id = ?",
(next_status, utc_now(), provenance_id),
)
self._event(
provenance_id,
"verified",
{"public_key_fingerprint": fingerprint},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def mark_expired(self, provenance_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(provenance_id)
if current == ProvenanceStatus.REVOKED:
self._connection.rollback()
return
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(ProvenanceStatus.EXPIRED.value, utc_now(), provenance_id),
)
self._event(provenance_id, "expired")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def revoke(self, provenance_id: str, *, reason: str, principal_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self.get(provenance_id)
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(ProvenanceStatus.REVOKED.value, utc_now(), provenance_id),
)
self._event(
provenance_id,
"revoked",
{
"reason": required(reason, "reason"),
"principal_id": required(principal_id, "principal_id"),
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def record_authorization(self, authorization: ProvenanceAuthorization) -> None:
receipt = json.dumps(
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(authorization.provenance_id)
if current in {ProvenanceStatus.REVOKED, ProvenanceStatus.EXPIRED}:
raise ProvenanceDenied(f"provenance state is {current.value}")
try:
self._connection.execute(
"INSERT INTO provenance_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
(
authorization.authorization_id,
authorization.provenance_id,
authorization.execution_path,
authorization.git_operation or "",
authorization.request_digest,
authorization.authorized_at,
receipt,
),
)
except sqlite3.IntegrityError as exc:
raise ProvenanceReplay(
"identical provenance path/operation/request digest already used"
) from exc
self._connection.execute(
"UPDATE provenance_records SET status = ?, updated_at = ? WHERE provenance_id = ?",
(
ProvenanceStatus.USED.value,
authorization.authorized_at,
authorization.provenance_id,
),
)
self._event(
authorization.provenance_id,
"used",
{
"authorization_id": authorization.authorization_id,
"execution_path": authorization.execution_path,
"git_operation": authorization.git_operation,
"request_digest": authorization.request_digest,
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def authorization_count(self, provenance_id: str) -> int:
with self._lock:
row = self._connection.execute(
"SELECT COUNT(*) AS total FROM provenance_usage WHERE provenance_id = ?",
(required(provenance_id, "provenance_id"),),
).fetchone()
return int(row["total"])
def events(self, provenance_id: str) -> list[dict[str, Any]]:
with self._lock:
rows = self._connection.execute(
"SELECT event_type, details_json, created_at FROM provenance_events WHERE provenance_id = ? ORDER BY created_at, event_id",
(required(provenance_id, "provenance_id"),),
).fetchall()
return [
{
"event_type": row["event_type"],
"details": json.loads(row["details_json"]),
"created_at": row["created_at"],
}
for row in rows
]
class ProvenanceVerifier:
"""验证 Owner 签名、上下文、路径、Git 操作、时效和重放。"""
def __init__(
self,
store: SQLiteProvenanceStore,
verifiers: Mapping[str, SignatureVerifier],
) -> None:
if not verifiers:
raise ValueError("at least one public key verifier is required")
self.store = store
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
def authorize(
self,
*,
provenance_id: str,
run_id: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
execution_path: str,
request_digest: str,
git_operation: str | None = None,
now: datetime | None = None,
) -> ProvenanceAuthorization:
provenance = self.store.get(provenance_id)
status = self.store.status(provenance.provenance_id)
if status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("provenance is revoked")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
if current >= parse_timestamp(provenance.expires_at):
self.store.mark_expired(provenance.provenance_id)
raise ProvenanceExpired("provenance is expired")
verifier = self.verifiers.get(provenance.key_id)
if verifier is None:
raise ProvenanceSignatureError("no trusted public key for provenance key_id")
if not provenance.signature or not verifier.verify(
provenance.canonical_bytes(), provenance.signature
):
raise ProvenanceSignatureError("invalid provenance signature")
expected = {
"run_id": required(run_id, "run_id"),
"tenant_id": required(tenant_id, "tenant_id"),
"task_id": required(task_id, "task_id"),
"agent_id": required(agent_id, "agent_id"),
"principal_id": required(principal_id, "principal_id"),
}
for field_name, expected_value in expected.items():
if getattr(provenance, field_name) != expected_value:
raise ProvenanceDenied(f"provenance {field_name} mismatch")
path = ExecutionPath(execution_path).value
if path not in provenance.allowed_paths:
raise ProvenanceDenied(f"execution path is not allowed: {path}")
operation: str | None = None
if path == ExecutionPath.GIT_OPERATION.value:
if git_operation is None:
raise ProvenanceDenied("git operation is required for git_operation path")
operation = GitOperation(git_operation).value
if operation not in provenance.allowed_git_operations:
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
elif git_operation is not None:
raise ProvenanceDenied("git_operation is only valid for git_operation path")
digest = _validate_digest(request_digest)
self.store.mark_verified(
provenance.provenance_id, fingerprint=verifier.fingerprint
)
authorization = ProvenanceAuthorization(
authorization_id=f"pauth_{uuid.uuid4().hex}",
provenance_id=provenance.provenance_id,
run_id=provenance.run_id,
tenant_id=provenance.tenant_id,
task_id=provenance.task_id,
agent_id=provenance.agent_id,
principal_id=provenance.principal_id,
execution_path=path,
git_operation=operation,
request_digest=digest,
policy_version=provenance.policy_version,
key_id=provenance.key_id,
public_key_fingerprint=verifier.fingerprint,
)
self.store.record_authorization(authorization)
return authorization
class ProvenanceControlledWriter:
"""七路径统一策略入口并持久写入授权回执。"""
def __init__(
self,
verifier: ProvenanceVerifier,
receipt_root: str | Path,
*,
writer_id: str = "aios-v2-provenance-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def authorize(self, **values: Any) -> ProvenanceAuthorization:
authorization = self.verifier.authorize(**values)
tenant = self._component(authorization.tenant_id)
provenance_id = self._component(authorization.provenance_id)
target_dir = self.receipt_root / tenant / provenance_id
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> ProvenanceAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
def authorize_runtime_execute(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
def authorize_evidence_write(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
def authorize_rollback(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
def authorize_audit(self, **values: Any) -> ProvenanceAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""AIOS V2 Run Provenance 与最小权限 Grant 运维 CLI。
私钥只用于签发;授权命令只加载一个与 ``--path`` 对应的 Grant。所有输出均为
不含 Secret 的 JSON。旧 ``issue`` 仅为候选兼容,生产必须使用
``issue-workflow`` 与 ``issue-grant``。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import (
Ed25519PrivateKeySigner,
Ed25519PublicKeyVerifier,
ExecutionPath,
GitOperation,
ProvenanceIssuer,
SQLiteProvenanceStore,
)
from .provenance_grants import (
GrantIssuer,
GrantType,
GrantVerifier,
SQLiteGrantStore,
WorkflowProvenanceIssuer,
)
from .provenance_operations import (
OperationContext,
ProvenanceOperationGate,
context_from_environment,
load_request_file,
)
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
def _json_object(value: str | None, file_path: str | None) -> Mapping[str, Any]:
if value and file_path:
raise ValueError("request-json and request-file are mutually exclusive")
if file_path:
return load_request_file(file_path)
parsed = json.loads(value or "{}")
if not isinstance(parsed, Mapping):
raise ValueError("request JSON must be an object")
return dict(parsed)
def _csv(value: str | None) -> tuple[str, ...]:
return tuple(item.strip() for item in str(value or "").split(",") if item.strip())
def _write_json(path: str | None, payload: Mapping[str, Any]) -> None:
encoded = json.dumps(dict(payload), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
if path:
target = Path(path).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(encoded, encoding="utf-8")
os.chmod(temporary, 0o640)
os.replace(temporary, target)
print(encoded, end="")
def _parent_store(args: argparse.Namespace) -> SQLiteProvenanceStore:
return SQLiteProvenanceStore(args.store_file)
def _grant_store(args: argparse.Namespace) -> SQLiteGrantStore:
return SQLiteGrantStore(args.store_file)
def _context(args: argparse.Namespace) -> OperationContext:
if args.context_from_env:
return context_from_environment()
values = {
"provenance_id": args.provenance_id,
"run_id": args.run_id,
"tenant_id": args.tenant_id,
"task_id": args.task_id,
"agent_id": args.agent_id,
"principal_id": args.principal_id,
}
missing = [name for name, value in values.items() if not str(value or "").strip()]
if missing:
raise ValueError(f"missing context arguments: {missing}")
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
def command_issue_legacy(args: argparse.Namespace) -> int:
store = _parent_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
provenance = ProvenanceIssuer(signer, store).issue(
run_id=args.run_id,
issuer=args.issuer,
tenant_id=args.tenant_id,
task_id=args.task_id,
agent_id=args.agent_id,
principal_id=args.principal_id,
allowed_paths=_csv(args.allowed_paths) or tuple(item.value for item in ExecutionPath),
allowed_git_operations=_csv(args.allowed_git_operations)
or tuple(item.value for item in GitOperation),
ttl_seconds=args.ttl_seconds,
policy_version=args.policy_version,
)
_write_json(
args.output,
{
"mode": "legacy-full-rights-candidate-only",
"provenance": provenance.as_dict(),
"status": store.status(provenance.provenance_id).value,
"secret_value_stored": False,
},
)
return 0
def command_issue_workflow(args: argparse.Namespace) -> int:
store = _parent_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
workflow = WorkflowProvenanceIssuer(signer, store).issue(
run_id=args.run_id,
issuer=args.issuer,
tenant_id=args.tenant_id,
task_id=args.task_id,
agent_id=args.agent_id,
principal_id=args.principal_id,
ttl_seconds=args.ttl_seconds,
)
_write_json(
args.output,
{
"mode": "least-privilege-workflow",
"workflow_provenance": workflow.as_dict(),
"status": store.status(workflow.provenance_id).value,
"secret_value_stored": False,
},
)
return 0
def command_issue_grant(args: argparse.Namespace) -> int:
parent_store = _parent_store(args)
grant_store = _grant_store(args)
signer = Ed25519PrivateKeySigner.from_file(args.private_key_file, key_id=args.key_id)
grant = GrantIssuer(signer, parent_store, grant_store).issue(
parent_provenance_id=args.parent_provenance_id,
grant_type=GrantType(args.grant_type),
ttl_seconds=args.ttl_seconds,
allowed_git_operations=_csv(args.allowed_git_operations),
)
_write_json(
args.output,
{
"grant": grant.as_dict(),
"status": grant_store.status(grant.grant_id).value,
"secret_value_stored": False,
},
)
return 0
def _single_scope_writer(args: argparse.Namespace) -> ScopedLeastPrivilegeProvenanceWriter:
path = ExecutionPath(args.path).value
parent_store = _parent_store(args)
grant_store = _grant_store(args)
verifier = Ed25519PublicKeyVerifier.from_file(args.public_key_file, key_id=args.key_id)
trusted = GrantVerifier(parent_store, grant_store, {args.key_id: verifier})
return ScopedLeastPrivilegeProvenanceWriter(
trusted,
args.receipt_dir,
{path: args.grant_id},
required_paths=(path,),
writer_id=f"aios-v2-cli-{path}",
)
def command_authorize(args: argparse.Namespace) -> int:
writer = _single_scope_writer(args)
context = _context(args)
receipt = ProvenanceOperationGate(writer).authorize(
context,
execution_path=args.path,
operation=args.operation,
request=_json_object(args.request_json, args.request_file),
)
_write_json(args.output, {"authorization": receipt.as_dict()})
return 0
def command_revoke(args: argparse.Namespace) -> int:
store = _parent_store(args)
store.revoke(args.provenance_id, reason=args.reason, principal_id=args.principal_id)
_write_json(
args.output,
{"provenance_id": args.provenance_id, "status": store.status(args.provenance_id).value},
)
return 0
def command_status(args: argparse.Namespace) -> int:
store = _parent_store(args)
provenance = store.get(args.provenance_id)
grant_store = _grant_store(args)
_write_json(
args.output,
{
"provenance": provenance.as_dict(),
"status": store.status(args.provenance_id).value,
"authorization_count": store.authorization_count(args.provenance_id),
"events": store.events(args.provenance_id),
"grants": [item.as_dict() for item in grant_store.list_for_parent(args.provenance_id)],
},
)
return 0
def command_grant_revoke(args: argparse.Namespace) -> int:
store = _grant_store(args)
store.revoke(args.grant_id, reason=args.reason, principal_id=args.principal_id)
_write_json(
args.output,
{"grant_id": args.grant_id, "status": store.status(args.grant_id).value},
)
return 0
def command_grant_status(args: argparse.Namespace) -> int:
store = _grant_store(args)
grant = store.get(args.grant_id)
_write_json(
args.output,
{"grant": grant.as_dict(), "status": store.status(args.grant_id).value},
)
return 0
def add_common_store(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--store-file",
default=os.getenv(
"AIOS_PROVENANCE_STORE_FILE",
"/var/lib/aios-v2/run-provenance.sqlite3",
),
)
parser.add_argument("--output", default=None)
def add_signing_context(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--private-key-file", required=True)
parser.add_argument("--key-id", required=True)
def add_workflow_identity(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--run-id", required=True)
parser.add_argument("--issuer", required=True)
parser.add_argument("--tenant-id", required=True)
parser.add_argument("--task-id", required=True)
parser.add_argument("--agent-id", required=True)
parser.add_argument("--principal-id", required=True)
def add_context(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--context-from-env", action="store_true")
parser.add_argument("--provenance-id")
parser.add_argument("--run-id")
parser.add_argument("--tenant-id")
parser.add_argument("--task-id")
parser.add_argument("--agent-id")
parser.add_argument("--principal-id")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AIOS V2 Run Provenance CLI")
subcommands = parser.add_subparsers(dest="command", required=True)
issue = subcommands.add_parser("issue", help="候选兼容:签发全权限 Provenance;生产禁止")
add_common_store(issue)
add_signing_context(issue)
add_workflow_identity(issue)
issue.add_argument("--allowed-paths")
issue.add_argument("--allowed-git-operations")
issue.add_argument("--ttl-seconds", type=int, default=3600)
issue.add_argument("--policy-version", default="aios.v2.candidate.legacy")
issue.set_defaults(handler=command_issue_legacy)
workflow = subcommands.add_parser(
"issue-workflow", help="签发不含操作权限的父 Workflow Provenance"
)
add_common_store(workflow)
add_signing_context(workflow)
add_workflow_identity(workflow)
workflow.add_argument("--ttl-seconds", type=int, default=14400)
workflow.set_defaults(handler=command_issue_workflow)
grant = subcommands.add_parser("issue-grant", help="签发单一路径最小权限 Grant")
add_common_store(grant)
add_signing_context(grant)
grant.add_argument("--parent-provenance-id", required=True)
grant.add_argument("--grant-type", choices=[item.value for item in GrantType], required=True)
grant.add_argument("--allowed-git-operations")
grant.add_argument("--ttl-seconds", type=int, default=900)
grant.set_defaults(handler=command_issue_grant)
authorize = subcommands.add_parser("authorize", help="使用单一子 Grant 授权一次运维操作")
add_common_store(authorize)
add_context(authorize)
authorize.add_argument("--public-key-file", required=True)
authorize.add_argument("--key-id", required=True)
authorize.add_argument("--grant-id", required=True)
authorize.add_argument(
"--receipt-dir",
default=os.getenv(
"AIOS_PROVENANCE_RECEIPT_DIR",
"/var/lib/aios-v2/provenance-receipts",
),
)
authorize.add_argument(
"--path",
required=True,
choices=[
ExecutionPath.CODE_CHANGE.value,
ExecutionPath.DEPLOYMENT.value,
ExecutionPath.RUNTIME_EXECUTE.value,
ExecutionPath.EVIDENCE_WRITE.value,
ExecutionPath.ROLLBACK.value,
ExecutionPath.AUDIT.value,
],
)
authorize.add_argument("--operation", required=True)
request_group = authorize.add_mutually_exclusive_group()
request_group.add_argument("--request-json")
request_group.add_argument("--request-file")
authorize.set_defaults(handler=command_authorize)
revoke = subcommands.add_parser("revoke", help="撤销父 Workflow Provenance")
add_common_store(revoke)
revoke.add_argument("--provenance-id", required=True)
revoke.add_argument("--reason", required=True)
revoke.add_argument("--principal-id", required=True)
revoke.set_defaults(handler=command_revoke)
status = subcommands.add_parser("status", help="查询父 Workflow 与子 Grant")
add_common_store(status)
status.add_argument("--provenance-id", required=True)
status.set_defaults(handler=command_status)
grant_revoke = subcommands.add_parser("grant-revoke", help="单独撤销子 Grant")
add_common_store(grant_revoke)
grant_revoke.add_argument("--grant-id", required=True)
grant_revoke.add_argument("--reason", required=True)
grant_revoke.add_argument("--principal-id", required=True)
grant_revoke.set_defaults(handler=command_grant_revoke)
grant_status = subcommands.add_parser("grant-status", help="查询子 Grant 状态")
add_common_store(grant_status)
grant_status.add_argument("--grant-id", required=True)
grant_status.set_defaults(handler=command_grant_status)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return int(args.handler(args))
except Exception as exc:
print(
json.dumps(
{"status": "denied", "error": type(exc).__name__, "message": str(exc)},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,416 @@
"""受最小权限 Run Provenance Grant 约束的 Git 写入器。
所有 Git 操作都使用参数数组执行。对于 Commit 和 Evidence Commit,先计算
工作树内容摘要并完成 Provenance 授权,之后才允许执行 ``git add``,避免未授权
请求改变 Git Index。
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
from .provenance import GitOperation, ProvenanceDenied, digest_payload, utc_now
_BRANCH_PATTERN = re.compile(r"^(?![-./])(?!.*\.\.)(?!.*//)[A-Za-z0-9._/-]{1,240}$")
_SECRET_URL = re.compile(r"(?i)(https?://)([^/@\s]+)@")
_TOKENISH = re.compile(r"(?i)(token|password|secret|authorization)([=:]\s*)([^\s]+)")
class GitAuthorizationWriter(Protocol):
def authorize_code_change(self, **values: Any) -> Any: ...
def authorize_git_operation(self, *, git_operation: str, **values: Any) -> Any: ...
class ProvenanceGitError(RuntimeError):
"""Git 操作被拒绝或执行失败。"""
@dataclass(frozen=True)
class GitExecutionContext:
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
@dataclass(frozen=True)
class GitCommandResult:
operation_id: str
operation: str
authorization_ids: Sequence[str]
command: Sequence[str]
exit_code: int
stdout: str
stderr: str
head_before: str | None
head_after: str | None
started_at: str
completed_at: str
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class ProvenanceGitWriter:
"""执行经过单路径 Grant 授权的窄范围 Git 操作。"""
def __init__(
self,
repo: str | Path,
writer: GitAuthorizationWriter,
*,
timeout_seconds: float = 120.0,
output_limit: int = 16000,
) -> None:
self.repo = Path(repo).resolve()
self.writer = writer
self.timeout_seconds = float(timeout_seconds)
self.output_limit = int(output_limit)
if self.timeout_seconds <= 0 or self.output_limit < 1024:
raise ValueError("invalid Git writer limits")
self._run_raw(["rev-parse", "--is-inside-work-tree"], check=True)
@staticmethod
def _validate_branch(branch: str) -> str:
normalized = str(branch or "").strip()
if not _BRANCH_PATTERN.fullmatch(normalized) or normalized.endswith((".", "/")):
raise ProvenanceGitError("invalid Git branch name")
if normalized in {"main", "master"}:
raise ProvenanceGitError("direct main/master operation is prohibited")
return normalized
def _validate_path(self, value: str | Path) -> str:
raw = Path(value)
candidate = (self.repo / raw).resolve() if not raw.is_absolute() else raw.resolve()
try:
relative = candidate.relative_to(self.repo)
except ValueError as exc:
raise ProvenanceGitError("Git path escapes repository") from exc
if str(relative) in {"", "."}:
raise ProvenanceGitError("repository root is not a valid file path")
return relative.as_posix()
@staticmethod
def _redact(value: str, limit: int) -> str:
text = _SECRET_URL.sub(r"\1***@", str(value or ""))
text = _TOKENISH.sub(r"\1\2***", text)
if len(text) > limit:
return text[:limit] + "\n...[truncated]"
return text
def _head(self) -> str | None:
process = self._run_raw(["rev-parse", "HEAD"], check=False)
return process.stdout.strip() if process.returncode == 0 else None
def _run_raw(
self,
args: Sequence[str],
*,
check: bool,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
command = ["git", "-C", str(self.repo), *[str(item) for item in args]]
safe_env = {
**os.environ,
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_NOSYSTEM": os.environ.get("GIT_CONFIG_NOSYSTEM", "1"),
}
if env:
safe_env.update({str(key): str(value) for key, value in env.items()})
process = subprocess.run(
command,
text=True,
capture_output=True,
timeout=self.timeout_seconds,
env=safe_env,
)
if check and process.returncode != 0:
raise ProvenanceGitError(
self._redact(process.stderr or process.stdout, self.output_limit)
or f"Git command failed with exit code {process.returncode}"
)
return process
def _path_digest(self, relative: str) -> str:
target = self.repo / relative
if target.exists():
if not target.is_file():
raise ProvenanceGitError("Git writer accepts file paths only")
hasher = hashlib.sha256()
with target.open("rb") as handle:
while True:
block = handle.read(65536)
if not block:
break
hasher.update(block)
return hasher.hexdigest()
tracked = self._run_raw(["ls-files", "--error-unmatch", "--", relative], check=False)
if tracked.returncode != 0:
raise ProvenanceGitError(f"path does not exist and is not tracked: {relative}")
return "deleted"
def _content_manifest(self, paths: Sequence[str]) -> Mapping[str, str]:
return {path: self._path_digest(path) for path in sorted(set(paths))}
def _authorize(
self,
context: GitExecutionContext,
*,
operation: GitOperation,
request: Mapping[str, Any],
code_change: bool,
) -> list[str]:
request_digest = digest_payload(
{
"operation": operation.value,
"repo": self.repo.name,
"request": dict(request),
"head": self._head(),
}
)
ids: list[str] = []
try:
if code_change:
code_authorization = self.writer.authorize_code_change(
provenance_id=context.provenance_id,
run_id=context.run_id,
tenant_id=context.tenant_id,
task_id=context.task_id,
agent_id=context.agent_id,
principal_id=context.principal_id,
request_digest=digest_payload(
{
"git_operation": operation.value,
"request_digest": request_digest,
}
),
)
ids.append(code_authorization.authorization_id)
git_authorization = self.writer.authorize_git_operation(
provenance_id=context.provenance_id,
run_id=context.run_id,
tenant_id=context.tenant_id,
task_id=context.task_id,
agent_id=context.agent_id,
principal_id=context.principal_id,
git_operation=operation.value,
request_digest=request_digest,
)
ids.append(git_authorization.authorization_id)
return ids
except ProvenanceDenied as exc:
raise ProvenanceGitError(f"Run Provenance denied Git operation: {exc}") from exc
def _run_authorized(
self,
*,
operation: GitOperation,
args: Sequence[str],
authorization_ids: Sequence[str],
) -> GitCommandResult:
started = utc_now()
before = self._head()
process = self._run_raw(args, check=False)
after = self._head()
result = GitCommandResult(
operation_id=f"gitop_{uuid.uuid4().hex}",
operation=operation.value,
authorization_ids=tuple(authorization_ids),
command=tuple(["git", "-C", "<repo>", *[str(item) for item in args]]),
exit_code=int(process.returncode),
stdout=self._redact(process.stdout, self.output_limit),
stderr=self._redact(process.stderr, self.output_limit),
head_before=before,
head_after=after,
started_at=started,
completed_at=utc_now(),
)
if process.returncode != 0:
raise ProvenanceGitError(
result.stderr or result.stdout or f"Git {operation.value} failed"
)
return result
def _execute(
self,
context: GitExecutionContext,
*,
operation: GitOperation,
args: Sequence[str],
request: Mapping[str, Any],
code_change: bool,
) -> GitCommandResult:
authorization_ids = self._authorize(
context,
operation=operation,
request=request,
code_change=code_change,
)
return self._run_authorized(
operation=operation,
args=args,
authorization_ids=authorization_ids,
)
def read_ref(self, context: GitExecutionContext, ref: str) -> GitCommandResult:
normalized = str(ref or "").strip()
if not normalized or any(ch.isspace() for ch in normalized):
raise ProvenanceGitError("invalid Git ref")
return self._execute(
context,
operation=GitOperation.READ_REF,
args=["rev-parse", "--verify", normalized],
request={"ref": normalized},
code_change=False,
)
def fetch(
self,
context: GitExecutionContext,
*,
remote: str,
refspecs: Sequence[str],
) -> GitCommandResult:
normalized_remote = str(remote or "").strip()
normalized_refspecs = tuple(str(item).strip() for item in refspecs if str(item).strip())
if not normalized_remote or not normalized_refspecs:
raise ProvenanceGitError("remote and refspecs are required")
if any(item.startswith("+") and ":refs/heads/" in item for item in normalized_refspecs):
raise ProvenanceGitError(
"fetching directly into local branch refs is prohibited; use remote-tracking refs"
)
return self._execute(
context,
operation=GitOperation.FETCH,
args=["fetch", "--prune", normalized_remote, *normalized_refspecs],
request={"remote": normalized_remote, "refspecs": normalized_refspecs},
code_change=False,
)
def create_branch(
self,
context: GitExecutionContext,
*,
branch: str,
start_point: str,
) -> GitCommandResult:
normalized = self._validate_branch(branch)
start = str(start_point or "").strip()
if not start:
raise ProvenanceGitError("start_point is required")
return self._execute(
context,
operation=GitOperation.CREATE_BRANCH,
args=["branch", normalized, start],
request={"branch": normalized, "start_point": start},
code_change=False,
)
def commit(
self,
context: GitExecutionContext,
*,
paths: Sequence[str | Path],
message: str,
) -> GitCommandResult:
normalized_paths = tuple(self._validate_path(item) for item in paths)
normalized_message = str(message or "").strip()
if not normalized_paths or not normalized_message:
raise ProvenanceGitError("commit paths and message are required")
if len(normalized_message) > 500:
raise ProvenanceGitError("commit message is too long")
request = {
"paths": normalized_paths,
"message": normalized_message,
"content_manifest": self._content_manifest(normalized_paths),
}
authorization_ids = self._authorize(
context,
operation=GitOperation.COMMIT,
request=request,
code_change=True,
)
self._run_raw(["add", "--", *normalized_paths], check=True)
return self._run_authorized(
operation=GitOperation.COMMIT,
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
authorization_ids=authorization_ids,
)
def merge(
self,
context: GitExecutionContext,
*,
source_ref: str,
message: str,
) -> GitCommandResult:
source = str(source_ref or "").strip()
normalized_message = str(message or "").strip()
if not source or not normalized_message:
raise ProvenanceGitError("source_ref and message are required")
return self._execute(
context,
operation=GitOperation.MERGE,
args=["merge", "--no-ff", "--no-edit", "-m", normalized_message, source],
request={"source_ref": source, "message": normalized_message},
code_change=True,
)
def push(
self,
context: GitExecutionContext,
*,
remote: str,
branch: str,
) -> GitCommandResult:
normalized_remote = str(remote or "").strip()
normalized_branch = self._validate_branch(branch)
if not normalized_remote:
raise ProvenanceGitError("remote is required")
return self._execute(
context,
operation=GitOperation.PUSH,
args=["push", normalized_remote, f"refs/heads/{normalized_branch}:refs/heads/{normalized_branch}"],
request={"remote": normalized_remote, "branch": normalized_branch},
code_change=False,
)
def update_evidence(
self,
context: GitExecutionContext,
*,
paths: Sequence[str | Path],
message: str,
) -> GitCommandResult:
normalized_paths = tuple(self._validate_path(item) for item in paths)
normalized_message = str(message or "").strip()
if not normalized_paths or not normalized_message:
raise ProvenanceGitError("evidence paths and message are required")
if any(not path.startswith(".agent/") for path in normalized_paths):
raise ProvenanceGitError("UPDATE_EVIDENCE may only commit .agent/ paths")
request = {
"paths": normalized_paths,
"message": normalized_message,
"content_manifest": self._content_manifest(normalized_paths),
}
authorization_ids = self._authorize(
context,
operation=GitOperation.UPDATE_EVIDENCE,
request=request,
code_change=False,
)
self._run_raw(["add", "--", *normalized_paths], check=True)
return self._run_authorized(
operation=GitOperation.UPDATE_EVIDENCE,
args=["commit", "--no-gpg-sign", "-m", normalized_message, "--", *normalized_paths],
authorization_ids=authorization_ids,
)
@@ -0,0 +1,744 @@
"""AIOS V2 最小权限 Run Provenance 子 Grant。
父 Workflow Provenance 只绑定 run/tenant/task/agent/principal 身份,不直接授予
操作权限。每个子 Grant 只允许一个 ExecutionPath;Git Grant 还必须限定具体
GitOperation。子 Grant 具备独立 TTL、nonce、状态、撤销和重放防护。
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import uuid
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import (
ExecutionPath,
GitOperation,
ProvenanceDenied,
ProvenanceExpired,
ProvenanceIssuer,
ProvenanceNotFound,
ProvenanceReplay,
ProvenanceRevoked,
ProvenanceSignatureError,
ProvenanceStatus,
ProvenanceVerifier,
RunProvenance,
SQLiteProvenanceStore,
Signer,
SignatureVerifier,
_validate_digest,
canonical_json_bytes,
parse_timestamp,
required,
utc_now,
)
WORKFLOW_POLICY_VERSION = "aios.v2.workflow.v1"
GRANT_SCHEMA_VERSION = "aios.v2.provenance-grant.v1"
class GrantType(str, Enum):
CODE_CHANGE = "code_change"
GIT = "git"
DEPLOYMENT = "deployment"
RUNTIME = "runtime"
EVIDENCE = "evidence"
ROLLBACK = "rollback"
AUDIT = "audit"
GRANT_PATHS: Mapping[GrantType, ExecutionPath] = {
GrantType.CODE_CHANGE: ExecutionPath.CODE_CHANGE,
GrantType.GIT: ExecutionPath.GIT_OPERATION,
GrantType.DEPLOYMENT: ExecutionPath.DEPLOYMENT,
GrantType.RUNTIME: ExecutionPath.RUNTIME_EXECUTE,
GrantType.EVIDENCE: ExecutionPath.EVIDENCE_WRITE,
GrantType.ROLLBACK: ExecutionPath.ROLLBACK,
GrantType.AUDIT: ExecutionPath.AUDIT,
}
class GrantStatus(str, Enum):
ISSUED = "issued"
VERIFIED = "verified"
USED = "used"
EXPIRED = "expired"
REVOKED = "revoked"
@dataclass(frozen=True)
class ProvenanceGrant:
grant_id: str
parent_provenance_id: str
grant_type: GrantType
execution_path: ExecutionPath
run_id: str
issuer: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
allowed_git_operations: Sequence[str]
issued_at: str
expires_at: str
policy_version: str
key_id: str
nonce: str
signature: str = ""
schema_version: str = GRANT_SCHEMA_VERSION
def __post_init__(self) -> None:
for name in (
"grant_id",
"parent_provenance_id",
"run_id",
"issuer",
"tenant_id",
"task_id",
"agent_id",
"principal_id",
"issued_at",
"expires_at",
"policy_version",
"key_id",
"nonce",
"schema_version",
):
object.__setattr__(self, name, required(getattr(self, name), name))
if self.schema_version != GRANT_SCHEMA_VERSION:
raise ValueError(f"unsupported grant schema: {self.schema_version}")
grant_type = GrantType(self.grant_type)
path = ExecutionPath(self.execution_path)
if GRANT_PATHS[grant_type] != path:
raise ValueError("grant_type does not match execution_path")
object.__setattr__(self, "grant_type", grant_type)
object.__setattr__(self, "execution_path", path)
operations = tuple(
sorted({GitOperation(item).value for item in self.allowed_git_operations})
)
if grant_type == GrantType.GIT and not operations:
raise ValueError("Git Grant requires explicit Git operations")
if grant_type != GrantType.GIT and operations:
raise ValueError("only Git Grant may contain Git operations")
object.__setattr__(self, "allowed_git_operations", operations)
issued = parse_timestamp(self.issued_at)
expires = parse_timestamp(self.expires_at)
if expires <= issued:
raise ValueError("grant expires_at must be later than issued_at")
object.__setattr__(self, "signature", str(self.signature or "").strip())
def unsigned_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"grant_id": self.grant_id,
"parent_provenance_id": self.parent_provenance_id,
"grant_type": self.grant_type.value,
"execution_path": self.execution_path.value,
"run_id": self.run_id,
"issuer": self.issuer,
"tenant_id": self.tenant_id,
"task_id": self.task_id,
"agent_id": self.agent_id,
"principal_id": self.principal_id,
"allowed_git_operations": list(self.allowed_git_operations),
"issued_at": self.issued_at,
"expires_at": self.expires_at,
"policy_version": self.policy_version,
"key_id": self.key_id,
"nonce": self.nonce,
}
def canonical_bytes(self) -> bytes:
return canonical_json_bytes(self.unsigned_dict())
def as_dict(self) -> dict[str, Any]:
value = self.unsigned_dict()
value["signature"] = self.signature
return value
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "ProvenanceGrant":
return cls(**dict(value))
@dataclass(frozen=True)
class GrantAuthorization:
authorization_id: str
grant_id: str
parent_provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
git_operation: str | None
request_digest: str
policy_version: str
key_id: str
public_key_fingerprint: str
authorized_at: str = field(default_factory=utc_now)
@property
def provenance_id(self) -> str:
return self.parent_provenance_id
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class WorkflowProvenanceIssuer:
"""签发不携带操作权限的父 Workflow Provenance。"""
def __init__(self, signer: Signer, store: SQLiteProvenanceStore) -> None:
self._issuer = ProvenanceIssuer(signer, store)
def issue(
self,
*,
run_id: str,
issuer: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
ttl_seconds: int = 14400,
provenance_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> RunProvenance:
return self._issuer.issue(
run_id=run_id,
issuer=issuer,
tenant_id=tenant_id,
task_id=task_id,
agent_id=agent_id,
principal_id=principal_id,
allowed_paths=(),
allowed_git_operations=(),
ttl_seconds=ttl_seconds,
policy_version=WORKFLOW_POLICY_VERSION,
provenance_id=provenance_id,
nonce=nonce,
now=now,
)
class SQLiteGrantStore:
"""在 Provenance SQLite 数据库中持久化子 Grant 与使用记录。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path).resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._connection = sqlite3.connect(
str(self.path), timeout=30.0, check_same_thread=False
)
self._connection.row_factory = sqlite3.Row
with self._lock:
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA synchronous=FULL")
self._connection.execute("PRAGMA foreign_keys=ON")
self._connection.executescript(
"""
CREATE TABLE IF NOT EXISTS provenance_grants (
grant_id TEXT PRIMARY KEY,
parent_provenance_id TEXT NOT NULL,
grant_type TEXT NOT NULL,
execution_path TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
signature_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_grants_parent
ON provenance_grants(parent_provenance_id, grant_type, status);
CREATE TABLE IF NOT EXISTS provenance_grant_usage (
authorization_id TEXT PRIMARY KEY,
grant_id TEXT NOT NULL,
execution_path TEXT NOT NULL,
git_operation TEXT NOT NULL DEFAULT '',
request_digest TEXT NOT NULL,
authorized_at TEXT NOT NULL,
receipt_json TEXT NOT NULL,
UNIQUE(grant_id, execution_path, git_operation, request_digest),
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
);
CREATE TABLE IF NOT EXISTS provenance_grant_events (
event_id TEXT PRIMARY KEY,
grant_id TEXT NOT NULL,
event_type TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(grant_id) REFERENCES provenance_grants(grant_id)
);
"""
)
self._connection.commit()
def close(self) -> None:
with self._lock:
self._connection.close()
def _event(
self,
grant_id: str,
event_type: str,
details: Mapping[str, Any] | None = None,
) -> None:
self._connection.execute(
"INSERT INTO provenance_grant_events VALUES (?, ?, ?, ?, ?)",
(
f"gevt_{uuid.uuid4().hex}",
grant_id,
event_type,
json.dumps(dict(details or {}), ensure_ascii=False, sort_keys=True),
utc_now(),
),
)
def save_issued(self, grant: ProvenanceGrant) -> None:
payload = json.dumps(
grant.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
now = utc_now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self._connection.execute(
"INSERT INTO provenance_grants VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
(
grant.grant_id,
grant.parent_provenance_id,
grant.grant_type.value,
grant.execution_path.value,
payload,
GrantStatus.ISSUED.value,
now,
now,
),
)
self._event(grant.grant_id, "issued")
self._connection.commit()
except sqlite3.IntegrityError as exc:
self._connection.rollback()
raise ProvenanceDenied("grant_id already exists") from exc
except Exception:
self._connection.rollback()
raise
def get(self, grant_id: str) -> ProvenanceGrant:
with self._lock:
row = self._connection.execute(
"SELECT payload_json FROM provenance_grants WHERE grant_id = ?",
(required(grant_id, "grant_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance grant not found")
value = json.loads(row["payload_json"])
if not isinstance(value, Mapping):
raise ProvenanceDenied("stored grant is invalid")
return ProvenanceGrant.from_dict(value)
def status(self, grant_id: str) -> GrantStatus:
with self._lock:
row = self._connection.execute(
"SELECT status FROM provenance_grants WHERE grant_id = ?",
(required(grant_id, "grant_id"),),
).fetchone()
if row is None:
raise ProvenanceNotFound("provenance grant not found")
return GrantStatus(row["status"])
def mark_verified(self, grant_id: str, *, fingerprint: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(grant_id)
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
raise ProvenanceDenied(f"cannot verify grant in state {current.value}")
next_status = (
GrantStatus.USED.value
if current == GrantStatus.USED
else GrantStatus.VERIFIED.value
)
self._connection.execute(
"UPDATE provenance_grants SET status = ?, signature_verified = 1, updated_at = ? WHERE grant_id = ?",
(next_status, utc_now(), grant_id),
)
self._event(
grant_id,
"verified",
{"public_key_fingerprint": fingerprint},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def mark_expired(self, grant_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
if self.status(grant_id) == GrantStatus.REVOKED:
self._connection.rollback()
return
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.EXPIRED.value, utc_now(), grant_id),
)
self._event(grant_id, "expired")
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def revoke(self, grant_id: str, *, reason: str, principal_id: str) -> None:
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self.get(grant_id)
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.REVOKED.value, utc_now(), grant_id),
)
self._event(
grant_id,
"revoked",
{
"reason": required(reason, "reason"),
"principal_id": required(principal_id, "principal_id"),
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def record_authorization(self, authorization: GrantAuthorization) -> None:
receipt = json.dumps(
authorization.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
current = self.status(authorization.grant_id)
if current in {GrantStatus.REVOKED, GrantStatus.EXPIRED}:
raise ProvenanceDenied(f"grant state is {current.value}")
try:
self._connection.execute(
"INSERT INTO provenance_grant_usage VALUES (?, ?, ?, ?, ?, ?, ?)",
(
authorization.authorization_id,
authorization.grant_id,
authorization.execution_path,
authorization.git_operation or "",
authorization.request_digest,
authorization.authorized_at,
receipt,
),
)
except sqlite3.IntegrityError as exc:
raise ProvenanceReplay(
"identical grant path/operation/request digest already used"
) from exc
self._connection.execute(
"UPDATE provenance_grants SET status = ?, updated_at = ? WHERE grant_id = ?",
(GrantStatus.USED.value, authorization.authorized_at, authorization.grant_id),
)
self._event(
authorization.grant_id,
"used",
{
"authorization_id": authorization.authorization_id,
"execution_path": authorization.execution_path,
"git_operation": authorization.git_operation,
"request_digest": authorization.request_digest,
},
)
self._connection.commit()
except Exception:
self._connection.rollback()
raise
def list_for_parent(self, parent_provenance_id: str) -> list[ProvenanceGrant]:
with self._lock:
rows = self._connection.execute(
"SELECT payload_json FROM provenance_grants WHERE parent_provenance_id = ? ORDER BY grant_type, grant_id",
(required(parent_provenance_id, "parent_provenance_id"),),
).fetchall()
return [ProvenanceGrant.from_dict(json.loads(row["payload_json"])) for row in rows]
class GrantIssuer:
"""从父 Workflow Provenance 派生单一路径最小权限 Grant。"""
def __init__(
self,
signer: Signer,
parent_store: SQLiteProvenanceStore,
grant_store: SQLiteGrantStore,
) -> None:
self.signer = signer
self.parent_store = parent_store
self.grant_store = grant_store
def issue(
self,
*,
parent_provenance_id: str,
grant_type: GrantType | str,
ttl_seconds: int = 900,
allowed_git_operations: Sequence[str] = (),
grant_id: str | None = None,
nonce: str | None = None,
now: datetime | None = None,
) -> ProvenanceGrant:
if ttl_seconds <= 0:
raise ValueError("grant ttl_seconds must be positive")
parent = self.parent_store.get(parent_provenance_id)
if parent.policy_version != WORKFLOW_POLICY_VERSION:
raise ProvenanceDenied("child grants require a workflow provenance parent")
if parent.allowed_paths or parent.allowed_git_operations:
raise ProvenanceDenied("workflow provenance parent must not contain operation rights")
parent_status = self.parent_store.status(parent.provenance_id)
if parent_status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("workflow provenance is revoked")
issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
parent_expiry = parse_timestamp(parent.expires_at)
expires = min(issued + timedelta(seconds=ttl_seconds), parent_expiry)
if expires <= issued:
raise ProvenanceExpired("workflow provenance has expired")
normalized_type = GrantType(grant_type)
operations = tuple(allowed_git_operations)
unsigned = ProvenanceGrant(
grant_id=grant_id or f"grant_{uuid.uuid4().hex}",
parent_provenance_id=parent.provenance_id,
grant_type=normalized_type,
execution_path=GRANT_PATHS[normalized_type],
run_id=parent.run_id,
issuer=parent.issuer,
tenant_id=parent.tenant_id,
task_id=parent.task_id,
agent_id=parent.agent_id,
principal_id=parent.principal_id,
allowed_git_operations=operations,
issued_at=issued.isoformat().replace("+00:00", "Z"),
expires_at=expires.isoformat().replace("+00:00", "Z"),
policy_version=f"{WORKFLOW_POLICY_VERSION}.{normalized_type.value}",
key_id=self.signer.key_id,
nonce=nonce or uuid.uuid4().hex,
)
signed = replace(unsigned, signature=self.signer.sign(unsigned.canonical_bytes()))
self.grant_store.save_issued(signed)
return signed
class GrantVerifier:
"""验证父 Workflow 与子 Grant,确保单一路径最小权限。"""
def __init__(
self,
parent_store: SQLiteProvenanceStore,
grant_store: SQLiteGrantStore,
verifiers: Mapping[str, SignatureVerifier],
) -> None:
if not verifiers:
raise ValueError("at least one trusted verifier is required")
self.parent_store = parent_store
self.grant_store = grant_store
self.verifiers = {required(key, "key_id"): value for key, value in verifiers.items()}
def _verify_parent(self, parent: RunProvenance, *, now: datetime) -> SignatureVerifier:
status = self.parent_store.status(parent.provenance_id)
if status == ProvenanceStatus.REVOKED:
raise ProvenanceRevoked("workflow provenance is revoked")
if now >= parse_timestamp(parent.expires_at):
self.parent_store.mark_expired(parent.provenance_id)
raise ProvenanceExpired("workflow provenance is expired")
verifier = self.verifiers.get(parent.key_id)
if verifier is None or not parent.signature or not verifier.verify(
parent.canonical_bytes(), parent.signature
):
raise ProvenanceSignatureError("invalid workflow provenance signature")
if parent.policy_version != WORKFLOW_POLICY_VERSION:
raise ProvenanceDenied("parent is not a workflow provenance")
if parent.allowed_paths or parent.allowed_git_operations:
raise ProvenanceDenied("workflow provenance must not contain operation rights")
return verifier
def authorize(
self,
*,
grant_id: str,
parent_provenance_id: str,
run_id: str,
tenant_id: str,
task_id: str,
agent_id: str,
principal_id: str,
execution_path: str,
request_digest: str,
git_operation: str | None = None,
now: datetime | None = None,
) -> GrantAuthorization:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
parent = self.parent_store.get(parent_provenance_id)
self._verify_parent(parent, now=current)
grant = self.grant_store.get(grant_id)
grant_status = self.grant_store.status(grant.grant_id)
if grant_status == GrantStatus.REVOKED:
raise ProvenanceRevoked("provenance grant is revoked")
if current >= parse_timestamp(grant.expires_at):
self.grant_store.mark_expired(grant.grant_id)
raise ProvenanceExpired("provenance grant is expired")
verifier = self.verifiers.get(grant.key_id)
if verifier is None or not grant.signature or not verifier.verify(
grant.canonical_bytes(), grant.signature
):
raise ProvenanceSignatureError("invalid provenance grant signature")
expected = {
"parent_provenance_id": parent.provenance_id,
"run_id": required(run_id, "run_id"),
"tenant_id": required(tenant_id, "tenant_id"),
"task_id": required(task_id, "task_id"),
"agent_id": required(agent_id, "agent_id"),
"principal_id": required(principal_id, "principal_id"),
}
for field_name, expected_value in expected.items():
if getattr(grant, field_name) != expected_value:
raise ProvenanceDenied(f"grant {field_name} mismatch")
if field_name != "parent_provenance_id" and getattr(parent, field_name) != expected_value:
raise ProvenanceDenied(f"workflow {field_name} mismatch")
path = ExecutionPath(execution_path)
if grant.execution_path != path:
raise ProvenanceDenied(
f"grant permits {grant.execution_path.value}, not {path.value}"
)
operation: str | None = None
if path == ExecutionPath.GIT_OPERATION:
if git_operation is None:
raise ProvenanceDenied("Git operation is required")
operation = GitOperation(git_operation).value
if operation not in grant.allowed_git_operations:
raise ProvenanceDenied(f"Git operation is not allowed: {operation}")
elif git_operation is not None:
raise ProvenanceDenied("git_operation is only valid for a Git Grant")
digest = _validate_digest(request_digest)
self.grant_store.mark_verified(grant.grant_id, fingerprint=verifier.fingerprint)
authorization = GrantAuthorization(
authorization_id=f"gauth_{uuid.uuid4().hex}",
grant_id=grant.grant_id,
parent_provenance_id=parent.provenance_id,
run_id=grant.run_id,
tenant_id=grant.tenant_id,
task_id=grant.task_id,
agent_id=grant.agent_id,
principal_id=grant.principal_id,
execution_path=path.value,
git_operation=operation,
request_digest=digest,
policy_version=grant.policy_version,
key_id=grant.key_id,
public_key_fingerprint=verifier.fingerprint,
)
self.grant_store.record_authorization(authorization)
return authorization
class LeastPrivilegeProvenanceWriter:
"""按路径选择独立子 Grant 的生产 Writer。
``provenance_id`` 参数始终指父 Workflow Provenance;Grant ID 来自受保护
配置映射,调用方无法通过请求体临时扩大权限。
"""
def __init__(
self,
verifier: GrantVerifier,
receipt_root: str | Path,
grant_ids: Mapping[str, str],
*,
writer_id: str = "aios-v2-least-privilege-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
self.grant_ids = {
ExecutionPath(path).value: required(grant_id, f"grant_id[{path}]")
for path, grant_id in grant_ids.items()
}
missing = sorted(path.value for path in ExecutionPath if path.value not in self.grant_ids)
if missing:
raise ValueError(f"missing least-privilege Grant mappings: {missing}")
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def authorize(self, **values: Any) -> GrantAuthorization:
path = ExecutionPath(values["execution_path"]).value
grant_id = self.grant_ids[path]
parent_provenance_id = values.pop("provenance_id")
authorization = self.verifier.authorize(
grant_id=grant_id,
parent_provenance_id=parent_provenance_id,
**values,
)
tenant = self._component(authorization.tenant_id)
parent = self._component(authorization.parent_provenance_id)
grant = self._component(authorization.grant_id)
target_dir = self.receipt_root / tenant / parent / grant
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.CODE_CHANGE.value, **values)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.DEPLOYMENT.value, **values)
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values)
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values)
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.ROLLBACK.value, **values)
def authorize_audit(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
@@ -0,0 +1,265 @@
"""AIOS V2 运维操作 Provenance Gate 与服务级 Scoped Writer 工厂。"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence
from .provenance import (
Ed25519PublicKeyVerifier,
ExecutionPath,
ProvenanceAuthorization,
ProvenanceControlledWriter,
ProvenanceDenied,
ProvenanceVerifier,
SQLiteProvenanceStore,
digest_payload,
)
from .provenance_grants import GrantAuthorization, GrantVerifier, SQLiteGrantStore
from .provenance_scoped import ScopedLeastPrivilegeProvenanceWriter
APPLICATION_PROVENANCE_SCOPE = (
ExecutionPath.RUNTIME_EXECUTE.value,
ExecutionPath.EVIDENCE_WRITE.value,
ExecutionPath.AUDIT.value,
)
class AuthorizationWriter(Protocol):
def authorize_deployment(self, **values: Any) -> Any: ...
def authorize_rollback(self, **values: Any) -> Any: ...
def authorize_audit(self, **values: Any) -> Any: ...
def authorize_code_change(self, **values: Any) -> Any: ...
def authorize_evidence_write(self, **values: Any) -> Any: ...
def authorize_runtime_execute(self, **values: Any) -> Any: ...
@dataclass(frozen=True)
class OperationContext:
provenance_id: str
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
def as_dict(self) -> dict[str, str]:
return asdict(self)
@dataclass(frozen=True)
class OperationReceipt:
operation: str
request_digest: str
authorization_id: str
provenance_id: str
grant_id: str | None
run_id: str
tenant_id: str
task_id: str
agent_id: str
principal_id: str
execution_path: str
authorized_at: str
def as_dict(self) -> dict[str, Any]:
return asdict(self)
class ProvenanceOperationGate:
"""部署、回滚、审计和运行操作的统一授权入口。"""
def __init__(self, writer: AuthorizationWriter) -> None:
self.writer = writer
def authorize(
self,
context: OperationContext,
*,
execution_path: str,
operation: str,
request: Mapping[str, Any],
) -> OperationReceipt:
path = ExecutionPath(execution_path).value
normalized_operation = str(operation or "").strip()
if not normalized_operation:
raise ValueError("operation is required")
digest = digest_payload(
{
"operation": normalized_operation,
"execution_path": path,
"request": dict(request),
}
)
values = {
"provenance_id": context.provenance_id,
"run_id": context.run_id,
"tenant_id": context.tenant_id,
"task_id": context.task_id,
"agent_id": context.agent_id,
"principal_id": context.principal_id,
"request_digest": digest,
}
if path == ExecutionPath.DEPLOYMENT.value:
authorization = self.writer.authorize_deployment(**values)
elif path == ExecutionPath.ROLLBACK.value:
authorization = self.writer.authorize_rollback(**values)
elif path == ExecutionPath.AUDIT.value:
authorization = self.writer.authorize_audit(**values)
elif path == ExecutionPath.CODE_CHANGE.value:
authorization = self.writer.authorize_code_change(**values)
elif path == ExecutionPath.EVIDENCE_WRITE.value:
authorization = self.writer.authorize_evidence_write(**values)
elif path == ExecutionPath.RUNTIME_EXECUTE.value:
authorization = self.writer.authorize_runtime_execute(**values)
else:
raise ProvenanceDenied(
"generic operation gate does not authorize git_operation; use ProvenanceGitWriter"
)
return self._receipt(normalized_operation, digest, authorization)
@staticmethod
def _receipt(
operation: str,
request_digest: str,
authorization: ProvenanceAuthorization | GrantAuthorization | Any,
) -> OperationReceipt:
return OperationReceipt(
operation=operation,
request_digest=request_digest,
authorization_id=authorization.authorization_id,
provenance_id=authorization.provenance_id,
grant_id=getattr(authorization, "grant_id", None),
run_id=authorization.run_id,
tenant_id=authorization.tenant_id,
task_id=authorization.task_id,
agent_id=authorization.agent_id,
principal_id=authorization.principal_id,
execution_path=authorization.execution_path,
authorized_at=authorization.authorized_at,
)
_GRANT_ENV_BY_PATH: Mapping[str, str] = {
ExecutionPath.CODE_CHANGE.value: "AIOS_PROVENANCE_GRANT_CODE_CHANGE_ID",
ExecutionPath.GIT_OPERATION.value: "AIOS_PROVENANCE_GRANT_GIT_ID",
ExecutionPath.DEPLOYMENT.value: "AIOS_PROVENANCE_GRANT_DEPLOYMENT_ID",
ExecutionPath.RUNTIME_EXECUTE.value: "AIOS_PROVENANCE_GRANT_RUNTIME_ID",
ExecutionPath.EVIDENCE_WRITE.value: "AIOS_PROVENANCE_GRANT_EVIDENCE_ID",
ExecutionPath.ROLLBACK.value: "AIOS_PROVENANCE_GRANT_ROLLBACK_ID",
ExecutionPath.AUDIT.value: "AIOS_PROVENANCE_GRANT_AUDIT_ID",
}
def grant_ids_from_environment(
*,
required_paths: Sequence[str] | None = None,
) -> dict[str, str]:
paths = tuple(
sorted(
{
ExecutionPath(path).value
for path in (required_paths or tuple(item.value for item in ExecutionPath))
}
)
)
values = {path: os.getenv(_GRANT_ENV_BY_PATH[path], "").strip() for path in paths}
missing = [_GRANT_ENV_BY_PATH[path] for path, value in values.items() if not value]
if missing:
raise RuntimeError(f"missing scoped Provenance Grant configuration: {missing}")
return values
def build_provenance_writer_from_env(
*,
state_dir: str | Path | None = None,
required: bool | None = None,
required_paths: Sequence[str] | None = None,
writer_id: str = "aios-v2-application-grant-writer",
) -> AuthorizationWriter | None:
"""从环境构建服务级最小权限 Writer。
生产应用默认只加载 Runtime、Evidence、Audit Grant。Git、部署和回滚进程
必须显式传入自己的 ``required_paths``,不能继承应用默认范围。旧全权限 Writer
只允许在显式关闭强制模式的候选/测试环境中使用。
"""
require_value = (
bool(required)
if required is not None
else os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0").strip().lower()
in {"1", "true", "yes", "on"}
)
public_key_file = os.getenv("AIOS_PROVENANCE_PUBLIC_KEY_FILE", "").strip()
key_id = os.getenv("AIOS_PROVENANCE_KEY_ID", "").strip()
base = Path(state_dir or os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
store_file = os.getenv(
"AIOS_PROVENANCE_STORE_FILE", str(base / "run-provenance.sqlite3")
).strip()
receipt_root = os.getenv(
"AIOS_PROVENANCE_RECEIPT_DIR", str(base / "provenance-receipts")
).strip()
configured = bool(public_key_file and key_id)
if not configured:
if require_value:
raise RuntimeError(
"Run Provenance is required but AIOS_PROVENANCE_PUBLIC_KEY_FILE or AIOS_PROVENANCE_KEY_ID is missing"
)
return None
verifier = Ed25519PublicKeyVerifier.from_file(public_key_file, key_id=key_id)
parent_store = SQLiteProvenanceStore(store_file)
normalized_paths = tuple(
sorted(
{
ExecutionPath(path).value
for path in (
required_paths
if required_paths is not None
else APPLICATION_PROVENANCE_SCOPE
)
}
)
)
if require_value:
if not normalized_paths:
raise RuntimeError("production Run Provenance requires a non-empty service scope")
grant_ids = grant_ids_from_environment(required_paths=normalized_paths)
grant_store = SQLiteGrantStore(store_file)
trusted_grants = GrantVerifier(parent_store, grant_store, {key_id: verifier})
return ScopedLeastPrivilegeProvenanceWriter(
trusted_grants,
receipt_root,
grant_ids,
required_paths=normalized_paths,
writer_id=writer_id,
)
legacy = ProvenanceVerifier(parent_store, {key_id: verifier})
return ProvenanceControlledWriter(legacy, receipt_root)
def context_from_environment() -> OperationContext:
values = {
"provenance_id": os.getenv("AIOS_PROVENANCE_ID", ""),
"run_id": os.getenv("AIOS_RUN_ID", ""),
"tenant_id": os.getenv("AIOS_TENANT_ID", ""),
"task_id": os.getenv("AIOS_TASK_ID", ""),
"agent_id": os.getenv("AIOS_AGENT_ID", ""),
"principal_id": os.getenv("AIOS_PRINCIPAL_ID", ""),
}
missing = [name for name, value in values.items() if not str(value).strip()]
if missing:
raise RuntimeError(f"missing Provenance context environment variables: {missing}")
return OperationContext(**{name: str(value).strip() for name, value in values.items()})
def load_request_file(path: str | Path) -> Mapping[str, Any]:
value = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(value, Mapping):
raise ValueError("request file must contain a JSON object")
return dict(value)
@@ -0,0 +1,131 @@
"""按服务范围加载的最小权限 Provenance Writer。
该 Writer 只持有调用进程需要的 Grant ID。未配置路径的方法即使被调用也会
默认拒绝,避免应用进程获得 Git、部署或回滚权限。
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from typing import Any, Mapping, Sequence
from .provenance import ExecutionPath, ProvenanceDenied, required
from .provenance_grants import GrantAuthorization, GrantVerifier
class ScopedLeastPrivilegeProvenanceWriter:
"""只暴露指定 ExecutionPath 的最小权限 Writer。"""
def __init__(
self,
verifier: GrantVerifier,
receipt_root: str | Path,
grant_ids: Mapping[str, str],
*,
required_paths: Sequence[str],
writer_id: str = "aios-v2-scoped-grant-writer",
) -> None:
self.verifier = verifier
self.receipt_root = Path(receipt_root).resolve()
self.receipt_root.mkdir(parents=True, exist_ok=True)
normalized_required = tuple(
sorted({ExecutionPath(path).value for path in required_paths})
)
if not normalized_required:
raise ValueError("required_paths must not be empty")
self.required_paths = frozenset(normalized_required)
self.grant_ids = {
path: required(grant_ids.get(path, ""), f"grant_id[{path}]")
for path in normalized_required
}
unexpected = sorted(set(grant_ids) - self.required_paths)
if unexpected:
raise ValueError(
f"scoped writer received unnecessary Grant mappings: {unexpected}"
)
self.writer_id = required(writer_id, "writer_id")
self._lock = threading.RLock()
@staticmethod
def _component(value: str) -> str:
normalized = required(value, "path component")
if normalized in {".", ".."} or "/" in normalized or "\\" in normalized:
raise ProvenanceDenied("invalid receipt path component")
return normalized
def _grant_for(self, path: str) -> str:
normalized = ExecutionPath(path).value
grant_id = self.grant_ids.get(normalized)
if grant_id is None:
raise ProvenanceDenied(
f"this process is not allowed to authorize {normalized}"
)
return grant_id
def authorize(self, **values: Any) -> GrantAuthorization:
path = ExecutionPath(values["execution_path"]).value
grant_id = self._grant_for(path)
parent_provenance_id = values.pop("provenance_id")
authorization = self.verifier.authorize(
grant_id=grant_id,
parent_provenance_id=parent_provenance_id,
**values,
)
tenant = self._component(authorization.tenant_id)
parent = self._component(authorization.parent_provenance_id)
grant = self._component(authorization.grant_id)
target_dir = self.receipt_root / tenant / parent / grant
target = target_dir / f"{authorization.authorization_id}.json"
payload = authorization.as_dict()
payload["writer_id"] = self.writer_id
payload["scope"] = sorted(self.required_paths)
with self._lock:
target_dir.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".tmp-{os.getpid()}")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
+ "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, target)
return authorization
def authorize_code_change(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.CODE_CHANGE.value, **values
)
def authorize_git_operation(
self, *, git_operation: str, **values: Any
) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.GIT_OPERATION.value,
git_operation=git_operation,
**values,
)
def authorize_deployment(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.DEPLOYMENT.value, **values
)
def authorize_runtime_execute(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.RUNTIME_EXECUTE.value, **values
)
def authorize_evidence_write(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.EVIDENCE_WRITE.value, **values
)
def authorize_rollback(self, **values: Any) -> GrantAuthorization:
return self.authorize(
execution_path=ExecutionPath.ROLLBACK.value, **values
)
def authorize_audit(self, **values: Any) -> GrantAuthorization:
return self.authorize(execution_path=ExecutionPath.AUDIT.value, **values)
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+18 -6
View File
@@ -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"
+120 -21
View File
@@ -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
View File
@@ -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 和保存的证据,不得仅凭测试文件存在宣布通过。
+65 -99
View File
@@ -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 证据为准。
+23 -8
View File
@@ -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 已通过;必须有实际执行日志、退出码和证据。
+9
View File
@@ -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.
+14
View File
@@ -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.
+10
View File
@@ -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 |
+14
View File
@@ -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
}
+1
View File
@@ -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()
+34
View File
@@ -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()
+54
View File
@@ -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()
+170 -7
View File
@@ -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()
+383
View File
@@ -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()